From 868c5d1d96cb35da6adc67178b2e9dfba970923e Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 6 Mar 2024 16:55:44 +0900 Subject: [PATCH 001/152] chore: init project --- .gitignore | 37 +++ build.gradle | 25 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 249 ++++++++++++++++++ gradlew.bat | 92 +++++++ settings.gradle | 1 + .../kr/co/pennyway/PennywayApplication.java | 13 + src/main/resources/application.properties | 1 + .../co/pennyway/PennywayApplicationTests.java | 13 + 10 files changed, 438 insertions(+) create mode 100644 .gitignore create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/kr/co/pennyway/PennywayApplication.java create mode 100644 src/main/resources/application.properties create mode 100644 src/test/java/kr/co/pennyway/PennywayApplicationTests.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..c2065bc26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..75747408a --- /dev/null +++ b/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.3' + id 'io.spring.dependency-management' version '1.1.4' +} + +group = 'kr.co' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '17' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter' + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d64cd4917707c1f8861d8cb53dd15194d4248596 GIT binary patch literal 43462 zcma&NWl&^owk(X(xVyW%ySuwf;qI=D6|RlDJ2cR^yEKh!@I- zp9QeisK*rlxC>+~7Dk4IxIRsKBHqdR9b3+fyL=ynHmIDe&|>O*VlvO+%z5;9Z$|DJ zb4dO}-R=MKr^6EKJiOrJdLnCJn>np?~vU-1sSFgPu;pthGwf}bG z(1db%xwr#x)r+`4AGu$j7~u2MpVs3VpLp|mx&;>`0p0vH6kF+D2CY0fVdQOZ@h;A` z{infNyvmFUiu*XG}RNMNwXrbec_*a3N=2zJ|Wh5z* z5rAX$JJR{#zP>KY**>xHTuw?|-Rg|o24V)74HcfVT;WtQHXlE+_4iPE8QE#DUm%x0 zEKr75ur~W%w#-My3Tj`hH6EuEW+8K-^5P62$7Sc5OK+22qj&Pd1;)1#4tKihi=~8C zHiQSst0cpri6%OeaR`PY>HH_;CPaRNty%WTm4{wDK8V6gCZlG@U3$~JQZ;HPvDJcT1V{ z?>H@13MJcCNe#5z+MecYNi@VT5|&UiN1D4ATT+%M+h4c$t;C#UAs3O_q=GxK0}8%8 z8J(_M9bayxN}69ex4dzM_P3oh@ZGREjVvn%%r7=xjkqxJP4kj}5tlf;QosR=%4L5y zWhgejO=vao5oX%mOHbhJ8V+SG&K5dABn6!WiKl{|oPkq(9z8l&Mm%(=qGcFzI=eLu zWc_oCLyf;hVlB@dnwY98?75B20=n$>u3b|NB28H0u-6Rpl((%KWEBOfElVWJx+5yg z#SGqwza7f}$z;n~g%4HDU{;V{gXIhft*q2=4zSezGK~nBgu9-Q*rZ#2f=Q}i2|qOp z!!y4p)4o=LVUNhlkp#JL{tfkhXNbB=Ox>M=n6soptJw-IDI|_$is2w}(XY>a=H52d z3zE$tjPUhWWS+5h=KVH&uqQS=$v3nRs&p$%11b%5qtF}S2#Pc`IiyBIF4%A!;AVoI zXU8-Rpv!DQNcF~(qQnyyMy=-AN~U>#&X1j5BLDP{?K!%h!;hfJI>$mdLSvktEr*89 zdJHvby^$xEX0^l9g$xW-d?J;L0#(`UT~zpL&*cEh$L|HPAu=P8`OQZV!-}l`noSp_ zQ-1$q$R-gDL)?6YaM!=8H=QGW$NT2SeZlb8PKJdc=F-cT@j7Xags+Pr*jPtlHFnf- zh?q<6;)27IdPc^Wdy-mX%2s84C1xZq9Xms+==F4);O`VUASmu3(RlgE#0+#giLh-& zcxm3_e}n4{%|X zJp{G_j+%`j_q5}k{eW&TlP}J2wtZ2^<^E(O)4OQX8FDp6RJq!F{(6eHWSD3=f~(h} zJXCf7=r<16X{pHkm%yzYI_=VDP&9bmI1*)YXZeB}F? z(%QsB5fo*FUZxK$oX~X^69;x~j7ms8xlzpt-T15e9}$4T-pC z6PFg@;B-j|Ywajpe4~bk#S6(fO^|mm1hKOPfA%8-_iGCfICE|=P_~e;Wz6my&)h_~ zkv&_xSAw7AZ%ThYF(4jADW4vg=oEdJGVOs>FqamoL3Np8>?!W#!R-0%2Bg4h?kz5I zKV-rKN2n(vUL%D<4oj@|`eJ>0i#TmYBtYmfla;c!ATW%;xGQ0*TW@PTlGG><@dxUI zg>+3SiGdZ%?5N=8uoLA|$4isK$aJ%i{hECP$bK{J#0W2gQ3YEa zZQ50Stn6hqdfxJ*9#NuSLwKFCUGk@c=(igyVL;;2^wi4o30YXSIb2g_ud$ zgpCr@H0qWtk2hK8Q|&wx)}4+hTYlf;$a4#oUM=V@Cw#!$(nOFFpZ;0lc!qd=c$S}Z zGGI-0jg~S~cgVT=4Vo)b)|4phjStD49*EqC)IPwyeKBLcN;Wu@Aeph;emROAwJ-0< z_#>wVm$)ygH|qyxZaet&(Vf%pVdnvKWJn9`%DAxj3ot;v>S$I}jJ$FLBF*~iZ!ZXE zkvui&p}fI0Y=IDX)mm0@tAd|fEHl~J&K}ZX(Mm3cm1UAuwJ42+AO5@HwYfDH7ipIc zmI;1J;J@+aCNG1M`Btf>YT>~c&3j~Qi@Py5JT6;zjx$cvOQW@3oQ>|}GH?TW-E z1R;q^QFjm5W~7f}c3Ww|awg1BAJ^slEV~Pk`Kd`PS$7;SqJZNj->it4DW2l15}xP6 zoCl$kyEF%yJni0(L!Z&14m!1urXh6Btj_5JYt1{#+H8w?5QI%% zo-$KYWNMJVH?Hh@1n7OSu~QhSswL8x0=$<8QG_zepi_`y_79=nK=_ZP_`Em2UI*tyQoB+r{1QYZCpb?2OrgUw#oRH$?^Tj!Req>XiE#~B|~ z+%HB;=ic+R@px4Ld8mwpY;W^A%8%l8$@B@1m5n`TlKI6bz2mp*^^^1mK$COW$HOfp zUGTz-cN9?BGEp}5A!mDFjaiWa2_J2Iq8qj0mXzk; z66JBKRP{p%wN7XobR0YjhAuW9T1Gw3FDvR5dWJ8ElNYF94eF3ebu+QwKjtvVu4L zI9ip#mQ@4uqVdkl-TUQMb^XBJVLW(-$s;Nq;@5gr4`UfLgF$adIhd?rHOa%D);whv z=;krPp~@I+-Z|r#s3yCH+c1US?dnm+C*)r{m+86sTJusLdNu^sqLrfWed^ndHXH`m zd3#cOe3>w-ga(Dus_^ppG9AC>Iq{y%%CK+Cro_sqLCs{VLuK=dev>OL1dis4(PQ5R zcz)>DjEkfV+MO;~>VUlYF00SgfUo~@(&9$Iy2|G0T9BSP?&T22>K46D zL*~j#yJ?)^*%J3!16f)@Y2Z^kS*BzwfAQ7K96rFRIh>#$*$_Io;z>ux@}G98!fWR@ zGTFxv4r~v)Gsd|pF91*-eaZ3Qw1MH$K^7JhWIdX%o$2kCbvGDXy)a?@8T&1dY4`;L z4Kn+f%SSFWE_rpEpL9bnlmYq`D!6F%di<&Hh=+!VI~j)2mfil03T#jJ_s?}VV0_hp z7T9bWxc>Jm2Z0WMU?`Z$xE74Gu~%s{mW!d4uvKCx@WD+gPUQ zV0vQS(Ig++z=EHN)BR44*EDSWIyT~R4$FcF*VEY*8@l=218Q05D2$|fXKFhRgBIEE zdDFB}1dKkoO^7}{5crKX!p?dZWNz$m>1icsXG2N+((x0OIST9Zo^DW_tytvlwXGpn zs8?pJXjEG;T@qrZi%#h93?FP$!&P4JA(&H61tqQi=opRzNpm zkrG}$^t9&XduK*Qa1?355wd8G2CI6QEh@Ua>AsD;7oRUNLPb76m4HG3K?)wF~IyS3`fXuNM>${?wmB zpVz;?6_(Fiadfd{vUCBM*_kt$+F3J+IojI;9L(gc9n3{sEZyzR9o!_mOwFC#tQ{Q~ zP3-`#uK#tP3Q7~Q;4H|wjZHO8h7e4IuBxl&vz2w~D8)w=Wtg31zpZhz%+kzSzL*dV zwp@{WU4i;hJ7c2f1O;7Mz6qRKeASoIv0_bV=i@NMG*l<#+;INk-^`5w@}Dj~;k=|}qM1vq_P z|GpBGe_IKq|LNy9SJhKOQ$c=5L{Dv|Q_lZl=-ky*BFBJLW9&y_C|!vyM~rQx=!vun z?rZJQB5t}Dctmui5i31C_;_}CEn}_W%>oSXtt>@kE1=JW*4*v4tPp;O6 zmAk{)m!)}34pTWg8{i>($%NQ(Tl;QC@J@FfBoc%Gr&m560^kgSfodAFrIjF}aIw)X zoXZ`@IsMkc8_=w%-7`D6Y4e*CG8k%Ud=GXhsTR50jUnm+R*0A(O3UKFg0`K;qp1bl z7``HN=?39ic_kR|^R^~w-*pa?Vj#7|e9F1iRx{GN2?wK!xR1GW!qa=~pjJb-#u1K8 zeR?Y2i-pt}yJq;SCiVHODIvQJX|ZJaT8nO+(?HXbLefulKKgM^B(UIO1r+S=7;kLJ zcH}1J=Px2jsh3Tec&v8Jcbng8;V-`#*UHt?hB(pmOipKwf3Lz8rG$heEB30Sg*2rx zV<|KN86$soN(I!BwO`1n^^uF2*x&vJ$2d$>+`(romzHP|)K_KkO6Hc>_dwMW-M(#S zK(~SiXT1@fvc#U+?|?PniDRm01)f^#55;nhM|wi?oG>yBsa?~?^xTU|fX-R(sTA+5 zaq}-8Tx7zrOy#3*JLIIVsBmHYLdD}!0NP!+ITW+Thn0)8SS!$@)HXwB3tY!fMxc#1 zMp3H?q3eD?u&Njx4;KQ5G>32+GRp1Ee5qMO0lZjaRRu&{W<&~DoJNGkcYF<5(Ab+J zgO>VhBl{okDPn78<%&e2mR{jwVCz5Og;*Z;;3%VvoGo_;HaGLWYF7q#jDX=Z#Ml`H z858YVV$%J|e<1n`%6Vsvq7GmnAV0wW4$5qQ3uR@1i>tW{xrl|ExywIc?fNgYlA?C5 zh$ezAFb5{rQu6i7BSS5*J-|9DQ{6^BVQ{b*lq`xS@RyrsJN?-t=MTMPY;WYeKBCNg z^2|pN!Q^WPJuuO4!|P@jzt&tY1Y8d%FNK5xK(!@`jO2aEA*4 zkO6b|UVBipci?){-Ke=+1;mGlND8)6+P;8sq}UXw2hn;fc7nM>g}GSMWu&v&fqh

iViYT=fZ(|3Ox^$aWPp4a8h24tD<|8-!aK0lHgL$N7Efw}J zVIB!7=T$U`ao1?upi5V4Et*-lTG0XvExbf!ya{cua==$WJyVG(CmA6Of*8E@DSE%L z`V^$qz&RU$7G5mg;8;=#`@rRG`-uS18$0WPN@!v2d{H2sOqP|!(cQ@ zUHo!d>>yFArLPf1q`uBvY32miqShLT1B@gDL4XoVTK&@owOoD)OIHXrYK-a1d$B{v zF^}8D3Y^g%^cnvScOSJR5QNH+BI%d|;J;wWM3~l>${fb8DNPg)wrf|GBP8p%LNGN# z3EaIiItgwtGgT&iYCFy9-LG}bMI|4LdmmJt@V@% zb6B)1kc=T)(|L@0;wr<>=?r04N;E&ef+7C^`wPWtyQe(*pD1pI_&XHy|0gIGHMekd zF_*M4yi6J&Z4LQj65)S zXwdM{SwUo%3SbPwFsHgqF@V|6afT|R6?&S;lw=8% z3}@9B=#JI3@B*#4s!O))~z zc>2_4Q_#&+5V`GFd?88^;c1i7;Vv_I*qt!_Yx*n=;rj!82rrR2rQ8u5(Ejlo{15P% zs~!{%XJ>FmJ})H^I9bn^Re&38H{xA!0l3^89k(oU;bZWXM@kn$#aoS&Y4l^-WEn-fH39Jb9lA%s*WsKJQl?n9B7_~P z-XM&WL7Z!PcoF6_D>V@$CvUIEy=+Z&0kt{szMk=f1|M+r*a43^$$B^MidrT0J;RI` z(?f!O<8UZkm$_Ny$Hth1J#^4ni+im8M9mr&k|3cIgwvjAgjH z8`N&h25xV#v*d$qBX5jkI|xOhQn!>IYZK7l5#^P4M&twe9&Ey@@GxYMxBZq2e7?`q z$~Szs0!g{2fGcp9PZEt|rdQ6bhAgpcLHPz?f-vB?$dc*!9OL?Q8mn7->bFD2Si60* z!O%y)fCdMSV|lkF9w%x~J*A&srMyYY3{=&$}H zGQ4VG_?$2X(0|vT0{=;W$~icCI{b6W{B!Q8xdGhF|D{25G_5_+%s(46lhvNLkik~R z>nr(&C#5wwOzJZQo9m|U<;&Wk!_#q|V>fsmj1g<6%hB{jGoNUPjgJslld>xmODzGjYc?7JSuA?A_QzjDw5AsRgi@Y|Z0{F{!1=!NES-#*f^s4l0Hu zz468))2IY5dmD9pa*(yT5{EyP^G>@ZWumealS-*WeRcZ}B%gxq{MiJ|RyX-^C1V=0 z@iKdrGi1jTe8Ya^x7yyH$kBNvM4R~`fbPq$BzHum-3Zo8C6=KW@||>zsA8-Y9uV5V z#oq-f5L5}V<&wF4@X@<3^C%ptp6+Ce)~hGl`kwj)bsAjmo_GU^r940Z-|`<)oGnh7 zFF0Tde3>ui?8Yj{sF-Z@)yQd~CGZ*w-6p2U<8}JO-sRsVI5dBji`01W8A&3$?}lxBaC&vn0E$c5tW* zX>5(zzZ=qn&!J~KdsPl;P@bmA-Pr8T*)eh_+Dv5=Ma|XSle6t(k8qcgNyar{*ReQ8 zTXwi=8vr>!3Ywr+BhggHDw8ke==NTQVMCK`$69fhzEFB*4+H9LIvdt-#IbhZvpS}} zO3lz;P?zr0*0$%-Rq_y^k(?I{Mk}h@w}cZpMUp|ucs55bcloL2)($u%mXQw({Wzc~ z;6nu5MkjP)0C(@%6Q_I_vsWrfhl7Zpoxw#WoE~r&GOSCz;_ro6i(^hM>I$8y>`!wW z*U^@?B!MMmb89I}2(hcE4zN2G^kwyWCZp5JG>$Ez7zP~D=J^LMjSM)27_0B_X^C(M z`fFT+%DcKlu?^)FCK>QzSnV%IsXVcUFhFdBP!6~se&xxrIxsvySAWu++IrH;FbcY$ z2DWTvSBRfLwdhr0nMx+URA$j3i7_*6BWv#DXfym?ZRDcX9C?cY9sD3q)uBDR3uWg= z(lUIzB)G$Hr!){>E{s4Dew+tb9kvToZp-1&c?y2wn@Z~(VBhqz`cB;{E4(P3N2*nJ z_>~g@;UF2iG{Kt(<1PyePTKahF8<)pozZ*xH~U-kfoAayCwJViIrnqwqO}7{0pHw$ zs2Kx?s#vQr7XZ264>5RNKSL8|Ty^=PsIx^}QqOOcfpGUU4tRkUc|kc7-!Ae6!+B{o~7nFpm3|G5^=0#Bnm6`V}oSQlrX(u%OWnC zoLPy&Q;1Jui&7ST0~#+}I^&?vcE*t47~Xq#YwvA^6^} z`WkC)$AkNub|t@S!$8CBlwbV~?yp&@9h{D|3z-vJXgzRC5^nYm+PyPcgRzAnEi6Q^gslXYRv4nycsy-SJu?lMps-? zV`U*#WnFsdPLL)Q$AmD|0`UaC4ND07+&UmOu!eHruzV|OUox<+Jl|Mr@6~C`T@P%s zW7sgXLF2SSe9Fl^O(I*{9wsFSYb2l%-;&Pi^dpv!{)C3d0AlNY6!4fgmSgj_wQ*7Am7&$z;Jg&wgR-Ih;lUvWS|KTSg!&s_E9_bXBkZvGiC6bFKDWZxsD$*NZ#_8bl zG1P-#@?OQzED7@jlMJTH@V!6k;W>auvft)}g zhoV{7$q=*;=l{O>Q4a@ ziMjf_u*o^PsO)#BjC%0^h>Xp@;5$p{JSYDt)zbb}s{Kbt!T*I@Pk@X0zds6wsefuU zW$XY%yyRGC94=6mf?x+bbA5CDQ2AgW1T-jVAJbm7K(gp+;v6E0WI#kuACgV$r}6L? zd|Tj?^%^*N&b>Dd{Wr$FS2qI#Ucs1yd4N+RBUQiSZGujH`#I)mG&VKoDh=KKFl4=G z&MagXl6*<)$6P}*Tiebpz5L=oMaPrN+caUXRJ`D?=K9!e0f{@D&cZLKN?iNP@X0aF zE(^pl+;*T5qt?1jRC=5PMgV!XNITRLS_=9{CJExaQj;lt!&pdzpK?8p>%Mb+D z?yO*uSung=-`QQ@yX@Hyd4@CI^r{2oiu`%^bNkz+Nkk!IunjwNC|WcqvX~k=><-I3 zDQdbdb|!v+Iz01$w@aMl!R)koD77Xp;eZwzSl-AT zr@Vu{=xvgfq9akRrrM)}=!=xcs+U1JO}{t(avgz`6RqiiX<|hGG1pmop8k6Q+G_mv zJv|RfDheUp2L3=^C=4aCBMBn0aRCU(DQwX-W(RkRwmLeuJYF<0urcaf(=7)JPg<3P zQs!~G)9CT18o!J4{zX{_e}4eS)U-E)0FAt}wEI(c0%HkxgggW;(1E=>J17_hsH^sP z%lT0LGgbUXHx-K*CI-MCrP66UP0PvGqM$MkeLyqHdbgP|_Cm!7te~b8p+e6sQ_3k| zVcwTh6d83ltdnR>D^)BYQpDKlLk3g0Hdcgz2}%qUs9~~Rie)A-BV1mS&naYai#xcZ z(d{8=-LVpTp}2*y)|gR~;qc7fp26}lPcLZ#=JpYcn3AT9(UIdOyg+d(P5T7D&*P}# zQCYplZO5|7+r19%9e`v^vfSS1sbX1c%=w1;oyruXB%Kl$ACgKQ6=qNWLsc=28xJjg zwvsI5-%SGU|3p>&zXVl^vVtQT3o-#$UT9LI@Npz~6=4!>mc431VRNN8od&Ul^+G_kHC`G=6WVWM z%9eWNyy(FTO|A+@x}Ou3CH)oi;t#7rAxdIXfNFwOj_@Y&TGz6P_sqiB`Q6Lxy|Q{`|fgmRG(k+!#b*M+Z9zFce)f-7;?Km5O=LHV9f9_87; zF7%R2B+$?@sH&&-$@tzaPYkw0;=i|;vWdI|Wl3q_Zu>l;XdIw2FjV=;Mq5t1Q0|f< zs08j54Bp`3RzqE=2enlkZxmX6OF+@|2<)A^RNQpBd6o@OXl+i)zO%D4iGiQNuXd+zIR{_lb96{lc~bxsBveIw6umhShTX+3@ZJ=YHh@ zWY3(d0azg;7oHn>H<>?4@*RQbi>SmM=JrHvIG(~BrvI)#W(EAeO6fS+}mxxcc+X~W6&YVl86W9WFSS}Vz-f9vS?XUDBk)3TcF z8V?$4Q)`uKFq>xT=)Y9mMFVTUk*NIA!0$?RP6Ig0TBmUFrq*Q-Agq~DzxjStQyJ({ zBeZ;o5qUUKg=4Hypm|}>>L=XKsZ!F$yNTDO)jt4H0gdQ5$f|d&bnVCMMXhNh)~mN z@_UV6D7MVlsWz+zM+inZZp&P4fj=tm6fX)SG5H>OsQf_I8c~uGCig$GzuwViK54bcgL;VN|FnyQl>Ed7(@>=8$a_UKIz|V6CeVSd2(P z0Uu>A8A+muM%HLFJQ9UZ5c)BSAv_zH#1f02x?h9C}@pN@6{>UiAp>({Fn(T9Q8B z^`zB;kJ5b`>%dLm+Ol}ty!3;8f1XDSVX0AUe5P#@I+FQ-`$(a;zNgz)4x5hz$Hfbg z!Q(z26wHLXko(1`;(BAOg_wShpX0ixfWq3ponndY+u%1gyX)_h=v1zR#V}#q{au6; z!3K=7fQwnRfg6FXtNQmP>`<;!N137paFS%y?;lb1@BEdbvQHYC{976l`cLqn;b8lp zIDY>~m{gDj(wfnK!lpW6pli)HyLEiUrNc%eXTil|F2s(AY+LW5hkKb>TQ3|Q4S9rr zpDs4uK_co6XPsn_z$LeS{K4jFF`2>U`tbgKdyDne`xmR<@6AA+_hPNKCOR-Zqv;xk zu5!HsBUb^!4uJ7v0RuH-7?l?}b=w5lzzXJ~gZcxRKOovSk@|#V+MuX%Y+=;14i*%{)_gSW9(#4%)AV#3__kac1|qUy!uyP{>?U#5wYNq}y$S9pCc zFc~4mgSC*G~j0u#qqp9 z${>3HV~@->GqEhr_Xwoxq?Hjn#=s2;i~g^&Hn|aDKpA>Oc%HlW(KA1?BXqpxB;Ydx)w;2z^MpjJ(Qi(X!$5RC z*P{~%JGDQqojV>2JbEeCE*OEu!$XJ>bWA9Oa_Hd;y)F%MhBRi*LPcdqR8X`NQ&1L# z5#9L*@qxrx8n}LfeB^J{%-?SU{FCwiWyHp682F+|pa+CQa3ZLzBqN1{)h4d6+vBbV zC#NEbQLC;}me3eeYnOG*nXOJZEU$xLZ1<1Y=7r0(-U0P6-AqwMAM`a(Ed#7vJkn6plb4eI4?2y3yOTGmmDQ!z9`wzbf z_OY#0@5=bnep;MV0X_;;SJJWEf^E6Bd^tVJ9znWx&Ks8t*B>AM@?;D4oWUGc z!H*`6d7Cxo6VuyS4Eye&L1ZRhrRmN6Lr`{NL(wDbif|y&z)JN>Fl5#Wi&mMIr5i;x zBx}3YfF>>8EC(fYnmpu~)CYHuHCyr5*`ECap%t@y=jD>!_%3iiE|LN$mK9>- zHdtpy8fGZtkZF?%TW~29JIAfi2jZT8>OA7=h;8T{{k?c2`nCEx9$r zS+*&vt~2o^^J+}RDG@+9&M^K*z4p{5#IEVbz`1%`m5c2};aGt=V?~vIM}ZdPECDI)47|CWBCfDWUbxBCnmYivQ*0Nu_xb*C>~C9(VjHM zxe<*D<#dQ8TlpMX2c@M<9$w!RP$hpG4cs%AI){jp*Sj|*`m)5(Bw*A0$*i-(CA5#%>a)$+jI2C9r6|(>J8InryENI z$NohnxDUB;wAYDwrb*!N3noBTKPpPN}~09SEL18tkG zxgz(RYU_;DPT{l?Q$+eaZaxnsWCA^ds^0PVRkIM%bOd|G2IEBBiz{&^JtNsODs;5z zICt_Zj8wo^KT$7Bg4H+y!Df#3mbl%%?|EXe!&(Vmac1DJ*y~3+kRKAD=Ovde4^^%~ zw<9av18HLyrf*_>Slp;^i`Uy~`mvBjZ|?Ad63yQa#YK`4+c6;pW4?XIY9G1(Xh9WO8{F-Aju+nS9Vmv=$Ac0ienZ+p9*O%NG zMZKy5?%Z6TAJTE?o5vEr0r>f>hb#2w2U3DL64*au_@P!J!TL`oH2r*{>ffu6|A7tv zL4juf$DZ1MW5ZPsG!5)`k8d8c$J$o;%EIL0va9&GzWvkS%ZsGb#S(?{!UFOZ9<$a| zY|a+5kmD5N&{vRqkgY>aHsBT&`rg|&kezoD)gP0fsNYHsO#TRc_$n6Lf1Z{?+DLziXlHrq4sf(!>O{?Tj;Eh@%)+nRE_2VxbN&&%%caU#JDU%vL3}Cb zsb4AazPI{>8H&d=jUaZDS$-0^AxE@utGs;-Ez_F(qC9T=UZX=>ok2k2 ziTn{K?y~a5reD2A)P${NoI^>JXn>`IeArow(41c-Wm~)wiryEP(OS{YXWi7;%dG9v zI?mwu1MxD{yp_rrk!j^cKM)dc4@p4Ezyo%lRN|XyD}}>v=Xoib0gOcdXrQ^*61HNj z=NP|pd>@yfvr-=m{8$3A8TQGMTE7g=z!%yt`8`Bk-0MMwW~h^++;qyUP!J~ykh1GO z(FZ59xuFR$(WE;F@UUyE@Sp>`aVNjyj=Ty>_Vo}xf`e7`F;j-IgL5`1~-#70$9_=uBMq!2&1l zomRgpD58@)YYfvLtPW}{C5B35R;ZVvB<<#)x%srmc_S=A7F@DW8>QOEGwD6suhwCg z>Pa+YyULhmw%BA*4yjDp|2{!T98~<6Yfd(wo1mQ!KWwq0eg+6)o1>W~f~kL<-S+P@$wx*zeI|1t7z#Sxr5 zt6w+;YblPQNplq4Z#T$GLX#j6yldXAqj>4gAnnWtBICUnA&-dtnlh=t0Ho_vEKwV` z)DlJi#!@nkYV#$!)@>udAU*hF?V`2$Hf=V&6PP_|r#Iv*J$9)pF@X3`k;5})9^o4y z&)~?EjX5yX12O(BsFy-l6}nYeuKkiq`u9145&3Ssg^y{5G3Pse z9w(YVa0)N-fLaBq1`P!_#>SS(8fh_5!f{UrgZ~uEdeMJIz7DzI5!NHHqQtm~#CPij z?=N|J>nPR6_sL7!f4hD_|KH`vf8(Wpnj-(gPWH+ZvID}%?~68SwhPTC3u1_cB`otq z)U?6qo!ZLi5b>*KnYHWW=3F!p%h1;h{L&(Q&{qY6)_qxNfbP6E3yYpW!EO+IW3?@J z);4>g4gnl^8klu7uA>eGF6rIGSynacogr)KUwE_R4E5Xzi*Qir@b-jy55-JPC8c~( zo!W8y9OGZ&`xmc8;=4-U9=h{vCqfCNzYirONmGbRQlR`WWlgnY+1wCXbMz&NT~9*| z6@FrzP!LX&{no2!Ln_3|I==_4`@}V?4a;YZKTdw;vT<+K+z=uWbW(&bXEaWJ^W8Td z-3&1bY^Z*oM<=M}LVt>_j+p=2Iu7pZmbXrhQ_k)ysE9yXKygFNw$5hwDn(M>H+e1&9BM5!|81vd%r%vEm zqxY3?F@fb6O#5UunwgAHR9jp_W2zZ}NGp2%mTW@(hz7$^+a`A?mb8|_G*GNMJ) zjqegXQio=i@AINre&%ofexAr95aop5C+0MZ0m-l=MeO8m3epm7U%vZB8+I+C*iNFM z#T3l`gknX;D$-`2XT^Cg*vrv=RH+P;_dfF++cP?B_msQI4j+lt&rX2)3GaJx%W*Nn zkML%D{z5tpHH=dksQ*gzc|}gzW;lwAbxoR07VNgS*-c3d&8J|;@3t^ zVUz*J*&r7DFRuFVDCJDK8V9NN5hvpgGjwx+5n)qa;YCKe8TKtdnh{I7NU9BCN!0dq zczrBk8pE{{@vJa9ywR@mq*J=v+PG;?fwqlJVhijG!3VmIKs>9T6r7MJpC)m!Tc#>g zMtVsU>wbwFJEfwZ{vB|ZlttNe83)$iz`~#8UJ^r)lJ@HA&G#}W&ZH*;k{=TavpjWE z7hdyLZPf*X%Gm}i`Y{OGeeu^~nB8=`{r#TUrM-`;1cBvEd#d!kPqIgYySYhN-*1;L z^byj%Yi}Gx)Wnkosi337BKs}+5H5dth1JA{Ir-JKN$7zC)*}hqeoD(WfaUDPT>0`- z(6sa0AoIqASwF`>hP}^|)a_j2s^PQn*qVC{Q}htR z5-)duBFXT_V56-+UohKXlq~^6uf!6sA#ttk1o~*QEy_Y-S$gAvq47J9Vtk$5oA$Ct zYhYJ@8{hsC^98${!#Ho?4y5MCa7iGnfz}b9jE~h%EAAv~Qxu)_rAV;^cygV~5r_~?l=B`zObj7S=H=~$W zPtI_m%g$`kL_fVUk9J@>EiBH zOO&jtn~&`hIFMS5S`g8w94R4H40mdNUH4W@@XQk1sr17b{@y|JB*G9z1|CrQjd+GX z6+KyURG3;!*BQrentw{B2R&@2&`2}n(z-2&X7#r!{yg@Soy}cRD~j zj9@UBW+N|4HW4AWapy4wfUI- zZ`gSL6DUlgj*f1hSOGXG0IVH8HxK?o2|3HZ;KW{K+yPAlxtb)NV_2AwJm|E)FRs&& z=c^e7bvUsztY|+f^k7NXs$o1EUq>cR7C0$UKi6IooHWlK_#?IWDkvywnzg&ThWo^? z2O_N{5X39#?eV9l)xI(>@!vSB{DLt*oY!K1R8}_?%+0^C{d9a%N4 zoxHVT1&Lm|uDX%$QrBun5e-F`HJ^T$ zmzv)p@4ZHd_w9!%Hf9UYNvGCw2TTTbrj9pl+T9%-_-}L(tES>Or-}Z4F*{##n3~L~TuxjirGuIY#H7{%$E${?p{Q01 zi6T`n;rbK1yIB9jmQNycD~yZq&mbIsFWHo|ZAChSFPQa<(%d8mGw*V3fh|yFoxOOiWJd(qvVb!Z$b88cg->N=qO*4k~6;R==|9ihg&riu#P~s4Oap9O7f%crSr^rljeIfXDEg>wi)&v*a%7zpz<9w z*r!3q9J|390x`Zk;g$&OeN&ctp)VKRpDSV@kU2Q>jtok($Y-*x8_$2piTxun81@vt z!Vj?COa0fg2RPXMSIo26T=~0d`{oGP*eV+$!0I<(4azk&Vj3SiG=Q!6mX0p$z7I}; z9BJUFgT-K9MQQ-0@Z=^7R<{bn2Fm48endsSs`V7_@%8?Bxkqv>BDoVcj?K#dV#uUP zL1ND~?D-|VGKe3Rw_7-Idpht>H6XRLh*U7epS6byiGvJpr%d}XwfusjH9g;Z98H`x zyde%%5mhGOiL4wljCaWCk-&uE4_OOccb9c!ZaWt4B(wYl!?vyzl%7n~QepN&eFUrw zFIOl9c({``6~QD+43*_tzP{f2x41h(?b43^y6=iwyB)2os5hBE!@YUS5?N_tXd=h( z)WE286Fbd>R4M^P{!G)f;h<3Q>Fipuy+d2q-)!RyTgt;wr$(?9ox3;q+{E*ZQHhOn;lM`cjnu9 zXa48ks-v(~b*;MAI<>YZH(^NV8vjb34beE<_cwKlJoR;k6lJNSP6v}uiyRD?|0w+X@o1ONrH8a$fCxXpf? z?$DL0)7|X}Oc%h^zrMKWc-NS9I0Utu@>*j}b@tJ=ixQSJ={4@854wzW@E>VSL+Y{i z#0b=WpbCZS>kUCO_iQz)LoE>P5LIG-hv9E+oG}DtlIDF>$tJ1aw9^LuhLEHt?BCj& z(O4I8v1s#HUi5A>nIS-JK{v!7dJx)^Yg%XjNmlkWAq2*cv#tHgz`Y(bETc6CuO1VkN^L-L3j_x<4NqYb5rzrLC-7uOv z!5e`GZt%B782C5-fGnn*GhDF$%(qP<74Z}3xx+{$4cYKy2ikxI7B2N+2r07DN;|-T->nU&!=Cm#rZt%O_5c&1Z%nlWq3TKAW0w zQqemZw_ue--2uKQsx+niCUou?HjD`xhEjjQd3%rrBi82crq*~#uA4+>vR<_S{~5ce z-2EIl?~s z1=GVL{NxP1N3%=AOaC}j_Fv=ur&THz zyO!d9kHq|c73kpq`$+t+8Bw7MgeR5~`d7ChYyGCBWSteTB>8WAU(NPYt2Dk`@#+}= zI4SvLlyk#pBgVigEe`?NG*vl7V6m+<}%FwPV=~PvvA)=#ths==DRTDEYh4V5}Cf$z@#;< zyWfLY_5sP$gc3LLl2x+Ii)#b2nhNXJ{R~vk`s5U7Nyu^3yFg&D%Txwj6QezMX`V(x z=C`{76*mNb!qHHs)#GgGZ_7|vkt9izl_&PBrsu@}L`X{95-2jf99K)0=*N)VxBX2q z((vkpP2RneSIiIUEnGb?VqbMb=Zia+rF~+iqslydE34cSLJ&BJW^3knX@M;t*b=EA zNvGzv41Ld_T+WT#XjDB840vovUU^FtN_)G}7v)1lPetgpEK9YS^OWFkPoE{ovj^=@ zO9N$S=G$1ecndT_=5ehth2Lmd1II-PuT~C9`XVePw$y8J#dpZ?Tss<6wtVglm(Ok7 z3?^oi@pPio6l&!z8JY(pJvG=*pI?GIOu}e^EB6QYk$#FJQ%^AIK$I4epJ+9t?KjqA+bkj&PQ*|vLttme+`9G=L% ziadyMw_7-M)hS(3E$QGNCu|o23|%O+VN7;Qggp?PB3K-iSeBa2b}V4_wY`G1Jsfz4 z9|SdB^;|I8E8gWqHKx!vj_@SMY^hLEIbSMCuE?WKq=c2mJK z8LoG-pnY!uhqFv&L?yEuxo{dpMTsmCn)95xanqBrNPTgXP((H$9N${Ow~Is-FBg%h z53;|Y5$MUN)9W2HBe2TD`ct^LHI<(xWrw}$qSoei?}s)&w$;&!14w6B6>Yr6Y8b)S z0r71`WmAvJJ`1h&poLftLUS6Ir zC$bG9!Im_4Zjse)#K=oJM9mHW1{%l8sz$1o?ltdKlLTxWWPB>Vk22czVt|1%^wnN@*!l)}?EgtvhC>vlHm^t+ogpgHI1_$1ox9e;>0!+b(tBrmXRB`PY1vp-R**8N7 zGP|QqI$m(Rdu#=(?!(N}G9QhQ%o!aXE=aN{&wtGP8|_qh+7a_j_sU5|J^)vxq;# zjvzLn%_QPHZZIWu1&mRAj;Sa_97p_lLq_{~j!M9N^1yp3U_SxRqK&JnR%6VI#^E12 z>CdOVI^_9aPK2eZ4h&^{pQs}xsijXgFYRIxJ~N7&BB9jUR1fm!(xl)mvy|3e6-B3j zJn#ajL;bFTYJ2+Q)tDjx=3IklO@Q+FFM}6UJr6km7hj7th9n_&JR7fnqC!hTZoM~T zBeaVFp%)0cbPhejX<8pf5HyRUj2>aXnXBqDJe73~J%P(2C?-RT{c3NjE`)om! zl$uewSgWkE66$Kb34+QZZvRn`fob~Cl9=cRk@Es}KQm=?E~CE%spXaMO6YmrMl%9Q zlA3Q$3|L1QJ4?->UjT&CBd!~ru{Ih^in&JXO=|<6J!&qp zRe*OZ*cj5bHYlz!!~iEKcuE|;U4vN1rk$xq6>bUWD*u(V@8sG^7>kVuo(QL@Ki;yL zWC!FT(q{E8#on>%1iAS0HMZDJg{Z{^!De(vSIq&;1$+b)oRMwA3nc3mdTSG#3uYO_ z>+x;7p4I;uHz?ZB>dA-BKl+t-3IB!jBRgdvAbW!aJ(Q{aT>+iz?91`C-xbe)IBoND z9_Xth{6?(y3rddwY$GD65IT#f3<(0o#`di{sh2gm{dw*#-Vnc3r=4==&PU^hCv$qd zjw;>i&?L*Wq#TxG$mFIUf>eK+170KG;~+o&1;Tom9}}mKo23KwdEM6UonXgc z!6N(@k8q@HPw{O8O!lAyi{rZv|DpgfU{py+j(X_cwpKqcalcqKIr0kM^%Br3SdeD> zHSKV94Yxw;pjzDHo!Q?8^0bb%L|wC;4U^9I#pd5O&eexX+Im{ z?jKnCcsE|H?{uGMqVie_C~w7GX)kYGWAg%-?8|N_1#W-|4F)3YTDC+QSq1s!DnOML3@d`mG%o2YbYd#jww|jD$gotpa)kntakp#K;+yo-_ZF9qrNZw<%#C zuPE@#3RocLgPyiBZ+R_-FJ_$xP!RzWm|aN)S+{$LY9vvN+IW~Kf3TsEIvP+B9Mtm! zpfNNxObWQpLoaO&cJh5>%slZnHl_Q~(-Tfh!DMz(dTWld@LG1VRF`9`DYKhyNv z2pU|UZ$#_yUx_B_|MxUq^glT}O5Xt(Vm4Mr02><%C)@v;vPb@pT$*yzJ4aPc_FZ3z z3}PLoMBIM>q_9U2rl^sGhk1VUJ89=*?7|v`{!Z{6bqFMq(mYiA?%KbsI~JwuqVA9$H5vDE+VocjX+G^%bieqx->s;XWlKcuv(s%y%D5Xbc9+ zc(_2nYS1&^yL*ey664&4`IoOeDIig}y-E~_GS?m;D!xv5-xwz+G`5l6V+}CpeJDi^ z%4ed$qowm88=iYG+(`ld5Uh&>Dgs4uPHSJ^TngXP_V6fPyl~>2bhi20QB%lSd#yYn zO05?KT1z@?^-bqO8Cg`;ft>ilejsw@2%RR7;`$Vs;FmO(Yr3Fp`pHGr@P2hC%QcA|X&N2Dn zYf`MqXdHi%cGR@%y7Rg7?d3?an){s$zA{!H;Ie5exE#c~@NhQUFG8V=SQh%UxUeiV zd7#UcYqD=lk-}sEwlpu&H^T_V0{#G?lZMxL7ih_&{(g)MWBnCZxtXg znr#}>U^6!jA%e}@Gj49LWG@*&t0V>Cxc3?oO7LSG%~)Y5}f7vqUUnQ;STjdDU}P9IF9d9<$;=QaXc zL1^X7>fa^jHBu_}9}J~#-oz3Oq^JmGR#?GO7b9a(=R@fw@}Q{{@`Wy1vIQ#Bw?>@X z-_RGG@wt|%u`XUc%W{J z>iSeiz8C3H7@St3mOr_mU+&bL#Uif;+Xw-aZdNYUpdf>Rvu0i0t6k*}vwU`XNO2he z%miH|1tQ8~ZK!zmL&wa3E;l?!!XzgV#%PMVU!0xrDsNNZUWKlbiOjzH-1Uoxm8E#r`#2Sz;-o&qcqB zC-O_R{QGuynW14@)7&@yw1U}uP(1cov)twxeLus0s|7ayrtT8c#`&2~Fiu2=R;1_4bCaD=*E@cYI>7YSnt)nQc zohw5CsK%m?8Ack)qNx`W0_v$5S}nO|(V|RZKBD+btO?JXe|~^Qqur%@eO~<8-L^9d z=GA3-V14ng9L29~XJ>a5k~xT2152zLhM*@zlp2P5Eu}bywkcqR;ISbas&#T#;HZSf z2m69qTV(V@EkY(1Dk3`}j)JMo%ZVJ*5eB zYOjIisi+igK0#yW*gBGj?@I{~mUOvRFQR^pJbEbzFxTubnrw(Muk%}jI+vXmJ;{Q6 zrSobKD>T%}jV4Ub?L1+MGOD~0Ir%-`iTnWZN^~YPrcP5y3VMAzQ+&en^VzKEb$K!Q z<7Dbg&DNXuow*eD5yMr+#08nF!;%4vGrJI++5HdCFcGLfMW!KS*Oi@=7hFwDG!h2< zPunUEAF+HncQkbfFj&pbzp|MU*~60Z(|Ik%Tn{BXMN!hZOosNIseT?R;A`W?=d?5X zK(FB=9mZusYahp|K-wyb={rOpdn=@;4YI2W0EcbMKyo~-#^?h`BA9~o285%oY zfifCh5Lk$SY@|2A@a!T2V+{^!psQkx4?x0HSV`(w9{l75QxMk!)U52Lbhn{8ol?S) zCKo*7R(z!uk<6*qO=wh!Pul{(qq6g6xW;X68GI_CXp`XwO zxuSgPRAtM8K7}5E#-GM!*ydOOG_{A{)hkCII<|2=ma*71ci_-}VPARm3crFQjLYV! z9zbz82$|l01mv`$WahE2$=fAGWkd^X2kY(J7iz}WGS z@%MyBEO=A?HB9=^?nX`@nh;7;laAjs+fbo!|K^mE!tOB>$2a_O0y-*uaIn8k^6Y zSbuv;5~##*4Y~+y7Z5O*3w4qgI5V^17u*ZeupVGH^nM&$qmAk|anf*>r zWc5CV;-JY-Z@Uq1Irpb^O`L_7AGiqd*YpGUShb==os$uN3yYvb`wm6d=?T*it&pDk zo`vhw)RZX|91^^Wa_ti2zBFyWy4cJu#g)_S6~jT}CC{DJ_kKpT`$oAL%b^!2M;JgT zM3ZNbUB?}kP(*YYvXDIH8^7LUxz5oE%kMhF!rnPqv!GiY0o}NR$OD=ITDo9r%4E>E0Y^R(rS^~XjWyVI6 zMOR5rPXhTp*G*M&X#NTL`Hu*R+u*QNoiOKg4CtNPrjgH>c?Hi4MUG#I917fx**+pJfOo!zFM&*da&G_x)L(`k&TPI*t3e^{crd zX<4I$5nBQ8Ax_lmNRa~E*zS-R0sxkz`|>7q_?*e%7bxqNm3_eRG#1ae3gtV9!fQpY z+!^a38o4ZGy9!J5sylDxZTx$JmG!wg7;>&5H1)>f4dXj;B+@6tMlL=)cLl={jLMxY zbbf1ax3S4>bwB9-$;SN2?+GULu;UA-35;VY*^9Blx)Jwyb$=U!D>HhB&=jSsd^6yw zL)?a|>GxU!W}ocTC(?-%z3!IUhw^uzc`Vz_g>-tv)(XA#JK^)ZnC|l1`@CdX1@|!| z_9gQ)7uOf?cR@KDp97*>6X|;t@Y`k_N@)aH7gY27)COv^P3ya9I{4z~vUjLR9~z1Z z5=G{mVtKH*&$*t0@}-i_v|3B$AHHYale7>E+jP`ClqG%L{u;*ff_h@)al?RuL7tOO z->;I}>%WI{;vbLP3VIQ^iA$4wl6@0sDj|~112Y4OFjMs`13!$JGkp%b&E8QzJw_L5 zOnw9joc0^;O%OpF$Qp)W1HI!$4BaXX84`%@#^dk^hFp^pQ@rx4g(8Xjy#!X%+X5Jd@fs3amGT`}mhq#L97R>OwT5-m|h#yT_-v@(k$q7P*9X~T*3)LTdzP!*B} z+SldbVWrrwQo9wX*%FyK+sRXTa@O?WM^FGWOE?S`R(0P{<6p#f?0NJvnBia?k^fX2 zNQs7K-?EijgHJY}&zsr;qJ<*PCZUd*x|dD=IQPUK_nn)@X4KWtqoJNHkT?ZWL_hF? zS8lp2(q>;RXR|F;1O}EE#}gCrY~#n^O`_I&?&z5~7N;zL0)3Tup`%)oHMK-^r$NT% zbFg|o?b9w(q@)6w5V%si<$!U<#}s#x@0aX-hP>zwS#9*75VXA4K*%gUc>+yzupTDBOKH8WR4V0pM(HrfbQ&eJ79>HdCvE=F z|J>s;;iDLB^3(9}?biKbxf1$lI!*Z%*0&8UUq}wMyPs_hclyQQi4;NUY+x2qy|0J; zhn8;5)4ED1oHwg+VZF|80<4MrL97tGGXc5Sw$wAI#|2*cvQ=jB5+{AjMiDHmhUC*a zlmiZ`LAuAn_}hftXh;`Kq0zblDk8?O-`tnilIh|;3lZp@F_osJUV9`*R29M?7H{Fy z`nfVEIDIWXmU&YW;NjU8)EJpXhxe5t+scf|VXM!^bBlwNh)~7|3?fWwo_~ZFk(22% zTMesYw+LNx3J-_|DM~`v93yXe=jPD{q;li;5PD?Dyk+b? zo21|XpT@)$BM$%F=P9J19Vi&1#{jM3!^Y&fr&_`toi`XB1!n>sbL%U9I5<7!@?t)~ z;&H%z>bAaQ4f$wIzkjH70;<8tpUoxzKrPhn#IQfS%9l5=Iu))^XC<58D!-O z{B+o5R^Z21H0T9JQ5gNJnqh#qH^na|z92=hONIM~@_iuOi|F>jBh-?aA20}Qx~EpDGElELNn~|7WRXRFnw+Wdo`|# zBpU=Cz3z%cUJ0mx_1($X<40XEIYz(`noWeO+x#yb_pwj6)R(__%@_Cf>txOQ74wSJ z0#F3(zWWaR-jMEY$7C*3HJrohc79>MCUu26mfYN)f4M~4gD`}EX4e}A!U}QV8!S47 z6y-U-%+h`1n`*pQuKE%Av0@)+wBZr9mH}@vH@i{v(m-6QK7Ncf17x_D=)32`FOjjo zg|^VPf5c6-!FxN{25dvVh#fog=NNpXz zfB$o+0jbRkHH{!TKhE709f+jI^$3#v1Nmf80w`@7-5$1Iv_`)W^px8P-({xwb;D0y z7LKDAHgX<84?l!I*Dvi2#D@oAE^J|g$3!)x1Ua;_;<@#l1fD}lqU2_tS^6Ht$1Wl} zBESo7o^)9-Tjuz$8YQSGhfs{BQV6zW7dA?0b(Dbt=UnQs&4zHfe_sj{RJ4uS-vQpC zX;Bbsuju4%!o8?&m4UZU@~ZZjeFF6ex2ss5_60_JS_|iNc+R0GIjH1@Z z=rLT9%B|WWgOrR7IiIwr2=T;Ne?30M!@{%Qf8o`!>=s<2CBpCK_TWc(DX51>e^xh8 z&@$^b6CgOd7KXQV&Y4%}_#uN*mbanXq(2=Nj`L7H7*k(6F8s6{FOw@(DzU`4-*77{ zF+dxpv}%mFpYK?>N_2*#Y?oB*qEKB}VoQ@bzm>ptmVS_EC(#}Lxxx730trt0G)#$b zE=wVvtqOct1%*9}U{q<)2?{+0TzZzP0jgf9*)arV)*e!f`|jgT{7_9iS@e)recI#z zbzolURQ+TOzE!ymqvBY7+5NnAbWxvMLsLTwEbFqW=CPyCsmJ}P1^V30|D5E|p3BC5 z)3|qgw@ra7aXb-wsa|l^in~1_fm{7bS9jhVRkYVO#U{qMp z)Wce+|DJ}4<2gp8r0_xfZpMo#{Hl2MfjLcZdRB9(B(A(f;+4s*FxV{1F|4d`*sRNd zp4#@sEY|?^FIJ;tmH{@keZ$P(sLh5IdOk@k^0uB^BWr@pk6mHy$qf&~rI>P*a;h0C{%oA*i!VjWn&D~O#MxN&f@1Po# zKN+ zrGrkSjcr?^R#nGl<#Q722^wbYcgW@{+6CBS<1@%dPA8HC!~a`jTz<`g_l5N1M@9wn9GOAZ>nqNgq!yOCbZ@1z`U_N`Z>}+1HIZxk*5RDc&rd5{3qjRh8QmT$VyS;jK z;AF+r6XnnCp=wQYoG|rT2@8&IvKq*IB_WvS%nt%e{MCFm`&W*#LXc|HrD?nVBo=(8*=Aq?u$sDA_sC_RPDUiQ+wnIJET8vx$&fxkW~kP9qXKt zozR)@xGC!P)CTkjeWvXW5&@2?)qt)jiYWWBU?AUtzAN}{JE1I)dfz~7$;}~BmQF`k zpn11qmObXwRB8&rnEG*#4Xax3XBkKlw(;tb?Np^i+H8m(Wyz9k{~ogba@laiEk;2! zV*QV^6g6(QG%vX5Um#^sT&_e`B1pBW5yVth~xUs#0}nv?~C#l?W+9Lsb_5)!71rirGvY zTIJ$OPOY516Y|_014sNv+Z8cc5t_V=i>lWV=vNu#!58y9Zl&GsMEW#pPYPYGHQ|;vFvd*9eM==$_=vc7xnyz0~ zY}r??$<`wAO?JQk@?RGvkWVJlq2dk9vB(yV^vm{=NVI8dhsX<)O(#nr9YD?I?(VmQ z^r7VfUBn<~p3()8yOBjm$#KWx!5hRW)5Jl7wY@ky9lNM^jaT##8QGVsYeaVywmpv>X|Xj7gWE1Ezai&wVLt3p)k4w~yrskT-!PR!kiyQlaxl(( zXhF%Q9x}1TMt3~u@|#wWm-Vq?ZerK={8@~&@9r5JW}r#45#rWii};t`{5#&3$W)|@ zbAf2yDNe0q}NEUvq_Quq3cTjcw z@H_;$hu&xllCI9CFDLuScEMg|x{S7GdV8<&Mq=ezDnRZAyX-8gv97YTm0bg=d)(>N z+B2FcqvI9>jGtnK%eO%y zoBPkJTk%y`8TLf4)IXPBn`U|9>O~WL2C~C$z~9|0m*YH<-vg2CD^SX#&)B4ngOSG$ zV^wmy_iQk>dfN@Pv(ckfy&#ak@MLC7&Q6Ro#!ezM*VEh`+b3Jt%m(^T&p&WJ2Oqvj zs-4nq0TW6cv~(YI$n0UkfwN}kg3_fp?(ijSV#tR9L0}l2qjc7W?i*q01=St0eZ=4h zyGQbEw`9OEH>NMuIe)hVwYHsGERWOD;JxEiO7cQv%pFCeR+IyhwQ|y@&^24k+|8fD zLiOWFNJ2&vu2&`Jv96_z-Cd5RLgmeY3*4rDOQo?Jm`;I_(+ejsPM03!ly!*Cu}Cco zrQSrEDHNyzT(D5s1rZq!8#?f6@v6dB7a-aWs(Qk>N?UGAo{gytlh$%_IhyL7h?DLXDGx zgxGEBQoCAWo-$LRvM=F5MTle`M})t3vVv;2j0HZY&G z22^iGhV@uaJh(XyyY%} zd4iH_UfdV#T=3n}(Lj^|n;O4|$;xhu*8T3hR1mc_A}fK}jfZ7LX~*n5+`8N2q#rI$ z@<_2VANlYF$vIH$ zl<)+*tIWW78IIINA7Rr7i{<;#^yzxoLNkXL)eSs=%|P>$YQIh+ea_3k z_s7r4%j7%&*NHSl?R4k%1>Z=M9o#zxY!n8sL5>BO-ZP;T3Gut>iLS@U%IBrX6BA3k z)&@q}V8a{X<5B}K5s(c(LQ=%v1ocr`t$EqqY0EqVjr65usa=0bkf|O#ky{j3)WBR(((L^wmyHRzoWuL2~WTC=`yZ zn%VX`L=|Ok0v7?s>IHg?yArBcync5rG#^+u)>a%qjES%dRZoIyA8gQ;StH z1Ao7{<&}6U=5}4v<)1T7t!J_CL%U}CKNs-0xWoTTeqj{5{?Be$L0_tk>M9o8 zo371}S#30rKZFM{`H_(L`EM9DGp+Mifk&IP|C2Zu_)Ghr4Qtpmkm1osCf@%Z$%t+7 zYH$Cr)Ro@3-QDeQJ8m+x6%;?YYT;k6Z0E-?kr>x33`H%*ueBD7Zx~3&HtWn0?2Wt} zTG}*|v?{$ajzt}xPzV%lL1t-URi8*Zn)YljXNGDb>;!905Td|mpa@mHjIH%VIiGx- zd@MqhpYFu4_?y5N4xiHn3vX&|e6r~Xt> zZG`aGq|yTNjv;9E+Txuoa@A(9V7g?1_T5FzRI;!=NP1Kqou1z5?%X~Wwb{trRfd>i z8&y^H)8YnKyA_Fyx>}RNmQIczT?w2J4SNvI{5J&}Wto|8FR(W;Qw#b1G<1%#tmYzQ zQ2mZA-PAdi%RQOhkHy9Ea#TPSw?WxwL@H@cbkZwIq0B!@ns}niALidmn&W?!Vd4Gj zO7FiuV4*6Mr^2xlFSvM;Cp_#r8UaqIzHJQg_z^rEJw&OMm_8NGAY2)rKvki|o1bH~ z$2IbfVeY2L(^*rMRU1lM5Y_sgrDS`Z??nR2lX;zyR=c%UyGb*%TC-Dil?SihkjrQy~TMv6;BMs7P8il`H7DmpVm@rJ;b)hW)BL)GjS154b*xq-NXq2cwE z^;VP7ua2pxvCmxrnqUYQMH%a%nHmwmI33nJM(>4LznvY*k&C0{8f*%?zggpDgkuz&JBx{9mfb@wegEl2v!=}Sq2Gaty0<)UrOT0{MZtZ~j5y&w zXlYa_jY)I_+VA-^#mEox#+G>UgvM!Ac8zI<%JRXM_73Q!#i3O|)lOP*qBeJG#BST0 zqohi)O!|$|2SeJQo(w6w7%*92S})XfnhrH_Z8qe!G5>CglP=nI7JAOW?(Z29;pXJ9 zR9`KzQ=WEhy*)WH>$;7Cdz|>*i>=##0bB)oU0OR>>N<21e4rMCHDemNi2LD>Nc$;& zQRFthpWniC1J6@Zh~iJCoLOxN`oCKD5Q4r%ynwgUKPlIEd#?QViIqovY|czyK8>6B zSP%{2-<;%;1`#0mG^B(8KbtXF;Nf>K#Di72UWE4gQ%(_26Koiad)q$xRL~?pN71ZZ zujaaCx~jXjygw;rI!WB=xrOJO6HJ!!w}7eiivtCg5K|F6$EXa)=xUC za^JXSX98W`7g-tm@uo|BKj39Dl;sg5ta;4qjo^pCh~{-HdLl6qI9Ix6f$+qiZ$}s= zNguKrU;u+T@ko(Vr1>)Q%h$?UKXCY>3se%&;h2osl2D zE4A9bd7_|^njDd)6cI*FupHpE3){4NQ*$k*cOWZ_?CZ>Z4_fl@n(mMnYK62Q1d@+I zr&O))G4hMihgBqRIAJkLdk(p(D~X{-oBUA+If@B}j& zsHbeJ3RzTq96lB7d($h$xTeZ^gP0c{t!Y0c)aQE;$FY2!mACg!GDEMKXFOPI^)nHZ z`aSPJpvV0|bbrzhWWkuPURlDeN%VT8tndV8?d)eN*i4I@u zVKl^6{?}A?P)Fsy?3oi#clf}L18t;TjNI2>eI&(ezDK7RyqFxcv%>?oxUlonv(px) z$vnPzRH`y5A(x!yOIfL0bmgeMQB$H5wenx~!ujQK*nUBW;@Em&6Xv2%s(~H5WcU2R z;%Nw<$tI)a`Ve!>x+qegJnQsN2N7HaKzrFqM>`6R*gvh%O*-%THt zrB$Nk;lE;z{s{r^PPm5qz(&lM{sO*g+W{sK+m3M_z=4=&CC>T`{X}1Vg2PEfSj2x_ zmT*(x;ov%3F?qoEeeM>dUn$a*?SIGyO8m806J1W1o+4HRhc2`9$s6hM#qAm zChQ87b~GEw{ADfs+5}FJ8+|bIlIv(jT$Ap#hSHoXdd9#w<#cA<1Rkq^*EEkknUd4& zoIWIY)sAswy6fSERVm&!SO~#iN$OgOX*{9@_BWFyJTvC%S++ilSfCrO(?u=Dc?CXZ zzCG&0yVR{Z`|ZF0eEApWEo#s9osV>F{uK{QA@BES#&;#KsScf>y zvs?vIbI>VrT<*!;XmQS=bhq%46-aambZ(8KU-wOO2=en~D}MCToB_u;Yz{)1ySrPZ z@=$}EvjTdzTWU7c0ZI6L8=yP+YRD_eMMos}b5vY^S*~VZysrkq<`cK3>>v%uy7jgq z0ilW9KjVDHLv0b<1K_`1IkbTOINs0=m-22c%M~l=^S}%hbli-3?BnNq?b`hx^HX2J zIe6ECljRL0uBWb`%{EA=%!i^4sMcj+U_TaTZRb+~GOk z^ZW!nky0n*Wb*r+Q|9H@ml@Z5gU&W`(z4-j!OzC1wOke`TRAYGZVl$PmQ16{3196( zO*?`--I}Qf(2HIwb2&1FB^!faPA2=sLg(@6P4mN)>Dc3i(B0;@O-y2;lM4akD>@^v z=u>*|!s&9zem70g7zfw9FXl1bpJW(C#5w#uy5!V?Q(U35A~$dR%LDVnq@}kQm13{} zd53q3N(s$Eu{R}k2esbftfjfOITCL;jWa$}(mmm}d(&7JZ6d3%IABCapFFYjdEjdK z&4Edqf$G^MNAtL=uCDRs&Fu@FXRgX{*0<(@c3|PNHa>L%zvxWS={L8%qw`STm+=Rd zA}FLspESSIpE_^41~#5yI2bJ=9`oc;GIL!JuW&7YetZ?0H}$$%8rW@*J37L-~Rsx!)8($nI4 zZhcZ2^=Y+p4YPl%j!nFJA|*M^gc(0o$i3nlphe+~-_m}jVkRN{spFs(o0ajW@f3K{ zDV!#BwL322CET$}Y}^0ixYj2w>&Xh12|R8&yEw|wLDvF!lZ#dOTHM9pK6@Nm-@9Lnng4ZHBgBSrr7KI8YCC9DX5Kg|`HsiwJHg2(7#nS;A{b3tVO?Z% za{m5b3rFV6EpX;=;n#wltDv1LE*|g5pQ+OY&*6qCJZc5oDS6Z6JD#6F)bWxZSF@q% z+1WV;m!lRB!n^PC>RgQCI#D1br_o^#iPk>;K2hB~0^<~)?p}LG%kigm@moD#q3PE+ zA^Qca)(xnqw6x>XFhV6ku9r$E>bWNrVH9fum0?4s?Rn2LG{Vm_+QJHse6xa%nzQ?k zKug4PW~#Gtb;#5+9!QBgyB@q=sk9=$S{4T>wjFICStOM?__fr+Kei1 z3j~xPqW;W@YkiUM;HngG!;>@AITg}vAE`M2Pj9Irl4w1fo4w<|Bu!%rh%a(Ai^Zhi zs92>v5;@Y(Zi#RI*ua*h`d_7;byQSa*v9E{2x$<-_=5Z<7{%)}4XExANcz@rK69T0x3%H<@frW>RA8^swA+^a(FxK| zFl3LD*ImHN=XDUkrRhp6RY5$rQ{bRgSO*(vEHYV)3Mo6Jy3puiLmU&g82p{qr0F?ohmbz)f2r{X2|T2 z$4fdQ=>0BeKbiVM!e-lIIs8wVTuC_m7}y4A_%ikI;Wm5$9j(^Y z(cD%U%k)X>_>9~t8;pGzL6L-fmQO@K; zo&vQzMlgY95;1BSkngY)e{`n0!NfVgf}2mB3t}D9@*N;FQ{HZ3Pb%BK6;5#-O|WI( zb6h@qTLU~AbVW#_6?c!?Dj65Now7*pU{h!1+eCV^KCuPAGs28~3k@ueL5+u|Z-7}t z9|lskE`4B7W8wMs@xJa{#bsCGDFoRSNSnmNYB&U7 zVGKWe%+kFB6kb)e;TyHfqtU6~fRg)f|>=5(N36)0+C z`hv65J<$B}WUc!wFAb^QtY31yNleq4dzmG`1wHTj=c*=hay9iD071Hc?oYoUk|M*_ zU1GihAMBsM@5rUJ(qS?9ZYJ6@{bNqJ`2Mr+5#hKf?doa?F|+^IR!8lq9)wS3tF_9n zW_?hm)G(M+MYb?V9YoX^_mu5h-LP^TL^!Q9Z7|@sO(rg_4+@=PdI)WL(B7`!K^ND- z-uIuVDCVEdH_C@c71YGYT^_Scf_dhB8Z2Xy6vGtBSlYud9vggOqv^L~F{BraSE_t} zIkP+Hp2&nH^-MNEs}^`oMLy11`PQW$T|K(`Bu*(f@)mv1-qY(_YG&J2M2<7k;;RK~ zL{Fqj9yCz8(S{}@c)S!65aF<=&eLI{hAMErCx&>i7OeDN>okvegO87OaG{Jmi<|}D zaT@b|0X{d@OIJ7zvT>r+eTzgLq~|Dpu)Z&db-P4z*`M$UL51lf>FLlq6rfG)%doyp z)3kk_YIM!03eQ8Vu_2fg{+osaEJPtJ-s36R+5_AEG12`NG)IQ#TF9c@$99%0iye+ zUzZ57=m2)$D(5Nx!n)=5Au&O0BBgwxIBaeI(mro$#&UGCr<;C{UjJVAbVi%|+WP(a zL$U@TYCxJ=1{Z~}rnW;7UVb7+ZnzgmrogDxhjLGo>c~MiJAWs&&;AGg@%U?Y^0JhL ze(x6Z74JG6FlOFK(T}SXQfhr}RIFl@QXKnIcXYF)5|V~e-}suHILKT-k|<*~Ij|VF zC;t@=uj=hot~*!C68G8hTA%8SzOfETOXQ|3FSaIEjvBJp(A)7SWUi5!Eu#yWgY+;n zlm<$+UDou*V+246_o#V4kMdto8hF%%Lki#zPh}KYXmMf?hrN0;>Mv%`@{0Qn`Ujp) z=lZe+13>^Q!9zT);H<(#bIeRWz%#*}sgUX9P|9($kexOyKIOc`dLux}c$7It4u|Rl z6SSkY*V~g_B-hMPo_ak>>z@AVQ(_N)VY2kB3IZ0G(iDUYw+2d7W^~(Jq}KY=JnWS( z#rzEa&0uNhJ>QE8iiyz;n2H|SV#Og+wEZv=f2%1ELX!SX-(d3tEj$5$1}70Mp<&eI zCkfbByL7af=qQE@5vDVxx1}FSGt_a1DoE3SDI+G)mBAna)KBG4p8Epxl9QZ4BfdAN zFnF|Y(umr;gRgG6NLQ$?ZWgllEeeq~z^ZS7L?<(~O&$5|y)Al^iMKy}&W+eMm1W z7EMU)u^ke(A1#XCV>CZ71}P}0x)4wtHO8#JRG3MA-6g=`ZM!FcICCZ{IEw8Dm2&LQ z1|r)BUG^0GzI6f946RrBlfB1Vs)~8toZf~7)+G;pv&XiUO(%5bm)pl=p>nV^o*;&T z;}@oZSibzto$arQgfkp|z4Z($P>dTXE{4O=vY0!)kDO* zGF8a4wq#VaFpLfK!iELy@?-SeRrdz%F*}hjKcA*y@mj~VD3!it9lhRhX}5YOaR9$} z3mS%$2Be7{l(+MVx3 z(4?h;P!jnRmX9J9sYN#7i=iyj_5q7n#X(!cdqI2lnr8T$IfOW<_v`eB!d9xY1P=2q&WtOXY=D9QYteP)De?S4}FK6#6Ma z=E*V+#s8>L;8aVroK^6iKo=MH{4yEZ_>N-N z`(|;aOATba1^asjxlILk<4}f~`39dBFlxj>Dw(hMYKPO3EEt1@S`1lxFNM+J@uB7T zZ8WKjz7HF1-5&2=l=fqF-*@>n5J}jIxdDwpT?oKM3s8Nr`x8JnN-kCE?~aM1H!hAE z%%w(3kHfGwMnMmNj(SU(w42OrC-euI>Dsjk&jz3ts}WHqmMpzQ3vZrsXrZ|}+MHA7 z068obeXZTsO*6RS@o3x80E4ok``rV^Y3hr&C1;|ZZ0|*EKO`$lECUYG2gVFtUTw)R z4Um<0ZzlON`zTdvVdL#KFoMFQX*a5wM0Czp%wTtfK4Sjs)P**RW&?lP$(<}q%r68Z zS53Y!d@&~ne9O)A^tNrXHhXBkj~$8j%pT1%%mypa9AW5E&s9)rjF4@O3ytH{0z6riz|@< zB~UPh*wRFg2^7EbQrHf0y?E~dHlkOxof_a?M{LqQ^C!i2dawHTPYUE=X@2(3<=OOxs8qn_(y>pU>u^}3y&df{JarR0@VJn0f+U%UiF=$Wyq zQvnVHESil@d|8&R<%}uidGh7@u^(%?$#|&J$pvFC-n8&A>utA=n3#)yMkz+qnG3wd zP7xCnF|$9Dif@N~L)Vde3hW8W!UY0BgT2v(wzp;tlLmyk2%N|0jfG$%<;A&IVrOI< z!L)o>j>;dFaqA3pL}b-Je(bB@VJ4%!JeX@3x!i{yIeIso^=n?fDX`3bU=eG7sTc%g%ye8$v8P@yKE^XD=NYxTb zbf!Mk=h|otpqjFaA-vs5YOF-*GwWPc7VbaOW&stlANnCN8iftFMMrUdYNJ_Bnn5Vt zxfz@Ah|+4&P;reZxp;MmEI7C|FOv8NKUm8njF7Wb6Gi7DeODLl&G~}G4be&*Hi0Qw z5}77vL0P+7-B%UL@3n1&JPxW^d@vVwp?u#gVcJqY9#@-3X{ok#UfW3<1fb%FT`|)V~ggq z(3AUoUS-;7)^hCjdT0Kf{i}h)mBg4qhtHHBti=~h^n^OTH5U*XMgDLIR@sre`AaB$ zg)IGBET_4??m@cx&c~bA80O7B8CHR7(LX7%HThkeC*@vi{-pL%e)yXp!B2InafbDF zjPXf1mko3h59{lT6EEbxKO1Z5GF71)WwowO6kY|6tjSVSWdQ}NsK2x{>i|MKZK8%Q zfu&_0D;CO-Jg0#YmyfctyJ!mRJp)e#@O0mYdp|8x;G1%OZQ3Q847YWTyy|%^cpA;m zze0(5p{tMu^lDkpe?HynyO?a1$_LJl2L&mpeKu%8YvgRNr=%2z${%WThHG=vrWY@4 zsA`OP#O&)TetZ>s%h!=+CE15lOOls&nvC~$Qz0Ph7tHiP;O$i|eDwpT{cp>+)0-|; zY$|bB+Gbel>5aRN3>c0x)4U=|X+z+{ zn*_p*EQoquRL+=+p;=lm`d71&1NqBz&_ph)MXu(Nv6&XE7(RsS)^MGj5Q?Fwude-(sq zjJ>aOq!7!EN>@(fK7EE#;i_BGvli`5U;r!YA{JRodLBc6-`n8K+Fjgwb%sX;j=qHQ z7&Tr!)!{HXoO<2BQrV9Sw?JRaLXV8HrsNevvnf>Y-6|{T!pYLl7jp$-nEE z#X!4G4L#K0qG_4Z;Cj6=;b|Be$hi4JvMH!-voxqx^@8cXp`B??eFBz2lLD8RRaRGh zn7kUfy!YV~p(R|p7iC1Rdgt$_24i0cd-S8HpG|`@my70g^y`gu%#Tf_L21-k?sRRZHK&at(*ED0P8iw{7?R$9~OF$Ko;Iu5)ur5<->x!m93Eb zFYpIx60s=Wxxw=`$aS-O&dCO_9?b1yKiPCQmSQb>T)963`*U+Ydj5kI(B(B?HNP8r z*bfSBpSu)w(Z3j7HQoRjUG(+d=IaE~tv}y14zHHs|0UcN52fT8V_<@2ep_ee{QgZG zmgp8iv4V{k;~8@I%M3<#B;2R>Ef(Gg_cQM7%}0s*^)SK6!Ym+~P^58*wnwV1BW@eG z4sZLqsUvBbFsr#8u7S1r4teQ;t)Y@jnn_m5jS$CsW1um!p&PqAcc8!zyiXHVta9QC zY~wCwCF0U%xiQPD_INKtTb;A|Zf29(mu9NI;E zc-e>*1%(LSXB`g}kd`#}O;veb<(sk~RWL|f3ljxCnEZDdNSTDV6#Td({6l&y4IjKF z^}lIUq*ZUqgTPumD)RrCN{M^jhY>E~1pn|KOZ5((%F)G|*ZQ|r4zIbrEiV%42hJV8 z3xS)=!X1+=olbdGJ=yZil?oXLct8FM{(6ikLL3E%=q#O6(H$p~gQu6T8N!plf!96| z&Q3=`L~>U0zZh;z(pGR2^S^{#PrPxTRHD1RQOON&f)Siaf`GLj#UOk&(|@0?zm;Sx ztsGt8=29-MZs5CSf1l1jNFtNt5rFNZxJPvkNu~2}7*9468TWm>nN9TP&^!;J{-h)_ z7WsHH9|F%I`Pb!>KAS3jQWKfGivTVkMJLO-HUGM_a4UQ_%RgL6WZvrW+Z4ujZn;y@ zz9$=oO!7qVTaQAA^BhX&ZxS*|5dj803M=k&2%QrXda`-Q#IoZL6E(g+tN!6CA!CP* zCpWtCujIea)ENl0liwVfj)Nc<9mV%+e@=d`haoZ*`B7+PNjEbXBkv=B+Pi^~L#EO$D$ZqTiD8f<5$eyb54-(=3 zh)6i8i|jp(@OnRrY5B8t|LFXFQVQ895n*P16cEKTrT*~yLH6Z4e*bZ5otpRDri&+A zfNbK1D5@O=sm`fN=WzWyse!za5n%^+6dHPGX#8DyIK>?9qyX}2XvBWVqbP%%D)7$= z=#$WulZlZR<{m#gU7lwqK4WS1Ne$#_P{b17qe$~UOXCl>5b|6WVh;5vVnR<%d+Lnp z$uEmML38}U4vaW8>shm6CzB(Wei3s#NAWE3)a2)z@i{4jTn;;aQS)O@l{rUM`J@K& l00vQ5JBs~;vo!vr%%-k{2_Fq1Mn4QF81S)AQ99zk{{c4yR+0b! literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..1af9e0930 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 000000000..1aa94a426 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..93e3f59f1 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..ded5be7e3 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'pennyway' diff --git a/src/main/java/kr/co/pennyway/PennywayApplication.java b/src/main/java/kr/co/pennyway/PennywayApplication.java new file mode 100644 index 000000000..14740355f --- /dev/null +++ b/src/main/java/kr/co/pennyway/PennywayApplication.java @@ -0,0 +1,13 @@ +package kr.co.pennyway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PennywayApplication { + + public static void main(String[] args) { + SpringApplication.run(PennywayApplication.class, args); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/src/test/java/kr/co/pennyway/PennywayApplicationTests.java b/src/test/java/kr/co/pennyway/PennywayApplicationTests.java new file mode 100644 index 000000000..80d7f4b35 --- /dev/null +++ b/src/test/java/kr/co/pennyway/PennywayApplicationTests.java @@ -0,0 +1,13 @@ +package kr.co.pennyway; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class PennywayApplicationTests { + + @Test + void contextLoads() { + } + +} From f69d7255e3a37bf0b128457c69ec794c410291bd Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 6 Mar 2024 18:04:18 +0900 Subject: [PATCH 002/152] =?UTF-8?q?feat:=20=EB=A9=80=ED=8B=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 47 ++++++++++++++---- gradle/wrapper/gradle-wrapper.jar | Bin 43462 -> 61624 bytes gradle/wrapper/gradle-wrapper.properties | 3 +- gradlew | 29 +++++------ pennyway-app-external-api/.gitignore | 42 ++++++++++++++++ pennyway-app-external-api/build.gradle | 23 +++++++++ .../PennywayExternalApiApplication.java | 19 +++++++ .../src/main/resources/application.yml | 0 .../src/test/resources/application.yml | 0 pennyway-common/.gitignore | 42 ++++++++++++++++ pennyway-common/build.gradle | 6 +++ .../src/main/resources/application-common.yml | 0 pennyway-domain/.gitignore | 42 ++++++++++++++++ pennyway-domain/build.gradle | 7 +++ .../src/main/resources/application-domain.yml | 0 pennyway-infra/.gitignore | 42 ++++++++++++++++ pennyway-infra/build.gradle | 6 +++ .../src/test/resources/application-infra.yml | 0 settings.gradle | 6 +++ .../kr/co/pennyway/PennywayApplication.java | 13 ----- src/main/resources/application.properties | 1 - .../co/pennyway/PennywayApplicationTests.java | 13 ----- 22 files changed, 284 insertions(+), 57 deletions(-) create mode 100644 pennyway-app-external-api/.gitignore create mode 100644 pennyway-app-external-api/build.gradle create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/PennywayExternalApiApplication.java create mode 100644 pennyway-app-external-api/src/main/resources/application.yml create mode 100644 pennyway-app-external-api/src/test/resources/application.yml create mode 100644 pennyway-common/.gitignore create mode 100644 pennyway-common/build.gradle create mode 100644 pennyway-common/src/main/resources/application-common.yml create mode 100644 pennyway-domain/.gitignore create mode 100644 pennyway-domain/build.gradle create mode 100644 pennyway-domain/src/main/resources/application-domain.yml create mode 100644 pennyway-infra/.gitignore create mode 100644 pennyway-infra/build.gradle create mode 100644 pennyway-infra/src/test/resources/application-infra.yml delete mode 100644 src/main/java/kr/co/pennyway/PennywayApplication.java delete mode 100644 src/main/resources/application.properties delete mode 100644 src/test/java/kr/co/pennyway/PennywayApplicationTests.java diff --git a/build.gradle b/build.gradle index 75747408a..39cad6583 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,9 @@ +buildscript { + repositories { + mavenCentral() + } +} + plugins { id 'java' id 'org.springframework.boot' version '3.2.3' @@ -7,19 +13,38 @@ plugins { group = 'kr.co' version = '0.0.1-SNAPSHOT' -java { +bootJar {enabled = false} +jar {enabled = true} + +allprojects { + group = 'kr.co' + version = '0.0.1-SNAPSHOT' sourceCompatibility = '17' } -repositories { - mavenCentral() -} +subprojects { + apply plugin: "java" + apply plugin: 'java-library' + apply plugin: "io.spring.dependency-management" + apply plugin: "org.springframework.boot" -dependencies { - implementation 'org.springframework.boot:spring-boot-starter' - testImplementation 'org.springframework.boot:spring-boot-starter-test' -} + repositories { + mavenCentral() + } -tasks.named('test') { - useJUnitPlatform() -} + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } + + dependencies { + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d64cd4917707c1f8861d8cb53dd15194d4248596..afba109285af78dbd2a1d187e33ac4f87c76e392 100644 GIT binary patch literal 61624 zcmb6AV{~QRwml9f72CFLyJFk6ZKq;e729@pY}>YNR8p1vbMJH7ubt# zZR`2@zJD1Ad^Oa6Hk1{VlN1wGR-u;_dyt)+kddaNpM#U8qn@6eX;fldWZ6BspQIa= zoRXcQk)#ENJ`XiXJuK3q0$`Ap92QXrW00Yv7NOrc-8ljOOOIcj{J&cR{W`aIGXJ-` z`ez%Mf7qBi8JgIb{-35Oe>Zh^GIVe-b^5nULQhxRDZa)^4+98@`hUJe{J%R>|LYHA z4K3~Hjcp8_owGF{d~lZVKJ;kc48^OQ+`_2migWY?JqgW&))70RgSB6KY9+&wm<*8 z_{<;(c;5H|u}3{Y>y_<0Z59a)MIGK7wRMX0Nvo>feeJs+U?bt-++E8bu7 zh#_cwz0(4#RaT@xy14c7d<92q-Dd}Dt<*RS+$r0a^=LGCM{ny?rMFjhgxIG4>Hc~r zC$L?-FW0FZ((8@dsowXlQq}ja%DM{z&0kia*w7B*PQ`gLvPGS7M}$T&EPl8mew3In z0U$u}+bk?Vei{E$6dAYI8Tsze6A5wah?d(+fyP_5t4ytRXNktK&*JB!hRl07G62m_ zAt1nj(37{1p~L|m(Bsz3vE*usD`78QTgYIk zQ6BF14KLzsJTCqx&E!h>XP4)bya|{*G7&T$^hR0(bOWjUs2p0uw7xEjbz1FNSBCDb@^NIA z$qaq^0it^(#pFEmuGVS4&-r4(7HLmtT%_~Xhr-k8yp0`$N|y>#$Ao#zibzGi*UKzi zhaV#@e1{2@1Vn2iq}4J{1-ox;7K(-;Sk{3G2_EtV-D<)^Pk-G<6-vP{W}Yd>GLL zuOVrmN@KlD4f5sVMTs7c{ATcIGrv4@2umVI$r!xI8a?GN(R;?32n0NS(g@B8S00-=zzLn z%^Agl9eV(q&8UrK^~&$}{S(6-nEXnI8%|hoQ47P?I0Kd=woZ-pH==;jEg+QOfMSq~ zOu>&DkHsc{?o&M5`jyJBWbfoPBv9Y#70qvoHbZXOj*qRM(CQV=uX5KN+b>SQf-~a8 ziZg}@&XHHXkAUqr)Q{y`jNd7`1F8nm6}n}+_She>KO`VNlnu(&??!(i#$mKOpWpi1 z#WfWxi3L)bNRodhPM~~?!5{TrrBY_+nD?CIUupkwAPGz-P;QYc-DcUoCe`w(7)}|S zRvN)9ru8b)MoullmASwsgKQo1U6nsVAvo8iKnbaWydto4y?#-|kP^%e6m@L`88KyDrLH`=EDx*6>?r5~7Iv~I zr__%SximG(izLKSnbTlXa-ksH@R6rvBrBavt4)>o3$dgztLt4W=!3=O(*w7I+pHY2(P0QbTma+g#dXoD7N#?FaXNQ^I0*;jzvjM}%=+km`YtC%O#Alm| zqgORKSqk!#^~6whtLQASqiJ7*nq?38OJ3$u=Tp%Y`x^eYJtOqTzVkJ60b2t>TzdQ{I}!lEBxm}JSy7sy8DpDb zIqdT%PKf&Zy--T^c-;%mbDCxLrMWTVLW}c=DP2>Td74)-mLl|70)8hU??(2)I@Zyo z2i`q5oyA!!(2xV~gahuKl&L(@_3SP012#x(7P!1}6vNFFK5f*A1xF({JwxSFwA|TM z&1z}!*mZKcUA-v4QzLz&5wS$7=5{M@RAlx@RkJaA4nWVqsuuaW(eDh^LNPPkmM~Al zwxCe@*-^4!ky#iNv2NIIU$CS+UW%ziW0q@6HN3{eCYOUe;2P)C*M`Bt{~-mC%T3%# zEaf)lATO1;uF33x>Hr~YD0Ju*Syi!Jz+x3myVvU^-O>C*lFCKS&=Tuz@>&o?68aF& zBv<^ziPywPu#;WSlTkzdZ9`GWe7D8h<1-v0M*R@oYgS5jlPbgHcx)n2*+!+VcGlYh?;9Ngkg% z=MPD+`pXryN1T|%I7c?ZPLb3bqWr7 zU4bfG1y+?!bw)5Iq#8IqWN@G=Ru%Thxf)#=yL>^wZXSCC8we@>$hu=yrU;2=7>h;5 zvj_pYgKg2lKvNggl1ALnsz2IlcvL;q79buN5T3IhXuJvy@^crqWpB-5NOm{7UVfxmPJ>`?;Tn@qHzF+W!5W{8Z&ZAnDOquw6r4$bv*jM#5lc%3v|c~^ zdqo4LuxzkKhK4Q+JTK8tR_|i6O(x#N2N0Fy5)!_trK&cn9odQu#Vlh1K~7q|rE z61#!ZPZ+G&Y7hqmY;`{XeDbQexC2@oFWY)Nzg@lL3GeEVRxWQlx@0?Zt`PcP0iq@6 zLgc)p&s$;*K_;q0L(mQ8mKqOJSrq$aQYO-Hbssf3P=wC6CvTVHudzJH-Jgm&foBSy zx0=qu$w477lIHk);XhaUR!R-tQOZ;tjLXFH6;%0)8^IAc*MO>Q;J={We(0OHaogG0 zE_C@bXic&m?F7slFAB~x|n#>a^@u8lu;=!sqE*?vq zu4`(x!Jb4F#&3+jQ|ygldPjyYn#uCjNWR)%M3(L!?3C`miKT;~iv_)dll>Q6b+I&c zrlB04k&>mSYLR7-k{Od+lARt~3}Bv!LWY4>igJl!L5@;V21H6dNHIGr+qV551e@yL z`*SdKGPE^yF?FJ|`#L)RQ?LJ;8+={+|Cl<$*ZF@j^?$H%V;jqVqt#2B0yVr}Nry5R z5D?S9n+qB_yEqvdy9nFc+8WxK$XME$3ftSceLb+L(_id5MMc*hSrC;E1SaZYow%jh zPgo#1PKjE+1QB`Of|aNmX?}3TP;y6~0iN}TKi3b+yvGk;)X&i3mTnf9M zuv3qvhErosfZ%Pb-Q>|BEm5(j-RV6Zf^$icM=sC-5^6MnAvcE9xzH@FwnDeG0YU{J zi~Fq?=bi0;Ir=hfOJu8PxC)qjYW~cv^+74Hs#GmU%Cw6?3LUUHh|Yab`spoqh8F@_ zm4bCyiXPx-Cp4!JpI~w!ShPfJOXsy>f*|$@P8L8(oeh#~w z-2a4IOeckn6}_TQ+rgl_gLArS3|Ml(i<`*Lqv6rWh$(Z5ycTYD#Z*&-5mpa}a_zHt z6E`Ty-^L9RK-M*mN5AasoBhc|XWZ7=YRQSvG)3$v zgr&U_X`Ny0)IOZtX}e$wNUzTpD%iF7Rgf?nWoG2J@PsS-qK4OD!kJ?UfO+1|F*|Bo z1KU`qDA^;$0*4mUJ#{EPOm7)t#EdX=Yx1R2T&xlzzThfRC7eq@pX&%MO&2AZVO%zw zS;A{HtJiL=rfXDigS=NcWL-s>Rbv|=)7eDoOVnVI>DI_8x>{E>msC$kXsS}z?R6*x zi(yO`$WN)_F1$=18cbA^5|f`pZA+9DG_Zu8uW?rA9IxUXx^QCAp3Gk1MSdq zBZv;_$W>*-zLL)F>Vn`}ti1k!%6{Q=g!g1J*`KONL#)M{ZC*%QzsNRaL|uJcGB7jD zTbUe%T(_x`UtlM!Ntp&-qu!v|mPZGcJw$mdnanY3Uo>5{oiFOjDr!ZznKz}iWT#x& z?*#;H$`M0VC|a~1u_<(}WD>ogx(EvF6A6S8l0%9U<( zH||OBbh8Tnzz*#bV8&$d#AZNF$xF9F2{_B`^(zWNC}af(V~J+EZAbeC2%hjKz3V1C zj#%d%Gf(uyQ@0Y6CcP^CWkq`n+YR^W0`_qkDw333O<0FoO9()vP^!tZ{`0zsNQx~E zb&BcBU>GTP2svE2Tmd;~73mj!_*V8uL?ZLbx}{^l9+yvR5fas+w&0EpA?_g?i9@A$j*?LnmctPDQG|zJ`=EF}Vx8aMD^LrtMvpNIR*|RHA`ctK*sbG= zjN7Q)(|dGpC}$+nt~bupuKSyaiU}Ws{?Tha@$q}cJ;tvH>+MuPih+B4d$Zbq9$Y*U z)iA(-dK?Ov@uCDq48Zm%%t5uw1GrnxDm7*ITGCEF!2UjA`BqPRiUR`yNq^zz|A3wU zG(8DAnY-GW+PR2&7@In{Sla(XnMz5Rk^*5u4UvCiDQs@hvZXoiziv{6*i?fihVI|( zPrY8SOcOIh9-AzyJ*wF4hq%ojB&Abrf;4kX@^-p$mmhr}xxn#fVU?ydmD=21&S)s*v*^3E96(K1}J$6bi8pyUr-IU)p zcwa$&EAF$0Aj?4OYPcOwb-#qB=kCEDIV8%^0oa567_u6`9+XRhKaBup z2gwj*m#(}=5m24fBB#9cC?A$4CCBj7kanaYM&v754(b%Vl!gg&N)ZN_gO0mv(jM0# z>FC|FHi=FGlEt6Hk6H3!Yc|7+q{&t%(>3n#>#yx@*aS+bw)(2!WK#M0AUD~wID>yG z?&{p66jLvP1;!T7^^*_9F322wJB*O%TY2oek=sA%AUQT75VQ_iY9`H;ZNKFQELpZd z$~M`wm^Y>lZ8+F0_WCJ0T2td`bM+b`)h3YOV%&@o{C#|t&7haQfq#uJJP;81|2e+$ z|K#e~YTE87s+e0zCE2X$df`o$`8tQhmO?nqO?lOuTJ%GDv&-m_kP9X<5GCo1=?+LY z?!O^AUrRb~3F!k=H7Aae5W0V1{KlgH379eAPTwq=2+MlNcJ6NM+4ztXFTwI)g+)&Q7G4H%KH_(}1rq%+eIJ*3$?WwnZxPZ;EC=@`QS@|-I zyl+NYh&G>k%}GL}1;ap8buvF>x^yfR*d+4Vkg7S!aQ++_oNx6hLz6kKWi>pjWGO5k zlUZ45MbA=v(xf>Oeqhg8ctl56y{;uDG?A9Ga5aEzZB80BW6vo2Bz&O-}WAq>(PaV;*SX0=xXgI_SJ< zYR&5HyeY%IW}I>yKu^?W2$~S!pw?)wd4(#6;V|dVoa}13Oiz5Hs6zA zgICc;aoUt$>AjDmr0nCzeCReTuvdD1{NzD1wr*q@QqVW*Wi1zn;Yw1dSwLvTUwg#7 zpp~Czra7U~nSZZTjieZxiu~=}!xgV68(!UmQz@#w9#$0Vf@y%!{uN~w^~U_d_Aa&r zt2l>)H8-+gA;3xBk?ZV2Cq!L71;-tb%7A0FWziYwMT|#s_Ze_B>orZQWqDOZuT{|@ zX04D%y&8u@>bur&*<2??1KnaA7M%%gXV@C3YjipS4|cQH68OSYxC`P#ncvtB%gnEI z%fxRuH=d{L70?vHMi>~_lhJ@MC^u#H66=tx?8{HG;G2j$9@}ZDYUuTetwpvuqy}vW)kDmj^a|A%z(xs7yY2mU0#X2$un&MCirr|7 z%m?8+9aekm0x5hvBQ2J+>XeAdel$cy>J<6R3}*O^j{ObSk_Ucv$8a3_WPTd5I4HRT z(PKP5!{l*{lk_19@&{5C>TRV8_D~v*StN~Pm*(qRP+`1N12y{#w_fsXrtSt={0hJw zQ(PyWgA;;tBBDql#^2J(pnuv;fPn(H>^d<6BlI%00ylJZ?Evkh%=j2n+|VqTM~EUh zTx|IY)W;3{%x(O{X|$PS&x0?z#S2q-kW&G}7#D?p7!Q4V&NtA_DbF~v?cz6_l+t8e zoh1`dk;P-%$m(Ud?wnoZn0R=Ka$`tnZ|yQ-FN!?!9Wmb^b(R!s#b)oj9hs3$p%XX9DgQcZJE7B_dz0OEF6C zx|%jlqj0WG5K4`cVw!19doNY+(;SrR_txAlXxf#C`uz5H6#0D>SzG*t9!Fn|^8Z8; z1w$uiQzufUzvPCHXhGma>+O327SitsB1?Rn6|^F198AOx}! zfXg22Lm0x%=gRvXXx%WU2&R!p_{_1H^R`+fRO2LT%;He@yiekCz3%coJ=8+Xbc$mN zJ;J7*ED|yKWDK3CrD?v#VFj|l-cTgtn&lL`@;sMYaM1;d)VUHa1KSB5(I54sBErYp z>~4Jz41?Vt{`o7T`j=Se{-kgJBJG^MTJ}hT00H%U)pY-dy!M|6$v+-d(CkZH5wmo1 zc2RaU`p3_IJ^hf{g&c|^;)k3zXC0kF1>rUljSxd}Af$!@@R1fJWa4g5vF?S?8rg=Z z4_I!$dap>3l+o|fyYy(sX}f@Br4~%&&#Z~bEca!nMKV zgQSCVC!zw^j<61!7#T!RxC6KdoMNONcM5^Q;<#~K!Q?-#6SE16F*dZ;qv=`5 z(kF|n!QIVd*6BqRR8b8H>d~N@ab+1+{3dDVPVAo>{mAB#m&jX{usKkCg^a9Fef`tR z?M79j7hH*;iC$XM)#IVm&tUoDv!(#f=XsTA$)(ZE37!iu3Gkih5~^Vlx#<(M25gr@ zOkSw4{l}6xI(b0Gy#ywglot$GnF)P<FQt~9ge1>qp8Q^k;_Dm1X@Tc^{CwYb4v_ld}k5I$&u}avIDQ-D(_EP zhgdc{)5r_iTFiZ;Q)5Uq=U73lW%uYN=JLo#OS;B0B=;j>APk?|!t{f3grv0nv}Z%` zM%XJk^#R69iNm&*^0SV0s9&>cl1BroIw*t3R0()^ldAsq)kWcI=>~4!6fM#0!K%TS ziZH=H%7-f=#-2G_XmF$~Wl~Um%^9%AeNSk)*`RDl##y+s)$V`oDlnK@{y+#LNUJp1^(e89sed@BB z^W)sHm;A^9*RgQ;f(~MHK~bJRvzezWGr#@jYAlXIrCk_iiUfC_FBWyvKj2mBF=FI;9|?0_~=E<)qnjLg9k*Qd!_ zl}VuSJB%#M>`iZm*1U^SP1}rkkI};91IRpZw%Hb$tKmr6&H5~m?A7?+uFOSnf)j14 zJCYLOYdaRu>zO%5d+VeXa-Ai7{7Z}iTn%yyz7hsmo7E|{ z@+g9cBcI-MT~2f@WrY0dpaC=v{*lDPBDX}OXtJ|niu$xyit;tyX5N&3pgmCxq>7TP zcOb9%(TyvOSxtw%Y2+O&jg39&YuOtgzn`uk{INC}^Na_-V;63b#+*@NOBnU{lG5TS zbC+N-qt)u26lggGPcdrTn@m+m>bcrh?sG4b(BrtdIKq3W<%?WuQtEW0Z)#?c_Lzqj*DlZ zVUpEV3~mG#DN$I#JJp3xc8`9ex)1%Il7xKwrpJt)qtpq}DXqI=5~~N}N?0g*YwETZ z(NKJO5kzh?Os`BQ7HYaTl>sXVr!b8>(Wd&PU*3ivSn{;q`|@n*J~-3tbm;4WK>j3&}AEZ*`_!gJ3F4w~4{{PyLZklDqWo|X}D zbZU_{2E6^VTCg#+6yJt{QUhu}uMITs@sRwH0z5OqM>taO^(_+w1c ztQ?gvVPj<_F_=(ISaB~qML59HT;#c9x(;0vkCi2#Zp`;_r@+8QOV1Ey2RWm6{*J&9 zG(Dt$zF^7qYpo9Ne}ce5re^j|rvDo*DQ&1Be#Fvo#?m4mfFrNZb1#D4f`Lf(t_Fib zwxL3lx(Zp(XVRjo_ocElY#yS$LHb6yl;9;Ycm1|5y_praEcGUZxLhS%7?b&es2skI z9l!O)b%D=cXBa@v9;64f^Q9IV$xOkl;%cG6WLQ`_a7I`woHbEX&?6NJ9Yn&z+#^#! zc8;5=jt~Unn7!cQa$=a7xSp}zuz#Lc#Q3-e7*i`Xk5tx_+^M~!DlyBOwVEq3c(?`@ zZ_3qlTN{eHOwvNTCLOHjwg0%niFYm({LEfAieI+k;U2&uTD4J;Zg#s`k?lxyJN<$mK6>j?J4eOM@T*o?&l@LFG$Gs5f4R*p*V1RkTdCfv9KUfa< z{k;#JfA3XA5NQJziGd%DchDR*Dkld&t;6i9e2t7{hQPIG_uDXN1q0T;IFCmCcua-e z`o#=uS2_en206(TuB4g-!#=rziBTs%(-b1N%(Bl}ea#xKK9zzZGCo@<*i1ZoETjeC zJ)ll{$mpX7Eldxnjb1&cB6S=7v@EDCsmIOBWc$p^W*;C0i^Hc{q(_iaWtE{0qbLjxWlqBe%Y|A z>I|4)(5mx3VtwRBrano|P))JWybOHUyOY67zRst259tx;l(hbY@%Z`v8Pz^0Sw$?= zwSd^HLyL+$l&R+TDnbV_u+h{Z>n$)PMf*YGQ}1Df@Nr{#Gr+@|gKlnv?`s1rm^$1+ zic`WeKSH?{+E}0^#T<&@P;dFf;P5zCbuCOijADb}n^{k=>mBehDD6PtCrn5ZBhh2L zjF$TbzvnwT#AzGEG_Rg>W1NS{PxmL9Mf69*?YDeB*pK!&2PQ7!u6eJEHk5e(H~cnG zZQ?X_rtws!;Tod88j=aMaylLNJbgDoyzlBv0g{2VYRXObL=pn!n8+s1s2uTwtZc

YH!Z*ZaR%>WTVy8-(^h5J^1%NZ$@&_ZQ)3AeHlhL~=X9=fKPzFbZ;~cS**=W-LF1 z5F82SZ zG8QZAet|10U*jK*GVOA(iULStsUDMjhT$g5MRIc4b8)5q_a?ma-G+@xyNDk{pR*YH zjCXynm-fV`*;}%3=+zMj**wlCo6a{}*?;`*j%fU`t+3Korws%dsCXAANKkmVby*eJ z6`2%GB{+&`g2;snG`LM9S~>#^G|nZ|JMnWLgSmJ4!kB->uAEF0sVn6km@s=#_=d)y zzld%;gJY>ypQuE z!wgqqTSPxaUPoG%FQ()1hz(VHN@5sfnE68of>9BgGsQP|9$7j zGqN{nxZx4CD6ICwmXSv6&RD<-etQmbyTHIXn!Q+0{18=!p))>To8df$nCjycnW07Q zsma_}$tY#Xc&?#OK}-N`wPm)+2|&)9=9>YOXQYfaCI*cV1=TUl5({a@1wn#V?y0Yn z(3;3-@(QF|0PA}|w4hBWQbTItc$(^snj$36kz{pOx*f`l7V8`rZK}82pPRuy zxwE=~MlCwOLRC`y%q8SMh>3BUCjxLa;v{pFSdAc7m*7!}dtH`MuMLB)QC4B^Uh2_? zApl6z_VHU}=MAA9*g4v-P=7~3?Lu#ig)cRe90>@B?>})@X*+v&yT6FvUsO=p#n8p{ zFA6xNarPy0qJDO1BPBYk4~~LP0ykPV ztoz$i+QC%Ch%t}|i^(Rb9?$(@ijUc@w=3F1AM}OgFo1b89KzF6qJO~W52U_;R_MsB zfAC29BNUXpl!w&!dT^Zq<__Hr#w6q%qS1CJ#5Wrb*)2P1%h*DmZ?br)*)~$^TExX1 zL&{>xnM*sh=@IY)i?u5@;;k6+MLjx%m(qwDF3?K3p>-4c2fe(cIpKq#Lc~;#I#Wwz zywZ!^&|9#G7PM6tpgwA@3ev@Ev_w`ZZRs#VS4}<^>tfP*(uqLL65uSi9H!Gqd59C&=LSDo{;#@Isg3caF1X+4T}sL2B+Q zK*kO0?4F7%8mx3di$B~b&*t7y|{x%2BUg4kLFXt`FK;Vi(FIJ+!H zW;mjBrfZdNT>&dDfc4m$^f@k)mum{DioeYYJ|XKQynXl-IDs~1c(`w{*ih0-y_=t$ zaMDwAz>^CC;p*Iw+Hm}%6$GN49<(rembdFvb!ZyayLoqR*KBLc^OIA*t8CXur+_e0 z3`|y|!T>7+jdny7x@JHtV0CP1jI^)9){!s#{C>BcNc5#*hioZ>OfDv)&PAM!PTjS+ zy1gRZirf>YoGpgprd?M1k<;=SShCMn406J>>iRVnw9QxsR|_j5U{Ixr;X5n$ih+-=X0fo(Oga zB=uer9jc=mYY=tV-tAe@_d-{aj`oYS%CP@V3m6Y{)mZ5}b1wV<9{~$`qR9 zEzXo|ok?1fS?zneLA@_C(BAjE_Bv7Dl2s?=_?E9zO5R^TBg8Be~fpG?$9I; zDWLH9R9##?>ISN8s2^wj3B?qJxrSSlC6YB}Yee{D3Ex8@QFLZ&zPx-?0>;Cafcb-! zlGLr)wisd=C(F#4-0@~P-C&s%C}GvBhb^tTiL4Y_dsv@O;S56@?@t<)AXpqHx9V;3 zgB!NXwp`=%h9!L9dBn6R0M<~;(g*nvI`A@&K!B`CU3^FpRWvRi@Iom>LK!hEh8VjX z_dSw5nh-f#zIUDkKMq|BL+IO}HYJjMo=#_srx8cRAbu9bvr&WxggWvxbS_Ix|B}DE zk!*;&k#1BcinaD-w#E+PR_k8I_YOYNkoxw5!g&3WKx4{_Y6T&EV>NrnN9W*@OH+niSC0nd z#x*dm=f2Zm?6qhY3}Kurxl@}d(~ z<}?Mw+>%y3T{!i3d1%ig*`oIYK|Vi@8Z~*vxY%Od-N0+xqtJ*KGrqo*9GQ14WluUn z+%c+og=f0s6Mcf%r1Be#e}&>1n!!ZxnWZ`7@F9ymfVkuFL;m6M5t%6OrnK#*lofS{ z=2;WPobvGCu{(gy8|Mn(9}NV99Feps6r*6s&bg(5aNw$eE ztbYsrm0yS`UIJ?Kv-EpZT#76g76*hVNg)L#Hr7Q@L4sqHI;+q5P&H{GBo1$PYkr@z zFeVdcS?N1klRoBt4>fMnygNrDL!3e)k3`TXoa3#F#0SFP(Xx^cc)#e2+&z9F=6{qk z%33-*f6=+W@baq){!d_;ouVthV1PREX^ykCjD|%WUMnNA2GbA#329aEihLk~0!!}k z)SIEXz(;0lemIO{|JdO{6d|-9LePs~$}6vZ>`xYCD(ODG;OuwOe3jeN;|G$~ml%r* z%{@<9qDf8Vsw581v9y+)I4&te!6ZDJMYrQ*g4_xj!~pUu#er`@_bJ34Ioez)^055M$)LfC|i*2*3E zLB<`5*H#&~R*VLYlNMCXl~=9%o0IYJ$bY+|m-0OJ-}6c@3m<~C;;S~#@j-p?DBdr<><3Y92rW-kc2C$zhqwyq09;dc5;BAR#PPpZxqo-@e_s9*O`?w5 zMnLUs(2c-zw9Pl!2c#+9lFpmTR>P;SA#Id;+fo|g{*n&gLi}7`K)(=tcK|?qR4qNT z%aEsSCL0j9DN$j8g(a+{Z-qPMG&O)H0Y9!c*d?aN0tC&GqC+`%(IFY$ll~!_%<2pX zuD`w_l)*LTG%Qq3ZSDE)#dt-xp<+n=3&lPPzo}r2u~>f8)mbcdN6*r)_AaTYq%Scv zEdwzZw&6Ls8S~RTvMEfX{t@L4PtDi{o;|LyG>rc~Um3;x)rOOGL^Bmp0$TbvPgnwE zJEmZ>ktIfiJzdW5i{OSWZuQWd13tz#czek~&*?iZkVlLkgxyiy^M~|JH(?IB-*o6% zZT8+svJzcVjcE0UEkL_5$kNmdrkOl3-`eO#TwpTnj?xB}AlV2`ks_Ua9(sJ+ok|%b z=2n2rgF}hvVRHJLA@9TK4h#pLzw?A8u31&qbr~KA9;CS7aRf$^f1BZ5fsH2W8z}FU zC}Yq76IR%%g|4aNF9BLx6!^RMhv|JYtoZW&!7uOskGSGL+}_>L$@Jg2Vzugq-NJW7 zzD$7QK7cftU1z*Fxd@}wcK$n6mje}=C|W)tm?*V<<{;?8V9hdoi2NRm#~v^#bhwlc z5J5{cSRAUztxc6NH>Nwm4yR{(T>0x9%%VeU&<&n6^vFvZ{>V3RYJ_kC9zN(M(` zp?1PHN>f!-aLgvsbIp*oTZv4yWsXM2Q=C}>t7V(iX*N8{aoWphUJ^(n3k`pncUt&` ze+sYjo)>>=I?>X}1B*ZrxYu`|WD0J&RIb~ zPA_~u)?&`}JPwc1tu=OlKlJ3f!9HXa)KMb|2%^~;)fL>ZtycHQg`j1Vd^nu^XexYkcae@su zOhxk8ws&Eid_KAm_<}65zbgGNzwshR#yv&rQ8Ae<9;S^S}Dsk zubzo?l{0koX8~q*{uA%)wqy*Vqh4>_Os7PPh-maB1|eT-4 zK>*v3q}TBk1QlOF!113XOn(Kzzb5o4Dz@?q3aEb9%X5m{xV6yT{;*rnLCoI~BO&SM zXf=CHLI>kaSsRP2B{z_MgbD;R_yLnd>^1g`l;uXBw7|)+Q_<_rO!!VaU-O+j`u%zO z1>-N8OlHDJlAqi2#z@2yM|Dsc$(nc>%ZpuR&>}r(i^+qO+sKfg(Ggj9vL%hB6 zJ$8an-DbmKBK6u6oG7&-c0&QD#?JuDYKvL5pWXG{ztpq3BWF)e|7aF-(91xvKt047 zvR{G@KVKz$0qPNXK*gt*%qL-boz-*E;7LJXSyj3f$7;%5wj)2p8gvX}9o_u}A*Q|7 z)hjs?k`8EOxv1zahjg2PQDz5pYF3*Cr{%iUW3J+JU3P+l?n%CwV;`noa#3l@vd#6N zc#KD2J;5(Wd1BP)`!IM;L|(d9m*L8QP|M7W#S7SUF3O$GFnWvSZOwC_Aq~5!=1X+s z6;_M++j0F|x;HU6kufX-Ciy|du;T%2@hASD9(Z)OSVMsJg+=7SNTAjV<8MYN-zX5U zVp~|N&{|#Z)c6p?BEBBexg4Q((kcFwE`_U>ZQotiVrS-BAHKQLr87lpmwMCF_Co1M z`tQI{{7xotiN%Q~q{=Mj5*$!{aE4vi6aE$cyHJC@VvmemE4l_v1`b{)H4v7=l5+lm^ ztGs>1gnN(Vl+%VuwB+|4{bvdhCBRxGj3ady^ zLxL@AIA>h@eP|H41@b}u4R`s4yf9a2K!wGcGkzUe?!21Dk)%N6l+#MP&}B0%1Ar*~ zE^88}(mff~iKMPaF+UEp5xn(gavK(^9pvsUQT8V;v!iJt|7@&w+_va`(s_57#t?i6 zh$p!4?BzS9fZm+ui`276|I307lA-rKW$-y^lK#=>N|<-#?WPPNs86Iugsa&n{x%*2 zzL_%$#TmshCw&Yo$Ol?^|hy{=LYEUb|bMMY`n@#(~oegs-nF){0ppwee|b{ca)OXzS~01a%cg&^ zp;}mI0ir3zapNB)5%nF>Sd~gR1dBI!tDL z&m24z9sE%CEv*SZh1PT6+O`%|SG>x74(!d!2xNOt#C5@I6MnY%ij6rK3Y+%d7tr3&<^4XU-Npx{^`_e z9$-|@$t`}A`UqS&T?cd@-+-#V7n7tiZU!)tD8cFo4Sz=u65?f#7Yj}MDFu#RH_GUQ z{_-pKVEMAQ7ljrJ5Wxg4*0;h~vPUI+Ce(?={CTI&(RyX&GVY4XHs>Asxcp%B+Y9rK z5L$q94t+r3=M*~seA3BO$<0%^iaEb2K=c7((dIW$ggxdvnC$_gq~UWy?wljgA0Dwd`ZsyqOC>)UCn-qU5@~!f znAWKSZeKRaq#L$3W21fDCMXS;$X(C*YgL7zi8E|grQg%Jq8>YTqC#2~ys%Wnxu&;ZG<`uZ1L<53jf2yxYR3f0>a;%=$SYI@zUE*g7f)a{QH^<3F?%({Gg)yx^zsdJ3^J2 z#(!C3qmwx77*3#3asBA(jsL`86|OLB)j?`0hQIh>v;c2A@|$Yg>*f+iMatg8w#SmM z<;Y?!$L--h9vH+DL|Wr3lnfggMk*kyGH^8P48or4m%K^H-v~`cBteWvnN9port02u zF;120HE2WUDi@8?&Oha6$sB20(XPd3LhaT~dRR2_+)INDTPUQ9(-370t6a!rLKHkIA`#d-#WUcqK%pMcTs6iS2nD?hln+F-cQPUtTz2bZ zq+K`wtc1;ex_iz9?S4)>Fkb~bj0^VV?|`qe7W02H)BiibE9=_N8=(5hQK7;(`v7E5Mi3o? z>J_)L`z(m(27_&+89P?DU|6f9J*~Ih#6FWawk`HU1bPWfdF?02aY!YSo_!v$`&W znzH~kY)ll^F07=UNo|h;ZG2aJ<5W~o7?*${(XZ9zP0tTCg5h-dNPIM=*x@KO>a|Bk zO13Cbnbn7+_Kj=EEMJh4{DW<))H!3)vcn?_%WgRy=FpIkVW>NuV`knP`VjT78dqzT z>~ay~f!F?`key$EWbp$+w$8gR1RHR}>wA8|l9rl7jsT+>sQLqs{aITUW{US&p{Y)O zRojdm|7yoA_U+`FkQkS?$4$uf&S52kOuUaJT9lP@LEqjKDM)iqp9aKNlkpMyJ76eb zAa%9G{YUTXa4c|UE>?CCv(x1X3ebjXuL&9Dun1WTlw@Wltn3zTareM)uOKs$5>0tR zDA~&tM~J~-YXA<)&H(ud)JyFm+ds_{O+qS*Swr$(CZQFM3vTfV8cH!1(-P@--Zui5A^)hFym@(GKIWqJAzx)Tw<$pXr zDBD>6f7(yo$`cAd>OdaX1c`onesK7^;4pFt@Ss#U;QF}vc}mD?LG`*$Vnur=Mj>g^ zak^JJ+M)=tWGKGgYAjtSHk-{;G&L9562Txj0@_WdosHI+vz}60(i`7D-e7u=tt^9a zOS2*MtQygcWA*8~ffCUQC53I6Lo5Kzml88!`yu>)iOy1BT$6zS-+?w*H%TN@CPdZs zyw>a^+Y6|mQsO5xO>D*}l8dy}Sgi{quxbKlAcBfCk;SR`66uVl6I>Wt&)ZA1iwd7V z095o&=^JMh%MQrIjkcSlZ3TM8ag42GW;GtpSp07j6!VTd*o})7*6BA#90nL)MP+m} zEazF=@qh=m6%&QeeGT|pvs0f3q-UHi{~U4)K#lmHy=RLIbka>k+SDsBTE#9(7q3uU zt|skyPz|TFjylK|%~wxLI9>v+bHOZHr!$aRdI`&{Wv2AWTB+ZZf$)j}dVkc!}ZgoEkeSilOaucEr!-=PQoDgBGMMFvM!g z&t~R)o|F>MFClOITHL};!z1x z7LzoH?+vnXDv2Q&047)o96S2LOmdGv&dn=_vYu>)M!J)V@K=tpuoK+4p%dJ6*d^a) z!9Rd_jaZ4_D~OU;04aBlq$f|+Ylwn#LJ49vmdWqWen7vjy~L2NJrhAh&QN=vQwp~! z#okIYCqhh^EpM$34~!egv>`tKFwtx^&r= z_>joAXh5zjePxe=5Zly!Tw|BL4by_T%s&{a@^ye?4nwtGnwdEwz7pk4DHPgM23GFUUR%;-FTg7`krvP>hOL&>i=RoD#va* zkUhUMeR_?I@$kyq6T-3a$~&li6+gM%VgAq_;B&YmdP!VP4?wmnj%)B}?EpmV{91eSB zu(nV^X2GZ-W{puKu{=X+fk9PfMV@2<#W?%A!^aAxQS0oiiMO+Y^-meqty+Z( zPx%~VRLNrGd066Gm|S)W#APzrQLst1rsyq3Bv)FfELvAp)@Zlb8$VSjPtaB%y{7#1 zOL5Ciqrikv(MZLV)h3$yu~gIJjnf zU_kn-QCI`pCy3^jBbLqbIE+-7g9A_?wo;UPs@mO)$7ryv|5l8nXF z4=}#=C(FtyISZCI=Jlv&(HYH!XS(#*(RJ}hX{imI+ERowq)GT(D=s!S%|ulx1O>kC z#TD_JIN@O`UIz21wo!>s#&QX2tgRp~uH|_8)`BlU&oviw1DmTjqTx6WS)aNUaKKmr zz1LbunJ_r9KpLSI$}CRlNM2`Kn5g}cQc$v3$`Ta8207Z@CheFEGh@p2;e`|8OQ6s3 zdw?NoSm!Xbup}!eB7psHAtElj_x}}DOjX;G}#Td!6sITGo zDg8p@)fKrEdo?P?j028@ba;u$WX>fK1ceFx43_qKg3>kE{o)m0&ru6eCjX@557!}O z#!G)Py)`b7#b1?|<@LS+sSPp$lx{~k_NAv2J%j*KU|!D==Me^C4$;McXq?IFc8FDQ zaiY(CJYo|y3m~a&2anw zMW3cpNl`zoiqF6Tiw!%~BbKaQ-CH-WP{;L@H#X67rg0#de7L)+#|$BV>+QK2MO=uaCw2_3HR$6t5fTIf1H6PW(+!l5>AsbW@$!MAJb@d5l! zOyeWE$)$@L{h3T=$Kks@h2E#qDdNpAJDR~!k_?WD1##7CUWLII|2Q^CNc+nTe|g$w z@w`Y4-68jK?$8IQb_^)Qt1vgO+^{dMo3c)O!C;{ujbJAMtbC4{3LV#= zYxu*bxi`)xdD1XTUOCa0>OEB5vj{~~cxstHY{=rogffY;NL_eM^jS6+HS-!y;g8%R zG_&hlrh7%`)UgA}kZY3AAIni9%Cm|T;Ql@FO*}IjnKJ9zVtqgf&G$^J3^i`}=)bL? z2i9L_#tRcLn|@dmjxgK?eXHH1OwUP(kG~%&UjC7KNc1 z)L?TYn-dnSGIZaQi**B1iQXZXssT}ST7PaUo^VuELPuZDoy&FBhGB+8LbwTJ=gR^` zX(IoM1R}zC$mcSVM<#Bqg(j#^vw8GQ&iKM%LT=_BTJ~1u=Rfa}^H5;&J;+Wad(OISt?O+<+Xwd<}tAYuM%GG}SaGjmW9&LbD2313* zXH0HC5dR`E&eL!=OjK^^l3#c_pgF}(Rmywk+<6X}4q3`gz_f{J+t{B3IvO2xLAX~0 z^gumcggKGqwN?$OA>$gsQ`$RyJT|#&9xckrwG6z(`*x;Y+apoNp2_Q`Kt|YrXGSc` zV>vxARUwo=!;e}LDg&b6`W}yQX6Z{H|NP@@%_!(QG;M)>V$g3192a5^DBZejfOmJ> zF|y{z7^vQlHhIz5VWGyPYt^;(y}GTl6bt?AF1U%vx!x1_#qpUr>{dE>6-nYMS;n-S z!p;7U5lglUFT`Xoko(YXG!>;Tc3T+gTuB|Z7N6w8H~RXR6Hr~|?0s$66jZF!t(?l1 zj=|cHy0RX5%xPC6eUBACEd5z6IBLdf*jKie)lpgwd~+DIJb2nfyPg}r0PBmr%iL6m z>xWfZR*~9G?Ti(=E2;90`sK#Z`rcZ>YMa#|bnlIB?xuP2;L=0G&+3^)%lk{!o^BHc zY}Xx9{clyW>uq@>h)G}YT3aH|K*@;qE9Qo!d;N|y5~ z1U0CkRRJ*2(ng>s`?vG6w$;tijm@T5-zf86QzeE}E3NKP^V8sMxeww7SOQhMU&8>< zl~+TzA^Qp(ehAJap>ZQvK@%sOLGb}w_YvnuP&or-l&<@nFbi?#zdb)*WZWWIS* z^*vCpctr2+iCvnC2CyKul`}-jNyuwyE<^}0P>#@E@`MpmAM=!&4=THO zZQ;gUh;~k-D(H8z@BZVbJD^jFMn<>BI?Io%XH%;!n83B(X`&WMaBp5w3l0G`8y=q4JLI@wa5!D`V}n04sePQx+F>@Qi{Lw zb&gbImDsdU`y3&`d6ha7J|5O-bZM24jffJCfHd~@lfo+5be4o}7t$SNW%QezTDd+F-7`;9O(E~DenhS95%M#;u7^S~!z5zbjdHKlRdA8vfe>mqx$ z(n16@`5|_TKk{KcdoK0Oz21Ed?qJ-^;I{J4;rb^?TUb34YYFYOz2B-X#hty{yXzB5 zw01L9_erFV_mkAv{p#v!jSEw4zO9e&CJ^W2R`C6+4Zxtvltz?SeQR4}+jQ5FM`MqO zW@vQQjPY%3fz~A6t^|gLFy7rMJ*xLPB4cEPe0x(+Z(M$XhXNdmY8^QNJxhGgsgP_bzlM zY)RO?*!wmpcWyR7dyd-xleJWm06%rdJQ|PsxE4*NBg)1}d68R5^h1;-Nwq=4#&Q)a z)Wm3z{GbRD2~x>1BMbt8#`eQk2ShEEN*%xr=U`rx8Zi2`6KB9uA@~ z!<%=&_qD)hD@qGqGwhEW17Gn!Ulj%Ma>!j;A{+ffyy zO5i7+wzTmn3hDEf3=0%^j+H}Q1FF+$d|Nvb_H`)P&Hgm2)zpX)%dp>& zk&L)>V}u`SDF?>t{<-iII`KHK<(q-3N6uZew!0_yk{|sMPul1*Uy|WV!aUdS^gg|2 z%WXGTuLM4WWk%DfXBW8C^T#veiX z*+jK_C?84cdxGRR5;VZPiKdA5A=pL@?g}>Gkx^fZ@PX^gNLv`&YkME=+ zMzEU7##^u$K7cC_*Pd@MO*A21NEe_7PmE{5WX#H%-fh)|#TataJb+6P1!DEPf@=#K zWM{>%eIx;_!?1X8cuyDR3sQ+YYfrL^{cUiO)&gLE5CyrR!gUE!d|vESBC%MdzVt%w-vQK-UeL$ zR`s{+*Ri6Zv74%L(8RxyNmA_5(OQnf6EDi`{KChC%L^CD2*^A>>{|2n;nPTJ*6^Hd zArnBllxQDQASfBVI{l%heO=945vEeQ}lkuag0F<9_Ybxyv~;6oDWwJVDr z&G+E+1_kv3XWss&f%F|qtD1{flDmguL)sZ5*m_&Lo@BW*WBfUObyI zRIzk&Z;+xfvPbDHg(#cT##=$PPB})A zblRtAM_XTI9ph^FyDYo?)%VU9HnQfFPY+@TVEfr;s>YX64G(C~oAlbzo zA#M4q5|2**gnn1S{t|erH)jBS^ALF4{cJG~Ct3tQ08$pn%E-l3(CQVEaOaFyA;NaMgh54a(U#BohL*&j1%qNO-i{cIoc zuH3AmH+>Qr__0U2f~HQ0C|zq9S9un;Vl$bgRfDr&)~@+zxj z@iyYkQ_;7L?#nz~hCeGQ@3tjL}z zlLeJ{$H3KaSxOdjLbPQw-FkZ%5-|s^1-xtLuhh-#j16H0^49a;3J&X4F*fNWvvLng z)8DSq4w1iHPRo;ovz8h~458lDYx;~&+;OfXgZM7=J-_e2`TCc#>@_%RD@_31^A=V{ zqtu&FqYN?To~>DK{{}B$!X7|EY~i1^>8Ke+TAq%4Wq@J7VQ$9)VZ!eD1%R>U#HgqA z5P~n?0(i*{Xu4?*xZd%=?2N!64_==zI5zX}{tHd|&akE5WLfz`ctG}!2?T8Gjve`e zlGt#G4o^(=GX$}NvRCnhwl0Vzt3MIbCq}u)rX>vx(rYX&M0Yn88;u9EguYrI`h@ud zQdL=Nfj+ho({(o6CZ&th!@bYWef8`W`QnW7anPXzM-t-%!`tG|D2m}n zb;w0q#U5zR+%0U)a)Ranc4wgrZE_N$w}N?Q)G%JEA%~($lk$_?m|T>^bhfzz)k|GD z5J!6%?g4CkQ%s%dgkotsIlN0Pp8E zKGqE~PcEB7d33xgPk)O~c@WxUR<)_{V>K=VIG|>i2|17~6lX^_t9$U89M5fAZsTwE zoZr#LjmTN^BLg3d)+eEkzvSmGSTwu3zTnT@`Jx2Ih5Q&{ z`IIcS#WzC|+JJUGtY2*j`5D9+oRH2#&`Z?B7#xtEye(&urASulg!)jjie~e6Yt6EH z0!i1I;XvMP2|7Z+kfA}i0&29S#OLdb$&+4r0CDnTdNDOV(=@feSI*zL*o@)^?)d_S zEy+}?KYDBn7pG_LvZ3DuzK~XfF)l-*dE8Lo_E-jQIVCXnVuU{6^a}xE4Uh>maC!~h zvdEEyaRv}TC+!$w$bM1a3^B|<=#OLG#2m91BPG2M)X7YLP$p24Dt+Db@;FtRDa{Qo z`ObdoBA&@{jqzlWbtR}}?X3Y;)2*YvBdwo&LWovw4^OAR`N3Zlqaz!rh57Q2I71K# zy0*BC*OObasWh@p*$~8-4VZ_m(9l=lks{-Fu6R)9&F!%_Pj$N#V7xuO7za)6L3j;W^#-85^MVlZIYf84Gdn%!3I!$yCb9|QYzSSLs(L9 zr0vue<(nj$wL*J9R(5x{opst7yqcAl>BN0G(9BqiV2(e&&v0g**_eN+%XEN2k`++8 z1H^g>!zHkq_~QSGo@1Z*!g>QBK-2fE!mMCg9ZY6zHASYC!}59~NHWsN3aN3z)Ptps ztFxCC7gk_-_Q;EuZI$u+3x?|^&ysf?C(d}AjPi}u<0}DK#<6<12x0}jmL_eR~6ilm1yi&zQ)eyb#J_?$)EsTS$+Ot9}19d1Z>7XuE?9ujh1D^u^ zpkg$>g?dJU9sJ1gc~rhcTmqUNuR4=hz~II)YMJA2gy*xKuK8_BC8dtMvQx1y3WNBQs)KdLNAxiM?jeO<5b& z&VoaG>3&ZH7$lJY!7?VsGde=@`1cj44cp)9!t0VSsW*==3HjXeKuix&S z9Gi!qG(dOuxs37L^^znePlxj9l=ws7T&`D6@#U=UFFp^0FlTWF!C`p$Vg7=I$q>oc zc70qB9=1(DcqqL;iz>NGau1k6j)E}c3i0S5z&fGZg2gyGqj1$s>E%g?n*&>bB`-`z zH^KfxoC>X7p>`kb;;LA~?n3>e-;bqdL@RNTop8+^Lg6+%>YttCS}wzaUO!4&s2?RQ z=YO+D9BeI&4W0fs_}}aVN!fmWLL=K~`7D5?Tt^cNwn6b9>1 zXdsC1->Rgv9{^wE2gnr+tHKA=*JoKAJC80Uwl{ROzn<$g`BAalt&Z!H#VA6ruwB5{ zkPslfMa5MuU4x_)JF@CF5efd_f@;^;sIRb1Ye;fV{xSS5{IEKCnu87>qoLs5Qkr(* zxN#S}rE>4jwJx4ZMe~|R5$G3e(`2a_LS*RRET#7JYHH@Sup$@|6m3!c)GIpqtbV$N zQ!RX&emWg{O0pvLx=E6Rv@4--S~QNLt5Gu=8VYWj*NFlSN-5=5~P$q@&t1ho{PFcQfNVuC>{cJEQ+ z+#Zz1TWCS|^fzEej>ts#sRdw0x(F3S*_$g_`O`ni1R-bGdH%7cA3w2=kUODGlwr17*x+R-j(|~0H)5o9d zM%ol3zyQ_0?pVYUi*#vcQzVQ)0%XB5Hh{GC9%~cJn_K=H>m({2>e0dx7vSE~(Bh-! zNlxKtC#A<`Oj`#msX`6&s-)&NRuJ*@C&@$@L@Do=2w;&|9`>Nzh$^!G0l;tT8Z)1U z>R~))4uLBRx9aA(I+*GO#{skFNf^_`^a2}r_Ky*k@(t}gT2X)G#e_eObzmG%yYdr& z;nM~C4VdYaNXd?W>G*S$O(A|$9vjxf8lzA-298rP^gu2FUlZGv^gK5CvHrDmVN2rY+Ebtl+i0)cF1~@H`kln{Ls#9 z^#ALPn7ZDZu|Kgu=*MaDPvYu-`Jw-~QSOJsujHWrL#21rw-PclHnjY|aC%A44Pj&+ zq_ub}D(|u&QgaAGZ(^13MO1~+z=Zu0IlBeF#H1#D2K$m04RuB$4gxCHkMLKxx-&qv zwzplN=MQq;>rtC?)JFbD_f5}}97o;viyPhVUv@Yw_EWviI5$UkyvO&m zc0$>_^tbuzCot6HogzSz=U?$1o6NWM{>ILKjCYZMNPt>lst)bJa*uB@t|^yJKznB8 zP0)4jh4|XX@}`j4Fc^!?ROz#*|K_V%v$zClop1q2R5>Ue^^vCbbi4$m7hR7)>u@Bn z)RMm0;CHF)gXQ3n3WjjsF1sn{rh3VarhyfAl<}fC#P>zL8Rk1xb_w{<&LrjD@?3*( zSGgw(zw2AqzuF=Igp_x)h_fk3xILZmY+uH69gSe^Rk9Zb+Tk*0Rf_8Of716{NyGuhPT#(j~f5u7XG+D2()aN&4T-Yp} z7aOcRp+AzlpcKSNBf;6pkF1ck+|CXX#g+Gb6Y?~ES0d=_?a+X+93F_Xy7klZ<*CJv z*Mf1k$%3M0tZTj;B#Sa}s2xJ61xs)k~uu_gpZIt5o2NP3@{S{1c+hl|LWChwE(N!jBU*;?T|PD7YarH z3$vb*JoXWDnR2WYL;r#Oo;xjTlwYhPI}58-qPifQzk1@0m?{pNK&9!Dqi2TdLBE4U zVa$Buq}OCWRPTUuxRK^iCFp@p=G6!@Q7_8LZXXs;l*JvC^M-(NwZ`xcECMn~2#01$ zehZ;htX4BeXVVfpriGWNZ((hn&dEO|7&{3!VpOFFyez8Xd8}5-Rkxl5b|FQH;?b=}o(fb5f4jhGAK_9Tm!BJYz&>Sb}g8J~>^yWXvt?VUq{t zf1AuOj%(ULjyy18Z}V4vXPjAaj*Lo-$hZ*A{Tgy)SIJ_*d7jg_HP?xppEMkk!@pX^ zi-2!j{A5ltyL_5>yy#3!+qC)2b^V5%X-P%zOqV*Zhn=(J&D@iHCdLSGMG-9_NQ>4|qkzMl1JS z_-Or;q-FK4??@-Z%pua$xej$$?FF)$bECX!Fg9{9Ek9qLo;MO9-Gp$?_zkh8%c4NmAT{#tL3UKlH#u`jL=h*F*BZ0Hac4Y^crJYk?I#;}hm}_p>6fnG| zvdA?(l^3yjCqJP%0CgqaPgX?y zGxdSyfB!G|x70{wLlH?8{Ts(|t&Td3figUxUQpr}5?!-Ook}$MEC>yNb<;ZS7(tbd z%b7{xti?@rH}{Kw>lef`$tq*>LaIxNZ{ootSEq!8L09kOTI0^si#FRg@8>6jU*W5S z=r1HjodFOCG@-O4dJ;p-oAFzLWO^cf6;bF^BduXi#^X4Yk*+9sR3oiEW&18XK^eK4 zU_0%8Fhm7L!Zrd!Y&H_F)o>jzVgV?9`PK2rLVQ?SeTiWo0Q``GpdTOYICFb8Lz6># zDn>x5lcK8((<|Z_74%n>@-Fm-^44Kv@;qVdNwY{Gx&G3)%|J5VMgu^&&_oP`zx-;{}-ZQ&U9(4^gQ250;%~ebaD|2JoG-rzq z>IhGSO)=dmD4y%xPh{r4v?7|s_oOAOM$|vEQ878aZCl8YK7B|zyHy^6(QIx4Br{lC zpl?sqNmIm96KoeQ(?%SK0o|dMXhZ$LxTe+w2~i95n@WYwah=DFC3a;av#~DD=@PG8 zQyeIj=!tYl{=-vP-DZI3)^w1$aOXC@>Wl|lHeG(uMZlOAnM4zYkD-crV0B5{kh20TlVNUYHcNH25 zqtXC*zvO5TW;}G@rw0(L>qLcIYZxh;n;m&!lC3p6R@$S6fVwXfc$AMUG?S7j8QBV6 z9kc-nodk?{-+017Qv3^x1CqK*{8h~#X1u&GFMtd3I>PW*CE_x&SAZ_KSeTy2*(WQB|s0OiQiuSx&gDh!I z_R{d()47W6+;RB!lBjBxzn>w^q;&j_aD%;B>2T%+r*fiFZoE?PUCQ_(7m>oDj7#<9 zt-^zcII$*~lO<2wxbf66=}=~sZ9_-tiCH*1<~{2lE5~TW&E(qEez{Mc`NQQx$XnxU zqjl~__8v0 z20Cak&1J2>CJ^_^>)6IGi7wIkigaw$EwF)Zg6dwa8B^&R64cyx*}q#Z#jx|>+WW`0v5g>7F&f2swdj8z4h)qR9S|fL=({2QDNQ8NUQ3eh0gbJKl~_c?q3fpF60v32XBOv*-IHSJ0;dK zJqK4{cqmOWj>Rt1m3ep|os}2Vtt^>5!X?qgP#|1)1@TTYn6n=e6c-dG>>|^ihOu3e zEBts>zO-*z@OJ9%g;c+3=XL}7Tu!9?SZ(Ns`+0GSwKn**3A(S0ordv=rCk{N`G+6# z3CDXBx1$)vJPZL{jy+qcoP5b5j=vP*nE{YeFeY&mzr!BXl!Dvg1Qap>ujCgT5;_1k z@H6lTIQy8m4Qi5886@ju}fcr3+mE)Cy>K0N<{lmRrDT$SPt&f|4g28g8#pIK}=l#xV?B&x_8@ z2vRSm5a=*HKC!8%WBMkV2I8>h2D-IK5A~2XJSkVA`2|#AOheCl76HLzm7*3$yyX}c zS;cS8uL&BJpt(NuGgb{ZIvxV+$~IKdyM^K;b?LM(bMX^=r`v2BHDI)SG@l@!S#~W% zbPIpxf5y1tPar2V{y212fBJ3$|HC5+8=L4mTRHvvBmX3!rVhrAj#B17DXGoBClJNT zJBt4pBxJ*y36m);E+m*g3#efMo|LD8Jipw+&&-_kn>uE*&|A1U>>gz3}r4MeNGP_}!)wX`>uHN;lge?#R1c(|&z2*_H-69J9UQP0n4_*2KFf}3 zu({cc<3q#HINkH%xIvmKyg-xn3S^;i@cYR17n{{QfYT)xSx?Rx5L&I!-^0x@FURd|3 zNmz<@Xu`Y5wbCbM_9b&*PokDl6r$kUbX5DgQWm0CcD6#AvW~+8DTLC(hT7Fp$VvRk zQAYT#wcErLs!8c}%3FnPJ8b=FULp;f)p!7Rm!gfB!PGMVPQR*h>&>>A9 zV@IN?+Aqx0VP~K#cAGq)Y*3lJiC%SRq)L4lJd8AmzA^6jO1B;y8U5;@-Er%Vs)R3?FE#ss{GBgf#!*MdLfFcRyq2@GSP~b7H!9aek zBZi&nao#!&_%1jg=oG!<3$ei53_7eQpF#Y~CX3iJ;)`aXL(q`15h4X+lOLa{34o-~ z3jbAH^eN6d^!KxB#3u~RD-OelfVeLr?kU;9T-KM!7~`JMd#Fb#TTeSA%C*06@Wn&?gpWW?B70vL_6*Po4-EYT;3^SD&XAaEe@+{| zGwZ$xoM+}{&_mRI8B&w48HX|DUo~KjV2Mk*9H8Ud@=t>v^$=uK$|c;fYLuK*O1!Bj zI`Gz*dc3pFA+B7lmt`p6?Lsp^l`PuYDcH%BYtDwdbbT`r0#KVMP-gE7HN{l&5p*n; z+YmlK#slLGp+}WOt-yn-p))K8*pwIsiO`R0NC+Zxpbj8MN>ZGJX+@2iN|Z%lcdv-v zmQYLisOsoM7&wp$Qz$5*kDsEzhz2>$!OShPh*bzXG3v;_Uq5X+CYp6WETP6&6Wndt zoCy(PS#lLEo@AIwbP>$~7D);BM6MiVrqbdeOXPpi{pXk~Y9T*b@RQ&8`~)QC{~;j# zL?AbJ0cR((pFu(9hX0p+nXGK>s3?N$^Gy0k+KPo~P^?s?6rNUOoj}+#ODLxxNAF#4 zE2rUqH6`P5=V9B`UjGR9hJhn3Z-UKt2JP#I0VX#B_XWWB8oqaFy)H2?6OrxolC^b` z#dE@8`oin+wJ`HbrqF1YT(pomi*+{CHQ9qS;^np{;ir;8FpY^m&=%teS^x<@B!-Zs z`VefRH5e2liGWO)wrIb`4_AXOzH4}Ng@mK(tYvt5zfx_%I72Vz)a_7n8JH(}+F6H$$Ix9wtS{5Cml-!T5+wBPO%bqm{TFpw?(kBJU)vPX{rh z;9x_MdVkKYwyZ?|2Cwue4Z~vN3(l=$2O{;dX z$+R7IU`(mQP1TFWA?DHXZ{VmsPp*tL7? zBMgsJ<)aM27&wjCx%x4NxKNy^94U6%BQP<>n?|RWGam|54U+Q*YJHSADO=Ln2ad*W zkq4~T^n)8P7_g=rZXidF{4DIi%Suh8BND_I4d1nR=rPwhvn>p>@e(0&zvb~tZ88#d zmyD95P+6%W7Fl_gHkD{Xi8bStvJNM9(P5{ir#970*q<7FG7E?+&`u(n7O_#P;Um~C zptsHoE?MnwV0)UUVqNvZ&*`KTRVv5kxLM4ee-LgP-czlY*jsQ<{p3MHHlhlivD;YE zg-?rH4_nzK5zXwy74izgT8#tg&7Jd)n%JxoCkdd^&eccfxKo5dI{pil|I6F zgfzYaRlXv*-l9o;L_>Z-B#g=RR-O)R7@-h8(sT(S5@p&Ki7NyxVwRVjeSZyLe>f6xDG7CWT@;q?z&TF<0|Eh!rT20ncl zJ*DI`IH4Y(JR%~vQJ)kbs8Sa(+gPs=>GY<)eKnMga^=!;bc!?$dEKrYE$Czfh1+ZXtEf^4Z>~lP|cnW-15smjD|y_CSMYp5=(Rlz7FwR>Jb- zk4W#dD;*kNQNyq_k#)#cwdq1s7_8t2L>ZdG^R=OIAYCcDB#s<;76)hq{b-Yca50Z< zl0B8StL{+&cx26*R)jvgl#i@&-$`<7??E7S$@w>wd&G^k^HY(x_x5BjZn#wC3wN)MQ>$=T(UhTlCnA(Nn`vm%KC9LC5^{(`kZs0JQJqzAP!w{;i6EpQB z`Z|R0Sm9yPtXT`{^@t~xxEUpG&$V8>vU2Pk?XB>R2UY2JA-Fji8JdvGd3k?_5MMN=G} zqlrw8Hi8}RS%c}6Um1hxOfC2r{AE|mYtrWVeWi%A zz=t4I5L&z+XGVJ=EF|jOk8%}d8NqS?PN*gwI?@I>g($HH5Zb?OM83Yd(7j!igRvHe*;$!Zxh%y9-81_MYM-&o#dZ2x)FIpgN1_;Qkub&0t_I&1GQPrS2Qz<2Ei}kL> zC(k?XiRz_xGt744%!c0I;c1~#vV1rdrKdkq&PhmBAG^BQk06Bi=Xiw%xhhN$J4JUb zoXEUo_C7InM^-E!>3Is~c%0;*XI3{gR;pJFh1wLXu;*Vvd*t^rnZKBKs_tmKDu;9T zHquH?$WJhLrd!QF)ZgU}xCSp}zOXUpCTb3_B>g7V*ljb zeSY{2!wGUd0!CXr3cbe5kdRXpUwWRR~w%rHcE zwn%rbc1}dnb^ev*i+16Q#Rqhb$V0O@vZX#Qi`TqtN? z?(}(pctgdz{pcSVkCH!lJ-9H}VNh9^-z9PWUUV@-0dnPhIfUqC0N8;tBflY|$)Hv3wzXvqRCjJ9)%-^c|wjcC&bf3bAkn?0sc4 zca&$kIWViw5ScsSqd8x=WwDKy=%jE4}W+D9M2-VKn;KFg`LF?iHQ>8FWi7x z;oaBx4jj9jZdn?~V{%2RofR`8yzuWHe*T2qlSE z4OeL6PB!#*P?M3-L@m)qy-lDFpC9=iVJJrL9OM#m9f^BXTPk*+jwv1ulAJEf*+Vu$ z0u;&CYU%@Cpph^+@XROdS(^SKUJkN>t(e#XHzsYe1NAVGF`ID6zRou@ihaWV!B=LF zKJ&bFg!q96N|l(V8ZU2GnbuL_Edc<13QC}&@;|9pB(Pi17w64WKNjr^H*yw@a7J~P zcu`o1K;fiBUb+x3nYZ^{hywA}WR%w_0yJ*8kA$6OsHRBsa$+Prd`0^}R#9il!0W@W`u$zZJGEMMw zRq~++SGG-tJ@z5X+!qsk7~T&|r-m4Jn-1zAZ2lj<-Z?nZa9iJwC$??dwr$&HM-$8> z6WbHpHYT={j-5&;F{;KKp!C{Z#+m{j7T5g?n8$edh6-8|8Z1ebkL;HskIN zx8bkmUl($pu1ASK9yJ1YANLU?Lt2|4!(mKj$ z?tq-g@h`Fmtqq*dQFX9z+9P|mKZv6&h3QMr(YhbJE~f^7iJ}aYRxqK5hd(wi!|$G) zpnY#!sZxK3c*7TANBO~6$usCNIA5J0Td11$%xstIG=f|t-RtW|ZmHX#Kpp!akF|(d zcC_9~65$M5%%I}utld>DsW`&n_Qren=^^iYF6niYw+ulfQ|?$XSXqhC2TU7F==nZ= z+Yk}z#G3vtADj^MxxB>i2C+*C13gHYvwXP6-QX~rHlar;uxj;VoiGUn{xaq)@O^45 zFUmo!U6WP_E|}wjZJ#N^O@`V(n7yUahPE5cFy6nv{Tu0w$wp?62I98R;`Zq=I&B^? zi-8E?%?t;C;ovo#I<~t1<@+C!rmpw{paRaRl9`{|&f#qpZvwf4#^AFa54hH%McPp;*=tk3(N?0Z$`5W#=TrrE z2d*Ui5GrLVl(>`lF7MhJ-X;F+O2bCLPiOUj?k0pE@3f+){^6o;b9dQ}^iXO~;|L}= z8^6TWmG&;FNmaUlpND{OIPVN0v?<`zKT=>Ew2QLJ1*i&d0BP6C(4eL9nklF?x?{SA z83V7!-g{^U9kb~$G9BNPqKZGlmcibfQ$?W-lyWoVg1T?-TM2e$wj-LbURM_ z7zKM(rTpS^bmd4hQLs6;$di>o_+I zlL?onPu?krDL~JzA@3oS0wJAU@PDicz0s(%iba-3NdKLn{Vr< z%Yo7s5RP_9)UI28x*R8YyTM6&ot9S361r+rmdOHXV0hi-f|WOIj!PRD1(9NABcB(O z4lVUwnF;Eu9`U2M_ihug)v#}|5(e;n@?fq*x7=EPo$4ot+K2>VF18I@t6X9;TtIHu ztI%FvwV|o299EXzk$|fA`D(aFOdnT0(7=>m^W-5K1==Pi&iPG2FqF9^C(Yd2X3=WO z{r0)hLf@;QzH9Tf4V*eM$j*5rHgHZ&p*WiGDRquYdHk*wH9J;N1j%;$cuEH=3%B1= z`}JJS;>i4Q_+Dr--tal)V-pjELkBD3=s{sz1SwUzsjwipz``aZQh^w?6c|q-1(#UDtyx3M;qo&5&j@RMHpnfR_RvgE?>g?>GfG?d}Gru~yPEop&D2;kzE z7+8o5!-h=S1)%e2Lhi#Iwy!`1W*3l{2r z$DosV(wHSS^Pw3v5^C0|=Dv4aykO#&-by^zYo&E5j8CU}0(D|Dk2YC${S!44yF&+>QmUE)=2N*#> z9tsf5q*8kX&%Gy}e?{i@4zkP(dr`61DgYMyB!{Tu+DRAHLA}u6lOvUA%}$$t$MO}^ z=`H}%_K=j#84tJSzk1*?%>97CA<)3O1iv0GObE1B6cK7cUiMD5w?4HN^`LAJv#99|w1F`tU&KSNsfNjb_KzhIVW-EB*g zeoB8r5C(_P(KzAn5zI!T2zR5iAQOf@a;p)8kfTfaOLR92Ji}B5v1FK6MUCmgC^U{+ z(6^nH@=D&uODWY0Ky%czwK9rWHtmai+jhGCMMG4d-ts%XJf=6tP(;=*SsYd7RZ&eg zoAP)Ie%<13y8bycl>A;~%v0H2C?BfgwC}(vu7y5_rp_mwkG!Hiv9ft|Kigj9p%@~5 z+;7w(ORbtorpmz8&&Kxr!BDeOR;qU>O1P#c2j?ib9rF8zpjNKdbsKo6twnCjvO%y& z86tl1I8t#s2wl2iD8R|sAOFD%P2~<#c6bc{iYos{=THCQ2)pzL(`?^u-1?`6Z6Pk? z(N>|P=A7k==L&sO0mduRgnp|P&pVang=z9f&<#~&ns!fPoKanKT~uQEi%VPtG(A9|63xv>%Ks~%XP?L3+P zuz&6A`E{75lsZt(=t{8*l+{a{RKSE84!Wiv*)xa;tm4jju-nQpg6>z=;N3AuXEXWp zUM5wAIynSUR;OQU*i31X2Ovdd*v*uvve2o={6z0N${5e+;MQl0sgxrI0Auh)u@ql{ zcFO^;|3-Kt;qirT{?ac7!T&D}_zdH6!+yahhp@8#{n3!mhoyl25m8h z*VWQR^{88#fy%~Sc}VbV=kgWgULkj76U_a1@IOFf{kDT~u$j9X=yFFHctCcO+D6eKd$ zCiX&;hR{P0oG^V z$0%XI2!m>^!@BEUnXQfD_ql^ihGc;j<5jj|t1`DN?0YPF+tHZzO<#{qw#eoQMsLeD z`p&bfl#b#4-u`xrFKZ%)BVRmcRD|b$jlr*;L8z7fx)CH7y z{XIq+9W3g)eGKLk-F}<*YK`qB*Y7j14XFGvZx5CT*dQqo>kNjRb15`{foG18NTzPv z5*c?BJC+S(vP~fsicHnp5OP}0X|uhgJ`zs=@nD=h2{H~IDEzWxj1~~gsq;|PkR2~O<0FHJjF@E{1A&3CCBDCAt97=n#g89HZaJCbu`!L z*Y+kgvi3E^CYXoBa6wB%Pi8Dfvf_UwqZTZS?T8 ziN(_@RQKAl>)mz|nZG^F0<9t_ozcHB!^3K4vf(UCG_JknwUgb=DxwjQrZn{1PsZnp zyNR7YJz`XH6sMZ-Jvj2)hv#Q~op|I=Hrrj7N&v4Rm2!#C;TrZd<7deerS)BWiQQTr z`I)f~2Zc4AT|DIZ+bHiSSpJlpUJ&fbXyErb~+(dOZ@5sQi6 zgUCM-i%Conu|4-B|5SvWiqfly6XE>HEhxvB9{z^I(g?N_jv;P^w1})H;`;!_?wDa` zeJt->*4rAesMgsrDWNul>!CkvcCzw-iF&f)PhdcIlv*|J;h`F~{>WkOxry19Ix>he z_AYQq<~qq=92v5iI&_#n)nahZ%8E zcZQt(bYg23+ae2YOWN1gxY^7QesehDy|{|FxTmvVY4)D-{dcrjXTPL{F$iI9QDS^6 zhp7fyN;o5Ot+aXA(+4oRJ6yXvs2JBpKg4cH#BLEG|47hz>ZU*uU4o%u?(iR1{nt5f zyl+@TwGl2Ty@f#TDg^ksj6~A#j^$vLIxMptkV~OpnC~1kh>3?Th_=CLZsN)~E!O8S z)_1v*89cLLkx((MrzP$vXM(Y212g_7A7C~LBViujIeMfO-lDs*h|43M;6kp*g-kn+4VQ@KhZKhJ6BYDyyW~&LGB=Mg&NlCZ|03-7 z>WsxU2U3?j4Qpw2mc&4K3g0T6ZH0puZB=oo@#p3sB$x#8-}kuRGgge}9I~O_?MYdm zw*^ZEKh1QH6&?Tc25g$+>aa)Y0@z>W{S-D2LK-+1pGqJE?+CBq=Z!$jA2aN~Kg z-~Jn}G43pg-ur6>B;-q*^M8murCd$SzecQIR`1eI4i@rGPIm6j|Jr|BQ(XIUN`WKy zhzgibl7mH;r6F$|fLxu0lgKv~Ce=?8F65V>)Pej}M>d?7Z?q5zQ7Y|sCe~e6&U+dp zM~t**V)?LlHo5nslvSX(SE|q=AuvgdH+J zBJECMVYrD3(h2#nFtc#sYDzRxU}7wZdUG6-K3r<%gok2qHzv&Z1}VO z`wXa6`)D&H-c6~3Pa#KB*2Hy5liFm*6#B*bD)q3 zcI;LscetfzSqV=^L;rT2=~EOjAKr$PVy>qh^WN207~`i?EIU2@0YAsz}8JS9g!UYgAO({H4Gxa}rYzjv&SACG_h zPbtUC4)#I$SIWBfbx8kn>MHXuG1)%@SK=#I?PG=y`J6aDKu76-HM}?NJ*}pNhY*?Z z*%(`xj0YBErE8T0^sgisnjC zw)a~mtfaYnqzDU?HrwhsohC27_R-P~TB1d8Zhq4}^^06AufJp_M}S4A%239Y<)*hB#YL}P+Lc3xuMdT(mlVa07Znm2$@=)(wCUnIWLl4ybx--t|XsK|ZQhjiDO5<`g+uUufLD11e8U&3tZIVw|a z&z97^p^ak5bx(IVscRC&Mp}FNllB zQ|T?!Lhr?gG}9D~bxJI#@?rF%@pJ*pnrbwYF%RF}^hju~L**9k;7cnOE6+#CA#M3B zLToAX1;mXh!$^+ckB*DzATfW>&6*SwEHI}!7C4?vSqAWtvY}vp%Uh?tJf+~{*f_E9 zfqZk&%*+?8QR8Z=majKz@T_>x3{6*595-B8^v+tlYxoT&8)}o_C8kiqp=-$Ti%KqI z)J8}qpI$>MC7DudMxeeKl!23cJF)t#EGv?nfvG(%DQHxYl_Q+YD07?i$ga0=HYRH= zW~fn}aoAP0DU^MUtcI0?A=|MfM4?}Gcc3+=HboQ3?z~7_4WDkIj9>=7?@Q8qE>q%0 zwkp#|-rCF!7*>70TKElgq(>aK+^ITonO_DXa_rYjKP3gJp%N0?Q7I_NaWgo33#K|s zdOjf8vMdUeNGYY3C)UYqq#Q#)LMgisur^nvDK!N~HlTlGZ9Jv9b?V<|Vrb5yTI$w0S1*!FG}>BY3y0ET!#uEkU61ec>nnf&hQ zQw?*RJd)IJz=+z73Ji5lxmh(wpm~C?Y1wUnB^(M0oW8#D-h2h?D*Y?>R3BLLw*s}R z`0puq$zQyu;vgw>U$|J>Cr(OoU#Z?NxPJw0qzPpX_Cw&7|-^InX=2YWqfEXA*wS`*ujJnL%;T~>(6|X^dn*O)jeH`f>u+j%3}1|!5A#~999TJHY6p(JVd4y?Pd9J5Ga7a{PYLR95ow zm?GnAxhr8H+qG_2xB3ZIFl4Hm&RCud(4esNgT!cOiJZz*Tbr=enkZ~eP3#=Ktv21f zX``RkOCJX_f5eyL!!_6!oNR_;3NzSC6Z^2St?xNG)wwO!v11Gwcw^;-mZ34k2|9$_ zj}wJK9BRu`X2nWY5pp+@@zpx7bN>@fHi#5tQRGz6p;wW^k-P7Es*x@Ne^sP@9s)yqUp+D10sT4VsydU= zA+<$WsT-gx@<5_(FsVfH^I)qr~LTk4YJrtZa zcUyHQy>bPVmG z0!JFOg(>PpwcQfR+!U+4rerM(oMQI)%e{T-A-XKH9yE6}R3Ltj?J*BAWvmWi-1a00 zpT^Ee%FqroNdcFr`r9eb2r#xhe4pi}Z1{q}mtGW;M60uIYK<0sla2?%_tLFi4|5i!_;0WFMe3cS7UtP8Tqm=k^lmAC@^55V8 z*a-e-MwXoP4;%TAEt?jDKO3S|TTdEA(t5CZu<6Ky*fL?15=^$~e>ZC3Elg}i9V=+y74fYtsN`1 zwhq%aoYu*N)uzlw9PgZ-8}|YxM5T>19qzwhyRL8+Z>$!AZO84j17J>n4add=Sp_Gp z6Gxv|pH>mjvTC@e@3v=gnH&^I4*uo?MqG z&e;f=rQ!reS(htXuK6Hp;Fkn$Ke=!7w8t!)gdMl2}^)!4uilGMKfCK1TGFiWeJLmI_j0z7#7RpHfatw1k`yjFufjjz7)jDHr04xM)R~3?Xoi ze_G<$gbqRM?;!$2Y4idl*?OMBpD^kCe|_kbF{(w4^Vwr+Svx{iIBT%Luk2Ba#zzyQ zE24mLp{y87FXz+C?xH8>P*3Fu)1@dPzt8rYmqKX6;OYqnGMFalz@{OXrw%a)Pm*Vr zrP*_e3VpvZNyB0v^C{cWvhL2a%gL39Jr)J@*je=0(L!t${eX|(b4$tY5h%yKs*J-T zTdUj6%WeSA#J-S23@0)^h)SJ+7pk4v!MBtOE5Je%Iy?6=dLxLx9iXAeK6QA=P0gZ0 zeBh}u1+{5=&7{3@Y?9K0cj%V{-;)>Z;iL}kTX1$mH`R5e#d z?q?t|Us&s}pQQPu8FabA-JfkvmaH;{Hm8?%iLaaO<2s**>uyejeqY1GFl)hXv_b=Z zm2^`ZN*Oktbedpm(OG<|9JOESLv!re7bG9gog%O|@Hl*i>CSOVf61{0S^l=Nr^(k-1IjW(ZE#e#xX`>Gzj=8H5X9@VVz8{RP`FiW+UiT3Pd+WwwUGESt zT%$hg(@wJ5kQN*fFF|;<4N;9>MG*UCD#cGBLAGjU)BVyPt^m_#BCC*iQM1@dCssHJ z0jWtow8731PlqeE$TN3zYv&rC8GJZB~?b|h!gP;LxSK z%Vh0~lDHWsy&_4kxn$9tRV9d4tbxU*O2amYuB*}g$HQ&6m`#&|-D!2X*7deHG_e;;!N;c%X=7_Pds2DP z81;~<(>cfbr(L1qj|zgRMXo>_8;Tt6xjfrCC1>SW6x?se{)_V9uqGhq_X;e_2d4)%T@{eUm;zJ`s1@UtXc_O-ZkWNAEM6yVO z=HOAi-}YQ-L!6RmmTJ74wz?Vc@Dbk<93<@{O(gdD=8l`%^RL#~wWeZfNc?IiSrOLs zF%(wh$MrduPx!ZiG1gYAtY_A&DryJZ0_l~Q8DVs*H^XUTG3n^+w%>f{R?|~1CpDvN zqQnGERu?k3IE`gpK9UX?%|7x6Cy%-3o>EJ@Xq~?P*8FxCFRr;hGF|V3Fpa;JFozl{ zbX4=XQ-4gm7*-j!YAKveJ;v*khKvIBn3q#xdON(qa1=PVv_gSq`nxIf&LC*_}L>r{8vC5p%}`0{tc>=`b&5fqtM z&l*wGlxgHC<}@?Pz)X`?<{X+=EZcEm2Jq!Y7i#&kZ!{iZbeY}H9`e*UzC*~T7i7Wo zf1#uVAE6s1wZVmD(mec-YONwcxl%Rx(`98Kh@nE&e&s_34$`#we^a-7m7KHoOt2Yq zR4P8lH^ewykfC#2ZchIjP4XO|=t+m_oz23fEh95dH#d_i2E#|IfXyQ!IYF{rD~Q#^ z!Sh*xfdEt6IJ?38{Ud1xG43Scx;0+-?Km~5kyWMSx`^3^y@?~ehZD*`pvYn^SCe(Y z9Qq1&Z8DYSc+s^EiPE;Lan+ERq6^HyKzW!I^bBTg<0j~v^U{$;D|Z$*7i@H_XLN%v z($hqc!~H>KE__tc!iecTYrcoEIU-fjv9lzjf%LlhanjyRbd&rx2S~DY%7xBbwGFDRuA>V&I--$5 zz#B8FB%@FZ8wNqvDl*Fo`YH<1iW6;X2R!`_b<7-p^vGBaHLN>&?7e#V)_Ht3)SG@6 z^^p0Fw&6-f&2JeCi1FbI6CFIP3MEuWGFcy@HAeuZjgq;`V~H%n!cf2qy`N&qH1L`C ze$GFOafhzwDYe{C2T-JlHH!s!;Wx;=UIKJQ)GR*Zc4_X`j1O}Gx?*aUo-=#}Y=KC^ zulyt)zoxc!oWz2C5#q_ym*zF|oM)dUKM+|ZKCBIqe}Mt^1>Ov@x`(-r-~75n4>O*> zNo!wNL=CkZy@_>c9CrFbvrbI21M6L_sxWwa9z_o61 z#@t_3oCdun*`XH^b~RPH!BIkar$RSNqNQILTs$4 z1=m#3Ws8sQ>C{`tPYH=s28^lkekSECK3jo3$y_9psEt_MdJF+Rcs@m;-&NC%5L9Tj zcuwBz>cX_nXjC3D&KmPDa;K(88gYp9A#C3&r@HqK0se-rhkNlnlxBf9f6RFot4Y6E zu$nUKQH8dDgWGqOnvDpe`0U8Nz65-9a!bk;ACN1v*uLdY{rLNv{i9%t={5)O!S)H+ z&zJS0dZ_hO!`nSplUL}@PyqOzXteZ<;IfzT)>0WPHLu9~Y2f-O1o)upF1+m?*q969 zGkcFSb(Zz#ogzXNded9KNm0B6{s8!AIDz3Jb;B@E3XXk;-uLv-4#d4bcrz24xALpe zPr0R?n@8f7KHR0~uAC@nEE|`-0K~+bg=lh=-b)RPB8Tp4w8*1v$f~+0#NBi@=80rG zLbHM3Xb9q3)Ba=bOVBcFnpI+L%N~K-0^ra6LgV zoQGgx@>Fp9_|&gOXj)aFJ2aGeiJp+DS-hVpb`CJWG#&s2R#*RW2CF8)l2lv)fs_&v zDH6#?z@2hy3!&!gNt%fc@!Nm-1}%xV8w&fnqTI0x>*N*9W$ zurS>2km>(UU~8pJRf;mu9NSo1@zl2Jmpy+$)gIw~cgXKV`<=1!G=NGH@`Ac4c9x9z%4ObK z;G7bdN@O|jg?Sf3nrODoqDo!msH&@n^@{eM zqKli`MXZiDI0tP82c;)z6<)$;J^#&N>kYIyl1;+Q4duK$jwT!FfOx&;%-`rT(md{O z2YCR|qGv_C?`53Ls zN|>Nb4r#H{ZpBXzwfJ@8zn#+6Z1cCbfPn9Y(ndXQU1bc9&v@B))5k7zS-fzF zu0uNf)X}d;%|r)cKW0ciK@{w1ke36I}#F>azW)}+{4LVRa6>hFDpE_v<>Yct&Gg7D#X zGr>TW@^tU-s2d#eOdI)f7ZoRtAOTask)AWxcP{A)Ik~dDNT(kCsX4vn8|tx#xZKS! z)f=!a&3$znKlPYE9&LorMehvqKhWHJ3MJShyA-(kxJiI-i01(`?bja$*t!J{ATy85 zwAJnWhw0= zO3gWmwV#rSf3Ss?iOL8npo-biH0DX`PC?qO_;EYHCzI!DWs{NkpiXl`E zSJ@<&hMQlD)nMK#R;BvHg1FsyCl*MWxkAoHZL|Akjbq9{I$C-_s~aBj|xLG{1Q0`fi6&eDmkg6gUWD~<>l@vIkp6aG|8#i4lghZ0RzlvA4k|oTx_|AvmwpblPh3Q?vQ$ zviJ|C(hRLvXDOjz=&2Uh<6N2IgW<2U=!rRJj4Hz1CI)bTZlo{Q!`vT#+X&)}n$Rk) zo{$eg-cAZsuQ_vZw2Os#?{oT}S za^fen2%uW+krK7?=d7&oOlIz{VyIpHMVWFuJ5lVEdoq%0n$_T)?3p`N65YCnVh+;Z`$VmW z$%@g#wr5`?(sM|8Bd^=q${SehcZ@T`B9}Ydz;kzWC8r)3r&)bprs5XYUd@oSAGyDc zH%XJI>yf-`tMO?&D#dF?(>g*v3gsCO2o$m(OQj2hZtpyW3xz*AlFC3Y`aO}=7zuM3 zSKbR0mdB@2_Xu+vEZ|u78HSYk7{gs$<%%FAOob@&36 z{hKz_5IPKGB$Ue8yKcmrhP&zri%crx0z0IbhcD@XeWe$9zD_SMXwHlAC8(b1VSsvk zQ`mmn$(&&-?zU=fj65cSJq)H6{E+z!%&6Cy)_HcSL|>XufSN%u!tJ~#WLTg^)F%SF zeN&DTu@Wz6f#DF{T2p@_qE(gb_|ai>Yrhvt<1I^(G$)hpWb%WvooLH5#Gv2E}-9uvfWH82rJAVfn#*F4&R{UEV@lq zs>PxC)PUPzxh9d$QPsWorDQ{p%l(`1qhAx@2`ZSStlSHEXK2&9*muUrcc~U_@b%2W zczLLsiu4J;rbOpA9)q_S##}Y%kw3ueP2VVhB&j z*q;e%B@o62C5kY_zU1y!Sx*XAIQ?d9z9GDIJz10A_*9nnNP>n*I1QqDFB*}|;Aw>c zW`asRpdxV>y#Xdzi0~rG5_?+<{Alf_+y5>SzUt9NG>hQ>{9`MJ@j1clg-&D+fE*3Vpq z<9t4ucL;IFLQID}02-cNTj(d>LXkrIRQQ^!;Yvo4IUTY{w2tv_AN4ufiYg42Sm--x z0>*@+B=sMm-4Nl+s>ho=nVx}EjM6R@)3t0BOT0UZTA5M7Md6n22Rp%s3}P0ft4Bd3 zMCijn=z04VaE$`8-+c8M4y0aX7_?QwPQ^28reU7vbp_!9VwlOPceZ*%rsXOP3}lX>fDn7_WS_#U8pGF^V?%logMxM@+(Z6Skmq;FcR zD88uWH!7OM+oyZ@K+k{=*a`L64qih0SA7LswNMG zW9<1(`WdkqyoLa&2D(Z0g(SpbL#=`$m6h}FU!t79(`FVYYM@T|sK_7a^>E|>Z(-74 zNLWb3w-yC+%#y*gQ@)&y;9!E%*0;&3o_+uWBP@$b#nag$&||4 z7vC6JAfqt4YG%=^o9;=u0vmY?T?Ac(nwC1S%VDi(12^%H!oswwG6c~Zh>&dN24)>? z7!#YD<-tVeil5I9Z^+u1XL?oa>7L#o&P2vyg9+wVjTKo&^F)){`M+HJaW1t?Vs$GF z=Q4wFn+fsq%{T{eoeG`S&r!WA(G`ItS_$#o_D0FUy!-octo}6BS65MVWiDLD|WSTyJHlU@PIQv%v&Q<);xL3=6F& z;X+`6tC%_}RC}(G%XW>8cA=8|%(U)R6I6sRLs$obMJsDhxDFBDxhe=lvd zV6Q*3`ZN%~-n~A-8UcO>6+B7j2ndY?N;$im7JerhX-d?;!2#-RAcsL@vhf2^DPyk* z=g1xR4>*pbKgHVCsAqQ^LliDw2*0;q`7fH;+)M*ugQps>(j5TohBNM!@-AZq47EcCwj`a=HdEIbHa;Z3!G^dmc``K9&&q!~f+L zgx$r~)J2hs4_#nZ*GEir4-Q2|vOvLQI^{15^Wu->wD~b63m9)MfLAlOeA%@x-DaVxn@V24)f9+a3kR-8Updh z?u%W1h9orH6Be>Or6M(i-L~K~g4td`HiX-DfA}FbkOAhHF?;K3qtC%0Ho1~gZU2{~| z=L3rY8-q>*=6*sI^bxlZpPQqpeOFgSf%QmmLcKBVP@$nE5?54t38A_iZ17Pz_KO9D zQ*;GX^dA=k;j5(bvPB!vZ)R(qEz=>GkWa&RU=rt$?N8znjJwHDwmwF99ijI0vN38u%J*D1`|}InU-#j zj-Z@v0~l7HWpr;4C%69eIv{%Uy^HJhf?8Tz7;`Aw@(mA5RL zcd?#qN((v3+M&SqdzT$3SAzKVw`^D2CN=*srP#!bM{m(V?z`wQrt$5xVes<; zOt3N~@bi6USpGym&-`k40Ry|p(}6=}@Ae$`#YS-im`k-T&8QW6&MR4W?G{*B zbwH71w}z*9-B9{o@?|LTt-Y}m=3W!)qDXub`4O#|f5FNBlkKM&OVnR&_<2zeTr(cXYdUqVI zr#zcI+?3P>nt!qdrAb?WjCfX~H#3{8&pE_dLnC}*un^QSL2l-dqlq8X*_f1*+H<|! zD0f?ZU9=BN&aVJ6tluBCa@`_a@=AXh!2}L~k?kfYcTfbhfo3c!#h!e{_}>}crmvto zq+Y!ar3()+zc)a54FeK@FPy;cJu202w%p6^g%L;JJ;1@`;`;%bQi3j|MEPqsBoRw- zm!P=QKm);OMp?g~aY$&Kx9u6^(D_Jg+)7UlQCSfhxd zBjG`FeLu`%?=4nGDVDOr)^!GFUSBswi0iVi?lo9OaG#r#PI-7+L!m8T&l|f{syEyl z9ew*n&_>N*u%Ji#-;q|2n+LQ&kse`IM_GJiO0+pgrQGfSLIG4uiSHkB8t@#zN0p&m zeDI_kaU2g7MU=5T7u`;Gs7^2RSQJSRpSm;jL~$Z4w`(4KU6MB}6qMhohz5N8ywhsf zm>24#qCp8xBg z_wIuWmKrn<^%t(f9wyFqq)!G!O@EZyd>iYsl zlMMQxjn>fy)X zX2$#Lme2>p6=@e-E}9A?8t6PRZV&dRGBeIkC0sL5YA-d#&4ksYKpRLlSW9qg;rUn| zo-T&L4)kjfb$aP1zI*KfRRPAG2=sB+_}0J*{|>w!A1|W_q{3Fp8KOlq^z=ZCfP*Jj zUlLwF2SnaimR)(x=2o| zx|9WL+fSN{Gh7Guk!ZufhQxH4|JT`dfK&bbf04|}9%avrYg00^w-U0lxh}F@o47J6 zlCraRWMz-ctW>fxlPyJYzhDst1{xFlc6_5T^2usg`xt;XcM5izd?f#Vj>AqBz9Im*epnrOfeh9e<(PA0OS*VXSa(wV+)0BiWb_*81c6irES>8E!>3bX$|)l!~RkDvJ8%{-$!Q;F)D6#Pz>}A}*mB$^xAIoxZHPB#*Vl#h8!(Qm|KPK4$h2f{sI*nKPW=ANu(tf=1#>mp&B8gALRL*$VUU24nVlT)-BqWs3vZP-iQ z@rYAQ@=lcCKgGzQ^2CMv6H9fanp5{|b5-Xp)X@jaD7bxuD(*vCD*{Zf;2@cxNZ9w_ zIdv$FtIoJL=>|V@!!q_iM#smiQm@}OBZmoEzPr?}?f(xx#3al=y>OkTd66q4zPMlT z7-5uFd5U@@`!WJp4sBv=Abd zDw(Rr&8Jsp9rLQh?!Nn!QZMkneQM(-_gwlKvECPd@c|eAx6}zM##UduFOC_wx67YB zrn^DcS#3t}ltNOhg7NHyyXlc_6KyzDt%?FwHmw3!!s%ARv~~wuDS=@7DTX<^Pn=~V3mw9q-l5k6jl{SgpSa)A zP9JuCQ)Qkfo}hXC++A(O?+TA0m_`A^nCo88wg^;lPd|V2TGm$HgoZ^V_=b z|0OK=p@svJRz=h}YhX0m$TY}NyJiz*J|suP=#qipplaY7DZ_5 z*mPj$pkphZuiu3ZqzzHZs2%KyFs$U=lST2N-j!ElM)gOGG1sIBf>_Z-k2jRig*FAD z#UB|=d;U(q+-i_)9P_1!z(P+rF&(!A!cV7{bEGd9a+M#Bo}TGEQ^GKx3!#k)i9gDa zxN6X%j??@mDJX4V2Dg9Z{K)#n$FH!NL@L-}9Ua4-nXj4Xyt}#dS*xAAf84LqLJ#iablv{`dv){H(mi`e zxz^;2AYrSCQ~E_h*T#-Bb ziRdh}xq<4KR3Yw^fcO>1WaB!HZ$}wgj*W~*n0^<+?mR!9cS9Y{+Y>ag81@_z8Zq7$ zi$)X`�Zy z^6AJh1X3pXq!CBB#`$5K8SM`A8- zu91@KW`jScvm}!^xaOr;l$}&)!qA=c4=tjb*AM^d9ZpDQjv*NDBXOUm9fM235A&Im zWb|jcBV^{}f>q*lY$s)A{g3K~i*dC}iz|ddMG+h2%gJJkYA%43!xj8A# zx}S=RPcxSSrC^je-O9-uG*4zN`%yO%D|8Y(M!;etj}#5<%)tweodG864mERu+wUwi zqO?7XNoGj5REy(>@FR?cmjdtzHh0Uyxc{bl7pq)x$iETy-gSOl4<=ay@B=!9(wjJhfW}ymgfT)tNU6b0S)wq zMeKw$AI+3w&@(KkXo2zZi+rD-;<`>S;(xh}N&A!yleW!DXaff`xq(&MU0v$=thsf{ zg(^n}x}gz%(ZMmnHv?lM149>hnCRcQl$2k+_R4YyxfW?lIfN`D`XCfH^dukp(N-@j zMOjDZSdpW2Zto4Xiwh$>MX#mx)#OxcM|qz7llutxlZ_J1E-I`Y&pzh)RfL03EK;d5 zsT1+B_S@MLCz)zQys)rDnV4a5!lT8<#kf<49)lNk;@0XW#dWoeCWlSU+e{zMyS1wNXB%6Un^?S8n~Jr%mk_^NT02xU zcTMjr6I|wbWAcf|&V@-_UA*XcHhl7mB~=D;T8nHdVRQX{LQT~{H7`n|hq82!6^^Qw zk3=bdrx(+2sKb?>S1*r#`#OK-jkDlW+^JkfcM1$YFJ9fi*s(8+3Ci?UHN7bY? zh4N;Ruf^YWl3Qug_Tt8ssOAr0u~l&@T3xKa)~WpBgpn}4a($+RfpKJts{-~X3lBbV zc}00$dp*~Rd#{MEJ)=}o%Ba+MxXj)G#S95An)W3pi<`?g$LYqs4y$@&P;h2dic|#Y zLG)4ki^^AYUpsZAtoN-`*PqRPm+BW{Sv93rQm8yHt2BO(SDmGJrDwCJ{h{LXJS+K? zT1`EUhgnKGwTy3CHN7c~OstGDJK;&0nUisI+TC|(NNeXbcpIy&DJ~-gy%PgMJwLdo zM-N=_#u(Fd`$DV<|BjAmhg*xPy8UhsziP>UzRJia${pQz)OyY|sn2Gsb@F5HMbeG4MJ)A6 zip8_D9EG_-mY)rt>E9tGKb6fE<=v;PY4-MR6_G!&r%+)@O^Sbo&N-QmW{8WLEyL}XI25|Lqcq;31FtfOg)YjO+kPkZx<1Xmr5EtjPCpi(FSH)6*cL~Wd3u@NkeeRsqV;PX~8DoAyr~*@QZEkWN8=j68 zK#oirFgtzpre!U$S(>lCULpEEsv^+Ew$A>6ZcsaAzLnn&J!{=Ke|!u)B`dFIl( z?vlF5euE?z5|cU)OPbl|@}Y3*ZkOOxEGXmrJOU-KoLFT{TuqWvZCG2==*;<06n)skW(dvAJ*9=S9v^7qHS$`Dl`eJ81@Mlj~ z%Bo)zV6lv$?7RyQZk6arskVWO0fvBrre8Jb*1R-cnz|i~~_ZLzp^Z zdUn~P6=9O$!Q)VJRz{VIA?$9b0acoc>g7?zFWpmZ`LCh`ie2bgsRy+C*Kf9A&<|h` zsZ76F{`l!LU2>tQjr$3#kYM{%d`Isn`WyaKUjrDwRSP0!kYpX9^R#RX!bjqmXkl!N zs))gf1ol~L3Xef4B?`<1GD_lBnuW{~+??9GRAgt)(@DZTFH|4Pb1o4CG6_f6rtEL@s<5ctjNIRvCMi=l?B-P+D8i*$H^-jz8Z{US(1{-DrHKNdc1xhp*${Nt%oj8oK2`gW#Eln z_W0bDj>|ck)XEBq1P`QeJDFebd}11SLV)K$4t+l=Q{P6MQl7?TD{C;U&*dbLVA^+O|OPt6jn6n7E<+DFOlud1?|k`TpU64 z;$jlu4;R1(yvFk@WgytV_g~pmB`+$<$!chFsmh@uY-a&yhCdS66WdAK#PQ(!wie!> za^US|K-U#D3pwGEmZaAO5FGbBetWB&z!hL(Y#21lO< z==S{#=CQN3-q!B>xq*jTqmfoF$8F`mZFNt^eYl~ZfNo4ZesiHf6ckDWcr$E=Jljnf2>9=rB~7>G4$a`w_O`ZQ>r=(b4ho+AfwCzm=D{`` zxKUQ313J(GXdjVXY;es$Y=PrSl(Ox@gV<_27CbzWPkyI|JZNrZP?!DnC<2`dh3H?f zl1?xeTOery;+#Pp_VzDOo33PR@(U$^hXMHgO(zGQ-u@f@FXqv(zXpH6P(7H2 z_BZ4J^&wCtEkGBMvvP8VYq*&1nE&7&Q|V%yoCd7S0*oDU|z z;;3i(25RC0#+>LbI=E&a?3fNgAO*FscLLGy4pEgQ+a;py{$7t;FDno1Gd|q8GdaBptjT1bT9H=(4$xg(a^;9al$zc!KrKq zG}eBa?`J81tSKCNupu9b9huAk)ms5{`wf}KcL*v~D`#g=p`T=682*7N*bv<$7ceyg zru~&l5j+Ib4uzYE6ZEf@!Y__6tN~QHfa>f%`(*+Ln!mQ$PpZE)QXFUfR5qAR(m^-e zcFWmK8Hh44whl@1*Qy9}vM%I+s+5DNeg8-*21Yz2%g21|mWF5LAD))kxG9Vie$C1GCQds%bZ6Ads?$z`tU5 z?SB|JXQy=zH6(LHy8kTU;v!ohrDI+JF=6#HPj6L z|5+8_zB(ti&9ez=A-s>L*YYw(a_ang3D#00_4+d%7%~TH_MtMMYJ%-CwE6y#;b4P%poCH0gPXelM>tU415{2?ON$z{cn`ie z;z0Pn#V|%CK#d2vM=<>0K!X2{4v7kl8m4a#Iw|o$Xq2FRsCcNs@b>U-CLN5oKQtaH z9%}rWJv`>@KjQr!%?1_vJW5cJJ?QzIKS3Yd$56fS_t3Dxe#5^OH@lP3zkTvii-zhZ zy$4p>cp%t5huZ&gnnqa?_nIo@#~ChARYp9>ReiBVku_RyDJ v9f-cOr*eQp04g-<;pZOo<=#I*?>`DvQ^o}A^zD`USu`GEG&HBt?O*=~soeXc literal 43462 zcma&NWl&^owk(X(xVyW%ySuwf;qI=D6|RlDJ2cR^yEKh!@I- zp9QeisK*rlxC>+~7Dk4IxIRsKBHqdR9b3+fyL=ynHmIDe&|>O*VlvO+%z5;9Z$|DJ zb4dO}-R=MKr^6EKJiOrJdLnCJn>np?~vU-1sSFgPu;pthGwf}bG z(1db%xwr#x)r+`4AGu$j7~u2MpVs3VpLp|mx&;>`0p0vH6kF+D2CY0fVdQOZ@h;A` z{infNyvmFUiu*XG}RNMNwXrbec_*a3N=2zJ|Wh5z* z5rAX$JJR{#zP>KY**>xHTuw?|-Rg|o24V)74HcfVT;WtQHXlE+_4iPE8QE#DUm%x0 zEKr75ur~W%w#-My3Tj`hH6EuEW+8K-^5P62$7Sc5OK+22qj&Pd1;)1#4tKihi=~8C zHiQSst0cpri6%OeaR`PY>HH_;CPaRNty%WTm4{wDK8V6gCZlG@U3$~JQZ;HPvDJcT1V{ z?>H@13MJcCNe#5z+MecYNi@VT5|&UiN1D4ATT+%M+h4c$t;C#UAs3O_q=GxK0}8%8 z8J(_M9bayxN}69ex4dzM_P3oh@ZGREjVvn%%r7=xjkqxJP4kj}5tlf;QosR=%4L5y zWhgejO=vao5oX%mOHbhJ8V+SG&K5dABn6!WiKl{|oPkq(9z8l&Mm%(=qGcFzI=eLu zWc_oCLyf;hVlB@dnwY98?75B20=n$>u3b|NB28H0u-6Rpl((%KWEBOfElVWJx+5yg z#SGqwza7f}$z;n~g%4HDU{;V{gXIhft*q2=4zSezGK~nBgu9-Q*rZ#2f=Q}i2|qOp z!!y4p)4o=LVUNhlkp#JL{tfkhXNbB=Ox>M=n6soptJw-IDI|_$is2w}(XY>a=H52d z3zE$tjPUhWWS+5h=KVH&uqQS=$v3nRs&p$%11b%5qtF}S2#Pc`IiyBIF4%A!;AVoI zXU8-Rpv!DQNcF~(qQnyyMy=-AN~U>#&X1j5BLDP{?K!%h!;hfJI>$mdLSvktEr*89 zdJHvby^$xEX0^l9g$xW-d?J;L0#(`UT~zpL&*cEh$L|HPAu=P8`OQZV!-}l`noSp_ zQ-1$q$R-gDL)?6YaM!=8H=QGW$NT2SeZlb8PKJdc=F-cT@j7Xags+Pr*jPtlHFnf- zh?q<6;)27IdPc^Wdy-mX%2s84C1xZq9Xms+==F4);O`VUASmu3(RlgE#0+#giLh-& zcxm3_e}n4{%|X zJp{G_j+%`j_q5}k{eW&TlP}J2wtZ2^<^E(O)4OQX8FDp6RJq!F{(6eHWSD3=f~(h} zJXCf7=r<16X{pHkm%yzYI_=VDP&9bmI1*)YXZeB}F? z(%QsB5fo*FUZxK$oX~X^69;x~j7ms8xlzpt-T15e9}$4T-pC z6PFg@;B-j|Ywajpe4~bk#S6(fO^|mm1hKOPfA%8-_iGCfICE|=P_~e;Wz6my&)h_~ zkv&_xSAw7AZ%ThYF(4jADW4vg=oEdJGVOs>FqamoL3Np8>?!W#!R-0%2Bg4h?kz5I zKV-rKN2n(vUL%D<4oj@|`eJ>0i#TmYBtYmfla;c!ATW%;xGQ0*TW@PTlGG><@dxUI zg>+3SiGdZ%?5N=8uoLA|$4isK$aJ%i{hECP$bK{J#0W2gQ3YEa zZQ50Stn6hqdfxJ*9#NuSLwKFCUGk@c=(igyVL;;2^wi4o30YXSIb2g_ud$ zgpCr@H0qWtk2hK8Q|&wx)}4+hTYlf;$a4#oUM=V@Cw#!$(nOFFpZ;0lc!qd=c$S}Z zGGI-0jg~S~cgVT=4Vo)b)|4phjStD49*EqC)IPwyeKBLcN;Wu@Aeph;emROAwJ-0< z_#>wVm$)ygH|qyxZaet&(Vf%pVdnvKWJn9`%DAxj3ot;v>S$I}jJ$FLBF*~iZ!ZXE zkvui&p}fI0Y=IDX)mm0@tAd|fEHl~J&K}ZX(Mm3cm1UAuwJ42+AO5@HwYfDH7ipIc zmI;1J;J@+aCNG1M`Btf>YT>~c&3j~Qi@Py5JT6;zjx$cvOQW@3oQ>|}GH?TW-E z1R;q^QFjm5W~7f}c3Ww|awg1BAJ^slEV~Pk`Kd`PS$7;SqJZNj->it4DW2l15}xP6 zoCl$kyEF%yJni0(L!Z&14m!1urXh6Btj_5JYt1{#+H8w?5QI%% zo-$KYWNMJVH?Hh@1n7OSu~QhSswL8x0=$<8QG_zepi_`y_79=nK=_ZP_`Em2UI*tyQoB+r{1QYZCpb?2OrgUw#oRH$?^Tj!Req>XiE#~B|~ z+%HB;=ic+R@px4Ld8mwpY;W^A%8%l8$@B@1m5n`TlKI6bz2mp*^^^1mK$COW$HOfp zUGTz-cN9?BGEp}5A!mDFjaiWa2_J2Iq8qj0mXzk; z66JBKRP{p%wN7XobR0YjhAuW9T1Gw3FDvR5dWJ8ElNYF94eF3ebu+QwKjtvVu4L zI9ip#mQ@4uqVdkl-TUQMb^XBJVLW(-$s;Nq;@5gr4`UfLgF$adIhd?rHOa%D);whv z=;krPp~@I+-Z|r#s3yCH+c1US?dnm+C*)r{m+86sTJusLdNu^sqLrfWed^ndHXH`m zd3#cOe3>w-ga(Dus_^ppG9AC>Iq{y%%CK+Cro_sqLCs{VLuK=dev>OL1dis4(PQ5R zcz)>DjEkfV+MO;~>VUlYF00SgfUo~@(&9$Iy2|G0T9BSP?&T22>K46D zL*~j#yJ?)^*%J3!16f)@Y2Z^kS*BzwfAQ7K96rFRIh>#$*$_Io;z>ux@}G98!fWR@ zGTFxv4r~v)Gsd|pF91*-eaZ3Qw1MH$K^7JhWIdX%o$2kCbvGDXy)a?@8T&1dY4`;L z4Kn+f%SSFWE_rpEpL9bnlmYq`D!6F%di<&Hh=+!VI~j)2mfil03T#jJ_s?}VV0_hp z7T9bWxc>Jm2Z0WMU?`Z$xE74Gu~%s{mW!d4uvKCx@WD+gPUQ zV0vQS(Ig++z=EHN)BR44*EDSWIyT~R4$FcF*VEY*8@l=218Q05D2$|fXKFhRgBIEE zdDFB}1dKkoO^7}{5crKX!p?dZWNz$m>1icsXG2N+((x0OIST9Zo^DW_tytvlwXGpn zs8?pJXjEG;T@qrZi%#h93?FP$!&P4JA(&H61tqQi=opRzNpm zkrG}$^t9&XduK*Qa1?355wd8G2CI6QEh@Ua>AsD;7oRUNLPb76m4HG3K?)wF~IyS3`fXuNM>${?wmB zpVz;?6_(Fiadfd{vUCBM*_kt$+F3J+IojI;9L(gc9n3{sEZyzR9o!_mOwFC#tQ{Q~ zP3-`#uK#tP3Q7~Q;4H|wjZHO8h7e4IuBxl&vz2w~D8)w=Wtg31zpZhz%+kzSzL*dV zwp@{WU4i;hJ7c2f1O;7Mz6qRKeASoIv0_bV=i@NMG*l<#+;INk-^`5w@}Dj~;k=|}qM1vq_P z|GpBGe_IKq|LNy9SJhKOQ$c=5L{Dv|Q_lZl=-ky*BFBJLW9&y_C|!vyM~rQx=!vun z?rZJQB5t}Dctmui5i31C_;_}CEn}_W%>oSXtt>@kE1=JW*4*v4tPp;O6 zmAk{)m!)}34pTWg8{i>($%NQ(Tl;QC@J@FfBoc%Gr&m560^kgSfodAFrIjF}aIw)X zoXZ`@IsMkc8_=w%-7`D6Y4e*CG8k%Ud=GXhsTR50jUnm+R*0A(O3UKFg0`K;qp1bl z7``HN=?39ic_kR|^R^~w-*pa?Vj#7|e9F1iRx{GN2?wK!xR1GW!qa=~pjJb-#u1K8 zeR?Y2i-pt}yJq;SCiVHODIvQJX|ZJaT8nO+(?HXbLefulKKgM^B(UIO1r+S=7;kLJ zcH}1J=Px2jsh3Tec&v8Jcbng8;V-`#*UHt?hB(pmOipKwf3Lz8rG$heEB30Sg*2rx zV<|KN86$soN(I!BwO`1n^^uF2*x&vJ$2d$>+`(romzHP|)K_KkO6Hc>_dwMW-M(#S zK(~SiXT1@fvc#U+?|?PniDRm01)f^#55;nhM|wi?oG>yBsa?~?^xTU|fX-R(sTA+5 zaq}-8Tx7zrOy#3*JLIIVsBmHYLdD}!0NP!+ITW+Thn0)8SS!$@)HXwB3tY!fMxc#1 zMp3H?q3eD?u&Njx4;KQ5G>32+GRp1Ee5qMO0lZjaRRu&{W<&~DoJNGkcYF<5(Ab+J zgO>VhBl{okDPn78<%&e2mR{jwVCz5Og;*Z;;3%VvoGo_;HaGLWYF7q#jDX=Z#Ml`H z858YVV$%J|e<1n`%6Vsvq7GmnAV0wW4$5qQ3uR@1i>tW{xrl|ExywIc?fNgYlA?C5 zh$ezAFb5{rQu6i7BSS5*J-|9DQ{6^BVQ{b*lq`xS@RyrsJN?-t=MTMPY;WYeKBCNg z^2|pN!Q^WPJuuO4!|P@jzt&tY1Y8d%FNK5xK(!@`jO2aEA*4 zkO6b|UVBipci?){-Ke=+1;mGlND8)6+P;8sq}UXw2hn;fc7nM>g}GSMWu&v&fqh

iViYT=fZ(|3Ox^$aWPp4a8h24tD<|8-!aK0lHgL$N7Efw}J zVIB!7=T$U`ao1?upi5V4Et*-lTG0XvExbf!ya{cua==$WJyVG(CmA6Of*8E@DSE%L z`V^$qz&RU$7G5mg;8;=#`@rRG`-uS18$0WPN@!v2d{H2sOqP|!(cQ@ zUHo!d>>yFArLPf1q`uBvY32miqShLT1B@gDL4XoVTK&@owOoD)OIHXrYK-a1d$B{v zF^}8D3Y^g%^cnvScOSJR5QNH+BI%d|;J;wWM3~l>${fb8DNPg)wrf|GBP8p%LNGN# z3EaIiItgwtGgT&iYCFy9-LG}bMI|4LdmmJt@V@% zb6B)1kc=T)(|L@0;wr<>=?r04N;E&ef+7C^`wPWtyQe(*pD1pI_&XHy|0gIGHMekd zF_*M4yi6J&Z4LQj65)S zXwdM{SwUo%3SbPwFsHgqF@V|6afT|R6?&S;lw=8% z3}@9B=#JI3@B*#4s!O))~z zc>2_4Q_#&+5V`GFd?88^;c1i7;Vv_I*qt!_Yx*n=;rj!82rrR2rQ8u5(Ejlo{15P% zs~!{%XJ>FmJ})H^I9bn^Re&38H{xA!0l3^89k(oU;bZWXM@kn$#aoS&Y4l^-WEn-fH39Jb9lA%s*WsKJQl?n9B7_~P z-XM&WL7Z!PcoF6_D>V@$CvUIEy=+Z&0kt{szMk=f1|M+r*a43^$$B^MidrT0J;RI` z(?f!O<8UZkm$_Ny$Hth1J#^4ni+im8M9mr&k|3cIgwvjAgjH z8`N&h25xV#v*d$qBX5jkI|xOhQn!>IYZK7l5#^P4M&twe9&Ey@@GxYMxBZq2e7?`q z$~Szs0!g{2fGcp9PZEt|rdQ6bhAgpcLHPz?f-vB?$dc*!9OL?Q8mn7->bFD2Si60* z!O%y)fCdMSV|lkF9w%x~J*A&srMyYY3{=&$}H zGQ4VG_?$2X(0|vT0{=;W$~icCI{b6W{B!Q8xdGhF|D{25G_5_+%s(46lhvNLkik~R z>nr(&C#5wwOzJZQo9m|U<;&Wk!_#q|V>fsmj1g<6%hB{jGoNUPjgJslld>xmODzGjYc?7JSuA?A_QzjDw5AsRgi@Y|Z0{F{!1=!NES-#*f^s4l0Hu zz468))2IY5dmD9pa*(yT5{EyP^G>@ZWumealS-*WeRcZ}B%gxq{MiJ|RyX-^C1V=0 z@iKdrGi1jTe8Ya^x7yyH$kBNvM4R~`fbPq$BzHum-3Zo8C6=KW@||>zsA8-Y9uV5V z#oq-f5L5}V<&wF4@X@<3^C%ptp6+Ce)~hGl`kwj)bsAjmo_GU^r940Z-|`<)oGnh7 zFF0Tde3>ui?8Yj{sF-Z@)yQd~CGZ*w-6p2U<8}JO-sRsVI5dBji`01W8A&3$?}lxBaC&vn0E$c5tW* zX>5(zzZ=qn&!J~KdsPl;P@bmA-Pr8T*)eh_+Dv5=Ma|XSle6t(k8qcgNyar{*ReQ8 zTXwi=8vr>!3Ywr+BhggHDw8ke==NTQVMCK`$69fhzEFB*4+H9LIvdt-#IbhZvpS}} zO3lz;P?zr0*0$%-Rq_y^k(?I{Mk}h@w}cZpMUp|ucs55bcloL2)($u%mXQw({Wzc~ z;6nu5MkjP)0C(@%6Q_I_vsWrfhl7Zpoxw#WoE~r&GOSCz;_ro6i(^hM>I$8y>`!wW z*U^@?B!MMmb89I}2(hcE4zN2G^kwyWCZp5JG>$Ez7zP~D=J^LMjSM)27_0B_X^C(M z`fFT+%DcKlu?^)FCK>QzSnV%IsXVcUFhFdBP!6~se&xxrIxsvySAWu++IrH;FbcY$ z2DWTvSBRfLwdhr0nMx+URA$j3i7_*6BWv#DXfym?ZRDcX9C?cY9sD3q)uBDR3uWg= z(lUIzB)G$Hr!){>E{s4Dew+tb9kvToZp-1&c?y2wn@Z~(VBhqz`cB;{E4(P3N2*nJ z_>~g@;UF2iG{Kt(<1PyePTKahF8<)pozZ*xH~U-kfoAayCwJViIrnqwqO}7{0pHw$ zs2Kx?s#vQr7XZ264>5RNKSL8|Ty^=PsIx^}QqOOcfpGUU4tRkUc|kc7-!Ae6!+B{o~7nFpm3|G5^=0#Bnm6`V}oSQlrX(u%OWnC zoLPy&Q;1Jui&7ST0~#+}I^&?vcE*t47~Xq#YwvA^6^} z`WkC)$AkNub|t@S!$8CBlwbV~?yp&@9h{D|3z-vJXgzRC5^nYm+PyPcgRzAnEi6Q^gslXYRv4nycsy-SJu?lMps-? zV`U*#WnFsdPLL)Q$AmD|0`UaC4ND07+&UmOu!eHruzV|OUox<+Jl|Mr@6~C`T@P%s zW7sgXLF2SSe9Fl^O(I*{9wsFSYb2l%-;&Pi^dpv!{)C3d0AlNY6!4fgmSgj_wQ*7Am7&$z;Jg&wgR-Ih;lUvWS|KTSg!&s_E9_bXBkZvGiC6bFKDWZxsD$*NZ#_8bl zG1P-#@?OQzED7@jlMJTH@V!6k;W>auvft)}g zhoV{7$q=*;=l{O>Q4a@ ziMjf_u*o^PsO)#BjC%0^h>Xp@;5$p{JSYDt)zbb}s{Kbt!T*I@Pk@X0zds6wsefuU zW$XY%yyRGC94=6mf?x+bbA5CDQ2AgW1T-jVAJbm7K(gp+;v6E0WI#kuACgV$r}6L? zd|Tj?^%^*N&b>Dd{Wr$FS2qI#Ucs1yd4N+RBUQiSZGujH`#I)mG&VKoDh=KKFl4=G z&MagXl6*<)$6P}*Tiebpz5L=oMaPrN+caUXRJ`D?=K9!e0f{@D&cZLKN?iNP@X0aF zE(^pl+;*T5qt?1jRC=5PMgV!XNITRLS_=9{CJExaQj;lt!&pdzpK?8p>%Mb+D z?yO*uSung=-`QQ@yX@Hyd4@CI^r{2oiu`%^bNkz+Nkk!IunjwNC|WcqvX~k=><-I3 zDQdbdb|!v+Iz01$w@aMl!R)koD77Xp;eZwzSl-AT zr@Vu{=xvgfq9akRrrM)}=!=xcs+U1JO}{t(avgz`6RqiiX<|hGG1pmop8k6Q+G_mv zJv|RfDheUp2L3=^C=4aCBMBn0aRCU(DQwX-W(RkRwmLeuJYF<0urcaf(=7)JPg<3P zQs!~G)9CT18o!J4{zX{_e}4eS)U-E)0FAt}wEI(c0%HkxgggW;(1E=>J17_hsH^sP z%lT0LGgbUXHx-K*CI-MCrP66UP0PvGqM$MkeLyqHdbgP|_Cm!7te~b8p+e6sQ_3k| zVcwTh6d83ltdnR>D^)BYQpDKlLk3g0Hdcgz2}%qUs9~~Rie)A-BV1mS&naYai#xcZ z(d{8=-LVpTp}2*y)|gR~;qc7fp26}lPcLZ#=JpYcn3AT9(UIdOyg+d(P5T7D&*P}# zQCYplZO5|7+r19%9e`v^vfSS1sbX1c%=w1;oyruXB%Kl$ACgKQ6=qNWLsc=28xJjg zwvsI5-%SGU|3p>&zXVl^vVtQT3o-#$UT9LI@Npz~6=4!>mc431VRNN8od&Ul^+G_kHC`G=6WVWM z%9eWNyy(FTO|A+@x}Ou3CH)oi;t#7rAxdIXfNFwOj_@Y&TGz6P_sqiB`Q6Lxy|Q{`|fgmRG(k+!#b*M+Z9zFce)f-7;?Km5O=LHV9f9_87; zF7%R2B+$?@sH&&-$@tzaPYkw0;=i|;vWdI|Wl3q_Zu>l;XdIw2FjV=;Mq5t1Q0|f< zs08j54Bp`3RzqE=2enlkZxmX6OF+@|2<)A^RNQpBd6o@OXl+i)zO%D4iGiQNuXd+zIR{_lb96{lc~bxsBveIw6umhShTX+3@ZJ=YHh@ zWY3(d0azg;7oHn>H<>?4@*RQbi>SmM=JrHvIG(~BrvI)#W(EAeO6fS+}mxxcc+X~W6&YVl86W9WFSS}Vz-f9vS?XUDBk)3TcF z8V?$4Q)`uKFq>xT=)Y9mMFVTUk*NIA!0$?RP6Ig0TBmUFrq*Q-Agq~DzxjStQyJ({ zBeZ;o5qUUKg=4Hypm|}>>L=XKsZ!F$yNTDO)jt4H0gdQ5$f|d&bnVCMMXhNh)~mN z@_UV6D7MVlsWz+zM+inZZp&P4fj=tm6fX)SG5H>OsQf_I8c~uGCig$GzuwViK54bcgL;VN|FnyQl>Ed7(@>=8$a_UKIz|V6CeVSd2(P z0Uu>A8A+muM%HLFJQ9UZ5c)BSAv_zH#1f02x?h9C}@pN@6{>UiAp>({Fn(T9Q8B z^`zB;kJ5b`>%dLm+Ol}ty!3;8f1XDSVX0AUe5P#@I+FQ-`$(a;zNgz)4x5hz$Hfbg z!Q(z26wHLXko(1`;(BAOg_wShpX0ixfWq3ponndY+u%1gyX)_h=v1zR#V}#q{au6; z!3K=7fQwnRfg6FXtNQmP>`<;!N137paFS%y?;lb1@BEdbvQHYC{976l`cLqn;b8lp zIDY>~m{gDj(wfnK!lpW6pli)HyLEiUrNc%eXTil|F2s(AY+LW5hkKb>TQ3|Q4S9rr zpDs4uK_co6XPsn_z$LeS{K4jFF`2>U`tbgKdyDne`xmR<@6AA+_hPNKCOR-Zqv;xk zu5!HsBUb^!4uJ7v0RuH-7?l?}b=w5lzzXJ~gZcxRKOovSk@|#V+MuX%Y+=;14i*%{)_gSW9(#4%)AV#3__kac1|qUy!uyP{>?U#5wYNq}y$S9pCc zFc~4mgSC*G~j0u#qqp9 z${>3HV~@->GqEhr_Xwoxq?Hjn#=s2;i~g^&Hn|aDKpA>Oc%HlW(KA1?BXqpxB;Ydx)w;2z^MpjJ(Qi(X!$5RC z*P{~%JGDQqojV>2JbEeCE*OEu!$XJ>bWA9Oa_Hd;y)F%MhBRi*LPcdqR8X`NQ&1L# z5#9L*@qxrx8n}LfeB^J{%-?SU{FCwiWyHp682F+|pa+CQa3ZLzBqN1{)h4d6+vBbV zC#NEbQLC;}me3eeYnOG*nXOJZEU$xLZ1<1Y=7r0(-U0P6-AqwMAM`a(Ed#7vJkn6plb4eI4?2y3yOTGmmDQ!z9`wzbf z_OY#0@5=bnep;MV0X_;;SJJWEf^E6Bd^tVJ9znWx&Ks8t*B>AM@?;D4oWUGc z!H*`6d7Cxo6VuyS4Eye&L1ZRhrRmN6Lr`{NL(wDbif|y&z)JN>Fl5#Wi&mMIr5i;x zBx}3YfF>>8EC(fYnmpu~)CYHuHCyr5*`ECap%t@y=jD>!_%3iiE|LN$mK9>- zHdtpy8fGZtkZF?%TW~29JIAfi2jZT8>OA7=h;8T{{k?c2`nCEx9$r zS+*&vt~2o^^J+}RDG@+9&M^K*z4p{5#IEVbz`1%`m5c2};aGt=V?~vIM}ZdPECDI)47|CWBCfDWUbxBCnmYivQ*0Nu_xb*C>~C9(VjHM zxe<*D<#dQ8TlpMX2c@M<9$w!RP$hpG4cs%AI){jp*Sj|*`m)5(Bw*A0$*i-(CA5#%>a)$+jI2C9r6|(>J8InryENI z$NohnxDUB;wAYDwrb*!N3noBTKPpPN}~09SEL18tkG zxgz(RYU_;DPT{l?Q$+eaZaxnsWCA^ds^0PVRkIM%bOd|G2IEBBiz{&^JtNsODs;5z zICt_Zj8wo^KT$7Bg4H+y!Df#3mbl%%?|EXe!&(Vmac1DJ*y~3+kRKAD=Ovde4^^%~ zw<9av18HLyrf*_>Slp;^i`Uy~`mvBjZ|?Ad63yQa#YK`4+c6;pW4?XIY9G1(Xh9WO8{F-Aju+nS9Vmv=$Ac0ienZ+p9*O%NG zMZKy5?%Z6TAJTE?o5vEr0r>f>hb#2w2U3DL64*au_@P!J!TL`oH2r*{>ffu6|A7tv zL4juf$DZ1MW5ZPsG!5)`k8d8c$J$o;%EIL0va9&GzWvkS%ZsGb#S(?{!UFOZ9<$a| zY|a+5kmD5N&{vRqkgY>aHsBT&`rg|&kezoD)gP0fsNYHsO#TRc_$n6Lf1Z{?+DLziXlHrq4sf(!>O{?Tj;Eh@%)+nRE_2VxbN&&%%caU#JDU%vL3}Cb zsb4AazPI{>8H&d=jUaZDS$-0^AxE@utGs;-Ez_F(qC9T=UZX=>ok2k2 ziTn{K?y~a5reD2A)P${NoI^>JXn>`IeArow(41c-Wm~)wiryEP(OS{YXWi7;%dG9v zI?mwu1MxD{yp_rrk!j^cKM)dc4@p4Ezyo%lRN|XyD}}>v=Xoib0gOcdXrQ^*61HNj z=NP|pd>@yfvr-=m{8$3A8TQGMTE7g=z!%yt`8`Bk-0MMwW~h^++;qyUP!J~ykh1GO z(FZ59xuFR$(WE;F@UUyE@Sp>`aVNjyj=Ty>_Vo}xf`e7`F;j-IgL5`1~-#70$9_=uBMq!2&1l zomRgpD58@)YYfvLtPW}{C5B35R;ZVvB<<#)x%srmc_S=A7F@DW8>QOEGwD6suhwCg z>Pa+YyULhmw%BA*4yjDp|2{!T98~<6Yfd(wo1mQ!KWwq0eg+6)o1>W~f~kL<-S+P@$wx*zeI|1t7z#Sxr5 zt6w+;YblPQNplq4Z#T$GLX#j6yldXAqj>4gAnnWtBICUnA&-dtnlh=t0Ho_vEKwV` z)DlJi#!@nkYV#$!)@>udAU*hF?V`2$Hf=V&6PP_|r#Iv*J$9)pF@X3`k;5})9^o4y z&)~?EjX5yX12O(BsFy-l6}nYeuKkiq`u9145&3Ssg^y{5G3Pse z9w(YVa0)N-fLaBq1`P!_#>SS(8fh_5!f{UrgZ~uEdeMJIz7DzI5!NHHqQtm~#CPij z?=N|J>nPR6_sL7!f4hD_|KH`vf8(Wpnj-(gPWH+ZvID}%?~68SwhPTC3u1_cB`otq z)U?6qo!ZLi5b>*KnYHWW=3F!p%h1;h{L&(Q&{qY6)_qxNfbP6E3yYpW!EO+IW3?@J z);4>g4gnl^8klu7uA>eGF6rIGSynacogr)KUwE_R4E5Xzi*Qir@b-jy55-JPC8c~( zo!W8y9OGZ&`xmc8;=4-U9=h{vCqfCNzYirONmGbRQlR`WWlgnY+1wCXbMz&NT~9*| z6@FrzP!LX&{no2!Ln_3|I==_4`@}V?4a;YZKTdw;vT<+K+z=uWbW(&bXEaWJ^W8Td z-3&1bY^Z*oM<=M}LVt>_j+p=2Iu7pZmbXrhQ_k)ysE9yXKygFNw$5hwDn(M>H+e1&9BM5!|81vd%r%vEm zqxY3?F@fb6O#5UunwgAHR9jp_W2zZ}NGp2%mTW@(hz7$^+a`A?mb8|_G*GNMJ) zjqegXQio=i@AINre&%ofexAr95aop5C+0MZ0m-l=MeO8m3epm7U%vZB8+I+C*iNFM z#T3l`gknX;D$-`2XT^Cg*vrv=RH+P;_dfF++cP?B_msQI4j+lt&rX2)3GaJx%W*Nn zkML%D{z5tpHH=dksQ*gzc|}gzW;lwAbxoR07VNgS*-c3d&8J|;@3t^ zVUz*J*&r7DFRuFVDCJDK8V9NN5hvpgGjwx+5n)qa;YCKe8TKtdnh{I7NU9BCN!0dq zczrBk8pE{{@vJa9ywR@mq*J=v+PG;?fwqlJVhijG!3VmIKs>9T6r7MJpC)m!Tc#>g zMtVsU>wbwFJEfwZ{vB|ZlttNe83)$iz`~#8UJ^r)lJ@HA&G#}W&ZH*;k{=TavpjWE z7hdyLZPf*X%Gm}i`Y{OGeeu^~nB8=`{r#TUrM-`;1cBvEd#d!kPqIgYySYhN-*1;L z^byj%Yi}Gx)Wnkosi337BKs}+5H5dth1JA{Ir-JKN$7zC)*}hqeoD(WfaUDPT>0`- z(6sa0AoIqASwF`>hP}^|)a_j2s^PQn*qVC{Q}htR z5-)duBFXT_V56-+UohKXlq~^6uf!6sA#ttk1o~*QEy_Y-S$gAvq47J9Vtk$5oA$Ct zYhYJ@8{hsC^98${!#Ho?4y5MCa7iGnfz}b9jE~h%EAAv~Qxu)_rAV;^cygV~5r_~?l=B`zObj7S=H=~$W zPtI_m%g$`kL_fVUk9J@>EiBH zOO&jtn~&`hIFMS5S`g8w94R4H40mdNUH4W@@XQk1sr17b{@y|JB*G9z1|CrQjd+GX z6+KyURG3;!*BQrentw{B2R&@2&`2}n(z-2&X7#r!{yg@Soy}cRD~j zj9@UBW+N|4HW4AWapy4wfUI- zZ`gSL6DUlgj*f1hSOGXG0IVH8HxK?o2|3HZ;KW{K+yPAlxtb)NV_2AwJm|E)FRs&& z=c^e7bvUsztY|+f^k7NXs$o1EUq>cR7C0$UKi6IooHWlK_#?IWDkvywnzg&ThWo^? z2O_N{5X39#?eV9l)xI(>@!vSB{DLt*oY!K1R8}_?%+0^C{d9a%N4 zoxHVT1&Lm|uDX%$QrBun5e-F`HJ^T$ zmzv)p@4ZHd_w9!%Hf9UYNvGCw2TTTbrj9pl+T9%-_-}L(tES>Or-}Z4F*{##n3~L~TuxjirGuIY#H7{%$E${?p{Q01 zi6T`n;rbK1yIB9jmQNycD~yZq&mbIsFWHo|ZAChSFPQa<(%d8mGw*V3fh|yFoxOOiWJd(qvVb!Z$b88cg->N=qO*4k~6;R==|9ihg&riu#P~s4Oap9O7f%crSr^rljeIfXDEg>wi)&v*a%7zpz<9w z*r!3q9J|390x`Zk;g$&OeN&ctp)VKRpDSV@kU2Q>jtok($Y-*x8_$2piTxun81@vt z!Vj?COa0fg2RPXMSIo26T=~0d`{oGP*eV+$!0I<(4azk&Vj3SiG=Q!6mX0p$z7I}; z9BJUFgT-K9MQQ-0@Z=^7R<{bn2Fm48endsSs`V7_@%8?Bxkqv>BDoVcj?K#dV#uUP zL1ND~?D-|VGKe3Rw_7-Idpht>H6XRLh*U7epS6byiGvJpr%d}XwfusjH9g;Z98H`x zyde%%5mhGOiL4wljCaWCk-&uE4_OOccb9c!ZaWt4B(wYl!?vyzl%7n~QepN&eFUrw zFIOl9c({``6~QD+43*_tzP{f2x41h(?b43^y6=iwyB)2os5hBE!@YUS5?N_tXd=h( z)WE286Fbd>R4M^P{!G)f;h<3Q>Fipuy+d2q-)!RyTgt;wr$(?9ox3;q+{E*ZQHhOn;lM`cjnu9 zXa48ks-v(~b*;MAI<>YZH(^NV8vjb34beE<_cwKlJoR;k6lJNSP6v}uiyRD?|0w+X@o1ONrH8a$fCxXpf? z?$DL0)7|X}Oc%h^zrMKWc-NS9I0Utu@>*j}b@tJ=ixQSJ={4@854wzW@E>VSL+Y{i z#0b=WpbCZS>kUCO_iQz)LoE>P5LIG-hv9E+oG}DtlIDF>$tJ1aw9^LuhLEHt?BCj& z(O4I8v1s#HUi5A>nIS-JK{v!7dJx)^Yg%XjNmlkWAq2*cv#tHgz`Y(bETc6CuO1VkN^L-L3j_x<4NqYb5rzrLC-7uOv z!5e`GZt%B782C5-fGnn*GhDF$%(qP<74Z}3xx+{$4cYKy2ikxI7B2N+2r07DN;|-T->nU&!=Cm#rZt%O_5c&1Z%nlWq3TKAW0w zQqemZw_ue--2uKQsx+niCUou?HjD`xhEjjQd3%rrBi82crq*~#uA4+>vR<_S{~5ce z-2EIl?~s z1=GVL{NxP1N3%=AOaC}j_Fv=ur&THz zyO!d9kHq|c73kpq`$+t+8Bw7MgeR5~`d7ChYyGCBWSteTB>8WAU(NPYt2Dk`@#+}= zI4SvLlyk#pBgVigEe`?NG*vl7V6m+<}%FwPV=~PvvA)=#ths==DRTDEYh4V5}Cf$z@#;< zyWfLY_5sP$gc3LLl2x+Ii)#b2nhNXJ{R~vk`s5U7Nyu^3yFg&D%Txwj6QezMX`V(x z=C`{76*mNb!qHHs)#GgGZ_7|vkt9izl_&PBrsu@}L`X{95-2jf99K)0=*N)VxBX2q z((vkpP2RneSIiIUEnGb?VqbMb=Zia+rF~+iqslydE34cSLJ&BJW^3knX@M;t*b=EA zNvGzv41Ld_T+WT#XjDB840vovUU^FtN_)G}7v)1lPetgpEK9YS^OWFkPoE{ovj^=@ zO9N$S=G$1ecndT_=5ehth2Lmd1II-PuT~C9`XVePw$y8J#dpZ?Tss<6wtVglm(Ok7 z3?^oi@pPio6l&!z8JY(pJvG=*pI?GIOu}e^EB6QYk$#FJQ%^AIK$I4epJ+9t?KjqA+bkj&PQ*|vLttme+`9G=L% ziadyMw_7-M)hS(3E$QGNCu|o23|%O+VN7;Qggp?PB3K-iSeBa2b}V4_wY`G1Jsfz4 z9|SdB^;|I8E8gWqHKx!vj_@SMY^hLEIbSMCuE?WKq=c2mJK z8LoG-pnY!uhqFv&L?yEuxo{dpMTsmCn)95xanqBrNPTgXP((H$9N${Ow~Is-FBg%h z53;|Y5$MUN)9W2HBe2TD`ct^LHI<(xWrw}$qSoei?}s)&w$;&!14w6B6>Yr6Y8b)S z0r71`WmAvJJ`1h&poLftLUS6Ir zC$bG9!Im_4Zjse)#K=oJM9mHW1{%l8sz$1o?ltdKlLTxWWPB>Vk22czVt|1%^wnN@*!l)}?EgtvhC>vlHm^t+ogpgHI1_$1ox9e;>0!+b(tBrmXRB`PY1vp-R**8N7 zGP|QqI$m(Rdu#=(?!(N}G9QhQ%o!aXE=aN{&wtGP8|_qh+7a_j_sU5|J^)vxq;# zjvzLn%_QPHZZIWu1&mRAj;Sa_97p_lLq_{~j!M9N^1yp3U_SxRqK&JnR%6VI#^E12 z>CdOVI^_9aPK2eZ4h&^{pQs}xsijXgFYRIxJ~N7&BB9jUR1fm!(xl)mvy|3e6-B3j zJn#ajL;bFTYJ2+Q)tDjx=3IklO@Q+FFM}6UJr6km7hj7th9n_&JR7fnqC!hTZoM~T zBeaVFp%)0cbPhejX<8pf5HyRUj2>aXnXBqDJe73~J%P(2C?-RT{c3NjE`)om! zl$uewSgWkE66$Kb34+QZZvRn`fob~Cl9=cRk@Es}KQm=?E~CE%spXaMO6YmrMl%9Q zlA3Q$3|L1QJ4?->UjT&CBd!~ru{Ih^in&JXO=|<6J!&qp zRe*OZ*cj5bHYlz!!~iEKcuE|;U4vN1rk$xq6>bUWD*u(V@8sG^7>kVuo(QL@Ki;yL zWC!FT(q{E8#on>%1iAS0HMZDJg{Z{^!De(vSIq&;1$+b)oRMwA3nc3mdTSG#3uYO_ z>+x;7p4I;uHz?ZB>dA-BKl+t-3IB!jBRgdvAbW!aJ(Q{aT>+iz?91`C-xbe)IBoND z9_Xth{6?(y3rddwY$GD65IT#f3<(0o#`di{sh2gm{dw*#-Vnc3r=4==&PU^hCv$qd zjw;>i&?L*Wq#TxG$mFIUf>eK+170KG;~+o&1;Tom9}}mKo23KwdEM6UonXgc z!6N(@k8q@HPw{O8O!lAyi{rZv|DpgfU{py+j(X_cwpKqcalcqKIr0kM^%Br3SdeD> zHSKV94Yxw;pjzDHo!Q?8^0bb%L|wC;4U^9I#pd5O&eexX+Im{ z?jKnCcsE|H?{uGMqVie_C~w7GX)kYGWAg%-?8|N_1#W-|4F)3YTDC+QSq1s!DnOML3@d`mG%o2YbYd#jww|jD$gotpa)kntakp#K;+yo-_ZF9qrNZw<%#C zuPE@#3RocLgPyiBZ+R_-FJ_$xP!RzWm|aN)S+{$LY9vvN+IW~Kf3TsEIvP+B9Mtm! zpfNNxObWQpLoaO&cJh5>%slZnHl_Q~(-Tfh!DMz(dTWld@LG1VRF`9`DYKhyNv z2pU|UZ$#_yUx_B_|MxUq^glT}O5Xt(Vm4Mr02><%C)@v;vPb@pT$*yzJ4aPc_FZ3z z3}PLoMBIM>q_9U2rl^sGhk1VUJ89=*?7|v`{!Z{6bqFMq(mYiA?%KbsI~JwuqVA9$H5vDE+VocjX+G^%bieqx->s;XWlKcuv(s%y%D5Xbc9+ zc(_2nYS1&^yL*ey664&4`IoOeDIig}y-E~_GS?m;D!xv5-xwz+G`5l6V+}CpeJDi^ z%4ed$qowm88=iYG+(`ld5Uh&>Dgs4uPHSJ^TngXP_V6fPyl~>2bhi20QB%lSd#yYn zO05?KT1z@?^-bqO8Cg`;ft>ilejsw@2%RR7;`$Vs;FmO(Yr3Fp`pHGr@P2hC%QcA|X&N2Dn zYf`MqXdHi%cGR@%y7Rg7?d3?an){s$zA{!H;Ie5exE#c~@NhQUFG8V=SQh%UxUeiV zd7#UcYqD=lk-}sEwlpu&H^T_V0{#G?lZMxL7ih_&{(g)MWBnCZxtXg znr#}>U^6!jA%e}@Gj49LWG@*&t0V>Cxc3?oO7LSG%~)Y5}f7vqUUnQ;STjdDU}P9IF9d9<$;=QaXc zL1^X7>fa^jHBu_}9}J~#-oz3Oq^JmGR#?GO7b9a(=R@fw@}Q{{@`Wy1vIQ#Bw?>@X z-_RGG@wt|%u`XUc%W{J z>iSeiz8C3H7@St3mOr_mU+&bL#Uif;+Xw-aZdNYUpdf>Rvu0i0t6k*}vwU`XNO2he z%miH|1tQ8~ZK!zmL&wa3E;l?!!XzgV#%PMVU!0xrDsNNZUWKlbiOjzH-1Uoxm8E#r`#2Sz;-o&qcqB zC-O_R{QGuynW14@)7&@yw1U}uP(1cov)twxeLus0s|7ayrtT8c#`&2~Fiu2=R;1_4bCaD=*E@cYI>7YSnt)nQc zohw5CsK%m?8Ack)qNx`W0_v$5S}nO|(V|RZKBD+btO?JXe|~^Qqur%@eO~<8-L^9d z=GA3-V14ng9L29~XJ>a5k~xT2152zLhM*@zlp2P5Eu}bywkcqR;ISbas&#T#;HZSf z2m69qTV(V@EkY(1Dk3`}j)JMo%ZVJ*5eB zYOjIisi+igK0#yW*gBGj?@I{~mUOvRFQR^pJbEbzFxTubnrw(Muk%}jI+vXmJ;{Q6 zrSobKD>T%}jV4Ub?L1+MGOD~0Ir%-`iTnWZN^~YPrcP5y3VMAzQ+&en^VzKEb$K!Q z<7Dbg&DNXuow*eD5yMr+#08nF!;%4vGrJI++5HdCFcGLfMW!KS*Oi@=7hFwDG!h2< zPunUEAF+HncQkbfFj&pbzp|MU*~60Z(|Ik%Tn{BXMN!hZOosNIseT?R;A`W?=d?5X zK(FB=9mZusYahp|K-wyb={rOpdn=@;4YI2W0EcbMKyo~-#^?h`BA9~o285%oY zfifCh5Lk$SY@|2A@a!T2V+{^!psQkx4?x0HSV`(w9{l75QxMk!)U52Lbhn{8ol?S) zCKo*7R(z!uk<6*qO=wh!Pul{(qq6g6xW;X68GI_CXp`XwO zxuSgPRAtM8K7}5E#-GM!*ydOOG_{A{)hkCII<|2=ma*71ci_-}VPARm3crFQjLYV! z9zbz82$|l01mv`$WahE2$=fAGWkd^X2kY(J7iz}WGS z@%MyBEO=A?HB9=^?nX`@nh;7;laAjs+fbo!|K^mE!tOB>$2a_O0y-*uaIn8k^6Y zSbuv;5~##*4Y~+y7Z5O*3w4qgI5V^17u*ZeupVGH^nM&$qmAk|anf*>r zWc5CV;-JY-Z@Uq1Irpb^O`L_7AGiqd*YpGUShb==os$uN3yYvb`wm6d=?T*it&pDk zo`vhw)RZX|91^^Wa_ti2zBFyWy4cJu#g)_S6~jT}CC{DJ_kKpT`$oAL%b^!2M;JgT zM3ZNbUB?}kP(*YYvXDIH8^7LUxz5oE%kMhF!rnPqv!GiY0o}NR$OD=ITDo9r%4E>E0Y^R(rS^~XjWyVI6 zMOR5rPXhTp*G*M&X#NTL`Hu*R+u*QNoiOKg4CtNPrjgH>c?Hi4MUG#I917fx**+pJfOo!zFM&*da&G_x)L(`k&TPI*t3e^{crd zX<4I$5nBQ8Ax_lmNRa~E*zS-R0sxkz`|>7q_?*e%7bxqNm3_eRG#1ae3gtV9!fQpY z+!^a38o4ZGy9!J5sylDxZTx$JmG!wg7;>&5H1)>f4dXj;B+@6tMlL=)cLl={jLMxY zbbf1ax3S4>bwB9-$;SN2?+GULu;UA-35;VY*^9Blx)Jwyb$=U!D>HhB&=jSsd^6yw zL)?a|>GxU!W}ocTC(?-%z3!IUhw^uzc`Vz_g>-tv)(XA#JK^)ZnC|l1`@CdX1@|!| z_9gQ)7uOf?cR@KDp97*>6X|;t@Y`k_N@)aH7gY27)COv^P3ya9I{4z~vUjLR9~z1Z z5=G{mVtKH*&$*t0@}-i_v|3B$AHHYale7>E+jP`ClqG%L{u;*ff_h@)al?RuL7tOO z->;I}>%WI{;vbLP3VIQ^iA$4wl6@0sDj|~112Y4OFjMs`13!$JGkp%b&E8QzJw_L5 zOnw9joc0^;O%OpF$Qp)W1HI!$4BaXX84`%@#^dk^hFp^pQ@rx4g(8Xjy#!X%+X5Jd@fs3amGT`}mhq#L97R>OwT5-m|h#yT_-v@(k$q7P*9X~T*3)LTdzP!*B} z+SldbVWrrwQo9wX*%FyK+sRXTa@O?WM^FGWOE?S`R(0P{<6p#f?0NJvnBia?k^fX2 zNQs7K-?EijgHJY}&zsr;qJ<*PCZUd*x|dD=IQPUK_nn)@X4KWtqoJNHkT?ZWL_hF? zS8lp2(q>;RXR|F;1O}EE#}gCrY~#n^O`_I&?&z5~7N;zL0)3Tup`%)oHMK-^r$NT% zbFg|o?b9w(q@)6w5V%si<$!U<#}s#x@0aX-hP>zwS#9*75VXA4K*%gUc>+yzupTDBOKH8WR4V0pM(HrfbQ&eJ79>HdCvE=F z|J>s;;iDLB^3(9}?biKbxf1$lI!*Z%*0&8UUq}wMyPs_hclyQQi4;NUY+x2qy|0J; zhn8;5)4ED1oHwg+VZF|80<4MrL97tGGXc5Sw$wAI#|2*cvQ=jB5+{AjMiDHmhUC*a zlmiZ`LAuAn_}hftXh;`Kq0zblDk8?O-`tnilIh|;3lZp@F_osJUV9`*R29M?7H{Fy z`nfVEIDIWXmU&YW;NjU8)EJpXhxe5t+scf|VXM!^bBlwNh)~7|3?fWwo_~ZFk(22% zTMesYw+LNx3J-_|DM~`v93yXe=jPD{q;li;5PD?Dyk+b? zo21|XpT@)$BM$%F=P9J19Vi&1#{jM3!^Y&fr&_`toi`XB1!n>sbL%U9I5<7!@?t)~ z;&H%z>bAaQ4f$wIzkjH70;<8tpUoxzKrPhn#IQfS%9l5=Iu))^XC<58D!-O z{B+o5R^Z21H0T9JQ5gNJnqh#qH^na|z92=hONIM~@_iuOi|F>jBh-?aA20}Qx~EpDGElELNn~|7WRXRFnw+Wdo`|# zBpU=Cz3z%cUJ0mx_1($X<40XEIYz(`noWeO+x#yb_pwj6)R(__%@_Cf>txOQ74wSJ z0#F3(zWWaR-jMEY$7C*3HJrohc79>MCUu26mfYN)f4M~4gD`}EX4e}A!U}QV8!S47 z6y-U-%+h`1n`*pQuKE%Av0@)+wBZr9mH}@vH@i{v(m-6QK7Ncf17x_D=)32`FOjjo zg|^VPf5c6-!FxN{25dvVh#fog=NNpXz zfB$o+0jbRkHH{!TKhE709f+jI^$3#v1Nmf80w`@7-5$1Iv_`)W^px8P-({xwb;D0y z7LKDAHgX<84?l!I*Dvi2#D@oAE^J|g$3!)x1Ua;_;<@#l1fD}lqU2_tS^6Ht$1Wl} zBESo7o^)9-Tjuz$8YQSGhfs{BQV6zW7dA?0b(Dbt=UnQs&4zHfe_sj{RJ4uS-vQpC zX;Bbsuju4%!o8?&m4UZU@~ZZjeFF6ex2ss5_60_JS_|iNc+R0GIjH1@Z z=rLT9%B|WWgOrR7IiIwr2=T;Ne?30M!@{%Qf8o`!>=s<2CBpCK_TWc(DX51>e^xh8 z&@$^b6CgOd7KXQV&Y4%}_#uN*mbanXq(2=Nj`L7H7*k(6F8s6{FOw@(DzU`4-*77{ zF+dxpv}%mFpYK?>N_2*#Y?oB*qEKB}VoQ@bzm>ptmVS_EC(#}Lxxx730trt0G)#$b zE=wVvtqOct1%*9}U{q<)2?{+0TzZzP0jgf9*)arV)*e!f`|jgT{7_9iS@e)recI#z zbzolURQ+TOzE!ymqvBY7+5NnAbWxvMLsLTwEbFqW=CPyCsmJ}P1^V30|D5E|p3BC5 z)3|qgw@ra7aXb-wsa|l^in~1_fm{7bS9jhVRkYVO#U{qMp z)Wce+|DJ}4<2gp8r0_xfZpMo#{Hl2MfjLcZdRB9(B(A(f;+4s*FxV{1F|4d`*sRNd zp4#@sEY|?^FIJ;tmH{@keZ$P(sLh5IdOk@k^0uB^BWr@pk6mHy$qf&~rI>P*a;h0C{%oA*i!VjWn&D~O#MxN&f@1Po# zKN+ zrGrkSjcr?^R#nGl<#Q722^wbYcgW@{+6CBS<1@%dPA8HC!~a`jTz<`g_l5N1M@9wn9GOAZ>nqNgq!yOCbZ@1z`U_N`Z>}+1HIZxk*5RDc&rd5{3qjRh8QmT$VyS;jK z;AF+r6XnnCp=wQYoG|rT2@8&IvKq*IB_WvS%nt%e{MCFm`&W*#LXc|HrD?nVBo=(8*=Aq?u$sDA_sC_RPDUiQ+wnIJET8vx$&fxkW~kP9qXKt zozR)@xGC!P)CTkjeWvXW5&@2?)qt)jiYWWBU?AUtzAN}{JE1I)dfz~7$;}~BmQF`k zpn11qmObXwRB8&rnEG*#4Xax3XBkKlw(;tb?Np^i+H8m(Wyz9k{~ogba@laiEk;2! zV*QV^6g6(QG%vX5Um#^sT&_e`B1pBW5yVth~xUs#0}nv?~C#l?W+9Lsb_5)!71rirGvY zTIJ$OPOY516Y|_014sNv+Z8cc5t_V=i>lWV=vNu#!58y9Zl&GsMEW#pPYPYGHQ|;vFvd*9eM==$_=vc7xnyz0~ zY}r??$<`wAO?JQk@?RGvkWVJlq2dk9vB(yV^vm{=NVI8dhsX<)O(#nr9YD?I?(VmQ z^r7VfUBn<~p3()8yOBjm$#KWx!5hRW)5Jl7wY@ky9lNM^jaT##8QGVsYeaVywmpv>X|Xj7gWE1Ezai&wVLt3p)k4w~yrskT-!PR!kiyQlaxl(( zXhF%Q9x}1TMt3~u@|#wWm-Vq?ZerK={8@~&@9r5JW}r#45#rWii};t`{5#&3$W)|@ zbAf2yDNe0q}NEUvq_Quq3cTjcw z@H_;$hu&xllCI9CFDLuScEMg|x{S7GdV8<&Mq=ezDnRZAyX-8gv97YTm0bg=d)(>N z+B2FcqvI9>jGtnK%eO%y zoBPkJTk%y`8TLf4)IXPBn`U|9>O~WL2C~C$z~9|0m*YH<-vg2CD^SX#&)B4ngOSG$ zV^wmy_iQk>dfN@Pv(ckfy&#ak@MLC7&Q6Ro#!ezM*VEh`+b3Jt%m(^T&p&WJ2Oqvj zs-4nq0TW6cv~(YI$n0UkfwN}kg3_fp?(ijSV#tR9L0}l2qjc7W?i*q01=St0eZ=4h zyGQbEw`9OEH>NMuIe)hVwYHsGERWOD;JxEiO7cQv%pFCeR+IyhwQ|y@&^24k+|8fD zLiOWFNJ2&vu2&`Jv96_z-Cd5RLgmeY3*4rDOQo?Jm`;I_(+ejsPM03!ly!*Cu}Cco zrQSrEDHNyzT(D5s1rZq!8#?f6@v6dB7a-aWs(Qk>N?UGAo{gytlh$%_IhyL7h?DLXDGx zgxGEBQoCAWo-$LRvM=F5MTle`M})t3vVv;2j0HZY&G z22^iGhV@uaJh(XyyY%} zd4iH_UfdV#T=3n}(Lj^|n;O4|$;xhu*8T3hR1mc_A}fK}jfZ7LX~*n5+`8N2q#rI$ z@<_2VANlYF$vIH$ zl<)+*tIWW78IIINA7Rr7i{<;#^yzxoLNkXL)eSs=%|P>$YQIh+ea_3k z_s7r4%j7%&*NHSl?R4k%1>Z=M9o#zxY!n8sL5>BO-ZP;T3Gut>iLS@U%IBrX6BA3k z)&@q}V8a{X<5B}K5s(c(LQ=%v1ocr`t$EqqY0EqVjr65usa=0bkf|O#ky{j3)WBR(((L^wmyHRzoWuL2~WTC=`yZ zn%VX`L=|Ok0v7?s>IHg?yArBcync5rG#^+u)>a%qjES%dRZoIyA8gQ;StH z1Ao7{<&}6U=5}4v<)1T7t!J_CL%U}CKNs-0xWoTTeqj{5{?Be$L0_tk>M9o8 zo371}S#30rKZFM{`H_(L`EM9DGp+Mifk&IP|C2Zu_)Ghr4Qtpmkm1osCf@%Z$%t+7 zYH$Cr)Ro@3-QDeQJ8m+x6%;?YYT;k6Z0E-?kr>x33`H%*ueBD7Zx~3&HtWn0?2Wt} zTG}*|v?{$ajzt}xPzV%lL1t-URi8*Zn)YljXNGDb>;!905Td|mpa@mHjIH%VIiGx- zd@MqhpYFu4_?y5N4xiHn3vX&|e6r~Xt> zZG`aGq|yTNjv;9E+Txuoa@A(9V7g?1_T5FzRI;!=NP1Kqou1z5?%X~Wwb{trRfd>i z8&y^H)8YnKyA_Fyx>}RNmQIczT?w2J4SNvI{5J&}Wto|8FR(W;Qw#b1G<1%#tmYzQ zQ2mZA-PAdi%RQOhkHy9Ea#TPSw?WxwL@H@cbkZwIq0B!@ns}niALidmn&W?!Vd4Gj zO7FiuV4*6Mr^2xlFSvM;Cp_#r8UaqIzHJQg_z^rEJw&OMm_8NGAY2)rKvki|o1bH~ z$2IbfVeY2L(^*rMRU1lM5Y_sgrDS`Z??nR2lX;zyR=c%UyGb*%TC-Dil?SihkjrQy~TMv6;BMs7P8il`H7DmpVm@rJ;b)hW)BL)GjS154b*xq-NXq2cwE z^;VP7ua2pxvCmxrnqUYQMH%a%nHmwmI33nJM(>4LznvY*k&C0{8f*%?zggpDgkuz&JBx{9mfb@wegEl2v!=}Sq2Gaty0<)UrOT0{MZtZ~j5y&w zXlYa_jY)I_+VA-^#mEox#+G>UgvM!Ac8zI<%JRXM_73Q!#i3O|)lOP*qBeJG#BST0 zqohi)O!|$|2SeJQo(w6w7%*92S})XfnhrH_Z8qe!G5>CglP=nI7JAOW?(Z29;pXJ9 zR9`KzQ=WEhy*)WH>$;7Cdz|>*i>=##0bB)oU0OR>>N<21e4rMCHDemNi2LD>Nc$;& zQRFthpWniC1J6@Zh~iJCoLOxN`oCKD5Q4r%ynwgUKPlIEd#?QViIqovY|czyK8>6B zSP%{2-<;%;1`#0mG^B(8KbtXF;Nf>K#Di72UWE4gQ%(_26Koiad)q$xRL~?pN71ZZ zujaaCx~jXjygw;rI!WB=xrOJO6HJ!!w}7eiivtCg5K|F6$EXa)=xUC za^JXSX98W`7g-tm@uo|BKj39Dl;sg5ta;4qjo^pCh~{-HdLl6qI9Ix6f$+qiZ$}s= zNguKrU;u+T@ko(Vr1>)Q%h$?UKXCY>3se%&;h2osl2D zE4A9bd7_|^njDd)6cI*FupHpE3){4NQ*$k*cOWZ_?CZ>Z4_fl@n(mMnYK62Q1d@+I zr&O))G4hMihgBqRIAJkLdk(p(D~X{-oBUA+If@B}j& zsHbeJ3RzTq96lB7d($h$xTeZ^gP0c{t!Y0c)aQE;$FY2!mACg!GDEMKXFOPI^)nHZ z`aSPJpvV0|bbrzhWWkuPURlDeN%VT8tndV8?d)eN*i4I@u zVKl^6{?}A?P)Fsy?3oi#clf}L18t;TjNI2>eI&(ezDK7RyqFxcv%>?oxUlonv(px) z$vnPzRH`y5A(x!yOIfL0bmgeMQB$H5wenx~!ujQK*nUBW;@Em&6Xv2%s(~H5WcU2R z;%Nw<$tI)a`Ve!>x+qegJnQsN2N7HaKzrFqM>`6R*gvh%O*-%THt zrB$Nk;lE;z{s{r^PPm5qz(&lM{sO*g+W{sK+m3M_z=4=&CC>T`{X}1Vg2PEfSj2x_ zmT*(x;ov%3F?qoEeeM>dUn$a*?SIGyO8m806J1W1o+4HRhc2`9$s6hM#qAm zChQ87b~GEw{ADfs+5}FJ8+|bIlIv(jT$Ap#hSHoXdd9#w<#cA<1Rkq^*EEkknUd4& zoIWIY)sAswy6fSERVm&!SO~#iN$OgOX*{9@_BWFyJTvC%S++ilSfCrO(?u=Dc?CXZ zzCG&0yVR{Z`|ZF0eEApWEo#s9osV>F{uK{QA@BES#&;#KsScf>y zvs?vIbI>VrT<*!;XmQS=bhq%46-aambZ(8KU-wOO2=en~D}MCToB_u;Yz{)1ySrPZ z@=$}EvjTdzTWU7c0ZI6L8=yP+YRD_eMMos}b5vY^S*~VZysrkq<`cK3>>v%uy7jgq z0ilW9KjVDHLv0b<1K_`1IkbTOINs0=m-22c%M~l=^S}%hbli-3?BnNq?b`hx^HX2J zIe6ECljRL0uBWb`%{EA=%!i^4sMcj+U_TaTZRb+~GOk z^ZW!nky0n*Wb*r+Q|9H@ml@Z5gU&W`(z4-j!OzC1wOke`TRAYGZVl$PmQ16{3196( zO*?`--I}Qf(2HIwb2&1FB^!faPA2=sLg(@6P4mN)>Dc3i(B0;@O-y2;lM4akD>@^v z=u>*|!s&9zem70g7zfw9FXl1bpJW(C#5w#uy5!V?Q(U35A~$dR%LDVnq@}kQm13{} zd53q3N(s$Eu{R}k2esbftfjfOITCL;jWa$}(mmm}d(&7JZ6d3%IABCapFFYjdEjdK z&4Edqf$G^MNAtL=uCDRs&Fu@FXRgX{*0<(@c3|PNHa>L%zvxWS={L8%qw`STm+=Rd zA}FLspESSIpE_^41~#5yI2bJ=9`oc;GIL!JuW&7YetZ?0H}$$%8rW@*J37L-~Rsx!)8($nI4 zZhcZ2^=Y+p4YPl%j!nFJA|*M^gc(0o$i3nlphe+~-_m}jVkRN{spFs(o0ajW@f3K{ zDV!#BwL322CET$}Y}^0ixYj2w>&Xh12|R8&yEw|wLDvF!lZ#dOTHM9pK6@Nm-@9Lnng4ZHBgBSrr7KI8YCC9DX5Kg|`HsiwJHg2(7#nS;A{b3tVO?Z% za{m5b3rFV6EpX;=;n#wltDv1LE*|g5pQ+OY&*6qCJZc5oDS6Z6JD#6F)bWxZSF@q% z+1WV;m!lRB!n^PC>RgQCI#D1br_o^#iPk>;K2hB~0^<~)?p}LG%kigm@moD#q3PE+ zA^Qca)(xnqw6x>XFhV6ku9r$E>bWNrVH9fum0?4s?Rn2LG{Vm_+QJHse6xa%nzQ?k zKug4PW~#Gtb;#5+9!QBgyB@q=sk9=$S{4T>wjFICStOM?__fr+Kei1 z3j~xPqW;W@YkiUM;HngG!;>@AITg}vAE`M2Pj9Irl4w1fo4w<|Bu!%rh%a(Ai^Zhi zs92>v5;@Y(Zi#RI*ua*h`d_7;byQSa*v9E{2x$<-_=5Z<7{%)}4XExANcz@rK69T0x3%H<@frW>RA8^swA+^a(FxK| zFl3LD*ImHN=XDUkrRhp6RY5$rQ{bRgSO*(vEHYV)3Mo6Jy3puiLmU&g82p{qr0F?ohmbz)f2r{X2|T2 z$4fdQ=>0BeKbiVM!e-lIIs8wVTuC_m7}y4A_%ikI;Wm5$9j(^Y z(cD%U%k)X>_>9~t8;pGzL6L-fmQO@K; zo&vQzMlgY95;1BSkngY)e{`n0!NfVgf}2mB3t}D9@*N;FQ{HZ3Pb%BK6;5#-O|WI( zb6h@qTLU~AbVW#_6?c!?Dj65Now7*pU{h!1+eCV^KCuPAGs28~3k@ueL5+u|Z-7}t z9|lskE`4B7W8wMs@xJa{#bsCGDFoRSNSnmNYB&U7 zVGKWe%+kFB6kb)e;TyHfqtU6~fRg)f|>=5(N36)0+C z`hv65J<$B}WUc!wFAb^QtY31yNleq4dzmG`1wHTj=c*=hay9iD071Hc?oYoUk|M*_ zU1GihAMBsM@5rUJ(qS?9ZYJ6@{bNqJ`2Mr+5#hKf?doa?F|+^IR!8lq9)wS3tF_9n zW_?hm)G(M+MYb?V9YoX^_mu5h-LP^TL^!Q9Z7|@sO(rg_4+@=PdI)WL(B7`!K^ND- z-uIuVDCVEdH_C@c71YGYT^_Scf_dhB8Z2Xy6vGtBSlYud9vggOqv^L~F{BraSE_t} zIkP+Hp2&nH^-MNEs}^`oMLy11`PQW$T|K(`Bu*(f@)mv1-qY(_YG&J2M2<7k;;RK~ zL{Fqj9yCz8(S{}@c)S!65aF<=&eLI{hAMErCx&>i7OeDN>okvegO87OaG{Jmi<|}D zaT@b|0X{d@OIJ7zvT>r+eTzgLq~|Dpu)Z&db-P4z*`M$UL51lf>FLlq6rfG)%doyp z)3kk_YIM!03eQ8Vu_2fg{+osaEJPtJ-s36R+5_AEG12`NG)IQ#TF9c@$99%0iye+ zUzZ57=m2)$D(5Nx!n)=5Au&O0BBgwxIBaeI(mro$#&UGCr<;C{UjJVAbVi%|+WP(a zL$U@TYCxJ=1{Z~}rnW;7UVb7+ZnzgmrogDxhjLGo>c~MiJAWs&&;AGg@%U?Y^0JhL ze(x6Z74JG6FlOFK(T}SXQfhr}RIFl@QXKnIcXYF)5|V~e-}suHILKT-k|<*~Ij|VF zC;t@=uj=hot~*!C68G8hTA%8SzOfETOXQ|3FSaIEjvBJp(A)7SWUi5!Eu#yWgY+;n zlm<$+UDou*V+246_o#V4kMdto8hF%%Lki#zPh}KYXmMf?hrN0;>Mv%`@{0Qn`Ujp) z=lZe+13>^Q!9zT);H<(#bIeRWz%#*}sgUX9P|9($kexOyKIOc`dLux}c$7It4u|Rl z6SSkY*V~g_B-hMPo_ak>>z@AVQ(_N)VY2kB3IZ0G(iDUYw+2d7W^~(Jq}KY=JnWS( z#rzEa&0uNhJ>QE8iiyz;n2H|SV#Og+wEZv=f2%1ELX!SX-(d3tEj$5$1}70Mp<&eI zCkfbByL7af=qQE@5vDVxx1}FSGt_a1DoE3SDI+G)mBAna)KBG4p8Epxl9QZ4BfdAN zFnF|Y(umr;gRgG6NLQ$?ZWgllEeeq~z^ZS7L?<(~O&$5|y)Al^iMKy}&W+eMm1W z7EMU)u^ke(A1#XCV>CZ71}P}0x)4wtHO8#JRG3MA-6g=`ZM!FcICCZ{IEw8Dm2&LQ z1|r)BUG^0GzI6f946RrBlfB1Vs)~8toZf~7)+G;pv&XiUO(%5bm)pl=p>nV^o*;&T z;}@oZSibzto$arQgfkp|z4Z($P>dTXE{4O=vY0!)kDO* zGF8a4wq#VaFpLfK!iELy@?-SeRrdz%F*}hjKcA*y@mj~VD3!it9lhRhX}5YOaR9$} z3mS%$2Be7{l(+MVx3 z(4?h;P!jnRmX9J9sYN#7i=iyj_5q7n#X(!cdqI2lnr8T$IfOW<_v`eB!d9xY1P=2q&WtOXY=D9QYteP)De?S4}FK6#6Ma z=E*V+#s8>L;8aVroK^6iKo=MH{4yEZ_>N-N z`(|;aOATba1^asjxlILk<4}f~`39dBFlxj>Dw(hMYKPO3EEt1@S`1lxFNM+J@uB7T zZ8WKjz7HF1-5&2=l=fqF-*@>n5J}jIxdDwpT?oKM3s8Nr`x8JnN-kCE?~aM1H!hAE z%%w(3kHfGwMnMmNj(SU(w42OrC-euI>Dsjk&jz3ts}WHqmMpzQ3vZrsXrZ|}+MHA7 z068obeXZTsO*6RS@o3x80E4ok``rV^Y3hr&C1;|ZZ0|*EKO`$lECUYG2gVFtUTw)R z4Um<0ZzlON`zTdvVdL#KFoMFQX*a5wM0Czp%wTtfK4Sjs)P**RW&?lP$(<}q%r68Z zS53Y!d@&~ne9O)A^tNrXHhXBkj~$8j%pT1%%mypa9AW5E&s9)rjF4@O3ytH{0z6riz|@< zB~UPh*wRFg2^7EbQrHf0y?E~dHlkOxof_a?M{LqQ^C!i2dawHTPYUE=X@2(3<=OOxs8qn_(y>pU>u^}3y&df{JarR0@VJn0f+U%UiF=$Wyq zQvnVHESil@d|8&R<%}uidGh7@u^(%?$#|&J$pvFC-n8&A>utA=n3#)yMkz+qnG3wd zP7xCnF|$9Dif@N~L)Vde3hW8W!UY0BgT2v(wzp;tlLmyk2%N|0jfG$%<;A&IVrOI< z!L)o>j>;dFaqA3pL}b-Je(bB@VJ4%!JeX@3x!i{yIeIso^=n?fDX`3bU=eG7sTc%g%ye8$v8P@yKE^XD=NYxTb zbf!Mk=h|otpqjFaA-vs5YOF-*GwWPc7VbaOW&stlANnCN8iftFMMrUdYNJ_Bnn5Vt zxfz@Ah|+4&P;reZxp;MmEI7C|FOv8NKUm8njF7Wb6Gi7DeODLl&G~}G4be&*Hi0Qw z5}77vL0P+7-B%UL@3n1&JPxW^d@vVwp?u#gVcJqY9#@-3X{ok#UfW3<1fb%FT`|)V~ggq z(3AUoUS-;7)^hCjdT0Kf{i}h)mBg4qhtHHBti=~h^n^OTH5U*XMgDLIR@sre`AaB$ zg)IGBET_4??m@cx&c~bA80O7B8CHR7(LX7%HThkeC*@vi{-pL%e)yXp!B2InafbDF zjPXf1mko3h59{lT6EEbxKO1Z5GF71)WwowO6kY|6tjSVSWdQ}NsK2x{>i|MKZK8%Q zfu&_0D;CO-Jg0#YmyfctyJ!mRJp)e#@O0mYdp|8x;G1%OZQ3Q847YWTyy|%^cpA;m zze0(5p{tMu^lDkpe?HynyO?a1$_LJl2L&mpeKu%8YvgRNr=%2z${%WThHG=vrWY@4 zsA`OP#O&)TetZ>s%h!=+CE15lOOls&nvC~$Qz0Ph7tHiP;O$i|eDwpT{cp>+)0-|; zY$|bB+Gbel>5aRN3>c0x)4U=|X+z+{ zn*_p*EQoquRL+=+p;=lm`d71&1NqBz&_ph)MXu(Nv6&XE7(RsS)^MGj5Q?Fwude-(sq zjJ>aOq!7!EN>@(fK7EE#;i_BGvli`5U;r!YA{JRodLBc6-`n8K+Fjgwb%sX;j=qHQ z7&Tr!)!{HXoO<2BQrV9Sw?JRaLXV8HrsNevvnf>Y-6|{T!pYLl7jp$-nEE z#X!4G4L#K0qG_4Z;Cj6=;b|Be$hi4JvMH!-voxqx^@8cXp`B??eFBz2lLD8RRaRGh zn7kUfy!YV~p(R|p7iC1Rdgt$_24i0cd-S8HpG|`@my70g^y`gu%#Tf_L21-k?sRRZHK&at(*ED0P8iw{7?R$9~OF$Ko;Iu5)ur5<->x!m93Eb zFYpIx60s=Wxxw=`$aS-O&dCO_9?b1yKiPCQmSQb>T)963`*U+Ydj5kI(B(B?HNP8r z*bfSBpSu)w(Z3j7HQoRjUG(+d=IaE~tv}y14zHHs|0UcN52fT8V_<@2ep_ee{QgZG zmgp8iv4V{k;~8@I%M3<#B;2R>Ef(Gg_cQM7%}0s*^)SK6!Ym+~P^58*wnwV1BW@eG z4sZLqsUvBbFsr#8u7S1r4teQ;t)Y@jnn_m5jS$CsW1um!p&PqAcc8!zyiXHVta9QC zY~wCwCF0U%xiQPD_INKtTb;A|Zf29(mu9NI;E zc-e>*1%(LSXB`g}kd`#}O;veb<(sk~RWL|f3ljxCnEZDdNSTDV6#Td({6l&y4IjKF z^}lIUq*ZUqgTPumD)RrCN{M^jhY>E~1pn|KOZ5((%F)G|*ZQ|r4zIbrEiV%42hJV8 z3xS)=!X1+=olbdGJ=yZil?oXLct8FM{(6ikLL3E%=q#O6(H$p~gQu6T8N!plf!96| z&Q3=`L~>U0zZh;z(pGR2^S^{#PrPxTRHD1RQOON&f)Siaf`GLj#UOk&(|@0?zm;Sx ztsGt8=29-MZs5CSf1l1jNFtNt5rFNZxJPvkNu~2}7*9468TWm>nN9TP&^!;J{-h)_ z7WsHH9|F%I`Pb!>KAS3jQWKfGivTVkMJLO-HUGM_a4UQ_%RgL6WZvrW+Z4ujZn;y@ zz9$=oO!7qVTaQAA^BhX&ZxS*|5dj803M=k&2%QrXda`-Q#IoZL6E(g+tN!6CA!CP* zCpWtCujIea)ENl0liwVfj)Nc<9mV%+e@=d`haoZ*`B7+PNjEbXBkv=B+Pi^~L#EO$D$ZqTiD8f<5$eyb54-(=3 zh)6i8i|jp(@OnRrY5B8t|LFXFQVQ895n*P16cEKTrT*~yLH6Z4e*bZ5otpRDri&+A zfNbK1D5@O=sm`fN=WzWyse!za5n%^+6dHPGX#8DyIK>?9qyX}2XvBWVqbP%%D)7$= z=#$WulZlZR<{m#gU7lwqK4WS1Ne$#_P{b17qe$~UOXCl>5b|6WVh;5vVnR<%d+Lnp z$uEmML38}U4vaW8>shm6CzB(Wei3s#NAWE3)a2)z@i{4jTn;;aQS)O@l{rUM`J@K& l00vQ5JBs~;vo!vr%%-k{2_Fq1Mn4QF81S)AQ99zk{{c4yR+0b! diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1af9e0930..c7d437bbb 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-bin.zip networkTimeout=10000 -validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1aa94a426..65dcd68d6 100644 --- a/gradlew +++ b/gradlew @@ -83,8 +83,10 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -131,13 +133,10 @@ location of your Java installation." fi else JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." - fi fi # Increase the maximum file descriptors if we can. @@ -145,7 +144,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -153,7 +152,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -198,15 +197,11 @@ if "$cygwin" || "$msys" ; then done fi - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ diff --git a/pennyway-app-external-api/.gitignore b/pennyway-app-external-api/.gitignore new file mode 100644 index 000000000..b63da4551 --- /dev/null +++ b/pennyway-app-external-api/.gitignore @@ -0,0 +1,42 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/pennyway-app-external-api/build.gradle b/pennyway-app-external-api/build.gradle new file mode 100644 index 000000000..5b73a1b67 --- /dev/null +++ b/pennyway-app-external-api/build.gradle @@ -0,0 +1,23 @@ +plugins { + id 'java' +} + +bootJar {enabled = true} +jar {enabled = false} + +group = 'kr.co' +version = 'unspecified' + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web:3.2.3' + implementation 'org.springframework.boot:spring-boot-starter-validation:3.2.3' + + implementation project(':pennyway-common') + implementation project(':pennyway-domain') + implementation project(':pennyway-infra') + +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/PennywayExternalApiApplication.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/PennywayExternalApiApplication.java new file mode 100644 index 000000000..f693eaa63 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/PennywayExternalApiApplication.java @@ -0,0 +1,19 @@ +package kr.co.pennyway; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import java.util.TimeZone; + +@SpringBootApplication +public class PennywayExternalApiApplication { + public static void main(String[] args) { + SpringApplication.run(PennywayExternalApiApplication.class, args); + } + + @PostConstruct + public void init() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } +} diff --git a/pennyway-app-external-api/src/main/resources/application.yml b/pennyway-app-external-api/src/main/resources/application.yml new file mode 100644 index 000000000..e69de29bb diff --git a/pennyway-app-external-api/src/test/resources/application.yml b/pennyway-app-external-api/src/test/resources/application.yml new file mode 100644 index 000000000..e69de29bb diff --git a/pennyway-common/.gitignore b/pennyway-common/.gitignore new file mode 100644 index 000000000..b63da4551 --- /dev/null +++ b/pennyway-common/.gitignore @@ -0,0 +1,42 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/pennyway-common/build.gradle b/pennyway-common/build.gradle new file mode 100644 index 000000000..fa3d7065b --- /dev/null +++ b/pennyway-common/build.gradle @@ -0,0 +1,6 @@ +bootJar {enabled = false} +jar {enabled = true} + +dependencies { + +} diff --git a/pennyway-common/src/main/resources/application-common.yml b/pennyway-common/src/main/resources/application-common.yml new file mode 100644 index 000000000..e69de29bb diff --git a/pennyway-domain/.gitignore b/pennyway-domain/.gitignore new file mode 100644 index 000000000..b63da4551 --- /dev/null +++ b/pennyway-domain/.gitignore @@ -0,0 +1,42 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/pennyway-domain/build.gradle b/pennyway-domain/build.gradle new file mode 100644 index 000000000..8544c3034 --- /dev/null +++ b/pennyway-domain/build.gradle @@ -0,0 +1,7 @@ +bootJar {enabled = false} +jar {enabled = true} + +dependencies { + implementation project(':pennyway-common') + +} diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml new file mode 100644 index 000000000..e69de29bb diff --git a/pennyway-infra/.gitignore b/pennyway-infra/.gitignore new file mode 100644 index 000000000..b63da4551 --- /dev/null +++ b/pennyway-infra/.gitignore @@ -0,0 +1,42 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/pennyway-infra/build.gradle b/pennyway-infra/build.gradle new file mode 100644 index 000000000..8b148823a --- /dev/null +++ b/pennyway-infra/build.gradle @@ -0,0 +1,6 @@ +bootJar {enabled = false} +jar {enabled = true} + +dependencies { + implementation project(':pennyway-common') +} diff --git a/pennyway-infra/src/test/resources/application-infra.yml b/pennyway-infra/src/test/resources/application-infra.yml new file mode 100644 index 000000000..e69de29bb diff --git a/settings.gradle b/settings.gradle index ded5be7e3..62e0155b7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,7 @@ rootProject.name = 'pennyway' + +include 'pennyway-app-external-api' +include 'pennyway-domain' +include 'pennyway-infra' +include 'pennyway-common' + diff --git a/src/main/java/kr/co/pennyway/PennywayApplication.java b/src/main/java/kr/co/pennyway/PennywayApplication.java deleted file mode 100644 index 14740355f..000000000 --- a/src/main/java/kr/co/pennyway/PennywayApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package kr.co.pennyway; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class PennywayApplication { - - public static void main(String[] args) { - SpringApplication.run(PennywayApplication.class, args); - } - -} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b1378917..000000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/test/java/kr/co/pennyway/PennywayApplicationTests.java b/src/test/java/kr/co/pennyway/PennywayApplicationTests.java deleted file mode 100644 index 80d7f4b35..000000000 --- a/src/test/java/kr/co/pennyway/PennywayApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package kr.co.pennyway; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class PennywayApplicationTests { - - @Test - void contextLoads() { - } - -} From 6861ea9884341dfcdc971a6c92ff59f9ffd5ed48 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 7 Mar 2024 10:40:03 +0900 Subject: [PATCH 003/152] =?UTF-8?q?feat:=20common-=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pennyway-common/build.gradle | 5 +++++ .../kr/co/pennyway/common/annotation/Adapter.java | 12 ++++++++++++ .../co/pennyway/common/annotation/DomainService.java | 12 ++++++++++++ .../kr/co/pennyway/common/annotation/Helper.java | 12 ++++++++++++ .../kr/co/pennyway/common/annotation/Mapper.java | 12 ++++++++++++ .../kr/co/pennyway/common/annotation/UseCase.java | 12 ++++++++++++ 6 files changed, 65 insertions(+) create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/annotation/Adapter.java create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/annotation/DomainService.java create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/annotation/Helper.java create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/annotation/Mapper.java create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/annotation/UseCase.java diff --git a/pennyway-common/build.gradle b/pennyway-common/build.gradle index fa3d7065b..5e0562de4 100644 --- a/pennyway-common/build.gradle +++ b/pennyway-common/build.gradle @@ -2,5 +2,10 @@ bootJar {enabled = false} jar {enabled = true} dependencies { + implementation 'org.springframework.boot:spring-boot-starter-aop' + + /* Jackson */ + api 'com.fasterxml.jackson.core:jackson-annotations:2.10.1' + api 'com.fasterxml.jackson.core:jackson-databind:2.13.5' } diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/Adapter.java b/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/Adapter.java new file mode 100644 index 000000000..715ca02cb --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/Adapter.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.common.annotation; + +import org.springframework.stereotype.Component; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface Adapter { +} diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/DomainService.java b/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/DomainService.java new file mode 100644 index 000000000..b3fc527ac --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/DomainService.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.common.annotation; + +import org.springframework.stereotype.Component; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface DomainService { +} diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/Helper.java b/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/Helper.java new file mode 100644 index 000000000..6de4d3795 --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/Helper.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.common.annotation; + +import org.springframework.stereotype.Component; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface Helper { +} diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/Mapper.java b/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/Mapper.java new file mode 100644 index 000000000..27536a91f --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/Mapper.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.common.annotation; + +import org.springframework.stereotype.Component; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface Mapper { +} diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/UseCase.java b/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/UseCase.java new file mode 100644 index 000000000..c473a7d8d --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/annotation/UseCase.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.common.annotation; + +import org.springframework.stereotype.Component; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface UseCase { +} From 1dc53dd6bbe7997145dffce789cbcab789cda092 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 7 Mar 2024 13:11:22 +0900 Subject: [PATCH 004/152] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=84=A4=EB=AA=85=20=EB=B0=8F=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20=EB=B3=84=20Convention=EA=B3=BC=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EC=9E=84=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c2065bc26..e9cd97583 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -HELP.md +README.md .gradle build/ !gradle/wrapper/gradle-wrapper.jar From 71e98c054c93749f476f1330f541f832a1133165 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 7 Mar 2024 14:17:09 +0900 Subject: [PATCH 005/152] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20readme=20=EC=9E=AC=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c7daea453..154ae27e8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,123 @@ -# pennyway-was-springboot -🪙 Pennyway Spring Boot Web Application Server +## 💰 Pennyway +> 지출 관리 SNS 플랫폼 + +| Version # | Revision Date | Description | Author | +|:---------:|:-------------:|:--------------|:------:| +| v0.0.1 | 2024.03.07 | 프로젝트 기본 설명 작성 | 양재서 | + +
+ +## 👪 Backend Team + + + + + + + + + + + + +
+ 양재서 + + 이진우 + + 안성윤 +
+ + + + + +
+ + +
+ +## 🌳 Branch Convention +> 💡 Git-Flow 전략을 사용합니다. +- main + - 배포 가능한 상태의 코드만을 관리하는 프로덕션용 브랜치 + - PM(양재서)의 승인 후 병합 가능 +- dev + - 개발 전용 브랜치 + - 한 명 이상의 팀원의 승인 후 병합 가능 + - 기능 개발이 완료된 브랜치를 병합하여 테스트를 진행 +- 이슈 기반 브랜치 + - 이슈는 `{티켓번호}-{브랜치명}`을 포함한다. + - `feat/{티켓번호}-{브랜치명}`: 신규 기능 개발 시 브랜치명 + - `fix/{티켓번호}-{브랜치명}`: 리팩토링, 수정 작업 시 브랜치명 + - `hotfix/{티켓번호}-{브랜치명}`: 빠르게 수정해야 하는 버그 조치 시 브랜치명 + +
+ +## 🤝 Commit Convention +> 💡 angular commit convention +- feat: 신규 기능 추가 #1 +- fix: 버그 수정 +- docs: 문서 수정 +- rename: 주석, 로그, 변수명 등 수정 +- style: 코드 포맷팅, 세미콜론 누락 (코드 변경 없는 경우) +- refactor: 코드 리팩토링 +- test: 테스트 코드, 리펙토링 테스트 코드 추가 +- chore: 빌드 업무 수정, 패키지 매니저 수정 + +
+ +## 📌 Architecture +### 1️⃣ System Architecture + +

+ +
+ +### 2️⃣ Infrastructure Architecture + +
+ +
+ +### 3️⃣ Multi Module Architecture + +
+ +
+ +### 4️⃣ ERD + +
+ +
+ +
+ +## 📗 Tech Stack +### 1️⃣ Framework & Library +- JDK 17 +- SpringBoot 3.2.3 +- SpringBoot Security 6.2.2 +- Spring Data JPA 3.2.3 +- Spring Doc Open API 2.3.0 +- Lombok 1.18.30 +- [JUnit 5](https://junit.org/junit5/docs/current/user-guide/) +- jjwt 0.11.5 +- httpclient5 5.2.25.RELEASE +- OpenFeign 4.0.6 + +### 2️⃣ Infrastructure Architecture +- Gradle 7.6.4 + +### 3️⃣ Multi Module Architecture +- MySQL 8 +- Redis 7.0 + +### 4️⃣ ERD +- AWS EC2 (for Build Server) +- AWS GW +- AWS S3 +- Docker & Docker-compose +- Ngnix +- GitHub Actions From cd7c7cd0f2297f5aec0c62575dd22bf9dd8f884e Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 7 Mar 2024 15:34:20 +0900 Subject: [PATCH 006/152] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=86=8C=EA=B0=9C=20README=20=EC=98=A4=ED=83=88?= =?UTF-8?q?=EC=9E=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 154ae27e8..cf6bbf055 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ ## 🤝 Commit Convention > 💡 angular commit convention -- feat: 신규 기능 추가 #1 +- feat: 신규 기능 추가 - fix: 버그 수정 - docs: 문서 수정 - rename: 주석, 로그, 변수명 등 수정 From bd57f324faa8330f8f3b8b9b431eb28bc5574505 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sat, 9 Mar 2024 11:17:44 +0900 Subject: [PATCH 007/152] =?UTF-8?q?docs:=20PULL=5FREQUEST=5FTEMPLATE.md=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: jinlee1703 --- PULL_REQUEST_TEMPLATE.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 PULL_REQUEST_TEMPLATE.md diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..773fb5c80 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## 작업 이유 + + +
+ +## 작업 사항 + + +
+ +## 리뷰어가 중점적으로 확인해야 하는 부분 + + +
+ +## 발견한 이슈 + + From 2cf48c1ea83c5890a95e1acc50a48a4f5461c836 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sat, 9 Mar 2024 11:22:43 +0900 Subject: [PATCH 008/152] =?UTF-8?q?fix:=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PULL_REQUEST_TEMPLATE.md => .github/PULL_REQUEST_TEMPLATE.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename PULL_REQUEST_TEMPLATE.md => .github/PULL_REQUEST_TEMPLATE.md (100%) diff --git a/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from PULL_REQUEST_TEMPLATE.md rename to .github/PULL_REQUEST_TEMPLATE.md From 18caaad2ebd9600a5a3f69150197439f9dfd2675 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 11 Mar 2024 22:38:36 +0900 Subject: [PATCH 009/152] =?UTF-8?q?feat:=207-bit=20error=20code=20enum=20?= =?UTF-8?q?=EB=B0=8F=20interface=20=EC=A0=95=EC=9D=98=20&&=20causedBy=20re?= =?UTF-8?q?cord=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pennyway-app-external-api/build.gradle | 5 +- .../pennyway/common/exception/CausedBy.java | 20 +++++++ .../pennyway/common/exception/DomainCode.java | 6 ++ .../pennyway/common/exception/FieldCode.java | 6 ++ .../pennyway/common/exception/ReasonCode.java | 58 +++++++++++++++++++ .../pennyway/common/exception/StatusCode.java | 27 +++++++++ 6 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/exception/DomainCode.java create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/exception/FieldCode.java create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/exception/StatusCode.java diff --git a/pennyway-app-external-api/build.gradle b/pennyway-app-external-api/build.gradle index 5b73a1b67..ff72ac957 100644 --- a/pennyway-app-external-api/build.gradle +++ b/pennyway-app-external-api/build.gradle @@ -13,11 +13,10 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web:3.2.3' - implementation 'org.springframework.boot:spring-boot-starter-validation:3.2.3' - implementation project(':pennyway-common') implementation project(':pennyway-domain') implementation project(':pennyway-infra') + implementation 'org.springframework.boot:spring-boot-starter-web:3.2.3' + implementation 'org.springframework.boot:spring-boot-starter-validation:3.2.3' } diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java new file mode 100644 index 000000000..43123cae4 --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.common.exception; + +public record CausedBy( + StatusCode statusCode, + ReasonCode reasonCode, + DomainCode domainCode, + FieldCode fieldCode +) { + public static CausedBy valueOf(StatusCode statusCode, ReasonCode reasonCode, DomainCode domainCode, FieldCode fieldCode) { + return new CausedBy(statusCode, reasonCode, domainCode, fieldCode); + } + + public String getCode() { + return String.valueOf(statusCode.getCode() * 10000 + reasonCode.getCode() * 1000 + domainCode.getCode() * 10 + fieldCode.getCode()); + } + + public String getReason() { + return reasonCode.name(); + } +} diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/DomainCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/DomainCode.java new file mode 100644 index 000000000..281cb1337 --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/DomainCode.java @@ -0,0 +1,6 @@ +package kr.co.pennyway.common.exception; + +public interface DomainCode { + int getCode(); + String getDomainName(); +} diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/FieldCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/FieldCode.java new file mode 100644 index 000000000..9d180275f --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/FieldCode.java @@ -0,0 +1,6 @@ +package kr.co.pennyway.common.exception; + +public interface FieldCode { + int getCode(); + String getFieldName(); +} diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java new file mode 100644 index 000000000..7d18c1dfa --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java @@ -0,0 +1,58 @@ +package kr.co.pennyway.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ReasonCode { + /* 400_BAD_REQUEST */ + INVALID_REQUEST_SYNTAX(0), + MISSING_REQUIRED_PARAMETER(1), + MALFORMED_PARAMETER(2), + MALFORMED_REQUEST_BODY(3), + + /* 401_UNAUTHORIZED */ + MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS(0), + EXPIRED_OR_REVOKED_TOKEN(1), + INSUFFICIENT_PERMISSIONS(2), + + /* 403_FORBIDDEN */ + ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN(0), + IP_ADDRESS_BLOCKED(1), + USER_ACCOUNT_SUSPENDED_OR_BANNED(2), + ACCESS_TO_RESOURCE_NOT_ALLOWED_FOR_USER_ROLE(3), + + /* 404_NOT_FOUND */ + REQUESTED_RESOURCE_NOT_FOUND(0), + INVALID_URL_OR_ENDPOINT(1), + RESOURCE_DELETED_OR_MOVED(2), + + /* 405_METHOD_NOT_ALLOWED */ + REQUEST_METHOD_NOT_SUPPORTED(0), + ATTEMPTED_TO_ACCESS_UNSUPPORTED_METHOD(1), + + /* 406_NOT_ACCEPTABLE */ + REQUESTED_RESPONSE_FORMAT_NOT_SUPPORTED(0), + + /* 409_CONFLICT */ + REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE(0), + RESOURCE_ALREADY_EXISTS(1), + CONCURRENT_MODIFICATION_CONFLICT(2), + + /* 412_PRECONDITION_FAILED */ + PRECONDITION_REQUEST_HEADER_NOT_MATCHED(0), + IF_MATCH_HEADER_OR_IF_NONE_MATCH_HEADER_NOT_MATCHED(1), + + /* 422_UNPROCESSABLE_CONTENT */ + REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY(0), + VALIDATION_ERROR_IN_REQUEST_BODY(1), + ; + + private final int code; + + @Override + public String toString() { + return name(); + } +} diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/StatusCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/StatusCode.java new file mode 100644 index 000000000..e2fe44118 --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/StatusCode.java @@ -0,0 +1,27 @@ +package kr.co.pennyway.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum StatusCode { + SUCCESS(200), + CREATED(201), + NO_CONTENT(204), + + BAD_REQUEST(400), + UNAUTHORIZED(401), + FORBIDDEN(403), + NOT_FOUND(404), + METHOD_NOT_ALLOWED(405), + NOT_ACCEPTABLE(406), + + CONFLICT(409), + PRECONDITION_FAILED(412), + UNPROCESSABLE_CONTENT(422), + INTERNAL_SERVER_ERROR(500), + SERVICE_UNAVAILABLE(503); + + private final int code; +} \ No newline at end of file From 0913e463e0d6a78af213f00acf9495050b5def32 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 12 Mar 2024 00:29:26 +0900 Subject: [PATCH 010/152] =?UTF-8?q?rename:=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/BaseErrorCode.java | 46 +++++++++++++++++++ .../pennyway/common/exception/CausedBy.java | 8 ++++ .../pennyway/common/exception/DomainCode.java | 32 +++++++++++++ .../pennyway/common/exception/FieldCode.java | 27 +++++++++++ .../exception/GlobalErrorException.java | 44 ++++++++++++++++++ .../pennyway/common/exception/ReasonCode.java | 3 ++ .../pennyway/common/exception/StatusCode.java | 3 ++ 7 files changed, 163 insertions(+) create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/exception/BaseErrorCode.java create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/exception/GlobalErrorException.java diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/BaseErrorCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/BaseErrorCode.java new file mode 100644 index 000000000..cb942c9f6 --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/BaseErrorCode.java @@ -0,0 +1,46 @@ +package kr.co.pennyway.common.exception; + +/** + * 커스텀 에러 코드를 정의하기 위한 인터페이스 + *
+ * 에러 코드는 다음과 같은 형식으로 정의한다. + *
+ *
+ * {@code
+ * @Getter
+ * @RequiredArgsConstructor
+ * public enum UserErrorCode implements BaseErrorCode {
+ *      NOT_FOUND_EMAIL(StatusCode.NOT_FOUND, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, FieldCode.EMAIL, "해당 유저의 이메일이 존재하지 않습니다."),
+ *      NOT_FOUND_USER(StatusCode.NOT_FOUND, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, FieldCode.ZERO, "해당 유저가 존재하지 않습니다."),
+ *
+ *      REQUIRED_EMAIL(StatusCode.UNPROCESSABLE_CONTENT, ReasonCode.REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY, FieldCode.EMAIL, "이메일은 필수 입력값입니다."),
+ *
+ *      private final StatusCode statusCode;
+ *      private final ReasonCode reasonCode;
+ *      private final FieldCode fieldCode;
+ *      private final String message;
+ *
+ *      @Override
+ *      public CausedBy causedBy() {
+ *          return CausedBy.valueOf(statusCode, reasonCode, DomainCode.USER, FieldCode.COMMON);
+ *      }
+ *
+ *      @Override
+ *      public String getExplainError() throws NoSuchFieldError {
+ *          return message;
+ *      }
+ * }
+ * }
+ * 
+ * 에러 코드는 다음과 같은 형식으로 사용한다. + *
+ * {@code throw new GlobalErrorException(UserErrorCode.NOT_FOUND_USER); }
+ * 
+ * See Also: + * {@link CausedBy}, {@link StatusCode}, {@link ReasonCode}, {@link DomainCode}, {@link FieldCode}, {@link GlobalErrorException} + * @author YANG JAESEO + */ +public interface BaseErrorCode { + CausedBy causedBy(); + String getExplainError() throws NoSuchFieldError; +} diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java index 43123cae4..7f56e7ba0 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java @@ -1,5 +1,13 @@ package kr.co.pennyway.common.exception; +/** + * 에러 코드를 구성하는 상세 코드 + * + * @param statusCode {@link StatusCode} 상태 코드 + * @param reasonCode {@link ReasonCode} 이유 코드 + * @param domainCode {@link DomainCode} 도메인 코드 + * @param fieldCode {@link FieldCode} 필드 코드 + */ public record CausedBy( StatusCode statusCode, ReasonCode reasonCode, diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/DomainCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/DomainCode.java index 281cb1337..464b2950f 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/DomainCode.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/DomainCode.java @@ -1,5 +1,37 @@ package kr.co.pennyway.common.exception; +/** + * 도메인 코드 + *
+ * 도메인 코드는 각 도메인별로 고유한 코드를 가지고 있으며, 해당 코드는 각 도메인별로 고유한 에러를 구분하기 위해 사용된다. + *
+ * ZERO(0)는 기본 코드로 사용되며, 도메인 코드를 사용하지 않는 경우에 사용한다. + *
+ * ZERO 필드는 모든 BaseErrorCode를 구현하는 모든 도메인 코드에서 정의되어야 한다.
+ * {@code
+ * @RequiredArgsConstructor
+ * public enum DomainCode implements BaseErrorCode {
+ *      ZERO(0),
+ *      USER(1),
+ *      PRODUCT(2),
+ *      ORDER(3);
+ *
+ *      private final int code;
+ *
+ *      @Override
+ *      public int getCode() {
+ *          return code;
+ *      }
+ *
+ *      @Override
+ *      public String getDomainName() {
+ *          return name().toLowerCase();
+ *      }
+ * }
+ * }
+ * 
+ * @author YANG JAESEO + */ public interface DomainCode { int getCode(); String getDomainName(); diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/FieldCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/FieldCode.java index 9d180275f..97ce121e5 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/FieldCode.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/FieldCode.java @@ -1,5 +1,32 @@ package kr.co.pennyway.common.exception; +/** + * 도메인의 필드 코드 + *
+ * {@code
+ * @RequiredArgsConstructor
+ * public enum UserFieldCode implements FieldCode {
+ *    ID(1),
+ *    PASSWORD(2),
+ *    NAME(3);
+ *
+ *    private final int code;
+ *    private final String fieldName;
+ *
+ *    @Override
+ *    public int getCode() {
+ *      return code;
+ *    }
+ *
+ *    @Override
+ *    public String getFieldName() {
+ *      return name().toLowerCase();
+ *    }
+ * }
+ * }
+ * 
+ * @author YANG JAESEO + */ public interface FieldCode { int getCode(); String getFieldName(); diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/GlobalErrorException.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/GlobalErrorException.java new file mode 100644 index 000000000..08779f3e6 --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/GlobalErrorException.java @@ -0,0 +1,44 @@ +package kr.co.pennyway.common.exception; + +import lombok.Getter; + +/** + * 전역 에러 처리를 위한 예외 클래스 + *
+ * 전역 에러 처리를 명시적인 에러 처리로 변경하고 싶다면 다음과 같이 사용한다. + *
+ * {@code
+ * public class DomainErrorException extends GlobalErrorException {
+ *    private final DomainErrorCode errorCode;
+ *
+ *    public DomainErrorException(DomainErrorCode errorCode) {
+ *      super(errorCode);
+ *      this.errorCode = errorCode;
+ *    }
+ *
+ *    public CausedBy causedBy() { return errorCode.causedBy(); }
+ *    public BaseErrorCode getErrorCode() { return errorCode; }
+ * }
+ * }
+ * 
+ * @author YANG JAESEO + */ +@Getter +public class GlobalErrorException extends RuntimeException { + private final BaseErrorCode baseErrorCode; + + public GlobalErrorException(BaseErrorCode baseErrorCode) { + super(baseErrorCode.causedBy().reasonCode().name()); + this.baseErrorCode = baseErrorCode; + } + + public CausedBy causedBy() { + return baseErrorCode.causedBy(); + } + + @Override + public String toString() { + return "GlobalErrorException(code=" + baseErrorCode.causedBy().getCode() + + ", message=" + baseErrorCode.getExplainError() + ")"; + } +} diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java index 7d18c1dfa..5ab0aa969 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java @@ -3,6 +3,9 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; +/** + * 에러 발생 이유 코드 + */ @Getter @RequiredArgsConstructor public enum ReasonCode { diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/StatusCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/StatusCode.java index e2fe44118..012ba6308 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/StatusCode.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/StatusCode.java @@ -3,6 +3,9 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; +/** + * HTTP 상태 코드 + */ @Getter @RequiredArgsConstructor public enum StatusCode { From e03f9178d84452fd655f647934ed53ce19fd8800 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 12 Mar 2024 10:33:15 +0900 Subject: [PATCH 011/152] =?UTF-8?q?feat:=20CausedBy=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20&&=20rename:=20Cau?= =?UTF-8?q?sedBy=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=B3=84=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pennyway/common/exception/CausedBy.java | 45 ++++++++++++++++++- .../pennyway/common/exception/ReasonCode.java | 1 + 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java index 7f56e7ba0..13c5668c5 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java @@ -1,5 +1,7 @@ package kr.co.pennyway.common.exception; +import java.util.Objects; + /** * 에러 코드를 구성하는 상세 코드 * @@ -14,15 +16,56 @@ public record CausedBy( DomainCode domainCode, FieldCode fieldCode ) { + private static final int STATUS_CODE_MULTIPLIER = 10000; + private static final int REASON_CODE_MULTIPLIER = 1000; + private static final int DOMAIN_CODE_MULTIPLIER = 10; + + public CausedBy { + Objects.requireNonNull(statusCode, "statusCode must not be null"); + Objects.requireNonNull(reasonCode, "reasonCode must not be null"); + Objects.requireNonNull(domainCode, "domainCode must not be null"); + Objects.requireNonNull(fieldCode, "fieldCode must not be null"); + + if (generateCode().length() != 7) { + throw new IllegalArgumentException("Invalid code length"); + } + } + + /** + * CausedBy 객체를 생성하는 정적 팩토리 메서드 + *
+ * 모든 코드의 조합으로 생성된 최종 코드는 7자리의 문자열로 구성된다. (상태 코드(2자리) + 이유 코드(2자리) + 도메인 코드(1자리) + 필드 코드(2자리)) + *
+ * 7자리가 아닌 경우 IllegalArgumentException을 발생시킨다. + * @param statusCode {@link StatusCode} 상태 코드 + * @param reasonCode {@link ReasonCode} 이유 코드 + * @param domainCode {@link DomainCode} 도메인 코드 + * @param fieldCode {@link FieldCode} 필드 코드 + * @return CausedBy + */ public static CausedBy valueOf(StatusCode statusCode, ReasonCode reasonCode, DomainCode domainCode, FieldCode fieldCode) { return new CausedBy(statusCode, reasonCode, domainCode, fieldCode); } + /** + * status code, reason code, domain code, field code를 조합하여 에러 코드를 생성한다. + * @return String : 7자리 정수로 구성된 에러 코드 + */ public String getCode() { - return String.valueOf(statusCode.getCode() * 10000 + reasonCode.getCode() * 1000 + domainCode.getCode() * 10 + fieldCode.getCode()); + return String.valueOf(generateCode()); } + /** + * 에러가 발생한 이유를 반환한다. + *
+ * Reason은 사전에 예외 문서에 명시한 정보를 반환한다. + * @return String : 에러가 발생한 이유 + */ public String getReason() { return reasonCode.name(); } + + private String generateCode() { + return String.valueOf(statusCode.getCode() * STATUS_CODE_MULTIPLIER + reasonCode.getCode() * REASON_CODE_MULTIPLIER + domainCode.getCode() * DOMAIN_CODE_MULTIPLIER + fieldCode.getCode()); + } } diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java index 5ab0aa969..111ae6e43 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java @@ -19,6 +19,7 @@ public enum ReasonCode { MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS(0), EXPIRED_OR_REVOKED_TOKEN(1), INSUFFICIENT_PERMISSIONS(2), + TAMPERED_OR_MALFORMED_TOKEN(3), /* 403_FORBIDDEN */ ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN(0), From bf787c1311a413d0de74e7425c5d1c8be9710896 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 12 Mar 2024 11:07:16 +0900 Subject: [PATCH 012/152] =?UTF-8?q?rename:=20Domain=20Code=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/kr/co/pennyway/common/exception/DomainCode.java | 2 +- .../main/java/kr/co/pennyway/common/exception/StatusCode.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/DomainCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/DomainCode.java index 464b2950f..f93bceddd 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/DomainCode.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/DomainCode.java @@ -10,7 +10,7 @@ * ZERO 필드는 모든 BaseErrorCode를 구현하는 모든 도메인 코드에서 정의되어야 한다. * {@code * @RequiredArgsConstructor - * public enum DomainCode implements BaseErrorCode { + * public enum DomainBitCode implements DomainCode { * ZERO(0), * USER(1), * PRODUCT(2), diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/StatusCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/StatusCode.java index 012ba6308..8bb66bc8e 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/StatusCode.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/StatusCode.java @@ -19,10 +19,10 @@ public enum StatusCode { NOT_FOUND(404), METHOD_NOT_ALLOWED(405), NOT_ACCEPTABLE(406), - CONFLICT(409), PRECONDITION_FAILED(412), UNPROCESSABLE_CONTENT(422), + INTERNAL_SERVER_ERROR(500), SERVICE_UNAVAILABLE(503); From 18e56743f0abc95e6828e96685bc881b390ffac1 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 12 Mar 2024 11:12:01 +0900 Subject: [PATCH 013/152] =?UTF-8?q?rename:=20Field=20Code=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/kr/co/pennyway/common/exception/FieldCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/FieldCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/FieldCode.java index 97ce121e5..024fed2d1 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/FieldCode.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/FieldCode.java @@ -6,12 +6,12 @@ * {@code * @RequiredArgsConstructor * public enum UserFieldCode implements FieldCode { + * ZERO(0), * ID(1), * PASSWORD(2), * NAME(3); * * private final int code; - * private final String fieldName; * * @Override * public int getCode() { From 5660173cb52139d51e89bf346801c6616f870b63 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 12 Mar 2024 11:19:13 +0900 Subject: [PATCH 014/152] =?UTF-8?q?rename:=20CausedBy=20=EC=A0=95=EC=A0=81?= =?UTF-8?q?=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=AA=85=20valueOf=20->=20of=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/kr/co/pennyway/common/exception/CausedBy.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java index 13c5668c5..c56664ba3 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java @@ -43,7 +43,7 @@ public record CausedBy( * @param fieldCode {@link FieldCode} 필드 코드 * @return CausedBy */ - public static CausedBy valueOf(StatusCode statusCode, ReasonCode reasonCode, DomainCode domainCode, FieldCode fieldCode) { + public static CausedBy of(StatusCode statusCode, ReasonCode reasonCode, DomainCode domainCode, FieldCode fieldCode) { return new CausedBy(statusCode, reasonCode, domainCode, fieldCode); } From 88a058a6afc77b1d12ef822fdbf7ef8994a35496 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 12 Mar 2024 13:04:49 +0900 Subject: [PATCH 015/152] =?UTF-8?q?fix:=20CausedBy=20code=20=EC=9E=90?= =?UTF-8?q?=EB=A6=BF=EC=88=98=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20&&=20test:=20CausedBy=20=EA=B0=9D=EC=B2=B4?= =?UTF-8?q?=206=EA=B0=80=EC=A7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pennyway/common/exception/CausedBy.java | 31 +++- .../common/exception/CausedByTest.java | 143 ++++++++++++++++++ 2 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java index c56664ba3..a2d617db1 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java @@ -1,14 +1,17 @@ package kr.co.pennyway.common.exception; import java.util.Objects; +import java.util.stream.Stream; /** * 에러 코드를 구성하는 상세 코드 * - * @param statusCode {@link StatusCode} 상태 코드 - * @param reasonCode {@link ReasonCode} 이유 코드 - * @param domainCode {@link DomainCode} 도메인 코드 - * @param fieldCode {@link FieldCode} 필드 코드 + * @param statusCode {@link StatusCode} 상태 코드 (3자리) + * @param reasonCode {@link ReasonCode} 이유 코드 (1자리) + * @param domainCode {@link DomainCode} 도메인 코드 (2자리) + * @param fieldCode {@link FieldCode} 필드 코드 (1자리) + * + * - see also: {@link StatusCode}, {@link ReasonCode}, {@link DomainCode}, {@link FieldCode} */ public record CausedBy( StatusCode statusCode, @@ -26,8 +29,8 @@ public record CausedBy( Objects.requireNonNull(domainCode, "domainCode must not be null"); Objects.requireNonNull(fieldCode, "fieldCode must not be null"); - if (generateCode().length() != 7) { - throw new IllegalArgumentException("Invalid code length"); + if (!isValidCodes(statusCode.getCode(), reasonCode.getCode(), domainCode.getCode(), fieldCode.getCode())) { + throw new IllegalArgumentException("Invalid bit count"); } } @@ -52,7 +55,7 @@ public static CausedBy of(StatusCode statusCode, ReasonCode reasonCode, DomainCo * @return String : 7자리 정수로 구성된 에러 코드 */ public String getCode() { - return String.valueOf(generateCode()); + return generateCode(); } /** @@ -68,4 +71,18 @@ public String getReason() { private String generateCode() { return String.valueOf(statusCode.getCode() * STATUS_CODE_MULTIPLIER + reasonCode.getCode() * REASON_CODE_MULTIPLIER + domainCode.getCode() * DOMAIN_CODE_MULTIPLIER + fieldCode.getCode()); } + + private boolean isValidCodes(int statusCode, int reasonCode, int domainCode, int fieldCode) { + return isValidDigit(statusCode, 3) && isValidDigit(reasonCode, 1) && (isValidDigit(domainCode, 1) || isValidDigit(domainCode, 2)) && isValidDigit(fieldCode, 1); + } + + private boolean isValidDigit(int number, long expectedDigit) { + return calcDigit(number) == expectedDigit; + } + + private long calcDigit(int number) { + if (number == 0) return 1; + return Stream.iterate(number, n -> n > 0, n -> n / 10) + .count(); + } } diff --git a/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java b/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java new file mode 100644 index 000000000..692b6cf1d --- /dev/null +++ b/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java @@ -0,0 +1,143 @@ +package kr.co.pennyway.common.exception; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +public class CausedByTest { + private StatusCode statusCode; + private ReasonCode reasonCode; + private DomainCode domainCode; + private FieldCode fieldCode; + + @BeforeEach + public void setUp() { + statusCode = StatusCode.UNPROCESSABLE_CONTENT; + reasonCode = ReasonCode.REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY; + domainCode = DomainBitCode.USER; + fieldCode = UserFieldCode.NAME; + } + + @Test + @DisplayName("모두 정상적인 인자로 생성할 수 있음을 확인한다.") + public void createWithValidArguments() { + // when + CausedBy causedBy = CausedBy.of(statusCode, reasonCode, domainCode, fieldCode); + + // then + assertNotNull(causedBy); + } + + @Test + @DisplayName("null 인자로 생성할 수 없음을 확인한다.") + public void createWithNullArguments() { + // given + StatusCode statusCode = null; + ReasonCode reasonCode = null; + DomainCode domainCode = null; + FieldCode fieldCode = null; + + // when-then + assertThrows(NullPointerException.class, () -> CausedBy.of(statusCode, reasonCode, domainCode, fieldCode)); + } + + @Test + @DisplayName("상태 코드(3), 이유 코드(1), 도메인 코드(2), 필드 코드(1)가 아니면 생성할 수 없음을 확인한다.") + public void createWithInvalidBitCountArguments() { + // given + FieldCode fieldCode = UserFieldCode.INVALID; + + // when-then + assertThrows(IllegalArgumentException.class, () -> CausedBy.of(statusCode, reasonCode, domainCode, fieldCode)); + } + + @Test + @DisplayName("생성된 코드가 예상값과 일치하는 지 확인한다.") + public void generateCodeWithValidArguments() { + // when + CausedBy causedBy = CausedBy.of(statusCode, reasonCode, domainCode, fieldCode); + + // then + assertEquals("4220013", causedBy.getCode()); + } + + @Test + @DisplayName("두 자리 수의 도메인 코드가 예상값과 일치하는 지 확인한다.") + public void generateCodeWithTwoDigitDomainCode() { + // given + StatusCode statusCode = StatusCode.BAD_REQUEST; + ReasonCode reasonCode = ReasonCode.MISSING_REQUIRED_PARAMETER; + DomainCode domainCode = DomainBitCode.ORDER; + FieldCode fieldCode = UserFieldCode.ZERO; + + // when + CausedBy causedBy = CausedBy.of(statusCode, reasonCode, domainCode, fieldCode); + + // then + assertEquals("4001130", causedBy.getCode()); + } + + @Test + @DisplayName("에러가 발생한 올바른 이유를 반환한다.") + public void getExplainError() { + // given + StatusCode statusCode = StatusCode.UNPROCESSABLE_CONTENT; + ReasonCode reasonCode = ReasonCode.REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY; + DomainCode domainCode = DomainBitCode.USER; + FieldCode fieldCode = UserFieldCode.NAME; + + // when + CausedBy causedBy = CausedBy.of(statusCode, reasonCode, domainCode, fieldCode); + + // then + assertEquals("REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY", causedBy.reasonCode().name()); + } + + private enum DomainBitCode implements DomainCode { + ZERO(0), USER(1), PRODUCT(2), + ORDER(13); + + private final int code; + + DomainBitCode(int code) { + this.code = code; + } + + @Override + public int getCode() { + return code; + } + + @Override + public String getDomainName() { + return name().toLowerCase(); + } + } + + private enum UserFieldCode implements FieldCode { + ZERO(0), ID(1), PASSWORD(2), NAME(3), + INVALID(10) + ; + + private final int code; + + UserFieldCode(int code) { + this.code = code; + } + + @Override + public int getCode() { + return code; + } + + @Override + public String getFieldName() { + return name().toLowerCase(); + } + } +} From 5b3ba6df66b1dbca5eabdf7643dd83e798729f99 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 12 Mar 2024 13:09:06 +0900 Subject: [PATCH 016/152] =?UTF-8?q?rename:=20CausedBy=20=EC=A0=95=EC=A0=81?= =?UTF-8?q?=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pennyway/common/exception/CausedBy.java | 22 +++++++++---------- .../common/exception/CausedByTest.java | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java index a2d617db1..938e271b8 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java @@ -6,10 +6,10 @@ /** * 에러 코드를 구성하는 상세 코드 * - * @param statusCode {@link StatusCode} 상태 코드 (3자리) - * @param reasonCode {@link ReasonCode} 이유 코드 (1자리) - * @param domainCode {@link DomainCode} 도메인 코드 (2자리) - * @param fieldCode {@link FieldCode} 필드 코드 (1자리) + * @param statusCode {@link StatusCode} 상태 코드 + * @param reasonCode {@link ReasonCode} 이유 코드 + * @param domainCode {@link DomainCode} 도메인 코드 + * @param fieldCode {@link FieldCode} 필드 코드 * * - see also: {@link StatusCode}, {@link ReasonCode}, {@link DomainCode}, {@link FieldCode} */ @@ -37,13 +37,13 @@ public record CausedBy( /** * CausedBy 객체를 생성하는 정적 팩토리 메서드 *
- * 모든 코드의 조합으로 생성된 최종 코드는 7자리의 문자열로 구성된다. (상태 코드(2자리) + 이유 코드(2자리) + 도메인 코드(1자리) + 필드 코드(2자리)) - *
- * 7자리가 아닌 경우 IllegalArgumentException을 발생시킨다. - * @param statusCode {@link StatusCode} 상태 코드 - * @param reasonCode {@link ReasonCode} 이유 코드 - * @param domainCode {@link DomainCode} 도메인 코드 - * @param fieldCode {@link FieldCode} 필드 코드 + * 모든 코드의 조합으로 생성된 최종 코드는 7자리의 문자열로 구성된다. + * @param statusCode {@link StatusCode} 상태 코드 (3자리) + * @param reasonCode {@link ReasonCode} 이유 코드 (1자리) + * @param domainCode {@link DomainCode} 도메인 코드 (1자리 or 2자리) + * @param fieldCode {@link FieldCode} 필드 코드 (1자리) + * @throws IllegalArgumentException 전체 코드가 7자리가 아닌 경우, 혹은 각 상태 코드가 자릿수를 준수하지 않은 경우 + * @throws NullPointerException 인자가 null인 경우 * @return CausedBy */ public static CausedBy of(StatusCode statusCode, ReasonCode reasonCode, DomainCode domainCode, FieldCode fieldCode) { diff --git a/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java b/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java index 692b6cf1d..ec5772d11 100644 --- a/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java +++ b/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java @@ -95,7 +95,7 @@ public void getExplainError() { CausedBy causedBy = CausedBy.of(statusCode, reasonCode, domainCode, fieldCode); // then - assertEquals("REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY", causedBy.reasonCode().name()); + assertEquals("REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY", causedBy.getReason()); } private enum DomainBitCode implements DomainCode { From 419521e816258cd2ea428540151a716af8f5cf2d Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 14 Mar 2024 11:12:40 +0900 Subject: [PATCH 017/152] =?UTF-8?q?=E2=9C=A8=20JwtProvider=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F=20AT,=20RT=20P?= =?UTF-8?q?rovider=20=EA=B5=AC=ED=98=84=EC=B2=B4=20=EC=A0=95=EC=9D=98=20(#?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: common 모듈에 spring-core 의존성 주입 && infra 모듈에 jwt 의존성 주입 * feat: Auth 상수 설정 * feat: JwtProvider 인터페이스 정의 * feat: domain & field zero 상수 작성 * feat: Jwt 예외 상수 설정 * feat: Jwt 예외 클래스 * fix: ReasonCode Zero bit 추가 * feat: JwtErrorCodeUtil 작성 * chore: application.yml profile 분리 * feat: AT, RT Qualifier 목적 커스텀 어노테이션 작성 * chore: jwt secret key & expiration time 환경변수 주입 * rename: provider annotation 네이밍 변경 -> 전략 * feat: common 모듈에 DateUtil 추가 * feat: access token claim dto && provider 작성 * test: AccessTokenProvider test 작성 * fix: test given절 축약 * refactor: AT Claims key 상수값으로 명시적 필드 지정 * feat: refresh token dto && payload key 상수화 * feat: refresh token provider 작성 * test: 서명 조작 토큰 에러 검증 * rename: getSubInfoFromToken -> getJwtClaimsFromToken 메서드명 변경 * rename: JwtProvider 주석 수정 및 메서드 순서 변경 * fix: 토큰 만료시 예외 핸들링 * test: 토큰 만료 시, true 반환 검사 테스트 코드 추가 * fix: isTokenExpired() 메서드 예외 핸들링 로직 수정 : 테스트 성공 --- .../annotation/AccessTokenStrategy.java | 13 +++ .../annotation/RefreshTokenStrategy.java | 13 +++ .../security/jwt/access/AccessTokenClaim.java | 28 +++++ .../jwt/access/AccessTokenClaimKeys.java | 16 +++ .../jwt/access/AccessTokenProvider.java | 106 +++++++++++++++++ .../jwt/refresh/RefreshTokenClaim.java | 28 +++++ .../jwt/refresh/RefreshTokenClaimKeys.java | 16 +++ .../jwt/refresh/RefreshTokenProvider.java | 107 ++++++++++++++++++ .../src/main/resources/application.yml | 20 ++++ .../jwt/access/AccessTokenProviderTest.java | 106 +++++++++++++++++ pennyway-common/build.gradle | 4 +- .../pennyway/common/exception/ReasonCode.java | 52 +++++---- .../kr/co/pennyway/common/util/DateUtil.java | 48 ++++++++ .../src/main/resources/application-common.yml | 11 ++ .../common/exception/CausedByTest.java | 4 +- .../src/main/resources/application-domain.yml | 11 ++ pennyway-infra/build.gradle | 5 + .../common/exception/DomainErrorCode.java | 21 ++++ .../common/exception/FieldErrorCode.java | 21 ++++ .../infra/common/exception/JwtErrorCode.java | 58 ++++++++++ .../common/exception/JwtErrorException.java | 21 ++++ .../kr/co/infra/common/jwt/AuthConstants.java | 18 +++ .../kr/co/infra/common/jwt/JwtClaims.java | 7 ++ .../kr/co/infra/common/jwt/JwtProvider.java | 55 +++++++++ .../infra/common/util/JwtErrorCodeUtil.java | 67 +++++++++++ .../src/main/resources/application-infra.yml | 11 ++ .../src/test/resources/application-infra.yml | 0 27 files changed, 839 insertions(+), 28 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/common/annotation/AccessTokenStrategy.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/common/annotation/RefreshTokenStrategy.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenClaim.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenClaimKeys.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProvider.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenClaim.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenClaimKeys.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenProvider.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProviderTest.java create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/util/DateUtil.java create mode 100644 pennyway-infra/src/main/java/kr/co/infra/common/exception/DomainErrorCode.java create mode 100644 pennyway-infra/src/main/java/kr/co/infra/common/exception/FieldErrorCode.java create mode 100644 pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java create mode 100644 pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorException.java create mode 100644 pennyway-infra/src/main/java/kr/co/infra/common/jwt/AuthConstants.java create mode 100644 pennyway-infra/src/main/java/kr/co/infra/common/jwt/JwtClaims.java create mode 100644 pennyway-infra/src/main/java/kr/co/infra/common/jwt/JwtProvider.java create mode 100644 pennyway-infra/src/main/java/kr/co/infra/common/util/JwtErrorCodeUtil.java create mode 100644 pennyway-infra/src/main/resources/application-infra.yml delete mode 100644 pennyway-infra/src/test/resources/application-infra.yml diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/annotation/AccessTokenStrategy.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/annotation/AccessTokenStrategy.java new file mode 100644 index 000000000..99fcb78d3 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/annotation/AccessTokenStrategy.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, + ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("accessTokenStrategy") +public @interface AccessTokenStrategy { +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/annotation/RefreshTokenStrategy.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/annotation/RefreshTokenStrategy.java new file mode 100644 index 000000000..e216bd94e --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/annotation/RefreshTokenStrategy.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, + ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("accessTokenStrategy") +public @interface RefreshTokenStrategy { +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenClaim.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenClaim.java new file mode 100644 index 000000000..8106aa5d1 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenClaim.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.common.security.jwt.access; + +import kr.co.infra.common.jwt.JwtClaims; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +import java.util.Map; + +import static kr.co.pennyway.common.security.jwt.access.AccessTokenClaimKeys.ROLE; +import static kr.co.pennyway.common.security.jwt.access.AccessTokenClaimKeys.USER_ID; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class AccessTokenClaim implements JwtClaims { + private final Map claims; + + public static AccessTokenClaim of(Long userId, String role) { + Map claims = Map.of( + USER_ID.getValue(), userId.toString(), + ROLE.getValue(), role + ); + return new AccessTokenClaim(claims); + } + + @Override + public Map getClaims() { + return claims; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenClaimKeys.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenClaimKeys.java new file mode 100644 index 000000000..04a259f4d --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenClaimKeys.java @@ -0,0 +1,16 @@ +package kr.co.pennyway.common.security.jwt.access; + +public enum AccessTokenClaimKeys { + USER_ID("userId"), + ROLE("role"); + + private final String value; + + AccessTokenClaimKeys(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProvider.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProvider.java new file mode 100644 index 000000000..53ca8ff3b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProvider.java @@ -0,0 +1,106 @@ +package kr.co.pennyway.common.security.jwt.access; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import kr.co.infra.common.exception.JwtErrorCode; +import kr.co.infra.common.exception.JwtErrorException; +import kr.co.infra.common.jwt.JwtClaims; +import kr.co.infra.common.jwt.JwtProvider; +import kr.co.infra.common.util.JwtErrorCodeUtil; +import kr.co.pennyway.common.annotation.AccessTokenStrategy; +import kr.co.pennyway.common.util.DateUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Date; +import java.util.Map; + +import static kr.co.pennyway.common.security.jwt.access.AccessTokenClaimKeys.ROLE; +import static kr.co.pennyway.common.security.jwt.access.AccessTokenClaimKeys.USER_ID; + +@Slf4j +@Primary +@Component +@AccessTokenStrategy +public class AccessTokenProvider implements JwtProvider { + private final SecretKey secretKey; + private final Duration tokenExpiration; + + public AccessTokenProvider( + @Value("${jwt.secret-key.access-token}") String jwtSecretKey, + @Value("${jwt.expiration-time.access-token}") Duration tokenExpiration + ) { + final byte[] secretKeyBytes = Base64.getDecoder().decode(jwtSecretKey); + this.secretKey = Keys.hmacShaKeyFor(secretKeyBytes); + this.tokenExpiration = tokenExpiration; + } + + @Override + public String generateToken(JwtClaims claims) { + Date now = new Date(); + + return Jwts.builder() + .header().add(createHeader()).and() + .claims(claims.getClaims()) + .signWith(secretKey) + .expiration(createExpireDate(now, tokenExpiration.toMillis())) + .compact(); + } + + @Override + public JwtClaims getJwtClaimsFromToken(String token) { + Claims claims = getClaimsFromToken(token); + return AccessTokenClaim.of(Long.parseLong(claims.get(USER_ID.getValue(), String.class)), claims.get(ROLE.getValue(), String.class)); + } + + @Override + public LocalDateTime getExpiryDate(String token) { + Claims claims = getClaimsFromToken(token); + return DateUtil.toLocalDateTime(claims.getExpiration()); + } + + @Override + public boolean isTokenExpired(String token) { + try { + Claims claims = getClaimsFromToken(token); + return claims.getExpiration().before(new Date()); + } catch (JwtErrorException e) { + if (JwtErrorCode.EXPIRED_TOKEN.equals(e.getErrorCode())) return true; + throw e; + } + } + + @Override + public Claims getClaimsFromToken(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (JwtException e) { + final JwtErrorCode errorCode = JwtErrorCodeUtil.determineErrorCode(e, JwtErrorCode.FAILED_AUTHENTICATION); + + log.warn("Error code : {}, Error - {}, {}", errorCode, e.getClass(), e.getMessage()); + throw new JwtErrorException(errorCode); + } + } + + private Map createHeader() { + return Map.of("typ", "JWT", + "alg", "HS256", + "regDate", System.currentTimeMillis()); + } + + private Date createExpireDate(final Date now, long expirationTime) { + return new Date(now.getTime() + expirationTime); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenClaim.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenClaim.java new file mode 100644 index 000000000..546540ebd --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenClaim.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.common.security.jwt.refresh; + +import kr.co.infra.common.jwt.JwtClaims; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +import java.util.Map; + +import static kr.co.pennyway.common.security.jwt.refresh.RefreshTokenClaimKeys.ROLE; +import static kr.co.pennyway.common.security.jwt.refresh.RefreshTokenClaimKeys.USER_ID; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class RefreshTokenClaim implements JwtClaims { + private final Map claims; + + public static RefreshTokenClaim of(Long userId, String role) { + Map claims = Map.of( + USER_ID.getValue(), userId.toString(), + ROLE.getValue(), role + ); + return new RefreshTokenClaim(claims); + } + + @Override + public Map getClaims() { + return claims; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenClaimKeys.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenClaimKeys.java new file mode 100644 index 000000000..deb87734d --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenClaimKeys.java @@ -0,0 +1,16 @@ +package kr.co.pennyway.common.security.jwt.refresh; + +public enum RefreshTokenClaimKeys { + USER_ID("userId"), + ROLE("role"); + + private final String value; + + RefreshTokenClaimKeys(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenProvider.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenProvider.java new file mode 100644 index 000000000..e9e294229 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenProvider.java @@ -0,0 +1,107 @@ +package kr.co.pennyway.common.security.jwt.refresh; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import kr.co.infra.common.exception.JwtErrorCode; +import kr.co.infra.common.exception.JwtErrorException; +import kr.co.infra.common.jwt.JwtClaims; +import kr.co.infra.common.jwt.JwtProvider; +import kr.co.infra.common.util.JwtErrorCodeUtil; +import kr.co.pennyway.common.annotation.RefreshTokenStrategy; +import kr.co.pennyway.common.security.jwt.access.AccessTokenClaim; +import kr.co.pennyway.common.util.DateUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Date; +import java.util.Map; + +import static kr.co.pennyway.common.security.jwt.refresh.RefreshTokenClaimKeys.ROLE; +import static kr.co.pennyway.common.security.jwt.refresh.RefreshTokenClaimKeys.USER_ID; + +@Slf4j +@Primary +@Component +@RefreshTokenStrategy +public class RefreshTokenProvider implements JwtProvider { + private final SecretKey secretKey; + private final Duration tokenExpiration; + + public RefreshTokenProvider( + @Value("${jwt.secret-key.refresh-token}") String jwtSecretKey, + @Value("${jwt.expiration-time.refresh-token}") Duration tokenExpiration + ) { + final byte[] secretKeyBytes = Base64.getDecoder().decode(jwtSecretKey); + this.secretKey = Keys.hmacShaKeyFor(secretKeyBytes); + this.tokenExpiration = tokenExpiration; + } + + @Override + public String generateToken(JwtClaims claims) { + Date now = new Date(); + + return Jwts.builder() + .header().add(createHeader()).and() + .claims(claims.getClaims()) + .signWith(secretKey) + .expiration(createExpireDate(now, tokenExpiration.toMillis())) + .compact(); + } + + @Override + public JwtClaims getJwtClaimsFromToken(String token) { + Claims claims = getClaimsFromToken(token); + return AccessTokenClaim.of(Long.parseLong(claims.get(USER_ID.getValue(), String.class)), claims.get(ROLE.getValue(), String.class)); + } + + @Override + public LocalDateTime getExpiryDate(String token) { + Claims claims = getClaimsFromToken(token); + return DateUtil.toLocalDateTime(claims.getExpiration()); + } + + @Override + public boolean isTokenExpired(String token) { + try { + Claims claims = getClaimsFromToken(token); + return claims.getExpiration().before(new Date()); + } catch (JwtErrorException e) { + if (JwtErrorCode.EXPIRED_TOKEN.equals(e.getErrorCode())) return true; + throw e; + } + } + + @Override + public Claims getClaimsFromToken(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (JwtException e) { + final JwtErrorCode errorCode = JwtErrorCodeUtil.determineErrorCode(e, JwtErrorCode.FAILED_AUTHENTICATION); + + log.warn("Error code : {}, Error - {}, {}", errorCode, e.getClass(), e.getMessage()); + throw new JwtErrorException(errorCode); + } + } + + private Map createHeader() { + return Map.of("typ", "JWT", + "alg", "HS256", + "regDate", System.currentTimeMillis()); + } + + private Date createExpireDate(final Date now, long expirationTime) { + return new Date(now.getTime() + expirationTime); + } +} diff --git a/pennyway-app-external-api/src/main/resources/application.yml b/pennyway-app-external-api/src/main/resources/application.yml index e69de29bb..e794dea7f 100644 --- a/pennyway-app-external-api/src/main/resources/application.yml +++ b/pennyway-app-external-api/src/main/resources/application.yml @@ -0,0 +1,20 @@ +jwt: + secret-key: + access-token: ${JWT_ACCESS_SECRET_KEY} + refresh-token: ${JWT_REFRESH_SECRET_KEY} + expiration-time: + # milliseconds 단위 + access-token: ${JWT_ACCESS_EXPIRATION_TIME} # 30m (30 * 60 * 1000) + refresh-token: ${JWT_REFRESH_EXPIRATION_TIME} # 7d (7 * 24 * 60 * 60 * 1000) + +--- +spring: + config: + activate: + on-profile: local + +--- +spring: + config: + activate: + on-profile: prod \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProviderTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProviderTest.java new file mode 100644 index 000000000..608d9cf47 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProviderTest.java @@ -0,0 +1,106 @@ +package kr.co.pennyway.common.security.jwt.access; + +import kr.co.infra.common.exception.JwtErrorCode; +import kr.co.infra.common.exception.JwtErrorException; +import kr.co.infra.common.jwt.JwtClaims; +import kr.co.infra.common.jwt.JwtProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +public class AccessTokenProviderTest { + private final String secretStr = "helloMyNameIsPennywayThisIsSecretKeyItNeedsToBeLongerThan256Bits"; + private JwtProvider jwtProvider; + private JwtClaims jwtClaims; + + @BeforeEach + public void setUp() { + jwtProvider = new AccessTokenProvider(secretStr, Duration.ofMinutes(5)); + jwtClaims = AccessTokenClaim.of(1L, "ROLE_USER"); + } + + @Test + @DisplayName("토큰 생성이 정상적으로 이루어지는지 확인한다.") + public void createToken() { + // when + String token = jwtProvider.generateToken(jwtClaims); + + // then + assertNotNull(token); + System.out.println(token); + } + + @Test + @DisplayName("토큰에서 정보를 추출할 수 있는지 확인한다.") + public void getSubInfoFromToken() { + // given + String token = jwtProvider.generateToken(jwtClaims); + + // when + JwtClaims subInfo = jwtProvider.getJwtClaimsFromToken(token); + + // then + assertNotNull(subInfo); + System.out.println(subInfo.getClaims()); + } + + @Test + @DisplayName("토큰의 만료일을 확인한다.") + public void getExpiryDate() { + // given + String token = jwtProvider.generateToken(jwtClaims); + + // when + jwtProvider.getExpiryDate(token); + + // then + assertNotNull(jwtProvider.getExpiryDate(token)); + System.out.println(jwtProvider.getExpiryDate(token)); + } + + @Test + @DisplayName("토큰의 만료되지 않았을 때 false를 반환한다.") + public void isTokenExpired() { + // given + String token = jwtProvider.generateToken(jwtClaims); + + // when + jwtProvider.isTokenExpired(token); + + // then + assertFalse(jwtProvider.isTokenExpired(token)); + } + + @Test + @DisplayName("토큰의 만료일이 지났을 때 true를 반환한다.") + public void isTokenExpiredWhenTokenIsExpired() { + // given + jwtClaims = AccessTokenClaim.of(1L, "ROLE_USER"); + jwtProvider = new AccessTokenProvider(secretStr, Duration.ofMillis(1)); + String token = jwtProvider.generateToken(jwtClaims); + + // then + assertTrue(jwtProvider.isTokenExpired(token)); + } + + @Test + @DisplayName("서명이 올바르지 않은 토큰을 파싱하면 TAMPERED_TOKEN 예외가 발생한다.") + public void getClaimsFromTokenWithInvalidSignature() { + // given + String token = jwtProvider.generateToken(jwtClaims); + String invalidToken = token + "invalid"; + + // when + JwtErrorException exception = assertThrows(JwtErrorException.class, () -> jwtProvider.getClaimsFromToken(invalidToken)); + + // then + assertEquals(JwtErrorCode.TAMPERED_TOKEN, exception.getErrorCode()); + } +} diff --git a/pennyway-common/build.gradle b/pennyway-common/build.gradle index 5e0562de4..a2b66f24a 100644 --- a/pennyway-common/build.gradle +++ b/pennyway-common/build.gradle @@ -4,8 +4,10 @@ jar {enabled = true} dependencies { implementation 'org.springframework.boot:spring-boot-starter-aop' + /* Spring Core */ + api group: 'org.springframework', name: 'spring-core', version: '6.1.4' + /* Jackson */ api 'com.fasterxml.jackson.core:jackson-annotations:2.10.1' api 'com.fasterxml.jackson.core:jackson-databind:2.13.5' - } diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java index 111ae6e43..ee856c924 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java @@ -9,48 +9,50 @@ @Getter @RequiredArgsConstructor public enum ReasonCode { + ZERO(0), + /* 400_BAD_REQUEST */ - INVALID_REQUEST_SYNTAX(0), - MISSING_REQUIRED_PARAMETER(1), - MALFORMED_PARAMETER(2), - MALFORMED_REQUEST_BODY(3), + INVALID_REQUEST_SYNTAX(1), + MISSING_REQUIRED_PARAMETER(2), + MALFORMED_PARAMETER(3), + MALFORMED_REQUEST_BODY(4), /* 401_UNAUTHORIZED */ - MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS(0), - EXPIRED_OR_REVOKED_TOKEN(1), - INSUFFICIENT_PERMISSIONS(2), - TAMPERED_OR_MALFORMED_TOKEN(3), + MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS(1), + EXPIRED_OR_REVOKED_TOKEN(2), + INSUFFICIENT_PERMISSIONS(3), + TAMPERED_OR_MALFORMED_TOKEN(4), /* 403_FORBIDDEN */ - ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN(0), - IP_ADDRESS_BLOCKED(1), - USER_ACCOUNT_SUSPENDED_OR_BANNED(2), - ACCESS_TO_RESOURCE_NOT_ALLOWED_FOR_USER_ROLE(3), + ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN(1), + IP_ADDRESS_BLOCKED(2), + USER_ACCOUNT_SUSPENDED_OR_BANNED(3), + ACCESS_TO_RESOURCE_NOT_ALLOWED_FOR_USER_ROLE(4), /* 404_NOT_FOUND */ - REQUESTED_RESOURCE_NOT_FOUND(0), - INVALID_URL_OR_ENDPOINT(1), - RESOURCE_DELETED_OR_MOVED(2), + REQUESTED_RESOURCE_NOT_FOUND(1), + INVALID_URL_OR_ENDPOINT(2), + RESOURCE_DELETED_OR_MOVED(3), /* 405_METHOD_NOT_ALLOWED */ - REQUEST_METHOD_NOT_SUPPORTED(0), - ATTEMPTED_TO_ACCESS_UNSUPPORTED_METHOD(1), + REQUEST_METHOD_NOT_SUPPORTED(1), + ATTEMPTED_TO_ACCESS_UNSUPPORTED_METHOD(2), /* 406_NOT_ACCEPTABLE */ - REQUESTED_RESPONSE_FORMAT_NOT_SUPPORTED(0), + REQUESTED_RESPONSE_FORMAT_NOT_SUPPORTED(1), /* 409_CONFLICT */ - REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE(0), - RESOURCE_ALREADY_EXISTS(1), - CONCURRENT_MODIFICATION_CONFLICT(2), + REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE(1), + RESOURCE_ALREADY_EXISTS(2), + CONCURRENT_MODIFICATION_CONFLICT(3), /* 412_PRECONDITION_FAILED */ - PRECONDITION_REQUEST_HEADER_NOT_MATCHED(0), - IF_MATCH_HEADER_OR_IF_NONE_MATCH_HEADER_NOT_MATCHED(1), + PRECONDITION_REQUEST_HEADER_NOT_MATCHED(1), + IF_MATCH_HEADER_OR_IF_NONE_MATCH_HEADER_NOT_MATCHED(2), /* 422_UNPROCESSABLE_CONTENT */ - REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY(0), - VALIDATION_ERROR_IN_REQUEST_BODY(1), + REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY(1), + VALIDATION_ERROR_IN_REQUEST_BODY(2), ; private final int code; diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/util/DateUtil.java b/pennyway-common/src/main/java/kr/co/pennyway/common/util/DateUtil.java new file mode 100644 index 000000000..1eac52f61 --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/util/DateUtil.java @@ -0,0 +1,48 @@ +package kr.co.pennyway.common.util; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +public final class DateUtil { + + private DateUtil() {} + + /** + * LocalDate를 Date로 변환 + * @param localDate LocalDate + * @return Date + */ + public static Date toDate(LocalDate localDate) { + return Date.from(localDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()); + } + + /** + * LocalDateTime을 Date로 변환 + * @param localDateTime LocalDateTime + * @return Date + */ + public static Date toDate(LocalDateTime localDateTime) { + return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant()); + } + + /** + * Date를 LocalDate로 변환 + * @param date Date + * @return LocalDate + */ + public static LocalDate toLocalDate(Date date) { + return Instant.ofEpochMilli(date.getTime()).atZone(ZoneId.systemDefault()).toLocalDate(); + } + + /** + * Date를 LocalDateTime으로 변환 + * @param date Date + * @return LocalDateTime + */ + public static LocalDateTime toLocalDateTime(Date date) { + return Instant.ofEpochMilli(date.getTime()).atZone(ZoneId.systemDefault()).toLocalDateTime(); + } +} diff --git a/pennyway-common/src/main/resources/application-common.yml b/pennyway-common/src/main/resources/application-common.yml index e69de29bb..a0ff13ad6 100644 --- a/pennyway-common/src/main/resources/application-common.yml +++ b/pennyway-common/src/main/resources/application-common.yml @@ -0,0 +1,11 @@ +--- +spring: + config: + activate: + on-profile: local + +--- +spring: + config: + activate: + on-profile: prod \ No newline at end of file diff --git a/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java b/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java index ec5772d11..e4fe3a3bf 100644 --- a/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java +++ b/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java @@ -63,7 +63,7 @@ public void generateCodeWithValidArguments() { CausedBy causedBy = CausedBy.of(statusCode, reasonCode, domainCode, fieldCode); // then - assertEquals("4220013", causedBy.getCode()); + assertEquals("4221013", causedBy.getCode()); } @Test @@ -79,7 +79,7 @@ public void generateCodeWithTwoDigitDomainCode() { CausedBy causedBy = CausedBy.of(statusCode, reasonCode, domainCode, fieldCode); // then - assertEquals("4001130", causedBy.getCode()); + assertEquals("4002130", causedBy.getCode()); } @Test diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml index e69de29bb..a0ff13ad6 100644 --- a/pennyway-domain/src/main/resources/application-domain.yml +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -0,0 +1,11 @@ +--- +spring: + config: + activate: + on-profile: local + +--- +spring: + config: + activate: + on-profile: prod \ No newline at end of file diff --git a/pennyway-infra/build.gradle b/pennyway-infra/build.gradle index 8b148823a..72db1b328 100644 --- a/pennyway-infra/build.gradle +++ b/pennyway-infra/build.gradle @@ -3,4 +3,9 @@ jar {enabled = true} dependencies { implementation project(':pennyway-common') + + /* jwt */ + api group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.12.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.12.5' } diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/exception/DomainErrorCode.java b/pennyway-infra/src/main/java/kr/co/infra/common/exception/DomainErrorCode.java new file mode 100644 index 000000000..4eb812545 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/infra/common/exception/DomainErrorCode.java @@ -0,0 +1,21 @@ +package kr.co.infra.common.exception; + +import kr.co.pennyway.common.exception.DomainCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum DomainErrorCode implements DomainCode { + ZERO(0); + + private final int code; + + @Override + public int getCode() { + return code; + } + + @Override + public String getDomainName() { + return name().toLowerCase(); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/exception/FieldErrorCode.java b/pennyway-infra/src/main/java/kr/co/infra/common/exception/FieldErrorCode.java new file mode 100644 index 000000000..19d444bf9 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/infra/common/exception/FieldErrorCode.java @@ -0,0 +1,21 @@ +package kr.co.infra.common.exception; + +import kr.co.pennyway.common.exception.FieldCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum FieldErrorCode implements FieldCode { + ZERO(0); + + private final int code; + + @Override + public int getCode() { + return code; + } + + @Override + public String getFieldName() { + return name().toLowerCase(); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java b/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java new file mode 100644 index 000000000..b49e6cbb6 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java @@ -0,0 +1,58 @@ +package kr.co.infra.common.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import static kr.co.pennyway.common.exception.ReasonCode.*; +import static kr.co.pennyway.common.exception.StatusCode.*; + +@Getter +@RequiredArgsConstructor +public enum JwtErrorCode implements BaseErrorCode { + /** + * 400 BAD_REQUEST: 클라이언트의 요청이 부적절 할 경우 + */ + INVALID_HEADER(BAD_REQUEST, INVALID_REQUEST_SYNTAX, "유효하지 않은 헤더 포맷입니다"), + + /** + * 401 UNAUTHORIZED: 인증되지 않은 사용자 + */ + EMPTY_ACCESS_TOKEN(UNAUTHORIZED, MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "토큰이 비어있습니다"), + FAILED_AUTHENTICATION(UNAUTHORIZED, MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "인증에 실패하였습니다"), + EXPIRED_TOKEN(UNAUTHORIZED, EXPIRED_OR_REVOKED_TOKEN, "사용기간이 만료된 토큰입니다"), + INSUFFICIENT_PERMISSIONS_TOKEN(UNAUTHORIZED, INSUFFICIENT_PERMISSIONS, "자원에 대한 충분한 권한이 없습니다."), + TAMPERED_TOKEN(UNAUTHORIZED, TAMPERED_OR_MALFORMED_TOKEN, "서명이 조작된 토큰입니다"), + MALFORMED_TOKEN(UNAUTHORIZED, TAMPERED_OR_MALFORMED_TOKEN, "비정상적인 토큰입니다"), + UNSUPPORTED_JWT_TOKEN(UNAUTHORIZED, TAMPERED_OR_MALFORMED_TOKEN, "지원하지 않는 토큰입니다"), + + /** + * 403 FORBIDDEN: 인증된 클라이언트가 권한이 없는 자원에 접근 + */ + FORBIDDEN_ACCESS_TOKEN(FORBIDDEN, ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "해당 토큰에는 엑세스 권한이 없습니다"), + SUSPENDED_OR_BANNED_TOKEN(FORBIDDEN, USER_ACCOUNT_SUSPENDED_OR_BANNED, "사용자 계정이 정지되었습니다"), + + /** + * 500 INTERNAL_SERVER_ERROR: 서버 내부 에러 + */ + INVALID_JWT_DTO_FORMAT(INTERNAL_SERVER_ERROR, ZERO, "서버 내부 에러가 발생했습니다."), + UNEXPECTED_ERROR(INTERNAL_SERVER_ERROR, ZERO, "예상치 못한 에러가 발생했습니다."); + ; + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode, DomainErrorCode.ZERO, FieldErrorCode.ZERO); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorException.java b/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorException.java new file mode 100644 index 000000000..831ccc499 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorException.java @@ -0,0 +1,21 @@ +package kr.co.infra.common.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class JwtErrorException extends GlobalErrorException { + private final JwtErrorCode errorCode; + + public JwtErrorException(JwtErrorCode jwtErrorCode) { + super(jwtErrorCode); + this.errorCode = jwtErrorCode; + } + + public CausedBy causedBy() { + return errorCode.causedBy(); + } + + public JwtErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/jwt/AuthConstants.java b/pennyway-infra/src/main/java/kr/co/infra/common/jwt/AuthConstants.java new file mode 100644 index 000000000..7ce4c4aa2 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/infra/common/jwt/AuthConstants.java @@ -0,0 +1,18 @@ +package kr.co.infra.common.jwt; + +import lombok.Getter; + +@Getter +public enum AuthConstants { + AUTHORIZATION("Authorization"), TOKEN_TYPE("Bearer "); + + private final String value; + + AuthConstants(String value) { + this.value = value; + } + + @Override public String toString() { + return "AuthConstants(value=" + this.value + ")"; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/jwt/JwtClaims.java b/pennyway-infra/src/main/java/kr/co/infra/common/jwt/JwtClaims.java new file mode 100644 index 000000000..f04ba8e6f --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/infra/common/jwt/JwtClaims.java @@ -0,0 +1,7 @@ +package kr.co.infra.common.jwt; + +import java.util.Map; + +public interface JwtClaims { + Map getClaims(); +} diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/jwt/JwtProvider.java b/pennyway-infra/src/main/java/kr/co/infra/common/jwt/JwtProvider.java new file mode 100644 index 000000000..9798985d2 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/infra/common/jwt/JwtProvider.java @@ -0,0 +1,55 @@ +package kr.co.infra.common.jwt; + + +import io.jsonwebtoken.Claims; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; + +public interface JwtProvider { + /** + * 헤더로부터 토큰을 추출하고 유효성을 검사하는 메서드 + * @param authHeader : 메시지 헤더 + * @return 값이 있다면 토큰, 없다면 빈 문자열 (빈 문자열을 반환하는 경우 예외 처리를 해주어야 한다.) + */ + default String resolveToken(String authHeader) { + if (StringUtils.hasText(authHeader) && authHeader.startsWith(AuthConstants.TOKEN_TYPE.getValue())) { + return authHeader.substring(AuthConstants.TOKEN_TYPE.getValue().length()); + } + return ""; + } + + /** + * 토큰을 생성하는 메서드 + * @param subs {@link JwtClaims} : 토큰 payload에 담을 정보 + * @return String : 토큰 + */ + String generateToken(JwtClaims subs); + + /** + * 토큰으로 부터 payload를 추출하여 JwtClaims 객체로 반환하는 메서드 + * @param token String : 토큰 + * @return {@link JwtClaims} : 사용자 정보 + */ + JwtClaims getJwtClaimsFromToken(String token); + + /** + * 토큰의 만료일을 추출하는 메서드 + * @param token String : 토큰 + * @return LocalDateTime : 만료일 + */ + LocalDateTime getExpiryDate(String token); + + /** + * 토큰의 만료 여부를 검사하는 메서드 + * @param token String : 토큰 + */ + boolean isTokenExpired(String token); + + /** + * 토큰으로부터 payload 정보를 추출하는 메서드 + * @param token String : 토큰 + * @return Claims : 사용자 정보 + */ + Claims getClaimsFromToken(String token); +} diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/util/JwtErrorCodeUtil.java b/pennyway-infra/src/main/java/kr/co/infra/common/util/JwtErrorCodeUtil.java new file mode 100644 index 000000000..15819d82d --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/infra/common/util/JwtErrorCodeUtil.java @@ -0,0 +1,67 @@ +package kr.co.infra.common.util; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.SignatureException; +import kr.co.infra.common.exception.JwtErrorCode; +import kr.co.infra.common.exception.JwtErrorException; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.Map; +import java.util.Optional; + +/** + * JWT 예외와 오류 코드를 매핑하는 유틸리티 클래스. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class JwtErrorCodeUtil { + private static final Map, JwtErrorCode> ERROR_CODE_MAP = Map.of( + ExpiredJwtException.class, JwtErrorCode.EXPIRED_TOKEN, + MalformedJwtException.class, JwtErrorCode.MALFORMED_TOKEN, + SignatureException.class, JwtErrorCode.TAMPERED_TOKEN, + UnsupportedJwtException.class, JwtErrorCode.UNSUPPORTED_JWT_TOKEN + ); + + /** + * 예외에 해당하는 오류 코드를 반환하거나 기본 오류 코드를 반환합니다. + * + * @param exception {@link Exception} : 발생한 예외 + * @param defaultErrorCode {@link JwtErrorCode} : 기본 오류 코드 + * @return {@link JwtErrorException} + */ + public static JwtErrorCode determineErrorCode(Exception exception, JwtErrorCode defaultErrorCode) { + if (exception instanceof JwtErrorException jwtErrorException) + return jwtErrorException.getErrorCode(); + + Class exceptionClass = exception.getClass(); + return ERROR_CODE_MAP.getOrDefault(exceptionClass, defaultErrorCode); + } + + /** + * 예외에 해당하는 {@link JwtErrorException}을 반환합니다. + * 기본 오류 코드는 400 UNEXPECTED_ERROR 입니다. + * 해당 메서드는 {@link #determineErrorCode(Exception, JwtErrorCode)} 메서드를 사용합니다. + * + * @param exception {@link Exception} : 발생한 예외 + * @return {@link JwtErrorException} + */ + public static JwtErrorException determineAuthErrorException(Exception exception) { + return findAuthErrorException(exception).orElseGet( + () -> { + JwtErrorCode authErrorCode = determineErrorCode(exception, JwtErrorCode.UNEXPECTED_ERROR); + return new JwtErrorException(authErrorCode); + } + ); + } + + private static Optional findAuthErrorException(Exception exception) { + if (exception instanceof JwtErrorException) { + return Optional.of((JwtErrorException) exception); + } else if (exception.getCause() instanceof JwtErrorException) { + return Optional.of((JwtErrorException) exception.getCause()); + } + return Optional.empty(); + } +} diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml new file mode 100644 index 000000000..a0ff13ad6 --- /dev/null +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -0,0 +1,11 @@ +--- +spring: + config: + activate: + on-profile: local + +--- +spring: + config: + activate: + on-profile: prod \ No newline at end of file diff --git a/pennyway-infra/src/test/resources/application-infra.yml b/pennyway-infra/src/test/resources/application-infra.yml deleted file mode 100644 index e69de29bb..000000000 From a27a3d97057d4036b8ea676fc677588353ba0e26 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 18 Mar 2024 17:07:16 +0900 Subject: [PATCH 018/152] =?UTF-8?q?=E2=9C=A8=20JDBC=20&=20JPA=20&=20QueryD?= =?UTF-8?q?sl=20Configuration=20=EC=84=A4=EC=A0=95=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: mysql & jpa & queryDsl gradle 의존성 추가 * chore: queryDsl generated 디렉토리 git 추적 제거 * chore: jdbc, jpa application.yml 설정 추가 * chore: extenal-api 모듈 application.yml 그룹 추가 * chore: jpa 설정 profile 분리 && profile 그룹 추가 * chore: infra 모듈 application.yml 그룹 추가 * feat: Jpa config * feat: QueryDsl config * chore: EnalbeJpaAuditing auditorAwareRef 설정 제거 * fix: config 디렉토리 수정 --- .../src/main/resources/application.yml | 6 +++ pennyway-domain/.gitignore | 2 + pennyway-domain/build.gradle | 30 +++++++++++++ .../kr/co/pennyway/DomainPackageLocation.java | 4 ++ .../java/kr/co/pennyway/config/JpaConfig.java | 14 +++++++ .../kr/co/pennyway/config/QueryDslConfig.java | 18 ++++++++ .../src/main/resources/application-domain.yml | 42 ++++++++++++++++++- .../src/main/resources/application-infra.yml | 6 +++ 8 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/DomainPackageLocation.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/config/JpaConfig.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/config/QueryDslConfig.java diff --git a/pennyway-app-external-api/src/main/resources/application.yml b/pennyway-app-external-api/src/main/resources/application.yml index e794dea7f..1e3147ec6 100644 --- a/pennyway-app-external-api/src/main/resources/application.yml +++ b/pennyway-app-external-api/src/main/resources/application.yml @@ -1,3 +1,9 @@ +spring: + profiles: + group: + local: common, domain, infra + prod: common, domain, infra + jwt: secret-key: access-token: ${JWT_ACCESS_SECRET_KEY} diff --git a/pennyway-domain/.gitignore b/pennyway-domain/.gitignore index b63da4551..91bbe90bc 100644 --- a/pennyway-domain/.gitignore +++ b/pennyway-domain/.gitignore @@ -28,6 +28,8 @@ bin/ !**/src/main/**/bin/ !**/src/test/**/bin/ +./main/generated/* + ### NetBeans ### /nbproject/private/ /nbbuild/ diff --git a/pennyway-domain/build.gradle b/pennyway-domain/build.gradle index 8544c3034..a0255c1ad 100644 --- a/pennyway-domain/build.gradle +++ b/pennyway-domain/build.gradle @@ -4,4 +4,34 @@ jar {enabled = true} dependencies { implementation project(':pennyway-common') + /* MySQL */ + implementation group: 'com.mysql', name: 'mysql-connector-j', version: '8.3.0' + + /* JPA */ + api group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa', version: '3.2.3' + + /* QueryDsl */ + implementation 'com.querydsl:querydsl-core:5.0.0' + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api:2.1.1" + annotationProcessor "jakarta.persistence:jakarta.persistence-api:3.1.0" +} + +def querydslDir = 'src/main/generated' + +sourceSets { + main.java.srcDirs += [querydslDir] } + +configurations { + querydsl.extendsFrom compileClasspath +} + +tasks.withType(JavaCompile).configureEach { + options.getGeneratedSourceOutputDirectory().set(file(querydslDir)) +} + +clean.doLast { + file(querydslDir).deleteDir() +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/DomainPackageLocation.java b/pennyway-domain/src/main/java/kr/co/pennyway/DomainPackageLocation.java new file mode 100644 index 000000000..02781d30e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/DomainPackageLocation.java @@ -0,0 +1,4 @@ +package kr.co.pennyway; + +public interface DomainPackageLocation { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/config/JpaConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/config/JpaConfig.java new file mode 100644 index 000000000..21544f186 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/config/JpaConfig.java @@ -0,0 +1,14 @@ +package kr.co.pennyway.config; + +import kr.co.pennyway.DomainPackageLocation; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@EnableJpaAuditing +@EntityScan(basePackageClasses = DomainPackageLocation.class) +@EnableJpaRepositories(basePackageClasses = DomainPackageLocation.class) +public class JpaConfig { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/config/QueryDslConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/config/QueryDslConfig.java new file mode 100644 index 000000000..799c4e5fd --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/config/QueryDslConfig.java @@ -0,0 +1,18 @@ +package kr.co.pennyway.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml index a0ff13ad6..31669a5b3 100644 --- a/pennyway-domain/src/main/resources/application-domain.yml +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -1,11 +1,51 @@ +spring: + profiles: + group: + local: common + prod: common + + datasource: + url: ${DB_URL} + username: ${DB_USER_NAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + --- spring: config: activate: on-profile: local + jpa: + database: MySQL + open-in-view: false + generate-ddl: false + hibernate: + ddl-auto: none + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + +logging: + level: + org.hibernate.sql: debug + org.hibernate.type: trace + com.zaxxer.hikari.HikariConfig: DEBUG + --- spring: config: activate: - on-profile: prod \ No newline at end of file + on-profile: prod + + jpa: + database: MySQL + open-in-view: false + generate-ddl: false + hibernate: + ddl-auto: none + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect \ No newline at end of file diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index a0ff13ad6..306c178f1 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -1,3 +1,9 @@ +spring: + profiles: + group: + local: common + prod: common + --- spring: config: From 08146117af0933deb4a9f25e25aae3f944404427 Mon Sep 17 00:00:00 2001 From: Jinwoo Lee Date: Mon, 18 Mar 2024 17:22:21 +0900 Subject: [PATCH 019/152] =?UTF-8?q?Conventional=20Commit=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Git=20Hooks=20=EC=84=A4=EC=A0=95=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: commit-lint 관련 패키지 설치 * chore: .gitignore에 node_modules 추가 * chore: commit convention 등록 * chore: commit-lint 적용 --- .gitignore | 3 + .husky/commit-msg | 4 + .husky/pre-commit | 7 + commitlint.config.js | 17 + package-lock.json | 1993 ++++++++++++++++++++++++++++++++++++++++++ package.json | 11 + 6 files changed, 2035 insertions(+) create mode 100755 .husky/commit-msg create mode 100755 .husky/pre-commit create mode 100644 commitlint.config.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.gitignore b/.gitignore index e9cd97583..226976d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ + +### Conventional Commit ### +/node_modules \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 000000000..1a089f456 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no-install commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..255389432 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +COMMIT_MSG_FILE=$1 +COMMIT_MSG=`cat $COMMIT_MSG_FILE` + +echo "$COMMIT_MSG" \ No newline at end of file diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 000000000..94b7c3b7e --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,17 @@ +module.exports = { + extends: ["@commitlint/config-conventional"], + rules: { + // 스코프는 컨벤션과 맞지 않기에, 사용하지 않는 것으로 한다. + "scope-empty": [2, "always"], + // 헤더의 길이는 100자로 제한 + "header-max-length": [2, "always", 100], + // 본문의 한 줄은 100자로 제한 + "body-max-line-length": [2, "always", 100], + // 타입은 아래의 태그만 가능 + "type-enum": [ + 2, + "always", + ["feat", "fix", "docs", "rename", "style", "refactor", "test", "chore"], + ], + }, +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..03a901303 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1993 @@ +{ + "name": "pennyway-was", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@commitlint/cli": "^19.2.0", + "@commitlint/config-conventional": "^19.1.0", + "commitlint-plugin-function-rules": "^3.1.0", + "husky": "^8.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@commitlint/cli": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.2.0.tgz", + "integrity": "sha512-8XnQDMyQR+1/ldbmIyhonvnDS2enEw48Wompo/967fsEvy9Vj5/JbDutzmSBKxANWDVeEbR9QQm0yHpw6ArrFw==", + "dev": true, + "dependencies": { + "@commitlint/format": "^19.0.3", + "@commitlint/lint": "^19.1.0", + "@commitlint/load": "^19.2.0", + "@commitlint/read": "^19.2.0", + "@commitlint/types": "^19.0.3", + "execa": "^8.0.1", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/cli/node_modules/@commitlint/ensure": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-19.0.3.tgz", + "integrity": "sha512-SZEpa/VvBLoT+EFZVb91YWbmaZ/9rPH3ESrINOl0HD2kMYsjvl0tF7nMHh0EpTcv4+gTtZBAe1y/SS6/OhfZzQ==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.0.3", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/cli/node_modules/@commitlint/is-ignored": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.0.3.tgz", + "integrity": "sha512-MqDrxJaRSVSzCbPsV6iOKG/Lt52Y+PVwFVexqImmYYFhe51iVJjK2hRhOG2jUAGiUHk4jpdFr0cZPzcBkSzXDQ==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.0.3", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/cli/node_modules/@commitlint/lint": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-19.1.0.tgz", + "integrity": "sha512-ESjaBmL/9cxm+eePyEr6SFlBUIYlYpI80n+Ltm7IA3MAcrmiP05UMhJdAD66sO8jvo8O4xdGn/1Mt2G5VzfZKw==", + "dev": true, + "dependencies": { + "@commitlint/is-ignored": "^19.0.3", + "@commitlint/parse": "^19.0.3", + "@commitlint/rules": "^19.0.3", + "@commitlint/types": "^19.0.3" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/cli/node_modules/@commitlint/message": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.0.0.tgz", + "integrity": "sha512-c9czf6lU+9oF9gVVa2lmKaOARJvt4soRsVmbR7Njwp9FpbBgste5i7l/2l5o8MmbwGh4yE1snfnsy2qyA2r/Fw==", + "dev": true, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/cli/node_modules/@commitlint/parse": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-19.0.3.tgz", + "integrity": "sha512-Il+tNyOb8VDxN3P6XoBBwWJtKKGzHlitEuXA5BP6ir/3loWlsSqDr5aecl6hZcC/spjq4pHqNh0qPlfeWu38QA==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.0.3", + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-parser": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/cli/node_modules/@commitlint/rules": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-19.0.3.tgz", + "integrity": "sha512-TspKb9VB6svklxNCKKwxhELn7qhtY1rFF8ls58DcFd0F97XoG07xugPjjbVnLqmMkRjZDbDIwBKt9bddOfLaPw==", + "dev": true, + "dependencies": { + "@commitlint/ensure": "^19.0.3", + "@commitlint/message": "^19.0.0", + "@commitlint/to-lines": "^19.0.0", + "@commitlint/types": "^19.0.3", + "execa": "^8.0.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/cli/node_modules/@commitlint/to-lines": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-19.0.0.tgz", + "integrity": "sha512-vkxWo+VQU5wFhiP9Ub9Sre0FYe019JxFikrALVoD5UGa8/t3yOJEpEhxC5xKiENKKhUkTpEItMTRAjHw2SCpZw==", + "dev": true, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.1.0.tgz", + "integrity": "sha512-KIKD2xrp6Uuk+dcZVj3++MlzIr/Su6zLE8crEDQCZNvWHNQSeeGbzOlNtsR32TUy6H3JbP7nWgduAHCaiGQ6EA==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.0.3", + "conventional-changelog-conventionalcommits": "^7.0.2" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-19.0.3.tgz", + "integrity": "sha512-2D3r4PKjoo59zBc2auodrSCaUnCSALCx54yveOFwwP/i2kfEAQrygwOleFWswLqK0UL/F9r07MFi5ev2ohyM4Q==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.0.3", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/ensure": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-18.6.1.tgz", + "integrity": "sha512-BPm6+SspyxQ7ZTsZwXc7TRQL5kh5YWt3euKmEIBZnocMFkJevqs3fbLRb8+8I/cfbVcAo4mxRlpTPfz8zX7SnQ==", + "dev": true, + "peer": true, + "dependencies": { + "@commitlint/types": "^18.6.1", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/ensure/node_modules/@commitlint/types": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-18.6.1.tgz", + "integrity": "sha512-gwRLBLra/Dozj2OywopeuHj2ac26gjGkz2cZ+86cTJOdtWfiRRr4+e77ZDAGc6MDWxaWheI+mAV5TLWWRwqrFg==", + "dev": true, + "peer": true, + "dependencies": { + "chalk": "^4.1.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/ensure/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@commitlint/ensure/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@commitlint/ensure/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-19.0.0.tgz", + "integrity": "sha512-mtsdpY1qyWgAO/iOK0L6gSGeR7GFcdW7tIjcNFxcWkfLDF5qVbPHKuGATFqRMsxcO8OUKNj0+3WOHB7EHm4Jdw==", + "dev": true, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/format": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-19.0.3.tgz", + "integrity": "sha512-QjjyGyoiVWzx1f5xOteKHNLFyhyweVifMgopozSgx1fGNrGV8+wp7k6n1t6StHdJ6maQJ+UUtO2TcEiBFRyR6Q==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.0.3", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-18.6.1.tgz", + "integrity": "sha512-MOfJjkEJj/wOaPBw5jFjTtfnx72RGwqYIROABudOtJKW7isVjFe9j0t8xhceA02QebtYf4P/zea4HIwnXg8rvA==", + "dev": true, + "peer": true, + "dependencies": { + "@commitlint/types": "^18.6.1", + "semver": "7.6.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/is-ignored/node_modules/@commitlint/types": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-18.6.1.tgz", + "integrity": "sha512-gwRLBLra/Dozj2OywopeuHj2ac26gjGkz2cZ+86cTJOdtWfiRRr4+e77ZDAGc6MDWxaWheI+mAV5TLWWRwqrFg==", + "dev": true, + "peer": true, + "dependencies": { + "chalk": "^4.1.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/is-ignored/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@commitlint/is-ignored/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@commitlint/is-ignored/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@commitlint/lint": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-18.6.1.tgz", + "integrity": "sha512-8WwIFo3jAuU+h1PkYe5SfnIOzp+TtBHpFr4S8oJWhu44IWKuVx6GOPux3+9H1iHOan/rGBaiacicZkMZuluhfQ==", + "dev": true, + "peer": true, + "dependencies": { + "@commitlint/is-ignored": "^18.6.1", + "@commitlint/parse": "^18.6.1", + "@commitlint/rules": "^18.6.1", + "@commitlint/types": "^18.6.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/lint/node_modules/@commitlint/types": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-18.6.1.tgz", + "integrity": "sha512-gwRLBLra/Dozj2OywopeuHj2ac26gjGkz2cZ+86cTJOdtWfiRRr4+e77ZDAGc6MDWxaWheI+mAV5TLWWRwqrFg==", + "dev": true, + "peer": true, + "dependencies": { + "chalk": "^4.1.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/lint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@commitlint/lint/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@commitlint/lint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@commitlint/load": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-19.2.0.tgz", + "integrity": "sha512-XvxxLJTKqZojCxaBQ7u92qQLFMMZc4+p9qrIq/9kJDy8DOrEa7P1yx7Tjdc2u2JxIalqT4KOGraVgCE7eCYJyQ==", + "dev": true, + "dependencies": { + "@commitlint/config-validator": "^19.0.3", + "@commitlint/execute-rule": "^19.0.0", + "@commitlint/resolve-extends": "^19.1.0", + "@commitlint/types": "^19.0.3", + "chalk": "^5.3.0", + "cosmiconfig": "^9.0.0", + "cosmiconfig-typescript-loader": "^5.0.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "lodash.uniq": "^4.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/message": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-18.6.1.tgz", + "integrity": "sha512-VKC10UTMLcpVjMIaHHsY1KwhuTQtdIKPkIdVEwWV+YuzKkzhlI3aNy6oo1eAN6b/D2LTtZkJe2enHmX0corYRw==", + "dev": true, + "peer": true, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/parse": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-18.6.1.tgz", + "integrity": "sha512-eS/3GREtvVJqGZrwAGRwR9Gdno3YcZ6Xvuaa+vUF8j++wsmxrA2En3n0ccfVO2qVOLJC41ni7jSZhQiJpMPGOQ==", + "dev": true, + "peer": true, + "dependencies": { + "@commitlint/types": "^18.6.1", + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-parser": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/parse/node_modules/@commitlint/types": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-18.6.1.tgz", + "integrity": "sha512-gwRLBLra/Dozj2OywopeuHj2ac26gjGkz2cZ+86cTJOdtWfiRRr4+e77ZDAGc6MDWxaWheI+mAV5TLWWRwqrFg==", + "dev": true, + "peer": true, + "dependencies": { + "chalk": "^4.1.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/parse/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@commitlint/parse/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@commitlint/parse/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@commitlint/read": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-19.2.0.tgz", + "integrity": "sha512-HlGeEd/jyp2a5Fb9mvtsaDm5hFCmj80dJYjLQkpG3DzWneWBc37YU3kM8Za1D1HUazZaTkdsWq73M3XDE4CvCA==", + "dev": true, + "dependencies": { + "@commitlint/top-level": "^19.0.0", + "@commitlint/types": "^19.0.3", + "execa": "^8.0.1", + "git-raw-commits": "^4.0.0", + "minimist": "^1.2.8" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-19.1.0.tgz", + "integrity": "sha512-z2riI+8G3CET5CPgXJPlzftH+RiWYLMYv4C9tSLdLXdr6pBNimSKukYP9MS27ejmscqCTVA4almdLh0ODD2KYg==", + "dev": true, + "dependencies": { + "@commitlint/config-validator": "^19.0.3", + "@commitlint/types": "^19.0.3", + "global-directory": "^4.0.1", + "import-meta-resolve": "^4.0.0", + "lodash.mergewith": "^4.6.2", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/rules": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-18.6.1.tgz", + "integrity": "sha512-kguM6HxZDtz60v/zQYOe0voAtTdGybWXefA1iidjWYmyUUspO1zBPQEmJZ05/plIAqCVyNUTAiRPWIBKLCrGew==", + "dev": true, + "peer": true, + "dependencies": { + "@commitlint/ensure": "^18.6.1", + "@commitlint/message": "^18.6.1", + "@commitlint/to-lines": "^18.6.1", + "@commitlint/types": "^18.6.1", + "execa": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/rules/node_modules/@commitlint/types": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-18.6.1.tgz", + "integrity": "sha512-gwRLBLra/Dozj2OywopeuHj2ac26gjGkz2cZ+86cTJOdtWfiRRr4+e77ZDAGc6MDWxaWheI+mAV5TLWWRwqrFg==", + "dev": true, + "peer": true, + "dependencies": { + "chalk": "^4.1.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/rules/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@commitlint/rules/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@commitlint/rules/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/rules/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@commitlint/rules/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/@commitlint/rules/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/rules/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@commitlint/rules/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "peer": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@commitlint/rules/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "peer": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/rules/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "peer": true + }, + "node_modules/@commitlint/rules/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@commitlint/rules/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "18.6.1", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-18.6.1.tgz", + "integrity": "sha512-Gl+orGBxYSNphx1+83GYeNy5N0dQsHBQ9PJMriaLQDB51UQHCVLBT/HBdOx5VaYksivSf5Os55TLePbRLlW50Q==", + "dev": true, + "peer": true, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/top-level": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-19.0.0.tgz", + "integrity": "sha512-KKjShd6u1aMGNkCkaX4aG1jOGdn7f8ZI8TR1VEuNqUOjWTOdcDSsmglinglJ18JTjuBX5I1PtjrhQCRcixRVFQ==", + "dev": true, + "dependencies": { + "find-up": "^7.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/types": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-19.0.3.tgz", + "integrity": "sha512-tpyc+7i6bPG9mvaBbtKUeghfyZSDgWquIDfMgqYtTbmZ9Y9VzEm2je9EYcQ0aoz5o7NvGS+rcDec93yO08MHYA==", + "dev": true, + "dependencies": { + "@types/conventional-commits-parser": "^5.0.0", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@types/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.11.28", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.28.tgz", + "integrity": "sha512-M/GPWVS2wLkSkNHVeLkrF2fD5Lx5UC4PxA0uZcKc6QqbIQUJyW1jVjueJYi1z8n0I5PxYrtpnPnWglE+y9A0KA==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/commitlint-plugin-function-rules": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/commitlint-plugin-function-rules/-/commitlint-plugin-function-rules-3.1.0.tgz", + "integrity": "sha512-K44912/g7ZZ0bEawrsJTn3giDEwDn6T18g/UcimUblv8hcSIoIxMX5uk6LY+uYO9cb/+3Suilbp7XoxP53Nk9g==", + "dev": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@commitlint/lint": ">=9.1.2 <19" + } + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/conventional-changelog-angular": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "dev": true, + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", + "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", + "dev": true, + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "dev": true, + "dependencies": { + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-5.0.0.tgz", + "integrity": "sha512-+8cK7jRAReYkMwMiG+bxhcNKiHJDM6bR9FD/nGBXOWdMLuYawjF5cGrtLilJ+LGd3ZjCXnJjR5DkfWPoIVlqJA==", + "dev": true, + "dependencies": { + "jiti": "^1.19.1" + }, + "engines": { + "node": ">=v16" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=8.2", + "typescript": ">=4" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dargs": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", + "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dev": true, + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/git-raw-commits": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", + "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", + "dev": true, + "dependencies": { + "dargs": "^8.0.0", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "git-raw-commits": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/husky": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", + "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "dev": true, + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz", + "integrity": "sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-text-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", + "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", + "dev": true, + "dependencies": { + "text-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jiti": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ] + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/text-extensions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", + "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", + "dev": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..53fa14457 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "devDependencies": { + "@commitlint/cli": "^19.2.0", + "@commitlint/config-conventional": "^19.1.0", + "commitlint-plugin-function-rules": "^3.1.0", + "husky": "^8.0.0" + }, + "scripts": { + "prepare": "husky install" + } +} From 032e8941ee0525f59ebe33ba670cb70e920beac7 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 19 Mar 2024 00:25:47 +0900 Subject: [PATCH 020/152] =?UTF-8?q?=E2=9C=A8=20Redis=20Configuration=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: infra 모듈 내 redis 의존성 주입 (api) * chore: domain 모듈 내 redis 의존성 주입 (implementation) * chore: redis 환경변수 설정 * feat: Domain Redis Connection Bean Qualify Annotation 생성 * feat: Domain Redis CacheManager Qualify Annotation 생성 * feat: Domain Redis Template Qualify Annotation 생성 * chore: domain 모듈 redis config 작성 * feat: Infra Redis CacheManager Qualify Annotation 생성 * feat: SecurityUser Redis CacheManager Qualify Annotation 생성 * feat: Oidc Redis CacheManager Qualify Annotation 생성 * chore: infra 모듈 cache config 설정 --- pennyway-domain/build.gradle | 3 + .../annotation/DomainRedisCacheManager.java | 13 ++ .../DomainRedisConnectionFactory.java | 13 ++ .../annotation/DomainRedisTemplate.java | 13 ++ .../kr/co/pennyway/config/RedisConfig.java | 83 ++++++++++++ .../src/main/resources/application-domain.yml | 5 + pennyway-infra/build.gradle | 3 + .../InfraRedisConnectionFactory.java | 13 ++ .../common/annotation/OidcCacheManager.java | 13 ++ .../annotation/SecurityUserCacheManager.java | 13 ++ .../java/kr/co/infra/config/CacheConfig.java | 123 ++++++++++++++++++ 11 files changed, 295 insertions(+) create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisCacheManager.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisConnectionFactory.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisTemplate.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/config/RedisConfig.java create mode 100644 pennyway-infra/src/main/java/kr/co/infra/common/annotation/InfraRedisConnectionFactory.java create mode 100644 pennyway-infra/src/main/java/kr/co/infra/common/annotation/OidcCacheManager.java create mode 100644 pennyway-infra/src/main/java/kr/co/infra/common/annotation/SecurityUserCacheManager.java create mode 100644 pennyway-infra/src/main/java/kr/co/infra/config/CacheConfig.java diff --git a/pennyway-domain/build.gradle b/pennyway-domain/build.gradle index a0255c1ad..01ef28387 100644 --- a/pennyway-domain/build.gradle +++ b/pennyway-domain/build.gradle @@ -16,6 +16,9 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api:2.1.1" annotationProcessor "jakarta.persistence:jakarta.persistence-api:3.1.0" + + /* Redis */ + implementation 'org.springframework.boot:spring-boot-starter-data-redis' } def querydslDir = 'src/main/generated' diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisCacheManager.java b/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisCacheManager.java new file mode 100644 index 000000000..b4413b963 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisCacheManager.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, + ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("domainRedisCacheManager") +public @interface DomainRedisCacheManager { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisConnectionFactory.java b/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisConnectionFactory.java new file mode 100644 index 000000000..ee1a97a5f --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisConnectionFactory.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, + ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("redisCacheConnectionFactory") +public @interface DomainRedisConnectionFactory { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisTemplate.java b/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisTemplate.java new file mode 100644 index 000000000..7444eb6a3 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisTemplate.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, + ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("domainRedisTemplate") +public @interface DomainRedisTemplate { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/config/RedisConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/config/RedisConfig.java new file mode 100644 index 000000000..a1bff05da --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/config/RedisConfig.java @@ -0,0 +1,83 @@ +package kr.co.pennyway.config; + +import kr.co.pennyway.common.annotation.DomainRedisCacheManager; +import kr.co.pennyway.common.annotation.DomainRedisConnectionFactory; +import kr.co.pennyway.common.annotation.DomainRedisTemplate; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import java.time.Duration; + +@Configuration +@EnableRedisRepositories +@EnableTransactionManagement +public class RedisConfig { + private final String host; + private final int port; + private final String password; + + public RedisConfig( + @Value("${spring.data.redis.host}") String host, + @Value("${spring.data.redis.port}") int port, + @Value("${spring.data.redis.password}") String password + ) { + this.host = host; + this.port = port; + this.password = password; + } + + @Bean + @DomainRedisConnectionFactory + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); + config.setPassword(password); + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder().build(); + return new LettuceConnectionFactory(config, clientConfig); + } + + @Bean + @DomainRedisTemplate + public RedisTemplate redisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + + template.setConnectionFactory(redisConnectionFactory()); + + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + return template; + } + + @Bean + @DomainRedisCacheManager + public CacheManager redisCacheManager(@DomainRedisConnectionFactory RedisConnectionFactory cf) { + RedisCacheConfiguration redisCacheConfiguration = + RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new StringRedisSerializer())) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer())) + .entryTtl(Duration.ofHours(1L)); + + return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf) + .cacheDefaults(redisCacheConfiguration) + .build(); + } +} diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml index 31669a5b3..7e4f79280 100644 --- a/pennyway-domain/src/main/resources/application-domain.yml +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -10,6 +10,11 @@ spring: password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + data.redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + --- spring: config: diff --git a/pennyway-infra/build.gradle b/pennyway-infra/build.gradle index 72db1b328..f2e1c0b01 100644 --- a/pennyway-infra/build.gradle +++ b/pennyway-infra/build.gradle @@ -8,4 +8,7 @@ dependencies { api group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.5' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.12.5' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.12.5' + + /* redis */ + api 'org.springframework.boot:spring-boot-starter-data-redis' } diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/annotation/InfraRedisConnectionFactory.java b/pennyway-infra/src/main/java/kr/co/infra/common/annotation/InfraRedisConnectionFactory.java new file mode 100644 index 000000000..64aaa4015 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/infra/common/annotation/InfraRedisConnectionFactory.java @@ -0,0 +1,13 @@ +package kr.co.infra.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, + ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("infraRedisConnectionFactory") +public @interface InfraRedisConnectionFactory { +} diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/annotation/OidcCacheManager.java b/pennyway-infra/src/main/java/kr/co/infra/common/annotation/OidcCacheManager.java new file mode 100644 index 000000000..4f8f149e7 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/infra/common/annotation/OidcCacheManager.java @@ -0,0 +1,13 @@ +package kr.co.infra.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, + ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("oidcCacheManager") +public @interface OidcCacheManager { +} diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/annotation/SecurityUserCacheManager.java b/pennyway-infra/src/main/java/kr/co/infra/common/annotation/SecurityUserCacheManager.java new file mode 100644 index 000000000..24e30ecc5 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/infra/common/annotation/SecurityUserCacheManager.java @@ -0,0 +1,13 @@ +package kr.co.infra.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, + ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("securityUserCacheManager") +public @interface SecurityUserCacheManager { +} diff --git a/pennyway-infra/src/main/java/kr/co/infra/config/CacheConfig.java b/pennyway-infra/src/main/java/kr/co/infra/config/CacheConfig.java new file mode 100644 index 000000000..bf207b54f --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/infra/config/CacheConfig.java @@ -0,0 +1,123 @@ +package kr.co.infra.config; + +import kr.co.infra.common.annotation.InfraRedisConnectionFactory; +import kr.co.infra.common.annotation.OidcCacheManager; +import kr.co.infra.common.annotation.SecurityUserCacheManager; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.CacheKeyPrefix; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.Map; + +@Configuration +@EnableCaching +public class CacheConfig { + private final String host; + private final int port; + private final String password; + + private final long defaultCacheTtlSec = 60; + private final long securityUserCacheTtlSec = 30; + private final long oidcCacheTtlDay = 7; + + public CacheConfig( + @Value("${spring.data.redis.host}") String host, + @Value("${spring.data.redis.port}") int port, + @Value("${spring.data.redis.password}") String password + ) { + this.host = host; + this.port = port; + this.password = password; + } + + @Bean + @InfraRedisConnectionFactory + public RedisConnectionFactory infraRedisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); + config.setPassword(password); + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder().build(); + return new LettuceConnectionFactory(config, clientConfig); + } + + /** + * CacheManager를 명시하지 않을 경우 default로 사용되는 CacheManager + */ + @Bean + @Primary + public CacheManager defaultCacheManager(@InfraRedisConnectionFactory RedisConnectionFactory redisConnectionFactory) { + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .disableCachingNullValues() + .computePrefixWith(CacheKeyPrefix.simple()) + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()) + ) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()) + ) + .entryTtl(Duration.ofSeconds(defaultCacheTtlSec)); + + return RedisCacheManager.RedisCacheManagerBuilder + .fromConnectionFactory(redisConnectionFactory) + .cacheDefaults(config) + .build(); + } + + @Bean + @SecurityUserCacheManager + public CacheManager securityUserCacheManager(@InfraRedisConnectionFactory RedisConnectionFactory redisConnectionFactory) { + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .disableCachingNullValues() + .computePrefixWith(CacheKeyPrefix.simple()) + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()) + ) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()) + ) + .entryTtl(Duration.ofSeconds(securityUserCacheTtlSec)); + Map redisCacheConfigurationMap = Map.of("securityConfig", config); + + return RedisCacheManager.RedisCacheManagerBuilder + .fromConnectionFactory(redisConnectionFactory) + .cacheDefaults(config) + .withInitialCacheConfigurations(redisCacheConfigurationMap) + .build(); + } + + @Bean + @OidcCacheManager + public CacheManager oidcCacheManger(@InfraRedisConnectionFactory RedisConnectionFactory cf) { + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new StringRedisSerializer() + )) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer() + )) + .entryTtl(Duration.ofDays(oidcCacheTtlDay)); + Map redisCacheConfigurationMap = Map.of("oidcConfig", config); + + return RedisCacheManager + .RedisCacheManagerBuilder + .fromConnectionFactory(cf) + .cacheDefaults(config) + .withInitialCacheConfigurations(redisCacheConfigurationMap) + .build(); + } +} From 1ddf37c931ce000c6f019d0350515d435d0063c3 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 19 Mar 2024 15:16:22 +0900 Subject: [PATCH 021/152] =?UTF-8?q?=E2=9C=A8=20OpenAPI=20Swagger=20config?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: external-api 모듈 springdoc-openapi 2.4.0 의존성 주입 * chore: external-api 모듈 내 openapi 설정 추가 * chore: swagger config 작성 * fix: application profile prod -> dev --- pennyway-app-external-api/build.gradle | 3 + .../kr/co/pennyway/config/SwaggerConfig.java | 69 +++++++++++++++++++ .../src/main/resources/application.yml | 33 ++++++++- .../src/main/resources/application-common.yml | 2 +- .../src/main/resources/application-domain.yml | 4 +- .../src/main/resources/application-infra.yml | 4 +- 6 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/config/SwaggerConfig.java diff --git a/pennyway-app-external-api/build.gradle b/pennyway-app-external-api/build.gradle index ff72ac957..f482cc3fe 100644 --- a/pennyway-app-external-api/build.gradle +++ b/pennyway-app-external-api/build.gradle @@ -17,6 +17,9 @@ dependencies { implementation project(':pennyway-domain') implementation project(':pennyway-infra') + /* Swagger */ + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0' + implementation 'org.springframework.boot:spring-boot-starter-web:3.2.3' implementation 'org.springframework.boot:spring-boot-starter-validation:3.2.3' } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/config/SwaggerConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/config/SwaggerConfig.java new file mode 100644 index 000000000..1a55e052b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/config/SwaggerConfig.java @@ -0,0 +1,69 @@ +package kr.co.pennyway.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.servers.Server; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.util.ObjectUtils; +import org.springframework.web.filter.ForwardedHeaderFilter; + +@Configuration +@OpenAPIDefinition( + servers = { + @Server(url = "${pennyway.domain.local}", description = "Local Server"), + @Server(url = "${pennyway.domain.dev}", description = "Develop Server") + } +) +@RequiredArgsConstructor +public class SwaggerConfig { + private static final String JWT = "JWT"; + private final Environment environment; + + @Bean + public OpenAPI openAPI() { + String activeProfile = ""; + if (!ObjectUtils.isEmpty(environment.getActiveProfiles()) && environment.getActiveProfiles().length >= 1) { + activeProfile = environment.getActiveProfiles()[0]; + } + + SecurityRequirement securityRequirement = new SecurityRequirement().addList(JWT); + + return new OpenAPI() + .info(apiInfo(activeProfile)) + .addServersItem(new io.swagger.v3.oas.models.servers.Server().url("")) + .addSecurityItem(securityRequirement) + .components(securitySchemes()); + } + + @Bean + ForwardedHeaderFilter forwardedHeaderFilter() { + return new ForwardedHeaderFilter(); + } + + private Components securitySchemes() { + final var securitySchemeAccessToken = new SecurityScheme() + .name(JWT) + .type(SecurityScheme.Type.HTTP) + .scheme("Bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization"); + + return new Components() + .addSecuritySchemes(JWT, securitySchemeAccessToken); + } + + private Info apiInfo(String activeProfile) { + return new Info() + .title("Pennyway API (" + activeProfile + ")") + .description("지출 관리 SNS 플랫폼 Pennyway API 명세서") + .version("v1.0.0"); + } +} diff --git a/pennyway-app-external-api/src/main/resources/application.yml b/pennyway-app-external-api/src/main/resources/application.yml index 1e3147ec6..a7d2fe067 100644 --- a/pennyway-app-external-api/src/main/resources/application.yml +++ b/pennyway-app-external-api/src/main/resources/application.yml @@ -2,7 +2,12 @@ spring: profiles: group: local: common, domain, infra - prod: common, domain, infra + dev: common, domain, infra + +pennywah: + domain: + local: ${PENNYWAY_DOMAIN_LOCAL} + dev: ${PENNYWAY_DOMAIN_DEV} jwt: secret-key: @@ -19,8 +24,32 @@ spring: activate: on-profile: local +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 + swagger-ui: + path: /swagger-ui + disable-swagger-default-url: true + display-request-duration: true + operations-sorter: alpha + api-docs: + groups: + enabled: true + --- spring: config: activate: - on-profile: prod \ No newline at end of file + on-profile: dev + +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 + swagger-ui: + path: /swagger-ui + disable-swagger-default-url: true + display-request-duration: true + operations-sorter: alpha + api-docs: + groups: + enabled: true \ No newline at end of file diff --git a/pennyway-common/src/main/resources/application-common.yml b/pennyway-common/src/main/resources/application-common.yml index a0ff13ad6..b1e7bbcd9 100644 --- a/pennyway-common/src/main/resources/application-common.yml +++ b/pennyway-common/src/main/resources/application-common.yml @@ -8,4 +8,4 @@ spring: spring: config: activate: - on-profile: prod \ No newline at end of file + on-profile: dev \ No newline at end of file diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml index 7e4f79280..fcf90ab18 100644 --- a/pennyway-domain/src/main/resources/application-domain.yml +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -2,7 +2,7 @@ spring: profiles: group: local: common - prod: common + dev: common datasource: url: ${DB_URL} @@ -42,7 +42,7 @@ logging: spring: config: activate: - on-profile: prod + on-profile: dev jpa: database: MySQL diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index 306c178f1..ee08bd17e 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -2,7 +2,7 @@ spring: profiles: group: local: common - prod: common + dev: common --- spring: @@ -14,4 +14,4 @@ spring: spring: config: activate: - on-profile: prod \ No newline at end of file + on-profile: dev \ No newline at end of file From 560598c833a45025205fee2dfb3e90d461378722 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 19 Mar 2024 16:00:57 +0900 Subject: [PATCH 022/152] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20Reason=20Code=20Ze?= =?UTF-8?q?ro=20bit=20=EC=A0=9C=EA=B1=B0=20=E2=86=92=20500=EB=B2=88?= =?UTF-8?q?=EB=8C=80=20Zero=20bit=20=EC=83=81=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: reason code zero bit 제거 && 500번대 0번 bit 상수 추가 * test: caused-by-test 예상 에러코드 수정 (통과 확인) * fix: jwt-error-code 변경된 reason code로 수정 --- .../pennyway/common/exception/ReasonCode.java | 55 ++++++++++--------- .../common/exception/CausedByTest.java | 4 +- .../infra/common/exception/JwtErrorCode.java | 4 +- 3 files changed, 32 insertions(+), 31 deletions(-) diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java index ee856c924..db2256741 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java @@ -9,50 +9,51 @@ @Getter @RequiredArgsConstructor public enum ReasonCode { - ZERO(0), - /* 400_BAD_REQUEST */ - INVALID_REQUEST_SYNTAX(1), - MISSING_REQUIRED_PARAMETER(2), - MALFORMED_PARAMETER(3), - MALFORMED_REQUEST_BODY(4), + INVALID_REQUEST_SYNTAX(0), + MISSING_REQUIRED_PARAMETER(1), + MALFORMED_PARAMETER(2), + MALFORMED_REQUEST_BODY(3), /* 401_UNAUTHORIZED */ - MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS(1), - EXPIRED_OR_REVOKED_TOKEN(2), - INSUFFICIENT_PERMISSIONS(3), - TAMPERED_OR_MALFORMED_TOKEN(4), + MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS(0), + EXPIRED_OR_REVOKED_TOKEN(1), + INSUFFICIENT_PERMISSIONS(2), + TAMPERED_OR_MALFORMED_TOKEN(3), /* 403_FORBIDDEN */ - ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN(1), - IP_ADDRESS_BLOCKED(2), - USER_ACCOUNT_SUSPENDED_OR_BANNED(3), - ACCESS_TO_RESOURCE_NOT_ALLOWED_FOR_USER_ROLE(4), + ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN(0), + IP_ADDRESS_BLOCKED(1), + USER_ACCOUNT_SUSPENDED_OR_BANNED(2), + ACCESS_TO_RESOURCE_NOT_ALLOWED_FOR_USER_ROLE(3), /* 404_NOT_FOUND */ - REQUESTED_RESOURCE_NOT_FOUND(1), - INVALID_URL_OR_ENDPOINT(2), - RESOURCE_DELETED_OR_MOVED(3), + REQUESTED_RESOURCE_NOT_FOUND(0), + INVALID_URL_OR_ENDPOINT(1), + RESOURCE_DELETED_OR_MOVED(2), /* 405_METHOD_NOT_ALLOWED */ - REQUEST_METHOD_NOT_SUPPORTED(1), - ATTEMPTED_TO_ACCESS_UNSUPPORTED_METHOD(2), + REQUEST_METHOD_NOT_SUPPORTED(0), + ATTEMPTED_TO_ACCESS_UNSUPPORTED_METHOD(1), /* 406_NOT_ACCEPTABLE */ - REQUESTED_RESPONSE_FORMAT_NOT_SUPPORTED(1), + REQUESTED_RESPONSE_FORMAT_NOT_SUPPORTED(0), /* 409_CONFLICT */ - REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE(1), - RESOURCE_ALREADY_EXISTS(2), - CONCURRENT_MODIFICATION_CONFLICT(3), + REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE(0), + RESOURCE_ALREADY_EXISTS(1), + CONCURRENT_MODIFICATION_CONFLICT(2), /* 412_PRECONDITION_FAILED */ - PRECONDITION_REQUEST_HEADER_NOT_MATCHED(1), - IF_MATCH_HEADER_OR_IF_NONE_MATCH_HEADER_NOT_MATCHED(2), + PRECONDITION_REQUEST_HEADER_NOT_MATCHED(0), + IF_MATCH_HEADER_OR_IF_NONE_MATCH_HEADER_NOT_MATCHED(1), /* 422_UNPROCESSABLE_CONTENT */ - REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY(1), - VALIDATION_ERROR_IN_REQUEST_BODY(2), + REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY(0), + VALIDATION_ERROR_IN_REQUEST_BODY(1), + + /* 500_INTERNAL_SERVER_ERROR */ + UNEXPECTED_ERROR(0), ; private final int code; diff --git a/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java b/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java index e4fe3a3bf..ec5772d11 100644 --- a/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java +++ b/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java @@ -63,7 +63,7 @@ public void generateCodeWithValidArguments() { CausedBy causedBy = CausedBy.of(statusCode, reasonCode, domainCode, fieldCode); // then - assertEquals("4221013", causedBy.getCode()); + assertEquals("4220013", causedBy.getCode()); } @Test @@ -79,7 +79,7 @@ public void generateCodeWithTwoDigitDomainCode() { CausedBy causedBy = CausedBy.of(statusCode, reasonCode, domainCode, fieldCode); // then - assertEquals("4002130", causedBy.getCode()); + assertEquals("4001130", causedBy.getCode()); } @Test diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java b/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java index b49e6cbb6..024b4ecd6 100644 --- a/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java +++ b/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java @@ -38,8 +38,8 @@ public enum JwtErrorCode implements BaseErrorCode { /** * 500 INTERNAL_SERVER_ERROR: 서버 내부 에러 */ - INVALID_JWT_DTO_FORMAT(INTERNAL_SERVER_ERROR, ZERO, "서버 내부 에러가 발생했습니다."), - UNEXPECTED_ERROR(INTERNAL_SERVER_ERROR, ZERO, "예상치 못한 에러가 발생했습니다."); + INVALID_JWT_DTO_FORMAT(INTERNAL_SERVER_ERROR, ReasonCode.UNEXPECTED_ERROR, "서버 내부 에러가 발생했습니다."), + UNEXPECTED_ERROR(INTERNAL_SERVER_ERROR, ReasonCode.UNEXPECTED_ERROR, "예상치 못한 에러가 발생했습니다."); ; private final StatusCode statusCode; From 2073046e6fa366d738e87852ba600cdf796a8e76 Mon Sep 17 00:00:00 2001 From: Jinwoo Lee Date: Wed, 20 Mar 2024 01:32:31 +0900 Subject: [PATCH 023/152] =?UTF-8?q?Swagger=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pennyway-app-external-api/src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pennyway-app-external-api/src/main/resources/application.yml b/pennyway-app-external-api/src/main/resources/application.yml index a7d2fe067..d13f5062a 100644 --- a/pennyway-app-external-api/src/main/resources/application.yml +++ b/pennyway-app-external-api/src/main/resources/application.yml @@ -4,7 +4,7 @@ spring: local: common, domain, infra dev: common, domain, infra -pennywah: +pennyway: domain: local: ${PENNYWAY_DOMAIN_LOCAL} dev: ${PENNYWAY_DOMAIN_DEV} From 0ccd493ac9c2d5f1051111c0ac121175c9ee3308 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 20 Mar 2024 14:26:01 +0900 Subject: [PATCH 024/152] =?UTF-8?q?=E2=9C=A8=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94=20=EB=B0=8F=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: test api directory .gitignore 경로 추가 * feat: 성공 응답 클래스 정의 * feat: error-response 공통 응답 클래스 작성 * fix: success 응답 nocontent() 응답 null -> empty object * feat: method argument not valid 전역 예외 처리 * fix: reason 422 error type mismatch 코드 추가 * feat: missing request header 전역 예외 처리 * feat: request json parsing 실패 전역 예외 처리 * feat: missing request parameter 전역 예외 처리 * feat: 존재하지 않는 url 요청 전역 예외 처리 * feat: 500 internal server error 전역 예외 처리 * feat: npe & exception 전역 예외 처리 * fix: response status annotation 처리 * style: intellij code convention setting 추가하여 reformat --- pennyway-app-external-api/.gitignore | 5 +- .../common/response/ErrorResponse.java | 78 +++++++ .../common/response/SuccessResponse.java | 65 ++++++ .../handler/GlobalExceptionHandler.java | 205 ++++++++++++++++++ .../common/response/SuccessResponseTest.java | 77 +++++++ .../pennyway/common/exception/ReasonCode.java | 1 + 6 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/ErrorResponse.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/SuccessResponse.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/handler/GlobalExceptionHandler.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/common/response/SuccessResponseTest.java diff --git a/pennyway-app-external-api/.gitignore b/pennyway-app-external-api/.gitignore index b63da4551..12e5a72e6 100644 --- a/pennyway-app-external-api/.gitignore +++ b/pennyway-app-external-api/.gitignore @@ -39,4 +39,7 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +## Test API +src/main/java/kr/co/pennyway/apis/test \ No newline at end of file diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/ErrorResponse.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/ErrorResponse.java new file mode 100644 index 000000000..d524f62ca --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/ErrorResponse.java @@ -0,0 +1,78 @@ +package kr.co.pennyway.common.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; + +import java.util.HashMap; +import java.util.Map; + +@Getter +@Schema(description = "API 응답 - 실패 및 에러") +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ErrorResponse { + @Schema(description = "응답 코드", defaultValue = "4000") + private String code; + @Schema(description = "응답 메시지", example = "에러 이유") + private String message; + @Schema(description = "에러 상세", example = "{\"field\":\"reason\"}") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Object fieldErrors; + + @Builder + private ErrorResponse(String code, String message, Object fieldErrors) { + this.code = code; + this.message = message; + this.fieldErrors = fieldErrors; + } + + /** + * 단일 필드 에러를 응답으로 변환한다. + * @param code String : {@link kr.co.pennyway.common.exception.CausedBy} 클래스의 getCode() 메소드로 반환되는 코드 + * @param message : 에러 이유 + * @return ErrorResponse + */ + public static ErrorResponse of(String code, String message) { + return ErrorResponse.builder() + .code(code) + .message(message) + .build(); + } + + /** + * 422 Unprocessable Entity 관련 에러를 응답으로 변환한다. + * @param code String : {@link kr.co.pennyway.common.exception.CausedBy} 클래스의 getCode() 메소드로 반환되는 코드 + * @param message : 에러 이유 + * @param fieldErrors : 에러 상세 + * @return ErrorResponse + */ + public static ErrorResponse failure(String code, String message, Object fieldErrors) { + return ErrorResponse.builder() + .code(code) + .message(message) + .fieldErrors(fieldErrors) + .build(); + } + + /** + * 422 Unprocessable Content 예외에서 발생한 BindingResult를 응답으로 변환한다. + * @param bindingResult : BindingResult + * @return ErrorResponse + */ + public static ErrorResponse failure(BindingResult bindingResult, ReasonCode reasonCode) { + Map fieldErrors = new HashMap<>(); + for (FieldError fieldError : bindingResult.getFieldErrors()) { + fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + + String code = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode()*10 + reasonCode.getCode()); + return failure(code, StatusCode.UNPROCESSABLE_CONTENT.name(), fieldErrors); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/SuccessResponse.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/SuccessResponse.java new file mode 100644 index 000000000..84635734b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/SuccessResponse.java @@ -0,0 +1,65 @@ +package kr.co.pennyway.common.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.util.Map; + +/** + * API Response의 success에 대한 공통적인 응답을 정의한다. + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Schema(description = "API 응답 - 성공") +public class SuccessResponse { + @Schema(description = "응답 코드", defaultValue = "2000000") + private final String code = "2000000"; + @Schema(description = "응답 메시지", example = """ + data: { + "aDomain": { // 단수명사는 object 형태로 반환 + ... + },` + "bDomains": [ // 복수명사는 array 형태로 반환 + ... + ] + } + """) + private T data; + + @Builder + private SuccessResponse(T data) {this.data = data;} + + /** + * data : { "key" : data } 형태의 성공 응답을 반환한다. + *
+ * 명시적으로 key의 이름을 지정하기 위해 사용한다. + */ + public static SuccessResponse> from(String key, V data) { + return SuccessResponse.>builder() + .data(Map.of(key, data)) + .build(); + } + + public static SuccessResponse from(T data) { + return SuccessResponse.builder() + .data(data) + .build(); + } + + /** + * data가 null인 경우 사용한다. + *
+ * data : {} 형태의 성공 응답을 반환한다. + */ + public static SuccessResponse noContent() { + return SuccessResponse.builder().data(Map.of()).build(); + } + + @Override + public String toString() { + return "SuccessResponse{" + + "code='" + code + '\'' + + ", data=" + data + + '}'; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/handler/GlobalExceptionHandler.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/handler/GlobalExceptionHandler.java new file mode 100644 index 000000000..5f1916a91 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/handler/GlobalExceptionHandler.java @@ -0,0 +1,205 @@ +package kr.co.pennyway.common.response.handler; + +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import kr.co.pennyway.common.exception.GlobalErrorException; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import kr.co.pennyway.common.response.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.NoHandlerFoundException; + +import java.nio.file.AccessDeniedException; +import java.util.HashMap; +import java.util.Map; + +import static kr.co.pennyway.common.exception.ReasonCode.TYPE_MISMATCH_ERROR_IN_REQUEST_BODY; + +/** + * Controller 하위 계층에서 발생하는 전역 예외를 처리하는 클래스 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + /** + * Pennyway Custom Exception을 처리하는 메서드 + * + * @see kr.co.pennyway.common.exception.GlobalErrorException + */ + @ExceptionHandler(GlobalErrorException.class) + protected ResponseEntity handleGlobalErrorException(GlobalErrorException e) { + log.warn("handleGlobalErrorException : {}", e.getMessage()); + ErrorResponse response = ErrorResponse.of(e.getBaseErrorCode().causedBy().getCode(), e.getBaseErrorCode().getExplainError()); + return ResponseEntity.status(e.getBaseErrorCode().causedBy().reasonCode().getCode()).body(response); + } + + /** + * API 호출 시 인가 관련 예외를 처리하는 메서드 + * + * @see AccessDeniedException + */ + @ResponseStatus(HttpStatus.FORBIDDEN) + @ExceptionHandler(AccessDeniedException.class) + protected ErrorResponse handleAccessDeniedException(AccessDeniedException e) { + log.warn("handleAccessDeniedException : {}", e.getMessage()); + return ErrorResponse.of(String.valueOf(StatusCode.FORBIDDEN.getCode()), e.getMessage()); + } + + /** + * API 호출 시 객체 혹은 파라미터 데이터 값이 유효하지 않은 경우 + * + * @see MethodArgumentNotValidException + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.warn("handleMethodArgumentNotValidException: {}", e.getMessage()); + BindingResult bindingResult = e.getBindingResult(); + ErrorResponse response = ErrorResponse.failure(bindingResult, ReasonCode.REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY); + return ResponseEntity.unprocessableEntity().body(response); + } + + /** + * API 호출 시 객체 혹은 파라미터 데이터 값이 유효하지 않은 경우 + * + * @see MethodArgumentTypeMismatchException + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + protected ResponseEntity handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) { + log.warn("handleMethodArgumentTypeMismatchException: {}", e.getMessage()); + + Class type = e.getRequiredType(); + assert type != null; + + Map fieldErrors = new HashMap<>(); + if (type.isEnum()) { + fieldErrors.put(e.getName(), "The parameter " + e.getName() + " must have a value among : " + StringUtils.join(type.getEnumConstants(), ", ")); + } else { + fieldErrors.put(e.getName(), "The parameter " + e.getName() + " must have a value of type " + type.getSimpleName()); + } + + String code = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + TYPE_MISMATCH_ERROR_IN_REQUEST_BODY.getCode()); + ErrorResponse response = ErrorResponse.failure(code, TYPE_MISMATCH_ERROR_IN_REQUEST_BODY.name(), fieldErrors); + return ResponseEntity.unprocessableEntity().body(response); + } + + /** + * API 호출 시 'Header' 내에 데이터 값이 유효하지 않은 경우 + * + * @see MissingRequestHeaderException + */ + @ExceptionHandler(MissingRequestHeaderException.class) + protected ResponseEntity handleMissingRequestHeaderException(MissingRequestHeaderException e) { + log.warn("handleMissingRequestHeaderException : {}", e.getMessage()); + + String code = String.valueOf(StatusCode.BAD_REQUEST.getCode() * 10 + ReasonCode.MISSING_REQUIRED_PARAMETER.getCode()); + ErrorResponse response = ErrorResponse.of(code, e.getMessage()); + return ResponseEntity.badRequest().body(response); + } + + /** + * JSON 형식의 요청 데이터를 파싱하는 과정에서 발생하는 예외를 처리하는 메서드 + * + * @see HttpMessageNotReadableException + */ + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.warn("handleHttpMessageNotReadableException : {}", e.getMessage()); + + String code; + if (e.getCause() instanceof MismatchedInputException mismatchedInputException) { + code = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + TYPE_MISMATCH_ERROR_IN_REQUEST_BODY.getCode()); + return ErrorResponse.of(code, mismatchedInputException.getPath().get(0).getFieldName() + " 필드의 값이 유효하지 않습니다."); + } + + code = String.valueOf(StatusCode.BAD_REQUEST.getCode() * 10 + ReasonCode.MALFORMED_REQUEST_BODY.getCode()); + return ErrorResponse.of(code, e.getMessage()); + } + + /** + * API 호출 시 'Parameter' 내에 데이터 값이 존재하지 않은 경우 + * + * @see MissingServletRequestParameterException + */ + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MissingServletRequestParameterException.class) + protected ErrorResponse handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { + log.warn("handleMissingServletRequestParameterException : {}", e.getMessage()); + + String code = String.valueOf(StatusCode.BAD_REQUEST.getCode() * 10 + ReasonCode.MISSING_REQUIRED_PARAMETER.getCode()); + return ErrorResponse.of(code, e.getMessage()); + } + + /** + * 잘못된 URL 호출 시 + * + * @see NoHandlerFoundException + */ + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(NoHandlerFoundException.class) + protected ErrorResponse handleNoHandlerFoundException(NoHandlerFoundException e) { + log.warn("handleNoHandlerFoundException : {}", e.getMessage()); + + String code = String.valueOf(StatusCode.NOT_FOUND.getCode() * 10 + ReasonCode.INVALID_URL_OR_ENDPOINT.getCode()); + return ErrorResponse.of(code, e.getMessage()); + } + + /** + * API 호출 시 데이터를 반환할 수 없는 경우 + * + * @return ResponseEntity + */ + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(HttpMessageNotWritableException.class) + protected ErrorResponse handleHttpMessageNotWritableException(HttpMessageNotWritableException e) { + log.warn("handleHttpMessageNotWritableException : {}", e.getMessage()); + + String code = String.valueOf(StatusCode.INTERNAL_SERVER_ERROR.getCode() * 10 + ReasonCode.UNEXPECTED_ERROR.getCode()); + return ErrorResponse.of(code, e.getMessage()); + } + + /** + * NullPointerException이 발생한 경우 + * + * @return ResponseEntity + */ + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(NullPointerException.class) + protected ErrorResponse handleNullPointerException(NullPointerException e) { + log.warn("handleNullPointerException : {}", e.getMessage()); + e.printStackTrace(); + + String code = String.valueOf(StatusCode.INTERNAL_SERVER_ERROR.getCode() * 10 + ReasonCode.UNEXPECTED_ERROR.getCode()); + return ErrorResponse.of(code, StatusCode.INTERNAL_SERVER_ERROR.name()); + } + + // ================================================================================== // + + /** + * 기타 예외가 발생한 경우 + * + * @param e Exception + * @return ResponseEntity + */ + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(Exception.class) + protected ErrorResponse handleException(Exception e) { + log.warn("{} : handleException : {}", e.getClass(), e.getMessage()); + e.printStackTrace(); + + String code = String.valueOf(StatusCode.INTERNAL_SERVER_ERROR.getCode() * 10 + ReasonCode.UNEXPECTED_ERROR.getCode()); + return ErrorResponse.of(code, StatusCode.INTERNAL_SERVER_ERROR.name()); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/response/SuccessResponseTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/response/SuccessResponseTest.java new file mode 100644 index 000000000..1fb7a59d6 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/response/SuccessResponseTest.java @@ -0,0 +1,77 @@ +package kr.co.pennyway.common.response; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +@ExtendWith(MockitoExtension.class) +public class SuccessResponseTest { + private TestDto dto; + + @BeforeEach + void setUp() { + dto = new TestDto("test", 1); + } + + @Test + @DisplayName("SuccessResponse.from() - data가 존재하는 성공 응답") + public void successResponseWithData() { + // Given + String key = "example"; + String value = "data"; + + // When + SuccessResponse response = SuccessResponse.from(key, value); + + // Then + assertEquals("2000000", response.getCode()); + assertEquals(Map.of(key, value), response.getData()); + } + + @Test + @DisplayName("SuccessResponse.from() - data가 존재하는 성공 응답") + public void successResponseWithNoContent() { + // When + SuccessResponse response = SuccessResponse.noContent(); + + // Then + assertEquals("2000000", response.getCode()); + assertEquals(Map.of(), response.getData()); + } + + @Test + @DisplayName("SuccessResponse.from() - DTO를 통한 성공 응답") + public void successResponseFromDto() { + // When + SuccessResponse response = SuccessResponse.from(dto); + + // Then + assertEquals("2000000", response.getCode()); + assertEquals(dto, response.getData()); + System.out.println(response); + } + + @Test + @DisplayName("SuccessResponse.from() - key와 DTO를 통한 성공 응답") + public void successResponseFromDtoWithKey() { + // Given + String key = "test"; + + // When + SuccessResponse> response = SuccessResponse.from(key, dto); + + // Then + assertEquals("2000000", response.getCode()); + assertEquals(Map.of(key, dto), response.getData()); + System.out.println(response); + } + + private record TestDto(String name, int age) { } +} diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java index db2256741..48508dc74 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java @@ -51,6 +51,7 @@ public enum ReasonCode { /* 422_UNPROCESSABLE_CONTENT */ REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY(0), VALIDATION_ERROR_IN_REQUEST_BODY(1), + TYPE_MISMATCH_ERROR_IN_REQUEST_BODY(2), /* 500_INTERNAL_SERVER_ERROR */ UNEXPECTED_ERROR(0), From e8ca4bbd11cbfc8ed0f6bd115d0b167c5652aea1 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 21 Mar 2024 00:08:45 +0900 Subject: [PATCH 025/152] =?UTF-8?q?=E2=9C=A8=20User=20Domain=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: db, application 타입 형변환 인터페이스 생성 * feat: code <-> enum 변환 util 작성 * feat: custom converter 구현을 위한 추상 클래스 작성 * style: api & domain package 경로 수정 * feat: create, update auditable 추상 클래스 작성 * feat: role type enum & conveter 정의 * feat: visibility type enum & conveter 정의 * rename: visibility to profile-visibility 클래스명 수정 * fix: converter 생성자 수정 * feat: user entity 생성 * feat: user jpa data repository 생성 * fix: 에러 체계 7자리 수 -> 4자리 수 * test: 4자리수 에러 체계 기반 테스트 코드 수정 * fix: reason code 400번대 4번 invalid request 추가 * feat: user error code & exception 정의 * feat: user domain service 작성 * feat: user domain service create 메서드 추가 * feat: user domain service exists 메서드 추가 * fix: jwt error code 내에서 domain, field code 제거 * chore: query-dsl generated 경로 .gitignore * fix: profile-visibility converter 오주입 수정 * fix: test api 삭제 * chore: 패키지 경로 수정으로 인한 test 패키지 경로 수정 * rename: caused by '7자리 에러코드' 주석 수정 --- pennyway-app-external-api/.gitignore | 2 +- .../annotation/AccessTokenStrategy.java | 2 +- .../annotation/RefreshTokenStrategy.java | 2 +- .../common/response/ErrorResponse.java | 13 +-- .../common/response/SuccessResponse.java | 11 ++- .../handler/GlobalExceptionHandler.java | 4 +- .../security/jwt/access/AccessTokenClaim.java | 9 +-- .../jwt/access/AccessTokenClaimKeys.java | 2 +- .../jwt/access/AccessTokenProvider.java | 12 +-- .../jwt/refresh/RefreshTokenClaim.java | 6 +- .../jwt/refresh/RefreshTokenClaimKeys.java | 2 +- .../jwt/refresh/RefreshTokenProvider.java | 14 ++-- .../co/pennyway/api/config/SwaggerConfig.java | 69 ++++++++++++++++ .../kr/co/pennyway/config/SwaggerConfig.java | 69 ---------------- .../common/response/SuccessResponseTest.java | 5 +- .../jwt/access/AccessTokenProviderTest.java | 2 + .../pennyway/common/exception/CausedBy.java | 44 +++++------ .../pennyway/common/exception/ReasonCode.java | 1 + .../common/exception/CausedByTest.java | 79 ++----------------- pennyway-domain/.gitignore | 2 +- .../{ => domain}/DomainPackageLocation.java | 2 +- .../annotation/DomainRedisCacheManager.java | 2 +- .../DomainRedisConnectionFactory.java | 2 +- .../annotation/DomainRedisTemplate.java | 2 +- .../AbstractLegacyEnumAttributeConverter.java | 48 +++++++++++ .../common/converter/LegacyCommonType.java | 10 +++ .../converter/ProfileVisibilityConverter.java | 13 +++ .../common/converter/RoleConverter.java | 13 +++ .../domain/common/model/DateAuditable.java | 24 ++++++ .../util/LegacyEnumValueConvertUtil.java | 28 +++++++ .../{ => domain}/config/JpaConfig.java | 4 +- .../{ => domain}/config/QueryDslConfig.java | 2 +- .../{ => domain}/config/RedisConfig.java | 10 +-- .../domains/user/domain/NotifySetting.java | 35 ++++++++ .../domain/domains/user/domain/User.java | 62 +++++++++++++++ .../domains/user/exception/UserErrorCode.java | 36 +++++++++ .../user/exception/UserErrorException.java | 21 +++++ .../user/repository/UserRepository.java | 7 ++ .../domains/user/service/UserService.java | 30 +++++++ .../domains/user/type/ProfileVisibility.java | 30 +++++++ .../domain/domains/user/type/Role.java | 42 ++++++++++ .../infra/common/exception/JwtErrorCode.java | 5 +- 42 files changed, 556 insertions(+), 222 deletions(-) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/{ => api}/common/annotation/AccessTokenStrategy.java (88%) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/{ => api}/common/annotation/RefreshTokenStrategy.java (88%) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/{ => api}/common/response/ErrorResponse.java (85%) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/{ => api}/common/response/SuccessResponse.java (88%) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/{ => api}/common/response/handler/GlobalExceptionHandler.java (98%) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/{ => api}/common/security/jwt/access/AccessTokenClaim.java (63%) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/{ => api}/common/security/jwt/access/AccessTokenClaimKeys.java (81%) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/{ => api}/common/security/jwt/access/AccessTokenProvider.java (87%) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/{ => api}/common/security/jwt/refresh/RefreshTokenClaim.java (72%) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/{ => api}/common/security/jwt/refresh/RefreshTokenClaimKeys.java (81%) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/{ => api}/common/security/jwt/refresh/RefreshTokenProvider.java (86%) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java delete mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/config/SwaggerConfig.java rename pennyway-domain/src/main/java/kr/co/pennyway/{ => domain}/DomainPackageLocation.java (58%) rename pennyway-domain/src/main/java/kr/co/pennyway/{ => domain}/common/annotation/DomainRedisCacheManager.java (87%) rename pennyway-domain/src/main/java/kr/co/pennyway/{ => domain}/common/annotation/DomainRedisConnectionFactory.java (88%) rename pennyway-domain/src/main/java/kr/co/pennyway/{ => domain}/common/annotation/DomainRedisTemplate.java (87%) create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AbstractLegacyEnumAttributeConverter.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/LegacyCommonType.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProfileVisibilityConverter.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/RoleConverter.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/model/DateAuditable.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/LegacyEnumValueConvertUtil.java rename pennyway-domain/src/main/java/kr/co/pennyway/{ => domain}/config/JpaConfig.java (84%) rename pennyway-domain/src/main/java/kr/co/pennyway/{ => domain}/config/QueryDslConfig.java (92%) rename pennyway-domain/src/main/java/kr/co/pennyway/{ => domain}/config/RedisConfig.java (91%) create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorException.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/Role.java diff --git a/pennyway-app-external-api/.gitignore b/pennyway-app-external-api/.gitignore index 12e5a72e6..f81dcf20a 100644 --- a/pennyway-app-external-api/.gitignore +++ b/pennyway-app-external-api/.gitignore @@ -42,4 +42,4 @@ bin/ .DS_Store ## Test API -src/main/java/kr/co/pennyway/apis/test \ No newline at end of file +src/main/java/kr/co/pennyway/api/apis/test \ No newline at end of file diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/annotation/AccessTokenStrategy.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/AccessTokenStrategy.java similarity index 88% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/common/annotation/AccessTokenStrategy.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/AccessTokenStrategy.java index 99fcb78d3..00b795b34 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/annotation/AccessTokenStrategy.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/AccessTokenStrategy.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.common.annotation; +package kr.co.pennyway.api.common.annotation; import org.springframework.beans.factory.annotation.Qualifier; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/annotation/RefreshTokenStrategy.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/RefreshTokenStrategy.java similarity index 88% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/common/annotation/RefreshTokenStrategy.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/RefreshTokenStrategy.java index e216bd94e..911bd2039 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/annotation/RefreshTokenStrategy.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/RefreshTokenStrategy.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.common.annotation; +package kr.co.pennyway.api.common.annotation; import org.springframework.beans.factory.annotation.Qualifier; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/ErrorResponse.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/ErrorResponse.java similarity index 85% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/ErrorResponse.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/ErrorResponse.java index d524f62ca..3f0d7ab51 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/ErrorResponse.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/ErrorResponse.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.common.response; +package kr.co.pennyway.api.common.response; import com.fasterxml.jackson.annotation.JsonInclude; import io.swagger.v3.oas.annotations.media.Schema; @@ -35,7 +35,8 @@ private ErrorResponse(String code, String message, Object fieldErrors) { /** * 단일 필드 에러를 응답으로 변환한다. - * @param code String : {@link kr.co.pennyway.common.exception.CausedBy} 클래스의 getCode() 메소드로 반환되는 코드 + * + * @param code String : {@link kr.co.pennyway.common.exception.CausedBy} 클래스의 getCode() 메소드로 반환되는 코드 * @param message : 에러 이유 * @return ErrorResponse */ @@ -48,8 +49,9 @@ public static ErrorResponse of(String code, String message) { /** * 422 Unprocessable Entity 관련 에러를 응답으로 변환한다. - * @param code String : {@link kr.co.pennyway.common.exception.CausedBy} 클래스의 getCode() 메소드로 반환되는 코드 - * @param message : 에러 이유 + * + * @param code String : {@link kr.co.pennyway.common.exception.CausedBy} 클래스의 getCode() 메소드로 반환되는 코드 + * @param message : 에러 이유 * @param fieldErrors : 에러 상세 * @return ErrorResponse */ @@ -63,6 +65,7 @@ public static ErrorResponse failure(String code, String message, Object fieldErr /** * 422 Unprocessable Content 예외에서 발생한 BindingResult를 응답으로 변환한다. + * * @param bindingResult : BindingResult * @return ErrorResponse */ @@ -72,7 +75,7 @@ public static ErrorResponse failure(BindingResult bindingResult, ReasonCode reas fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage()); } - String code = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode()*10 + reasonCode.getCode()); + String code = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + reasonCode.getCode()); return failure(code, StatusCode.UNPROCESSABLE_CONTENT.name(), fieldErrors); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/SuccessResponse.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/SuccessResponse.java similarity index 88% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/SuccessResponse.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/SuccessResponse.java index 84635734b..2fe1a9ab5 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/SuccessResponse.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/SuccessResponse.java @@ -1,7 +1,10 @@ -package kr.co.pennyway.common.response; +package kr.co.pennyway.api.common.response; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import java.util.Map; @@ -27,7 +30,9 @@ public class SuccessResponse { private T data; @Builder - private SuccessResponse(T data) {this.data = data;} + private SuccessResponse(T data) { + this.data = data; + } /** * data : { "key" : data } 형태의 성공 응답을 반환한다. diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/handler/GlobalExceptionHandler.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java similarity index 98% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/handler/GlobalExceptionHandler.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java index 5f1916a91..df75b73b9 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/response/handler/GlobalExceptionHandler.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java @@ -1,10 +1,10 @@ -package kr.co.pennyway.common.response.handler; +package kr.co.pennyway.api.common.response.handler; import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import kr.co.pennyway.api.common.response.ErrorResponse; import kr.co.pennyway.common.exception.GlobalErrorException; import kr.co.pennyway.common.exception.ReasonCode; import kr.co.pennyway.common.exception.StatusCode; -import kr.co.pennyway.common.response.ErrorResponse; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.http.HttpStatus; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenClaim.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenClaim.java similarity index 63% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenClaim.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenClaim.java index 8106aa5d1..577bc9590 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenClaim.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenClaim.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.common.security.jwt.access; +package kr.co.pennyway.api.common.security.jwt.access; import kr.co.infra.common.jwt.JwtClaims; import lombok.AccessLevel; @@ -6,17 +6,14 @@ import java.util.Map; -import static kr.co.pennyway.common.security.jwt.access.AccessTokenClaimKeys.ROLE; -import static kr.co.pennyway.common.security.jwt.access.AccessTokenClaimKeys.USER_ID; - @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public class AccessTokenClaim implements JwtClaims { private final Map claims; public static AccessTokenClaim of(Long userId, String role) { Map claims = Map.of( - USER_ID.getValue(), userId.toString(), - ROLE.getValue(), role + AccessTokenClaimKeys.USER_ID.getValue(), userId.toString(), + AccessTokenClaimKeys.ROLE.getValue(), role ); return new AccessTokenClaim(claims); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenClaimKeys.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenClaimKeys.java similarity index 81% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenClaimKeys.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenClaimKeys.java index 04a259f4d..73c2c8def 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenClaimKeys.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenClaimKeys.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.common.security.jwt.access; +package kr.co.pennyway.api.common.security.jwt.access; public enum AccessTokenClaimKeys { USER_ID("userId"), diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProvider.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenProvider.java similarity index 87% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProvider.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenProvider.java index 53ca8ff3b..5716e9ed8 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProvider.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenProvider.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.common.security.jwt.access; +package kr.co.pennyway.api.common.security.jwt.access; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; @@ -9,7 +9,7 @@ import kr.co.infra.common.jwt.JwtClaims; import kr.co.infra.common.jwt.JwtProvider; import kr.co.infra.common.util.JwtErrorCodeUtil; -import kr.co.pennyway.common.annotation.AccessTokenStrategy; +import kr.co.pennyway.api.common.annotation.AccessTokenStrategy; import kr.co.pennyway.common.util.DateUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -23,8 +23,8 @@ import java.util.Date; import java.util.Map; -import static kr.co.pennyway.common.security.jwt.access.AccessTokenClaimKeys.ROLE; -import static kr.co.pennyway.common.security.jwt.access.AccessTokenClaimKeys.USER_ID; +import static kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaimKeys.ROLE; +import static kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaimKeys.USER_ID; @Slf4j @Primary @@ -35,8 +35,8 @@ public class AccessTokenProvider implements JwtProvider { private final Duration tokenExpiration; public AccessTokenProvider( - @Value("${jwt.secret-key.access-token}") String jwtSecretKey, - @Value("${jwt.expiration-time.access-token}") Duration tokenExpiration + @Value("${jwt.secret-key.access-token}") String jwtSecretKey, + @Value("${jwt.expiration-time.access-token}") Duration tokenExpiration ) { final byte[] secretKeyBytes = Base64.getDecoder().decode(jwtSecretKey); this.secretKey = Keys.hmacShaKeyFor(secretKeyBytes); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenClaim.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java similarity index 72% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenClaim.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java index 546540ebd..b492c1968 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenClaim.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.common.security.jwt.refresh; +package kr.co.pennyway.api.common.security.jwt.refresh; import kr.co.infra.common.jwt.JwtClaims; import lombok.AccessLevel; @@ -6,8 +6,8 @@ import java.util.Map; -import static kr.co.pennyway.common.security.jwt.refresh.RefreshTokenClaimKeys.ROLE; -import static kr.co.pennyway.common.security.jwt.refresh.RefreshTokenClaimKeys.USER_ID; +import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.ROLE; +import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.USER_ID; @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public class RefreshTokenClaim implements JwtClaims { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenClaimKeys.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java similarity index 81% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenClaimKeys.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java index deb87734d..b7a880732 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenClaimKeys.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.common.security.jwt.refresh; +package kr.co.pennyway.api.common.security.jwt.refresh; public enum RefreshTokenClaimKeys { USER_ID("userId"), diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenProvider.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java similarity index 86% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenProvider.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java index e9e294229..a40469fdd 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/common/security/jwt/refresh/RefreshTokenProvider.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.common.security.jwt.refresh; +package kr.co.pennyway.api.common.security.jwt.refresh; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; @@ -9,8 +9,8 @@ import kr.co.infra.common.jwt.JwtClaims; import kr.co.infra.common.jwt.JwtProvider; import kr.co.infra.common.util.JwtErrorCodeUtil; -import kr.co.pennyway.common.annotation.RefreshTokenStrategy; -import kr.co.pennyway.common.security.jwt.access.AccessTokenClaim; +import kr.co.pennyway.api.common.annotation.RefreshTokenStrategy; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; import kr.co.pennyway.common.util.DateUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -24,8 +24,8 @@ import java.util.Date; import java.util.Map; -import static kr.co.pennyway.common.security.jwt.refresh.RefreshTokenClaimKeys.ROLE; -import static kr.co.pennyway.common.security.jwt.refresh.RefreshTokenClaimKeys.USER_ID; +import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.ROLE; +import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.USER_ID; @Slf4j @Primary @@ -36,8 +36,8 @@ public class RefreshTokenProvider implements JwtProvider { private final Duration tokenExpiration; public RefreshTokenProvider( - @Value("${jwt.secret-key.refresh-token}") String jwtSecretKey, - @Value("${jwt.expiration-time.refresh-token}") Duration tokenExpiration + @Value("${jwt.secret-key.refresh-token}") String jwtSecretKey, + @Value("${jwt.expiration-time.refresh-token}") Duration tokenExpiration ) { final byte[] secretKeyBytes = Base64.getDecoder().decode(jwtSecretKey); this.secretKey = Keys.hmacShaKeyFor(secretKeyBytes); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java new file mode 100644 index 000000000..ab6515cd3 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java @@ -0,0 +1,69 @@ +package kr.co.pennyway.api.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.servers.Server; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.util.ObjectUtils; +import org.springframework.web.filter.ForwardedHeaderFilter; + +@Configuration +@OpenAPIDefinition( + servers = { + @Server(url = "${pennyway.domain.local}", description = "Local Server"), + @Server(url = "${pennyway.domain.dev}", description = "Develop Server") + } +) +@RequiredArgsConstructor +public class SwaggerConfig { + private static final String JWT = "JWT"; + private final Environment environment; + + @Bean + public OpenAPI openAPI() { + String activeProfile = ""; + if (!ObjectUtils.isEmpty(environment.getActiveProfiles()) && environment.getActiveProfiles().length >= 1) { + activeProfile = environment.getActiveProfiles()[0]; + } + + SecurityRequirement securityRequirement = new SecurityRequirement().addList(JWT); + + return new OpenAPI() + .info(apiInfo(activeProfile)) + .addServersItem(new io.swagger.v3.oas.models.servers.Server().url("")) + .addSecurityItem(securityRequirement) + .components(securitySchemes()); + } + + @Bean + ForwardedHeaderFilter forwardedHeaderFilter() { + return new ForwardedHeaderFilter(); + } + + private Components securitySchemes() { + final var securitySchemeAccessToken = new SecurityScheme() + .name(JWT) + .type(SecurityScheme.Type.HTTP) + .scheme("Bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization"); + + return new Components() + .addSecuritySchemes(JWT, securitySchemeAccessToken); + } + + private Info apiInfo(String activeProfile) { + return new Info() + .title("Pennyway API (" + activeProfile + ")") + .description("지출 관리 SNS 플랫폼 Pennyway API 명세서") + .version("v1.0.0"); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/config/SwaggerConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/config/SwaggerConfig.java deleted file mode 100644 index 1a55e052b..000000000 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/config/SwaggerConfig.java +++ /dev/null @@ -1,69 +0,0 @@ -package kr.co.pennyway.config; - -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.servers.Server; -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import io.swagger.v3.oas.models.security.SecurityScheme; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; -import org.springframework.util.ObjectUtils; -import org.springframework.web.filter.ForwardedHeaderFilter; - -@Configuration -@OpenAPIDefinition( - servers = { - @Server(url = "${pennyway.domain.local}", description = "Local Server"), - @Server(url = "${pennyway.domain.dev}", description = "Develop Server") - } -) -@RequiredArgsConstructor -public class SwaggerConfig { - private static final String JWT = "JWT"; - private final Environment environment; - - @Bean - public OpenAPI openAPI() { - String activeProfile = ""; - if (!ObjectUtils.isEmpty(environment.getActiveProfiles()) && environment.getActiveProfiles().length >= 1) { - activeProfile = environment.getActiveProfiles()[0]; - } - - SecurityRequirement securityRequirement = new SecurityRequirement().addList(JWT); - - return new OpenAPI() - .info(apiInfo(activeProfile)) - .addServersItem(new io.swagger.v3.oas.models.servers.Server().url("")) - .addSecurityItem(securityRequirement) - .components(securitySchemes()); - } - - @Bean - ForwardedHeaderFilter forwardedHeaderFilter() { - return new ForwardedHeaderFilter(); - } - - private Components securitySchemes() { - final var securitySchemeAccessToken = new SecurityScheme() - .name(JWT) - .type(SecurityScheme.Type.HTTP) - .scheme("Bearer") - .bearerFormat("JWT") - .in(SecurityScheme.In.HEADER) - .name("Authorization"); - - return new Components() - .addSecuritySchemes(JWT, securitySchemeAccessToken); - } - - private Info apiInfo(String activeProfile) { - return new Info() - .title("Pennyway API (" + activeProfile + ")") - .description("지출 관리 SNS 플랫폼 Pennyway API 명세서") - .version("v1.0.0"); - } -} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/response/SuccessResponseTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/response/SuccessResponseTest.java index 1fb7a59d6..f12ad3aa4 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/response/SuccessResponseTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/response/SuccessResponseTest.java @@ -1,5 +1,6 @@ package kr.co.pennyway.common.response; +import kr.co.pennyway.api.common.response.SuccessResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,7 +10,6 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; @ExtendWith(MockitoExtension.class) public class SuccessResponseTest { @@ -73,5 +73,6 @@ public void successResponseFromDtoWithKey() { System.out.println(response); } - private record TestDto(String name, int age) { } + private record TestDto(String name, int age) { + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProviderTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProviderTest.java index 608d9cf47..e31cb8258 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProviderTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProviderTest.java @@ -4,6 +4,8 @@ import kr.co.infra.common.exception.JwtErrorException; import kr.co.infra.common.jwt.JwtClaims; import kr.co.infra.common.jwt.JwtProvider; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java index 938e271b8..39b6b8639 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/CausedBy.java @@ -8,28 +8,19 @@ * * @param statusCode {@link StatusCode} 상태 코드 * @param reasonCode {@link ReasonCode} 이유 코드 - * @param domainCode {@link DomainCode} 도메인 코드 - * @param fieldCode {@link FieldCode} 필드 코드 - * - * - see also: {@link StatusCode}, {@link ReasonCode}, {@link DomainCode}, {@link FieldCode} + * @author YANG JAESEO */ public record CausedBy( - StatusCode statusCode, - ReasonCode reasonCode, - DomainCode domainCode, - FieldCode fieldCode + StatusCode statusCode, + ReasonCode reasonCode ) { - private static final int STATUS_CODE_MULTIPLIER = 10000; - private static final int REASON_CODE_MULTIPLIER = 1000; - private static final int DOMAIN_CODE_MULTIPLIER = 10; + private static final int STATUS_CODE_MULTIPLIER = 10; public CausedBy { Objects.requireNonNull(statusCode, "statusCode must not be null"); Objects.requireNonNull(reasonCode, "reasonCode must not be null"); - Objects.requireNonNull(domainCode, "domainCode must not be null"); - Objects.requireNonNull(fieldCode, "fieldCode must not be null"); - if (!isValidCodes(statusCode.getCode(), reasonCode.getCode(), domainCode.getCode(), fieldCode.getCode())) { + if (!isValidCodes(statusCode.getCode(), reasonCode.getCode())) { throw new IllegalArgumentException("Invalid bit count"); } } @@ -37,22 +28,22 @@ public record CausedBy( /** * CausedBy 객체를 생성하는 정적 팩토리 메서드 *
- * 모든 코드의 조합으로 생성된 최종 코드는 7자리의 문자열로 구성된다. + * 모든 코드의 조합으로 생성된 최종 코드는 4자리의 정수 문자열로 구성된다. + * * @param statusCode {@link StatusCode} 상태 코드 (3자리) * @param reasonCode {@link ReasonCode} 이유 코드 (1자리) - * @param domainCode {@link DomainCode} 도메인 코드 (1자리 or 2자리) - * @param fieldCode {@link FieldCode} 필드 코드 (1자리) - * @throws IllegalArgumentException 전체 코드가 7자리가 아닌 경우, 혹은 각 상태 코드가 자릿수를 준수하지 않은 경우 - * @throws NullPointerException 인자가 null인 경우 * @return CausedBy + * @throws IllegalArgumentException 전체 코드가 4자리가 아닌 경우, 혹은 각 상태 코드가 자릿수를 준수하지 않은 경우 + * @throws NullPointerException 인자가 null인 경우 */ - public static CausedBy of(StatusCode statusCode, ReasonCode reasonCode, DomainCode domainCode, FieldCode fieldCode) { - return new CausedBy(statusCode, reasonCode, domainCode, fieldCode); + public static CausedBy of(StatusCode statusCode, ReasonCode reasonCode) { + return new CausedBy(statusCode, reasonCode); } /** * status code, reason code, domain code, field code를 조합하여 에러 코드를 생성한다. - * @return String : 7자리 정수로 구성된 에러 코드 + * + * @return String : 4자리 정수로 구성된 에러 코드 */ public String getCode() { return generateCode(); @@ -62,6 +53,7 @@ public String getCode() { * 에러가 발생한 이유를 반환한다. *
* Reason은 사전에 예외 문서에 명시한 정보를 반환한다. + * * @return String : 에러가 발생한 이유 */ public String getReason() { @@ -69,11 +61,11 @@ public String getReason() { } private String generateCode() { - return String.valueOf(statusCode.getCode() * STATUS_CODE_MULTIPLIER + reasonCode.getCode() * REASON_CODE_MULTIPLIER + domainCode.getCode() * DOMAIN_CODE_MULTIPLIER + fieldCode.getCode()); + return String.valueOf(statusCode.getCode() * STATUS_CODE_MULTIPLIER + reasonCode.getCode()); } - private boolean isValidCodes(int statusCode, int reasonCode, int domainCode, int fieldCode) { - return isValidDigit(statusCode, 3) && isValidDigit(reasonCode, 1) && (isValidDigit(domainCode, 1) || isValidDigit(domainCode, 2)) && isValidDigit(fieldCode, 1); + private boolean isValidCodes(int statusCode, int reasonCode) { + return isValidDigit(statusCode, 3) && isValidDigit(reasonCode, 1); } private boolean isValidDigit(int number, long expectedDigit) { @@ -83,6 +75,6 @@ private boolean isValidDigit(int number, long expectedDigit) { private long calcDigit(int number) { if (number == 0) return 1; return Stream.iterate(number, n -> n > 0, n -> n / 10) - .count(); + .count(); } } diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java index 48508dc74..0780dd4c8 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java @@ -14,6 +14,7 @@ public enum ReasonCode { MISSING_REQUIRED_PARAMETER(1), MALFORMED_PARAMETER(2), MALFORMED_REQUEST_BODY(3), + INVALID_REQUEST(4), /* 401_UNAUTHORIZED */ MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS(0), diff --git a/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java b/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java index ec5772d11..1867405e7 100644 --- a/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java +++ b/pennyway-common/src/test/java/kr/co/pennyway/common/exception/CausedByTest.java @@ -12,22 +12,18 @@ public class CausedByTest { private StatusCode statusCode; private ReasonCode reasonCode; - private DomainCode domainCode; - private FieldCode fieldCode; @BeforeEach public void setUp() { statusCode = StatusCode.UNPROCESSABLE_CONTENT; reasonCode = ReasonCode.REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY; - domainCode = DomainBitCode.USER; - fieldCode = UserFieldCode.NAME; } @Test @DisplayName("모두 정상적인 인자로 생성할 수 있음을 확인한다.") public void createWithValidArguments() { // when - CausedBy causedBy = CausedBy.of(statusCode, reasonCode, domainCode, fieldCode); + CausedBy causedBy = CausedBy.of(statusCode, reasonCode); // then assertNotNull(causedBy); @@ -39,47 +35,33 @@ public void createWithNullArguments() { // given StatusCode statusCode = null; ReasonCode reasonCode = null; - DomainCode domainCode = null; - FieldCode fieldCode = null; // when-then - assertThrows(NullPointerException.class, () -> CausedBy.of(statusCode, reasonCode, domainCode, fieldCode)); - } - - @Test - @DisplayName("상태 코드(3), 이유 코드(1), 도메인 코드(2), 필드 코드(1)가 아니면 생성할 수 없음을 확인한다.") - public void createWithInvalidBitCountArguments() { - // given - FieldCode fieldCode = UserFieldCode.INVALID; - - // when-then - assertThrows(IllegalArgumentException.class, () -> CausedBy.of(statusCode, reasonCode, domainCode, fieldCode)); + assertThrows(NullPointerException.class, () -> CausedBy.of(statusCode, reasonCode)); } @Test @DisplayName("생성된 코드가 예상값과 일치하는 지 확인한다.") public void generateCodeWithValidArguments() { // when - CausedBy causedBy = CausedBy.of(statusCode, reasonCode, domainCode, fieldCode); + CausedBy causedBy = CausedBy.of(statusCode, reasonCode); // then - assertEquals("4220013", causedBy.getCode()); + assertEquals("4220", causedBy.getCode()); } @Test - @DisplayName("두 자리 수의 도메인 코드가 예상값과 일치하는 지 확인한다.") + @DisplayName("BAD_REQUEST - MISSING_REQUIRED_PARAMETER 에러 코드가 예상값과 일치하는 지 확인한다.") public void generateCodeWithTwoDigitDomainCode() { // given StatusCode statusCode = StatusCode.BAD_REQUEST; ReasonCode reasonCode = ReasonCode.MISSING_REQUIRED_PARAMETER; - DomainCode domainCode = DomainBitCode.ORDER; - FieldCode fieldCode = UserFieldCode.ZERO; // when - CausedBy causedBy = CausedBy.of(statusCode, reasonCode, domainCode, fieldCode); + CausedBy causedBy = CausedBy.of(statusCode, reasonCode); // then - assertEquals("4001130", causedBy.getCode()); + assertEquals("4001", causedBy.getCode()); } @Test @@ -88,56 +70,11 @@ public void getExplainError() { // given StatusCode statusCode = StatusCode.UNPROCESSABLE_CONTENT; ReasonCode reasonCode = ReasonCode.REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY; - DomainCode domainCode = DomainBitCode.USER; - FieldCode fieldCode = UserFieldCode.NAME; // when - CausedBy causedBy = CausedBy.of(statusCode, reasonCode, domainCode, fieldCode); + CausedBy causedBy = CausedBy.of(statusCode, reasonCode); // then assertEquals("REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY", causedBy.getReason()); } - - private enum DomainBitCode implements DomainCode { - ZERO(0), USER(1), PRODUCT(2), - ORDER(13); - - private final int code; - - DomainBitCode(int code) { - this.code = code; - } - - @Override - public int getCode() { - return code; - } - - @Override - public String getDomainName() { - return name().toLowerCase(); - } - } - - private enum UserFieldCode implements FieldCode { - ZERO(0), ID(1), PASSWORD(2), NAME(3), - INVALID(10) - ; - - private final int code; - - UserFieldCode(int code) { - this.code = code; - } - - @Override - public int getCode() { - return code; - } - - @Override - public String getFieldName() { - return name().toLowerCase(); - } - } } diff --git a/pennyway-domain/.gitignore b/pennyway-domain/.gitignore index 91bbe90bc..17348f5bc 100644 --- a/pennyway-domain/.gitignore +++ b/pennyway-domain/.gitignore @@ -28,7 +28,7 @@ bin/ !**/src/main/**/bin/ !**/src/test/**/bin/ -./main/generated/* +src/main/generated/** ### NetBeans ### /nbproject/private/ diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/DomainPackageLocation.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/DomainPackageLocation.java similarity index 58% rename from pennyway-domain/src/main/java/kr/co/pennyway/DomainPackageLocation.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/DomainPackageLocation.java index 02781d30e..8144c4cb0 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/DomainPackageLocation.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/DomainPackageLocation.java @@ -1,4 +1,4 @@ -package kr.co.pennyway; +package kr.co.pennyway.domain; public interface DomainPackageLocation { } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisCacheManager.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisCacheManager.java similarity index 87% rename from pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisCacheManager.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisCacheManager.java index b4413b963..11783e25a 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisCacheManager.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisCacheManager.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.common.annotation; +package kr.co.pennyway.domain.common.annotation; import org.springframework.beans.factory.annotation.Qualifier; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisConnectionFactory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisConnectionFactory.java similarity index 88% rename from pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisConnectionFactory.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisConnectionFactory.java index ee1a97a5f..12c232a3b 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisConnectionFactory.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisConnectionFactory.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.common.annotation; +package kr.co.pennyway.domain.common.annotation; import org.springframework.beans.factory.annotation.Qualifier; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisTemplate.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisTemplate.java similarity index 87% rename from pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisTemplate.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisTemplate.java index 7444eb6a3..5e8a4edf3 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/common/annotation/DomainRedisTemplate.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisTemplate.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.common.annotation; +package kr.co.pennyway.domain.common.annotation; import org.springframework.beans.factory.annotation.Qualifier; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AbstractLegacyEnumAttributeConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AbstractLegacyEnumAttributeConverter.java new file mode 100644 index 000000000..09cafe044 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AbstractLegacyEnumAttributeConverter.java @@ -0,0 +1,48 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.AttributeConverter; +import kr.co.pennyway.domain.common.util.LegacyEnumValueConvertUtil; +import lombok.Getter; +import org.springframework.util.StringUtils; + +@Getter +public class AbstractLegacyEnumAttributeConverter & LegacyCommonType> implements AttributeConverter { + /** + * 대상 Enum 클래스 {@link Class} 객체 + */ + private final Class targetEnumClass; + + /** + * nullable = false면, 변환할 값이 null로 들어왔을 때 예외를 발생시킨다.
+ * nullable = true면, 변환할 값이 null로 들어왔을 때 예외 없이 실행하며,
+ * legacy code로 변환 시엔 빈 문자열("")로 변환한다. + */ + private final boolean nullable; + + /** + * nullable = false일 때 출력할 오류 메시지에서 enum에 대한 설명을 위해 Enum의 설명적 이름을 받는다. + */ + private final String enumName; + + public AbstractLegacyEnumAttributeConverter(Class targetEnumClass, boolean nullable, String enumName) { + this.targetEnumClass = targetEnumClass; + this.nullable = nullable; + this.enumName = enumName; + } + + @Override + public String convertToDatabaseColumn(E attribute) { + if (!nullable && attribute == null) { + throw new IllegalArgumentException(String.format("%s을(를) null로 변환할 수 없습니다.", enumName)); + } + return LegacyEnumValueConvertUtil.toLegacyCode(attribute); + } + + @Override + public E convertToEntityAttribute(String dbData) { + if (!nullable && !StringUtils.hasText(dbData)) { + throw new IllegalArgumentException(String.format("%s(이)가 DB에 null 혹은 Empty로(%s) 저장되어 있습니다.", enumName, dbData)); + } + return LegacyEnumValueConvertUtil.ofLegacyCode(targetEnumClass, dbData); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/LegacyCommonType.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/LegacyCommonType.java new file mode 100644 index 000000000..3b251147e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/LegacyCommonType.java @@ -0,0 +1,10 @@ +package kr.co.pennyway.domain.common.converter; + +public interface LegacyCommonType { + /** + * Legacy Super System 공통 코드를 반환한다. + * + * @return String 공통 코드 + */ + String getCode(); +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProfileVisibilityConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProfileVisibilityConverter.java new file mode 100644 index 000000000..cf2f42639 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProfileVisibilityConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; + +@Converter +public class ProfileVisibilityConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "프로필 공개 범위"; + + public ProfileVisibilityConverter() { + super(ProfileVisibility.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/RoleConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/RoleConverter.java new file mode 100644 index 000000000..ee6f5da55 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/RoleConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.user.type.Role; + +@Converter +public class RoleConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "유저 권한"; + + public RoleConverter() { + super(Role.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/model/DateAuditable.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/model/DateAuditable.java new file mode 100644 index 000000000..3ae674cca --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/model/DateAuditable.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.common.model; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +public abstract class DateAuditable { + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/LegacyEnumValueConvertUtil.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/LegacyEnumValueConvertUtil.java new file mode 100644 index 000000000..d79b46c32 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/LegacyEnumValueConvertUtil.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.domain.common.util; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.util.StringUtils; + +import java.util.EnumSet; + +/** + * {@link LegacyCommonType} enum을 String과 상호 변환하는 유틸리티 클래스 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class LegacyEnumValueConvertUtil { + public static & LegacyCommonType> T ofLegacyCode(Class enumClass, String code) { + if (!StringUtils.hasText(code)) return null; + return EnumSet.allOf(enumClass).stream() + .filter(e -> e.getCode().equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + String.format("enum=[%s], code=[%s]가 존재하지 않습니다.", enumClass.getName(), code))); + } + + public static & LegacyCommonType> String toLegacyCode(T enumValue) { + if (enumValue == null) return ""; + return enumValue.getCode(); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/config/JpaConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java similarity index 84% rename from pennyway-domain/src/main/java/kr/co/pennyway/config/JpaConfig.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java index 21544f186..671227146 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/config/JpaConfig.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java @@ -1,6 +1,6 @@ -package kr.co.pennyway.config; +package kr.co.pennyway.domain.config; -import kr.co.pennyway.DomainPackageLocation; +import kr.co.pennyway.domain.DomainPackageLocation; import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/config/QueryDslConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java similarity index 92% rename from pennyway-domain/src/main/java/kr/co/pennyway/config/QueryDslConfig.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java index 799c4e5fd..41dc6a1c8 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/config/QueryDslConfig.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.config; +package kr.co.pennyway.domain.config; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/config/RedisConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java similarity index 91% rename from pennyway-domain/src/main/java/kr/co/pennyway/config/RedisConfig.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java index a1bff05da..d09fb95d4 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/config/RedisConfig.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java @@ -1,14 +1,12 @@ -package kr.co.pennyway.config; +package kr.co.pennyway.domain.config; -import kr.co.pennyway.common.annotation.DomainRedisCacheManager; -import kr.co.pennyway.common.annotation.DomainRedisConnectionFactory; -import kr.co.pennyway.common.annotation.DomainRedisTemplate; -import lombok.RequiredArgsConstructor; +import kr.co.pennyway.domain.common.annotation.DomainRedisCacheManager; +import kr.co.pennyway.domain.common.annotation.DomainRedisConnectionFactory; +import kr.co.pennyway.domain.common.annotation.DomainRedisTemplate; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.CacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java new file mode 100644 index 000000000..fe2f86413 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.domain.domains.user.domain; + +import jakarta.persistence.Embeddable; +import lombok.*; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicInsert +@ToString(of = {"accountBookNotify", "feedNotify", "feedCommentNotify"}) +public class NotifySetting { + @ColumnDefault("true") + private Boolean accountBookNotify; + @ColumnDefault("true") + private Boolean feedNotify; + @ColumnDefault("true") + private Boolean feedCommentNotify; + + @Builder + private NotifySetting(Boolean accountBookNotify, Boolean feedNotify, Boolean feedCommentNotify) { + this.accountBookNotify = accountBookNotify; + this.feedNotify = feedNotify; + this.feedCommentNotify = feedCommentNotify; + } + + public static NotifySetting of(Boolean accountBookNotify, Boolean feedNotify, Boolean feedCommentNotify) { + return NotifySetting.builder() + .accountBookNotify(accountBookNotify) + .feedNotify(feedNotify) + .feedCommentNotify(feedCommentNotify) + .build(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java new file mode 100644 index 000000000..41d4a6a25 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java @@ -0,0 +1,62 @@ +package kr.co.pennyway.domain.domains.user.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.ProfileVisibilityConverter; +import kr.co.pennyway.domain.common.converter.RoleConverter; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "user") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicInsert +public class User extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String username; + private String name; + @ColumnDefault("NULL") + private String password; + @ColumnDefault("NULL") + private LocalDateTime passwordUpdatedAt; + @ColumnDefault("NULL") + private String profileImageUrl; + private String phone; + @Convert(converter = RoleConverter.class) + private Role role; + @Convert(converter = ProfileVisibilityConverter.class) + private ProfileVisibility profileVisibility; + @ColumnDefault("false") + private Boolean locked; + @Embedded + private NotifySetting notifySetting; + @ColumnDefault("NULL") + private LocalDateTime deletedAt; + + @Builder + private User(String username, String name, String password, LocalDateTime passwordUpdatedAt, String profileImageUrl, String phone, Role role, ProfileVisibility profileVisibility, NotifySetting notifySetting, Boolean locked, LocalDateTime deletedAt) { + this.username = username; + this.name = name; + this.password = password; + this.passwordUpdatedAt = passwordUpdatedAt; + this.profileImageUrl = profileImageUrl; + this.phone = phone; + this.role = role; + this.profileVisibility = profileVisibility; + this.notifySetting = notifySetting; + this.locked = locked; + this.deletedAt = deletedAt; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java new file mode 100644 index 000000000..4a6a0443e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.domain.domains.user.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum UserErrorCode implements BaseErrorCode { + /* 400 BAD_REQUEST */ + ALREADY_SIGNUP(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "이미 회원가입한 유저입니다."), + + /* 401 UNAUTHORIZED */ + INVALID_USERNAME_OR_PASSWORD(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "유효하지 않은 아이디 또는 비밀번호입니다."), + + /* 403 FORBIDDEN */ + ALREADY_WITHDRAWAL(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "이미 탈퇴한 유저입니다."), + + /* 404 NOT_FOUND */ + NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "유저를 찾을 수 없습니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorException.java new file mode 100644 index 000000000..e0f352ed4 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorException.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.domain.domains.user.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class UserErrorException extends GlobalErrorException { + private final UserErrorCode userErrorCode; + + public UserErrorException(UserErrorCode userErrorCode) { + super(userErrorCode); + this.userErrorCode = userErrorCode; + } + + public CausedBy causedBy() { + return userErrorCode.causedBy(); + } + + public String getExplainError() { + return userErrorCode.getExplainError(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java new file mode 100644 index 000000000..439c9e160 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.domains.user.repository; + +import kr.co.pennyway.domain.domains.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java new file mode 100644 index 000000000..12a75c703 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.domain.domains.user.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +@DomainService +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + + @Transactional + public User createUser(User user) { + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public User readUser(Long id) { + return userRepository.findById(id).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + } + + @Transactional(readOnly = true) + public boolean isExistUser(Long id) { + return userRepository.existsById(id); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java new file mode 100644 index 000000000..30649677e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.domain.domains.user.type; + +import com.fasterxml.jackson.annotation.JsonValue; +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum ProfileVisibility implements LegacyCommonType { + PUBLIC("0", "전체 공개"), + FRIEND("1", "친구 공개"), + PRIVATE("2", "비공개"); + + private final String code; + private final String type; + + @Override + public String getCode() { + return code; + } + + @JsonValue + public String getType() { + return type; + } + + @Override + public String toString() { + return type; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/Role.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/Role.java new file mode 100644 index 000000000..1d46744f6 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/Role.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.domain.domains.user.type; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +import java.util.Map; +import java.util.stream.Stream; + +import static java.util.stream.Collectors.toMap; + +@RequiredArgsConstructor +public enum Role implements LegacyCommonType { + ADMIN("0", "ROLE_ADMIN"), + USER("1", "ROLE_USER"); + + private static final Map stringToEnum = + Stream.of(values()).collect(toMap(Object::toString, e -> e)); + private final String code; + private final String type; + + @JsonCreator + public static Role fromString(String type) { + return stringToEnum.get(type.toUpperCase()); + } + + @Override + public String getCode() { + return code; + } + + @JsonValue + public String getType() { + return type; + } + + @Override + public String toString() { + return type; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java b/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java index 024b4ecd6..c632d2b5d 100644 --- a/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java +++ b/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java @@ -39,8 +39,7 @@ public enum JwtErrorCode implements BaseErrorCode { * 500 INTERNAL_SERVER_ERROR: 서버 내부 에러 */ INVALID_JWT_DTO_FORMAT(INTERNAL_SERVER_ERROR, ReasonCode.UNEXPECTED_ERROR, "서버 내부 에러가 발생했습니다."), - UNEXPECTED_ERROR(INTERNAL_SERVER_ERROR, ReasonCode.UNEXPECTED_ERROR, "예상치 못한 에러가 발생했습니다."); - ; + UNEXPECTED_ERROR(INTERNAL_SERVER_ERROR, ReasonCode.UNEXPECTED_ERROR, "예상치 못한 에러가 발생했습니다.");; private final StatusCode statusCode; private final ReasonCode reasonCode; @@ -48,7 +47,7 @@ public enum JwtErrorCode implements BaseErrorCode { @Override public CausedBy causedBy() { - return CausedBy.of(statusCode, reasonCode, DomainErrorCode.ZERO, FieldErrorCode.ZERO); + return CausedBy.of(statusCode, reasonCode); } @Override From 7507217d4087103d15f0761f0815a19329dcc649 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 22 Mar 2024 11:47:31 +0900 Subject: [PATCH 026/152] =?UTF-8?q?=E2=9C=A8=EF=B8=8F=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20API=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: white space validator 작성 * rename: not-white space 주석 추가 * feat: 전화번호 인증 요청 dto 작성 * rename: cerification -> verification 단어 수정 * feat: refresh token redis entity 작성 * feat: refresh token repository 작성 * fix: refresh token ttl time unit seconds -> milliseconds * feat: refresh token service 구현 * feat: 이모지 유효성 검증 어노테이션 작성 * feat: 패스워드 유효성 검증 어노테이션 작성 * feat: 일반 회원가입 dto 작성 * fix: 일반 회원가입 dto code 필드 추가 * feat: jwt tokens 편의 dto 클래스 생성 * fix: refresh token ttl timeunit milliseconde -> seconds * feat: jwts 생성 mapper 정의 * fix: refresh token provider primary bean 제거 * feat: cookie util 작성 * style: jwts dto 클래스 패키지 경로 수정 * fix: jwt auth mapper 토큰 생성 로직 수정 * feat: 회원가입 usecase 구현(인증번호 미확인) * feat: auth controller sign up api 개방 * style: test 모듈 내 경로 수정 * rename: sign-up dto 전화번호 예시 문자 수정 * fix: phone pattern \n 제거 * fix: not empty -> not blank validation check 변경 * fix: cookie util max age int -> long * fix: auth controller cookie util 의존성 주입 * test: auth controller 7가지 시나리오 유효성 검사 * test: 필드 누락 시나리오 추가 && cookie 헤더 검증 수정 * fix: jwt mapper에서 rt provider에 access claim -> refresh claim 수정 --- .../apis/auth/controller/AuthController.java | 47 ++++ .../apis/auth/dto/PhoneVerificationReq.java | 29 +++ .../pennyway/api/apis/auth/dto/SignUpReq.java | 59 +++++ .../api/apis/auth/mapper/JwtAuthMapper.java | 53 +++++ .../api/apis/auth/usecase/AuthUseCase.java | 31 +++ .../api/common/security/jwt/Jwts.java | 10 + .../jwt/refresh/RefreshTokenProvider.java | 2 - .../pennyway/api/common/util/CookieUtil.java | 70 ++++++ .../api/common/validator/NotEmoji.java | 29 +++ .../common/validator/NotEmojiValidator.java | 24 ++ .../api/common/validator/NotWhiteSpace.java | 30 +++ .../validator/NotWhiteSpaceValidator.java | 18 ++ .../api/common/validator/Password.java | 29 +++ .../common/validator/PasswordValidator.java | 15 ++ .../auth/AuthControllerValidationTest.java | 216 ++++++++++++++++++ .../common/response/SuccessResponseTest.java | 3 +- .../jwt/access/AccessTokenProviderTest.java | 4 +- .../common/redis/refresh/RefreshToken.java | 38 +++ .../redis/refresh/RefreshTokenRepository.java | 6 + .../redis/refresh/RefreshTokenService.java | 31 +++ .../refresh/RefreshTokenServiceImpl.java | 69 ++++++ 21 files changed, 806 insertions(+), 7 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationReq.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/JwtAuthMapper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/Jwts.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/util/CookieUtil.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotEmoji.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotEmojiValidator.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotWhiteSpace.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotWhiteSpaceValidator.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/Password.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/PasswordValidator.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/AuthControllerValidationTest.java rename pennyway-app-external-api/src/test/java/kr/co/pennyway/{ => api}/common/response/SuccessResponseTest.java (95%) rename pennyway-app-external-api/src/test/java/kr/co/pennyway/{ => api}/common/security/jwt/access/AccessTokenProviderTest.java (94%) create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshToken.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenService.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceImpl.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java new file mode 100644 index 000000000..50702af16 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java @@ -0,0 +1,47 @@ +package kr.co.pennyway.api.apis.auth.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.api.apis.auth.usecase.AuthUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.jwt.Jwts; +import kr.co.pennyway.api.common.util.CookieUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; +import java.util.Map; + +@Slf4j +@Tag(name = "[인증 API]") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth") +public class AuthController { + private final AuthUseCase authUseCase; + private final CookieUtil cookieUtil; + + @Operation(summary = "일반 회원가입") + @PostMapping("/sign-up") + // TODO: Spring Security 설정 후 @PreAuthorize("isAnonymous()") 추가 + public ResponseEntity signUp(@RequestBody @Validated SignUpReq.General request) { + Pair jwts = authUseCase.signUp(request); + ResponseCookie cookie = cookieUtil.createCookie("refreshToken", jwts.getValue().refreshToken(), Duration.ofDays(7).toSeconds()); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .header(HttpHeaders.AUTHORIZATION, jwts.getValue().accessToken()) + .body(SuccessResponse.from("user", Map.of("id", jwts.getKey()))) + ; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationReq.java new file mode 100644 index 000000000..e1aa99ffc --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationReq.java @@ -0,0 +1,29 @@ +package kr.co.pennyway.api.apis.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public class PhoneVerificationReq { + @Schema(title = "인증번호 요청 DTO", description = "전화번호로 인증번호 송신 요청을 위한 DTO") + public record PushCodeReq( + @Schema(description = "전화번호", example = "01012345678") + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phone + ) { + } + + @Schema(title = "인증번호 검증 DTO", description = "전화번호로 인증번호 검증 요청을 위한 DTO") + public record VerifyCodeReq( + @Schema(description = "전화번호", example = "01012345678") + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phone, + @Schema(description = "6자리 정수 인증번호", example = "123456") + @NotBlank(message = "인증번호는 필수입니다.") + @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") + String code + ) { + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java new file mode 100644 index 000000000..8fe879f90 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java @@ -0,0 +1,59 @@ +package kr.co.pennyway.api.apis.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import kr.co.pennyway.api.common.validator.Password; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; + +/** + * 회원가입 요청 Dto + *
+ * 일반 회원가입 시엔 General, 소셜 회원가입 시엔 Oauth를 사용합니다. + */ +public class SignUpReq { + @Schema(title = "일반 회원가입 요청 DTO") + public record General( + @Schema(description = "아이디", example = "pennyway") + @NotBlank(message = "아이디를 입력해주세요") + @Pattern(regexp = "^[a-z-_.]{5,20}$", message = "5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.") + String username, + @Schema(description = "이름", example = "페니웨이") + @NotBlank(message = "이름을 입력해주세요") + @Pattern(regexp = "^[가-힣a-zA-Z]{2,20}$", message = "2~20자의 한글, 영문 대/소문자만 사용 가능합니다.") + String name, + @Schema(description = "비밀번호", example = "pennyway1234") + @NotBlank(message = "비밀번호를 입력해주세요") + @Password(message = "8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)") + String password, + @Schema(description = "전화번호", example = "010-1234-5678") + @NotBlank(message = "전화번호를 입력해주세요") + @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phone, + @Schema(description = "6자리 정수 인증번호", example = "123456") + @NotBlank(message = "인증번호는 필수입니다.") + @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") + String code + ) { + public User toEntity() { + return User.builder() + .username(username) + .name(name) + .password(password) + .phone(phone) + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .build(); + } + + } + + @Schema(title = "소셜 회원가입 요청 DTO") + public record Oauth( + + ) { + + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/JwtAuthMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/JwtAuthMapper.java new file mode 100644 index 000000000..255172167 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/JwtAuthMapper.java @@ -0,0 +1,53 @@ +package kr.co.pennyway.api.apis.auth.mapper; + +import kr.co.infra.common.jwt.JwtProvider; +import kr.co.pennyway.api.common.annotation.AccessTokenStrategy; +import kr.co.pennyway.api.common.annotation.RefreshTokenStrategy; +import kr.co.pennyway.api.common.security.jwt.Jwts; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; +import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaim; +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.common.redis.refresh.RefreshToken; +import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.extern.slf4j.Slf4j; + +import java.time.Duration; +import java.time.LocalDateTime; + +@Slf4j +@Mapper +public class JwtAuthMapper { + private final JwtProvider accessTokenProvider; + private final JwtProvider refreshTokenProvider; + private final RefreshTokenService refreshTokenService; + + public JwtAuthMapper( + @AccessTokenStrategy JwtProvider accessTokenProvider, + @RefreshTokenStrategy JwtProvider refreshTokenProvider, + RefreshTokenService refreshTokenService + ) { + this.accessTokenProvider = accessTokenProvider; + this.refreshTokenProvider = refreshTokenProvider; + this.refreshTokenService = refreshTokenService; + } + + /** + * 사용자 정보 기반으로 access token과 refresh token을 생성하는 메서드
+ * refresh token은 redis에 저장된다. + * + * @param user {@link User} + * @return {@link Jwts} + */ + public Jwts createToken(User user) { + String accessToken = accessTokenProvider.generateToken(AccessTokenClaim.of(user.getId(), user.getRole().getType())); + String refreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(user.getId(), user.getRole().getType())); + + refreshTokenService.save(RefreshToken.of(user.getId(), refreshToken, toSeconds(refreshTokenProvider.getExpiryDate(refreshToken)))); + return Jwts.of(accessToken, refreshToken); + } + + private long toSeconds(LocalDateTime expiryTime) { + return Duration.between(LocalDateTime.now(), expiryTime).getSeconds(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java new file mode 100644 index 000000000..d4014555e --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.api.apis.auth.usecase; + +import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.api.apis.auth.mapper.JwtAuthMapper; +import kr.co.pennyway.api.common.security.jwt.Jwts; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class AuthUseCase { + private final UserService userService; + + private final JwtAuthMapper jwtAuthMapper; + + @Transactional + public Pair signUp(SignUpReq.General request) { + // TODO: 인증 번호 확인 로직 추가 + // phoneVerificationHelper.verify(request.phone(), request.code()); + + User user = userService.createUser(request.toEntity()); + + return Pair.of(user.getId(), jwtAuthMapper.createToken(user)); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/Jwts.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/Jwts.java new file mode 100644 index 000000000..b301c2d08 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/Jwts.java @@ -0,0 +1,10 @@ +package kr.co.pennyway.api.common.security.jwt; + +public record Jwts( + String accessToken, + String refreshToken +) { + public static Jwts of(String accessToken, String refreshToken) { + return new Jwts(accessToken, refreshToken); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java index a40469fdd..182c75235 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java @@ -14,7 +14,6 @@ import kr.co.pennyway.common.util.DateUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; @@ -28,7 +27,6 @@ import static kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys.USER_ID; @Slf4j -@Primary @Component @RefreshTokenStrategy public class RefreshTokenProvider implements JwtProvider { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/util/CookieUtil.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/util/CookieUtil.java new file mode 100644 index 000000000..5b4c0c8d8 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/util/CookieUtil.java @@ -0,0 +1,70 @@ +package kr.co.pennyway.api.common.util; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Optional; + +@Component +public class CookieUtil { + /** + * request에서 cookieName에 해당하는 쿠키를 찾아서 반환합니다. + * + * @param request HttpServletRequest : 쿠키를 찾을 request + * @param cookieName String : 찾을 쿠키의 이름 + * @return Optional : 쿠키가 존재하면 해당 쿠키를, 존재하지 않으면 Optional.empty()를 반환합니다. + */ + public Optional getCookieFromRequest(HttpServletRequest request, String cookieName) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Optional.empty(); + } + + return Arrays.stream(cookies) + .filter(cookie -> cookieName.equals(cookie.getName())) + .findAny(); + } + + /** + * cookieName에 해당하는 쿠키를 생성합니다. + * + * @param cookieName String : 생성할 쿠키의 이름 + * @param value String : 생성할 쿠키의 값 + * @param maxAge long : 생성할 쿠키의 만료 시간 + * @return ResponseCookie : 생성된 쿠키 + */ + public ResponseCookie createCookie(String cookieName, String value, long maxAge) { + return ResponseCookie.from(cookieName, value) + .path("/") + .httpOnly(true) + .maxAge(maxAge) + .secure(true) + .sameSite("None") + .build(); + } + + /** + * cookieName에 해당하는 쿠키를 제거합니다. + * + * @param request HttpServletRequest : 쿠키를 제거할 request + * @param response HttpServletResponse : 쿠키를 제거할 response + * @param cookieName String : 제거할 쿠키의 이름 + * @return Optional : 쿠키가 존재하면 제거된 쿠키를, 존재하지 않으면 Optional.empty()를 반환합니다. + */ + public Optional deleteCookie(HttpServletRequest request, HttpServletResponse response, String cookieName) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Optional.empty(); + } + + return Arrays.stream(cookies) + .filter(cookie -> cookieName.equals(cookie.getName())) + .findAny() + .map(cookie -> createCookie(cookieName, "", 0)); + } +} + diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotEmoji.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotEmoji.java new file mode 100644 index 000000000..dd456c42f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotEmoji.java @@ -0,0 +1,29 @@ +package kr.co.pennyway.api.common.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * 어노테이션된 요소는 반드시 이모지가 포함되어서는 안 됩니다.
+ * 단, null인 경우 true를 반환합니다. + * + * @author Yang JaeSeo + */ +@Documented +@Constraint(validatedBy = {NotEmojiValidator.class}) +@Target({FIELD}) +@Retention(RUNTIME) +public @interface NotEmoji { + String message() default "특수기호는 허용되지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotEmojiValidator.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotEmojiValidator.java new file mode 100644 index 000000000..6da07cf15 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotEmojiValidator.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.api.common.validator; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class NotEmojiValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null || value.isEmpty()) { + return true; + } + + return !hasEmoji(value); + } + + private boolean hasEmoji(String value) { + return value.codePoints().anyMatch( + codePoint -> codePoint == 0x0 || codePoint == 0x9 || codePoint == 0xA || codePoint == 0xD + || (codePoint >= 0x20 && codePoint <= 0xD7FF) + || (codePoint >= 0xE000 && codePoint <= 0xFFFD) + || (codePoint >= 0x10000 && codePoint <= 0x10FFFF) + ); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotWhiteSpace.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotWhiteSpace.java new file mode 100644 index 000000000..332c6e6c0 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotWhiteSpace.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.api.common.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * 어노테이션된 요소는 반드시 공백문자가 포함되어서는 안 됩니다.
+ * 단, null인 경우 false를 반환합니다. + * + * @author Yang JaeSeo + * @see Character#isWhitespace(char) + */ +@Documented +@Constraint(validatedBy = {NotWhiteSpaceValidator.class}) +@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE}) +@Retention(RUNTIME) +public @interface NotWhiteSpace { + String message() default "공백 문자는 허용되지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotWhiteSpaceValidator.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotWhiteSpaceValidator.java new file mode 100644 index 000000000..6660fa634 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/NotWhiteSpaceValidator.java @@ -0,0 +1,18 @@ +package kr.co.pennyway.api.common.validator; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class NotWhiteSpaceValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + return !hasWhiteSpace(value); + } + + private boolean hasWhiteSpace(String value) { + return value.chars().anyMatch(Character::isWhitespace); + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/Password.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/Password.java new file mode 100644 index 000000000..0c72138ec --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/Password.java @@ -0,0 +1,29 @@ +package kr.co.pennyway.api.common.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * 어노테이션된 요소는 반드시 8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해야 합니다.
+ * 적어도 하나 이상의 소문자 알파벳과 숫자가 포함되어야 합니다. + * + * @author Yang JaeSeo + */ +@Documented +@Constraint(validatedBy = {PasswordValidator.class}) +@Target({FIELD}) +@Retention(RUNTIME) +public @interface Password { + String message() default "8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/PasswordValidator.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/PasswordValidator.java new file mode 100644 index 000000000..a676df060 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/validator/PasswordValidator.java @@ -0,0 +1,15 @@ +package kr.co.pennyway.api.common.validator; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.regex.Pattern; + +public class PasswordValidator implements ConstraintValidator { + private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?=.*[a-z])(?=.*\\d)[A-Za-z\\d@$!%*?&]{8,16}$"); + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return value != null && PASSWORD_PATTERN.matcher(value).matches(); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/AuthControllerValidationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/AuthControllerValidationTest.java new file mode 100644 index 000000000..3ada86cc9 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/AuthControllerValidationTest.java @@ -0,0 +1,216 @@ +package kr.co.pennyway.api.apis.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.auth.controller.AuthController; +import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.api.apis.auth.usecase.AuthUseCase; +import kr.co.pennyway.api.common.security.jwt.Jwts; +import kr.co.pennyway.api.common.util.CookieUtil; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.Duration; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(controllers = {AuthController.class}) +@ActiveProfiles("local") +public class AuthControllerValidationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private AuthUseCase authUseCase; + + @MockBean + private CookieUtil cookieUtil; + + @DisplayName("[1] 아이디, 이름, 비밀번호, 전화번호, 인증번호 필수 입력") + @Test + void requiredInputError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("", "", "", "", ""); + + // when + ResultActions resultActions = mockMvc.perform( + post("/api/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.username").exists()) + .andExpect(jsonPath("$.fieldErrors.name").exists()) + .andExpect(jsonPath("$.fieldErrors.password").exists()) + .andExpect(jsonPath("$.fieldErrors.phone").exists()) + .andExpect(jsonPath("$.fieldErrors.code").exists()) + .andDo(print()); + } + + @DisplayName("[2] 아이디는 5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.") + @Test + void idValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("#pennyway", "페니웨이", "pennyway1234", "010-1234-5678", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/api/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.username").value("5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.")) + .andDo(print()); + } + + @DisplayName("[3] 이름은 2~20자의 한글, 영문 대/소문자만 사용 가능합니다.") + @Test + void nameValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이1", "pennyway1234", "010-1234-5678", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/api/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.name").value("2~20자의 한글, 영문 대/소문자만 사용 가능합니다.")) + .andDo(print()); + } + + @DisplayName("[4] 비밀번호는 8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)") + @Test + void passwordValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway", "010-1234-5678", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/api/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.password").value("8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)")) + .andDo(print()); + } + + @DisplayName("[5] 전화번호는 010 혹은 011로 시작하는, 010-0000-0000 형식이어야 합니다.") + @Test + void phoneValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", "01012345673", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/api/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.phone").value("전화번호 형식이 올바르지 않습니다.")) + .andDo(print()); + } + + @DisplayName("[6] 인증번호는 6자리 숫자여야 합니다.") + @Test + void codeValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", "010-1234-5678", "12345"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/api/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.code").value("인증번호는 6자리 숫자여야 합니다.")) + .andDo(print()); + } + + @DisplayName("[7] 일부 필드 누락") + @Test + void someFieldMissingError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", "010-1234-5678", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/api/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request) + .replace("\"username\":\"pennyway\",", "") + .replace("\"phone\":\"010-1234-5678\",", ""))); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.username").value("아이디를 입력해주세요")) + .andExpect(jsonPath("$.fieldErrors.phone").value("전화번호를 입력해주세요")) + .andDo(print()); + } + + @DisplayName("[8] 정상적인 회원가입 요청 - 쿠키/인증 헤더와 회원 pk 반환") + @Test + void signUp() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", "010-1234-5678", "123456"); + ResponseCookie expectedCookie = ResponseCookie.from("refreshToken", "refreshToken").maxAge(Duration.ofDays(7).toSeconds()).httpOnly(true).path("/").build(); + + given(authUseCase.signUp(request)) + .willReturn(Pair.of(1L, Jwts.of("accessToken", "refreshToken"))); + given(cookieUtil.createCookie("refreshToken", "refreshToken", Duration.ofDays(7).toSeconds())) + .willReturn(expectedCookie); + + // when + ResultActions resultActions = mockMvc.perform( + post("/api/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(header().string("Set-Cookie", expectedCookie.toString())) + .andExpect(header().string("Authorization", "accessToken")) + .andExpect(jsonPath("$.data.user.id").value(1)) + .andDo(print()); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/response/SuccessResponseTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/response/SuccessResponseTest.java similarity index 95% rename from pennyway-app-external-api/src/test/java/kr/co/pennyway/common/response/SuccessResponseTest.java rename to pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/response/SuccessResponseTest.java index f12ad3aa4..8e9e44ba9 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/response/SuccessResponseTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/response/SuccessResponseTest.java @@ -1,6 +1,5 @@ -package kr.co.pennyway.common.response; +package kr.co.pennyway.api.common.response; -import kr.co.pennyway.api.common.response.SuccessResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProviderTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenProviderTest.java similarity index 94% rename from pennyway-app-external-api/src/test/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProviderTest.java rename to pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenProviderTest.java index e31cb8258..9ef5202af 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/common/security/jwt/access/AccessTokenProviderTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenProviderTest.java @@ -1,11 +1,9 @@ -package kr.co.pennyway.common.security.jwt.access; +package kr.co.pennyway.api.common.security.jwt.access; import kr.co.infra.common.exception.JwtErrorCode; import kr.co.infra.common.exception.JwtErrorException; import kr.co.infra.common.jwt.JwtClaims; import kr.co.infra.common.jwt.JwtProvider; -import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; -import kr.co.pennyway.api.common.security.jwt.access.AccessTokenProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshToken.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshToken.java new file mode 100644 index 000000000..db38f7a4c --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshToken.java @@ -0,0 +1,38 @@ +package kr.co.pennyway.domain.common.redis.refresh; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +@RedisHash("refreshToken") +@Getter +@ToString(of = {"userId", "token", "ttl"}) +@EqualsAndHashCode(of = {"userId", "token"}) +public class RefreshToken { + @Id + private final Long userId; + private final long ttl; + private String token; + + @Builder + private RefreshToken(String token, Long userId, long ttl) { + this.token = token; + this.userId = userId; + this.ttl = ttl; + } + + public static RefreshToken of(Long userId, String token, long ttl) { + return RefreshToken.builder() + .userId(userId) + .token(token) + .ttl(ttl) + .build(); + } + + protected void rotation(String token) { + this.token = token; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenRepository.java new file mode 100644 index 000000000..35467af12 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenRepository.java @@ -0,0 +1,6 @@ +package kr.co.pennyway.domain.common.redis.refresh; + +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository { +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenService.java new file mode 100644 index 000000000..98158f8b7 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenService.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.domain.common.redis.refresh; + +public interface RefreshTokenService { + /** + * refresh token을 redis에 저장한다. + * + * @param refreshToken : {@link RefreshToken} + */ + void save(RefreshToken refreshToken); + + /** + * 사용자가 보낸 refresh token으로 기존 refresh token과 비교 검증 후, 새로운 refresh token으로 저장한다. + * + * @param userId : 토큰 주인 pk + * @param oldRefreshToken : 사용자가 보낸 refresh token + * @param newRefreshToken : 교체할 refresh token + * @return {@link RefreshToken} + * @throws IllegalArgumentException : userId에 해당하는 refresh token이 없을 경우 + * @throws IllegalStateException : 요청한 토큰과 저장된 토큰이 다르다면 토큰이 탈취되었다고 판단하여 값 삭제 + */ + RefreshToken refresh(Long userId, String oldRefreshToken, String newRefreshToken) throws IllegalArgumentException, IllegalStateException; + + /** + * access token 으로 refresh token을 찾아서 제거 (로그아웃) + * + * @param userId : 토큰 주인 pk + * @param refreshToken : 검증용 refresh token + * @throws IllegalArgumentException : userId에 해당하는 refresh token이 없을 경우 + */ + void delete(Long userId, String refreshToken) throws IllegalArgumentException; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceImpl.java new file mode 100644 index 000000000..194ed90b9 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceImpl.java @@ -0,0 +1,69 @@ +package kr.co.pennyway.domain.common.redis.refresh; + +import kr.co.pennyway.common.annotation.DomainService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class RefreshTokenServiceImpl implements RefreshTokenService { + private final RefreshTokenRepository refreshTokenRepository; + + @Override + public void save(RefreshToken refreshToken) { + refreshTokenRepository.save(refreshToken); + log.debug("리프레시 토큰 저장 : {}", refreshToken); + } + + @Override + public RefreshToken refresh(Long userId, String oldRefreshToken, String newRefreshToken) throws IllegalArgumentException, IllegalStateException { + RefreshToken refreshToken = findOrElseThrow(userId); + + validateToken(oldRefreshToken, refreshToken); + + refreshToken.rotation(newRefreshToken); + refreshTokenRepository.save(refreshToken); + + log.info("사용자 {}의 리프레시 토큰 갱신", userId); + return refreshToken; + } + + @Override + public void delete(Long userId, String refreshToken) throws IllegalArgumentException { + RefreshToken token = findOrElseThrow(userId); + refreshTokenRepository.delete(token); + log.info("사용자 {}의 리프레시 토큰 삭제", userId); + } + + private RefreshToken findOrElseThrow(Long userId) { + return refreshTokenRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("refresh token not found")); + } + + /** + * @param requestRefreshToken String : 사용자가 보낸 refresh token + * @param expectedRefreshToken String : Redis에 저장된 refresh token + * @throws IllegalStateException : 요청한 토큰과 저장된 토큰이 다르다면 토큰이 탈취되었다고 판단하여 값 삭제 + */ + private void validateToken(String requestRefreshToken, RefreshToken expectedRefreshToken) throws IllegalStateException { + if (isTakenAway(requestRefreshToken, expectedRefreshToken.getToken())) { + log.warn("리프레시 토큰 불일치(탈취). expected : {}, actual : {}", requestRefreshToken, expectedRefreshToken.getToken()); + refreshTokenRepository.delete(expectedRefreshToken); + log.info("사용자 {}의 리프레시 토큰 삭제", expectedRefreshToken.getUserId()); + + throw new IllegalStateException("refresh token mismatched"); + } + } + + /** + * 토큰 탈취 여부 확인 + * + * @param requestRefreshToken String : 사용자가 보낸 refresh token + * @param expectedRefreshToken String : Redis에 저장된 refresh token + * @return boolean : 탈취되었다면 true, 아니면 false + */ + private boolean isTakenAway(String requestRefreshToken, String expectedRefreshToken) { + return !requestRefreshToken.equals(expectedRefreshToken); + } +} From 23ecc674aa86c21471badc0cdacdcb37285a5add Mon Sep 17 00:00:00 2001 From: Jinwoo Lee Date: Mon, 25 Mar 2024 18:54:29 +0900 Subject: [PATCH 027/152] =?UTF-8?q?Dockfile=20=EC=9E=91=EC=84=B1=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: .gitignore에 .env 파일 경로 추가 * feat: dockerfile 작성 * fix: dockerfile의 profile을 local에서 dev로 수정 --- .gitignore | 5 ++++- Dockerfile | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 Dockerfile diff --git a/.gitignore b/.gitignore index 226976d9c..d992032ad 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ out/ .vscode/ ### Conventional Commit ### -/node_modules \ No newline at end of file +/node_modules + +### docker-compose environments ### +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..a343f0ec6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17 +ARG JAR_FILE=pennyway-app-external-api/build/libs/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java","-jar","/app.jar","--spring.profiles.active=dev","-Duser.timezone=Asia/Seoul"] \ No newline at end of file From 0d5adfe0b450350f40f692d98de70d9f3f1b3d0b Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 26 Mar 2024 13:27:57 +0900 Subject: [PATCH 028/152] =?UTF-8?q?=E2=9C=A8=20=EC=9D=BC=EB=B0=98=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=A0=84=ED=99=94?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EC=9D=B8=EC=A6=9D=20API=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 인증번호 송신 dto 작성 * feat: 인증번호 전송 & 검증 API 설계 * chore: domain 모듈 redis unit test 목적 embedded-redis 의존성 추가 * chore: redis test 라이브러리 embedded -> container * test: redis container config 작성 * chore: domain 모듈 test application.yml 작성 * chore: redis template bean primary 추가 * feat: phone validation code(회원가입, 아이디/비밀번호 찾기) 상수 지정 * test: phone validation repository 테스트 작성 * feat: phone validation repository 작성 * test: phone validation repository 삭제 테스트 케이스 추가 * feat: phone verification service 작성 * feat: validation code converter 작성 * feat: web config 설정 * feat: 전화번호 인증/검증 dto codetype 필드 추가 * fix: phone verification repository save 시, expires_at 반환 * fix: phone verification service create 메서드, expires_at 반환 * rename: read by phone error log 문구 수정 * feat: phone verification 에러 코드 정의 * feat: phone verification 에러 클래스 정의 * feat: sns dto 클래스 선언 * feat: sms provider 인터페이스 정의 * rename: request time -> request at 변수명 수정 * feat: infra module component scan 목적의 application 클래스 작성 * feat: aws sms provider mock 구현체 작성 * style: infra 상위 패키지 pennyway 추가 -> 디렉토리 이동 * rename: code -> phone-verificatio-code 클래스명 수정 * fix: 사용자에게 code type 입력받는 필드 & converter 제거 * fix: 인증번호 검증 dto내 code 필드 복구 * feat: phone verification mapper 클래스 정의 * feat: 코드 불일치 시 error 반환하도록 수정 * rename: saveCode -> sendCode * feat: 인증번호 검증 응답 dto 작성 * rename: of -> value of * feat: auth use case 전화번호 인증 추가 * fix: global exception handler내 global error exception status 삽입 메서드 수정 * rename: send_time, expire_time -> send_at, expires_at 필드명 수정 * fix: 성공 응답 상태코드 2000000 -> 2000 수정 * fix: hash table -> value && ttl 적용 * feat: user find by phone 메서드 추가 * fix: 인증번호 검증 시 oauth user 여부 확인 필드 추가 * fix: user notify 필드 feed-comment-notify -> chat-notify 수정 * feat: user 계정 연동 helper class 정의 * feat: 전화번호 인증 성공 시 ttl rollback 메서드 추가 * fix: transaction 내 exception 발생 시 rollback -> sync helper의 transaction 어노테이션 제거 * fix: user auth use case 의존성 주입 및 분기 처리 로직 추가 * test: auth controller validation test 경로 수정 * rename: phone verification mapper 메서드 주석 추가 * test: user sync helper 클래스 test 작성 * fix: 이미 회원가입한 유저인 경우, 인증 코드 cache 제거 * fix: verify-code-res 기존 사용자 존재할 시 반환 필드 추가 * fix: user-sync-helper에서 oauth 계정 있으면 username 반환 * fix: auth user case 일반 회원가입 이력 없고, oauth 계정 있으면 username 반환 * rename: phone verification repository remove() -> delete() * rename: phone-verification-code -> phone-verification-type * fix: web config 제거 --- .../apis/auth/controller/AuthController.java | 17 ++- .../apis/auth/dto/PhoneVerificationDto.java | 75 +++++++++++++ .../apis/auth/dto/PhoneVerificationReq.java | 29 ----- .../api/apis/auth/helper/UserSyncHelper.java | 42 ++++++++ .../api/apis/auth/mapper/JwtAuthMapper.java | 2 +- .../auth/mapper/PhoneVerificationMapper.java | 56 ++++++++++ .../api/apis/auth/usecase/AuthUseCase.java | 31 ++++++ .../exception/PhoneVerificationErrorCode.java | 31 ++++++ .../exception/PhoneVerificationException.java | 22 ++++ .../api/common/response/SuccessResponse.java | 4 +- .../handler/GlobalExceptionHandler.java | 4 +- .../security/jwt/access/AccessTokenClaim.java | 2 +- .../jwt/access/AccessTokenProvider.java | 10 +- .../jwt/refresh/RefreshTokenClaim.java | 2 +- .../jwt/refresh/RefreshTokenProvider.java | 10 +- .../AuthControllerValidationTest.java | 19 ++-- .../apis/auth/helper/UserSyncHelperTest.java | 65 ++++++++++++ .../jwt/access/AccessTokenProviderTest.java | 8 +- pennyway-domain/build.gradle | 11 +- .../phone/PhoneVerificationRepository.java | 36 +++++++ .../redis/phone/PhoneVerificationService.java | 65 ++++++++++++ .../redis/phone/PhoneVerificationType.java | 12 +++ .../pennyway/domain/config/RedisConfig.java | 2 + .../domains/user/domain/NotifySetting.java | 12 +-- .../user/repository/UserRepository.java | 3 + .../domains/user/service/UserService.java | 5 + .../redis/phone/PhoneValidationDaoTest.java | 83 +++++++++++++++ .../config/ContainerRedisTestConfig.java | 28 +++++ .../src/test/resources/application-domain.yml | 8 ++ .../co/pennyway/PennywayInfraApplication.java | 7 ++ .../infra/client/aws/sms/AwsSmsProvider.java | 30 ++++++ .../pennyway/infra/client/aws/sms/SmsDto.java | 100 ++++++++++++++++++ .../infra/client/aws/sms/SmsProvider.java | 11 ++ .../InfraRedisConnectionFactory.java | 2 +- .../common/annotation/OidcCacheManager.java | 2 +- .../annotation/SecurityUserCacheManager.java | 2 +- .../common/exception/DomainErrorCode.java | 2 +- .../common/exception/FieldErrorCode.java | 2 +- .../infra/common/exception/JwtErrorCode.java | 2 +- .../common/exception/JwtErrorException.java | 2 +- .../infra/common/jwt/AuthConstants.java | 5 +- .../infra/common/jwt/JwtClaims.java | 2 +- .../infra/common/jwt/JwtProvider.java | 8 +- .../infra/common/util/JwtErrorCodeUtil.java | 8 +- .../infra/config/CacheConfig.java | 8 +- 45 files changed, 799 insertions(+), 88 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java delete mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationReq.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/PhoneVerificationMapper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationErrorCode.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationException.java rename pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/{ => controller}/AuthControllerValidationTest.java (95%) create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationService.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationType.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/phone/PhoneValidationDaoTest.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerRedisTestConfig.java create mode 100644 pennyway-domain/src/test/resources/application-domain.yml create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/PennywayInfraApplication.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/AwsSmsProvider.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsDto.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsProvider.java rename pennyway-infra/src/main/java/kr/co/{ => pennyway}/infra/common/annotation/InfraRedisConnectionFactory.java (88%) rename pennyway-infra/src/main/java/kr/co/{ => pennyway}/infra/common/annotation/OidcCacheManager.java (87%) rename pennyway-infra/src/main/java/kr/co/{ => pennyway}/infra/common/annotation/SecurityUserCacheManager.java (88%) rename pennyway-infra/src/main/java/kr/co/{ => pennyway}/infra/common/exception/DomainErrorCode.java (88%) rename pennyway-infra/src/main/java/kr/co/{ => pennyway}/infra/common/exception/FieldErrorCode.java (88%) rename pennyway-infra/src/main/java/kr/co/{ => pennyway}/infra/common/exception/JwtErrorCode.java (98%) rename pennyway-infra/src/main/java/kr/co/{ => pennyway}/infra/common/exception/JwtErrorException.java (91%) rename pennyway-infra/src/main/java/kr/co/{ => pennyway}/infra/common/jwt/AuthConstants.java (77%) rename pennyway-infra/src/main/java/kr/co/{ => pennyway}/infra/common/jwt/JwtClaims.java (67%) rename pennyway-infra/src/main/java/kr/co/{ => pennyway}/infra/common/jwt/JwtProvider.java (95%) rename pennyway-infra/src/main/java/kr/co/{ => pennyway}/infra/common/util/JwtErrorCodeUtil.java (91%) rename pennyway-infra/src/main/java/kr/co/{ => pennyway}/infra/config/CacheConfig.java (95%) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java index 50702af16..ef18d27b0 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; import kr.co.pennyway.api.apis.auth.usecase.AuthUseCase; import kr.co.pennyway.api.common.response.SuccessResponse; @@ -26,11 +27,25 @@ @Tag(name = "[인증 API]") @RestController @RequiredArgsConstructor -@RequestMapping("/api/v1/auth") +@RequestMapping("/v1/auth") public class AuthController { private final AuthUseCase authUseCase; private final CookieUtil cookieUtil; + @Operation(summary = "인증번호 전송") + @PostMapping("/phone") + // TODO: Spring Security 설정 후 @PreAuthorize("permitAll()") 추가 && ip 당 횟수 제한 + public ResponseEntity sendCode(@RequestBody @Validated PhoneVerificationDto.PushCodeReq request) { + return ResponseEntity.ok(SuccessResponse.from("sms", authUseCase.sendCode(request))); + } + + @Operation(summary = "인증번호 검증") + @PostMapping("/phone/verification") + // TODO: Spring Security 설정 후 @PreAuthorize("permitAll()") 추가 + public ResponseEntity verifyCode(@RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request) { + return ResponseEntity.ok(SuccessResponse.from("sms", authUseCase.verifyCode(request))); + } + @Operation(summary = "일반 회원가입") @PostMapping("/sign-up") // TODO: Spring Security 설정 후 @PreAuthorize("isAnonymous()") 추가 diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java new file mode 100644 index 000000000..989a6e0ad --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java @@ -0,0 +1,75 @@ +package kr.co.pennyway.api.apis.auth.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +import java.time.LocalDateTime; + +public class PhoneVerificationDto { + @Schema(title = "인증번호 요청 DTO", description = "전화번호로 인증번호 송신 요청을 위한 DTO") + public record PushCodeReq( + @Schema(description = "전화번호", example = "01012345678") + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phone + ) { + } + + @Schema(title = "인증번호 발송 응답 DTO", description = "전화번호로 인증번호 송신 응답을 위한 DTO") + public record PushCodeRes( + @Schema(description = "수신자 번호") + String to, + @Schema(description = "발송 시간") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime sendAt, + @Schema(description = "만료 시간 (default: 3분)", example = "2021-08-01T00:00:00") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime expiresAt + ) { + /** + * 인증번호 발송 응답 객체 생성 + * + * @param to String : 수신자 번호 + * @param sendAt LocalDateTime : 발송 시간 + * @param expiresAt LocalDateTime : 만료 시간 (default: 5분) + */ + public static PushCodeRes of(String to, LocalDateTime sendAt, LocalDateTime expiresAt) { + return new PushCodeRes(to, sendAt, expiresAt); + } + } + + @Schema(title = "인증번호 검증 DTO", description = "전화번호로 인증번호 검증 요청을 위한 DTO") + public record VerifyCodeReq( + @Schema(description = "전화번호", example = "01012345678") + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phone, + @Schema(description = "6자리 정수 인증번호", example = "123456") + @NotBlank(message = "인증번호는 필수입니다.") + @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") + String code + ) { + } + + @Schema(title = "인증번호 검증 응답 DTO") + public record VerifyCodeRes( + @Schema(description = "코드 일치 여부 : 일치하지 않으면 예외이므로 성공하면 언제나 true", example = "true") + Boolean code, + @Schema(description = "oauth 사용자 여부", example = "true") + Boolean oauth, + @Schema(description = "기존 사용자 아이디", example = "pennyway") + @JsonInclude(JsonInclude.Include.NON_NULL) + String username + ) { + public static VerifyCodeRes valueOf(Boolean isValidCode, Boolean isOauthUser, String username) { + return new VerifyCodeRes(isValidCode, isOauthUser, username); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationReq.java deleted file mode 100644 index e1aa99ffc..000000000 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationReq.java +++ /dev/null @@ -1,29 +0,0 @@ -package kr.co.pennyway.api.apis.auth.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; - -public class PhoneVerificationReq { - @Schema(title = "인증번호 요청 DTO", description = "전화번호로 인증번호 송신 요청을 위한 DTO") - public record PushCodeReq( - @Schema(description = "전화번호", example = "01012345678") - @NotBlank(message = "전화번호는 필수입니다.") - @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") - String phone - ) { - } - - @Schema(title = "인증번호 검증 DTO", description = "전화번호로 인증번호 검증 요청을 위한 DTO") - public record VerifyCodeReq( - @Schema(description = "전화번호", example = "01012345678") - @NotBlank(message = "전화번호는 필수입니다.") - @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") - String phone, - @Schema(description = "6자리 정수 인증번호", example = "123456") - @NotBlank(message = "인증번호는 필수입니다.") - @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") - String code - ) { - } -} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java new file mode 100644 index 000000000..8dce3c4fe --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.api.apis.auth.helper; + +import kr.co.pennyway.common.annotation.Helper; +import kr.co.pennyway.common.exception.GlobalErrorException; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; + +@Slf4j +@Helper +@RequiredArgsConstructor +public class UserSyncHelper { + private final UserService userService; + + /** + * 일반 회원가입 시 이미 가입된 회원인지 확인 + * + * @param phone String : 전화번호 + * @return Pair : 이미 가입된 회원인지 여부 (TRUE: 가입되지 않은 회원, FALSE: 가입된 회원), 가입된 회원인 경우 회원 ID 반환 + * @throws UserErrorException : 이미 일반 회원가입을 한 유저인 경우 + */ + public Pair isSignedUserWhenGeneral(String phone) { + User user; + try { + user = userService.readUserByPhone(phone); + } catch (GlobalErrorException e) { + log.info("User not found. phone: {}", phone); + return Pair.of(Boolean.TRUE, null); + } + + if (user.getPassword() != null) { + log.warn("User already exists. phone: {}", phone); + throw new UserErrorException(UserErrorCode.ALREADY_SIGNUP); + } + + return Pair.of(Boolean.FALSE, user.getUsername()); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/JwtAuthMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/JwtAuthMapper.java index 255172167..4d1ab9fbf 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/JwtAuthMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/JwtAuthMapper.java @@ -1,6 +1,5 @@ package kr.co.pennyway.api.apis.auth.mapper; -import kr.co.infra.common.jwt.JwtProvider; import kr.co.pennyway.api.common.annotation.AccessTokenStrategy; import kr.co.pennyway.api.common.annotation.RefreshTokenStrategy; import kr.co.pennyway.api.common.security.jwt.Jwts; @@ -10,6 +9,7 @@ import kr.co.pennyway.domain.common.redis.refresh.RefreshToken; import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.infra.common.jwt.JwtProvider; import lombok.extern.slf4j.Slf4j; import java.time.Duration; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/PhoneVerificationMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/PhoneVerificationMapper.java new file mode 100644 index 000000000..ff1cb8502 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/PhoneVerificationMapper.java @@ -0,0 +1,56 @@ +package kr.co.pennyway.api.apis.auth.mapper; + +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; +import kr.co.pennyway.api.common.exception.PhoneVerificationException; +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; +import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; +import kr.co.pennyway.infra.client.aws.sms.SmsDto; +import kr.co.pennyway.infra.client.aws.sms.SmsProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; + +@Slf4j +@Mapper +@RequiredArgsConstructor +public class PhoneVerificationMapper { + private final PhoneVerificationService phoneVerificationService; + private final SmsProvider smsProvider; + + /** + * 휴대폰 번호로 인증 코드를 발송하고 캐싱한다. (5분간 유효) + * + * @param request {@link PhoneVerificationDto.PushCodeReq} + * @param codeType {@link PhoneVerificationType} + * @return {@link PhoneVerificationDto.PushCodeRes} + */ + public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeReq request, PhoneVerificationType codeType) { + SmsDto.Info info = smsProvider.sendCode(SmsDto.To.of(request.phone())); + LocalDateTime expiresAt = phoneVerificationService.create(request.phone(), info.code(), codeType); + return PhoneVerificationDto.PushCodeRes.of(request.phone(), info.requestAt(), expiresAt); + } + + /** + * 휴대폰 번호로 인증 코드를 확인한다. + * + * @param request {@link PhoneVerificationDto.VerifyCodeReq} + * @param codeType {@link PhoneVerificationType} + * @return Boolean : 인증 코드가 유효한지 여부 (TRUE: 유효, 실패하는 경우 예외가 발생하므로 FALSE가 반환되지 않음) + * @throws PhoneVerificationException : 전화번호가 만료되었거나 유효하지 않은 경우(EXPIRED_OR_INVALID_PHONE), 인증 코드가 유효하지 않은 경우(IS_NOT_VALID_CODE) + */ + public Boolean isValidCode(PhoneVerificationDto.VerifyCodeReq request, PhoneVerificationType codeType) { + String expectedCode; + try { + expectedCode = phoneVerificationService.readByPhone(request.phone(), codeType); + } catch (IllegalArgumentException e) { + throw new PhoneVerificationException(PhoneVerificationErrorCode.EXPIRED_OR_INVALID_PHONE); + } + + if (!expectedCode.equals(request.code())) + throw new PhoneVerificationException(PhoneVerificationErrorCode.IS_NOT_VALID_CODE); + return Boolean.TRUE; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java index d4014555e..711d5dcfb 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java @@ -1,10 +1,16 @@ package kr.co.pennyway.api.apis.auth.usecase; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.api.apis.auth.helper.UserSyncHelper; import kr.co.pennyway.api.apis.auth.mapper.JwtAuthMapper; +import kr.co.pennyway.api.apis.auth.mapper.PhoneVerificationMapper; import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; +import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -16,8 +22,24 @@ @RequiredArgsConstructor public class AuthUseCase { private final UserService userService; + private final UserSyncHelper userSyncHelper; private final JwtAuthMapper jwtAuthMapper; + private final PhoneVerificationMapper phoneVerificationMapper; + private final PhoneVerificationService phoneVerificationService; + + public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeReq request) { + return phoneVerificationMapper.sendCode(request, PhoneVerificationType.SIGN_UP); + } + + public PhoneVerificationDto.VerifyCodeRes verifyCode(PhoneVerificationDto.VerifyCodeReq request) { + Boolean isValidCode = phoneVerificationMapper.isValidCode(request, PhoneVerificationType.SIGN_UP); + Pair isOauthUser = checkOauthUser(request.phone()); + + phoneVerificationService.extendTimeToLeave(request.phone(), PhoneVerificationType.SIGN_UP); + + return PhoneVerificationDto.VerifyCodeRes.valueOf(isValidCode, isOauthUser.getKey(), isOauthUser.getValue()); + } @Transactional public Pair signUp(SignUpReq.General request) { @@ -28,4 +50,13 @@ public Pair signUp(SignUpReq.General request) { return Pair.of(user.getId(), jwtAuthMapper.createToken(user)); } + + private Pair checkOauthUser(String phone) { + try { + return userSyncHelper.isSignedUserWhenGeneral(phone); + } catch (UserErrorException e) { + phoneVerificationService.delete(phone, PhoneVerificationType.SIGN_UP); + throw e; + } + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationErrorCode.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationErrorCode.java new file mode 100644 index 000000000..4537aba77 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationErrorCode.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.api.common.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PhoneVerificationErrorCode implements BaseErrorCode { + // 401 Unauthorized + EXPIRED_OR_INVALID_PHONE(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "만료되었거나 등록되지 않은 휴대폰 정보입니다."), + IS_NOT_VALID_CODE(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "인증코드가 일치하지 않습니다."), + ; + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationException.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationException.java new file mode 100644 index 000000000..dcd3408bc --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationException.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.api.common.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class PhoneVerificationException extends GlobalErrorException { + private final PhoneVerificationErrorCode errorCode; + + public PhoneVerificationException(PhoneVerificationErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + @Override + public CausedBy causedBy() { + return errorCode.causedBy(); + } + + public String getExplainError() { + return errorCode.getExplainError(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/SuccessResponse.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/SuccessResponse.java index 2fe1a9ab5..ea5ec97e7 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/SuccessResponse.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/SuccessResponse.java @@ -15,8 +15,8 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) @Schema(description = "API 응답 - 성공") public class SuccessResponse { - @Schema(description = "응답 코드", defaultValue = "2000000") - private final String code = "2000000"; + @Schema(description = "응답 코드", defaultValue = "2000") + private final String code = "2000"; @Schema(description = "응답 메시지", example = """ data: { "aDomain": { // 단수명사는 object 형태로 반환 diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java index df75b73b9..dbb101da3 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java @@ -36,13 +36,13 @@ public class GlobalExceptionHandler { /** * Pennyway Custom Exception을 처리하는 메서드 * - * @see kr.co.pennyway.common.exception.GlobalErrorException + * @see GlobalErrorException */ @ExceptionHandler(GlobalErrorException.class) protected ResponseEntity handleGlobalErrorException(GlobalErrorException e) { log.warn("handleGlobalErrorException : {}", e.getMessage()); ErrorResponse response = ErrorResponse.of(e.getBaseErrorCode().causedBy().getCode(), e.getBaseErrorCode().getExplainError()); - return ResponseEntity.status(e.getBaseErrorCode().causedBy().reasonCode().getCode()).body(response); + return ResponseEntity.status(e.getBaseErrorCode().causedBy().statusCode().getCode()).body(response); } /** diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenClaim.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenClaim.java index 577bc9590..3a8b88820 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenClaim.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenClaim.java @@ -1,6 +1,6 @@ package kr.co.pennyway.api.common.security.jwt.access; -import kr.co.infra.common.jwt.JwtClaims; +import kr.co.pennyway.infra.common.jwt.JwtClaims; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenProvider.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenProvider.java index 5716e9ed8..00c4209b1 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenProvider.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenProvider.java @@ -4,13 +4,13 @@ import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; -import kr.co.infra.common.exception.JwtErrorCode; -import kr.co.infra.common.exception.JwtErrorException; -import kr.co.infra.common.jwt.JwtClaims; -import kr.co.infra.common.jwt.JwtProvider; -import kr.co.infra.common.util.JwtErrorCodeUtil; import kr.co.pennyway.api.common.annotation.AccessTokenStrategy; import kr.co.pennyway.common.util.DateUtil; +import kr.co.pennyway.infra.common.exception.JwtErrorCode; +import kr.co.pennyway.infra.common.exception.JwtErrorException; +import kr.co.pennyway.infra.common.jwt.JwtClaims; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import kr.co.pennyway.infra.common.util.JwtErrorCodeUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Primary; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java index b492c1968..1d346e49c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaim.java @@ -1,6 +1,6 @@ package kr.co.pennyway.api.common.security.jwt.refresh; -import kr.co.infra.common.jwt.JwtClaims; +import kr.co.pennyway.infra.common.jwt.JwtClaims; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java index 182c75235..ff04b0366 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenProvider.java @@ -4,14 +4,14 @@ import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; -import kr.co.infra.common.exception.JwtErrorCode; -import kr.co.infra.common.exception.JwtErrorException; -import kr.co.infra.common.jwt.JwtClaims; -import kr.co.infra.common.jwt.JwtProvider; -import kr.co.infra.common.util.JwtErrorCodeUtil; import kr.co.pennyway.api.common.annotation.RefreshTokenStrategy; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; import kr.co.pennyway.common.util.DateUtil; +import kr.co.pennyway.infra.common.exception.JwtErrorCode; +import kr.co.pennyway.infra.common.exception.JwtErrorException; +import kr.co.pennyway.infra.common.jwt.JwtClaims; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import kr.co.pennyway.infra.common.util.JwtErrorCodeUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/AuthControllerValidationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java similarity index 95% rename from pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/AuthControllerValidationTest.java rename to pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java index 3ada86cc9..832be89eb 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/AuthControllerValidationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java @@ -1,7 +1,6 @@ -package kr.co.pennyway.api.apis.auth; +package kr.co.pennyway.api.apis.auth.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import kr.co.pennyway.api.apis.auth.controller.AuthController; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; import kr.co.pennyway.api.apis.auth.usecase.AuthUseCase; import kr.co.pennyway.api.common.security.jwt.Jwts; @@ -48,7 +47,7 @@ void requiredInputError() throws Exception { // when ResultActions resultActions = mockMvc.perform( - post("/api/v1/auth/sign-up") + post("/v1/auth/sign-up") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ); @@ -72,7 +71,7 @@ void idValidError() throws Exception { // when ResultActions resultActions = mockMvc.perform( - post("/api/v1/auth/sign-up") + post("/v1/auth/sign-up") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ); @@ -92,7 +91,7 @@ void nameValidError() throws Exception { // when ResultActions resultActions = mockMvc.perform( - post("/api/v1/auth/sign-up") + post("/v1/auth/sign-up") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ); @@ -112,7 +111,7 @@ void passwordValidError() throws Exception { // when ResultActions resultActions = mockMvc.perform( - post("/api/v1/auth/sign-up") + post("/v1/auth/sign-up") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ); @@ -132,7 +131,7 @@ void phoneValidError() throws Exception { // when ResultActions resultActions = mockMvc.perform( - post("/api/v1/auth/sign-up") + post("/v1/auth/sign-up") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ); @@ -152,7 +151,7 @@ void codeValidError() throws Exception { // when ResultActions resultActions = mockMvc.perform( - post("/api/v1/auth/sign-up") + post("/v1/auth/sign-up") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ); @@ -172,7 +171,7 @@ void someFieldMissingError() throws Exception { // when ResultActions resultActions = mockMvc.perform( - post("/api/v1/auth/sign-up") + post("/v1/auth/sign-up") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request) .replace("\"username\":\"pennyway\",", "") @@ -200,7 +199,7 @@ void signUp() throws Exception { // when ResultActions resultActions = mockMvc.perform( - post("/api/v1/auth/sign-up") + post("/v1/auth/sign-up") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java new file mode 100644 index 000000000..12f01b378 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java @@ -0,0 +1,65 @@ +package kr.co.pennyway.api.apis.auth.helper; + +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +public class UserSyncHelperTest { + private final String phone = "010-1234-5678"; + private UserSyncHelper userSyncHelper; + @Mock + private UserService userService; + + @BeforeEach + void setUp() { + userSyncHelper = new UserSyncHelper(userService); + } + + @DisplayName("일반 회원가입 시, 회원 정보가 없으면 FALSE를 반환한다.") + @Test + void isSignedUserWhenGeneralReturnFalse() { + // given + given(userService.readUserByPhone(phone)).willThrow(new UserErrorException(UserErrorCode.NOT_FOUND)); + + // when + Boolean result = userSyncHelper.isSignedUserWhenGeneral(phone); + + // then + assertEquals(result, Boolean.FALSE); + } + + @DisplayName("일반 회원가입 시, oauth 회원 정보가 있으면 TRUE를 반환한다.") + @Test + void isSignedUserWhenGeneralReturnTrue() { + // given + given(userService.readUserByPhone(phone)).willReturn(User.builder().password(null).build()); + + // when + Boolean result = userSyncHelper.isSignedUserWhenGeneral(phone); + + // then + assertEquals(result, Boolean.TRUE); + } + + @DisplayName("일반 회원가입 시, 이미 일반회원 가입된 회원인 경우 UserErrorException을 발생시킨다.") + @Test + void isSignedUserWhenGeneralThrowUserErrorException() { + // given + given(userService.readUserByPhone(phone)).willReturn(User.builder().password("password").build()); + + // when - then + UserErrorException exception = org.junit.jupiter.api.Assertions.assertThrows(UserErrorException.class, () -> userSyncHelper.isSignedUserWhenGeneral(phone)); + System.out.println(exception.getExplainError()); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenProviderTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenProviderTest.java index 9ef5202af..9eb3fa348 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenProviderTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenProviderTest.java @@ -1,9 +1,9 @@ package kr.co.pennyway.api.common.security.jwt.access; -import kr.co.infra.common.exception.JwtErrorCode; -import kr.co.infra.common.exception.JwtErrorException; -import kr.co.infra.common.jwt.JwtClaims; -import kr.co.infra.common.jwt.JwtProvider; +import kr.co.pennyway.infra.common.exception.JwtErrorCode; +import kr.co.pennyway.infra.common.exception.JwtErrorException; +import kr.co.pennyway.infra.common.jwt.JwtClaims; +import kr.co.pennyway.infra.common.jwt.JwtProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/pennyway-domain/build.gradle b/pennyway-domain/build.gradle index 01ef28387..36e6a3717 100644 --- a/pennyway-domain/build.gradle +++ b/pennyway-domain/build.gradle @@ -1,5 +1,5 @@ -bootJar {enabled = false} -jar {enabled = true} +bootJar { enabled = false } +jar { enabled = true } dependencies { implementation project(':pennyway-common') @@ -19,6 +19,7 @@ dependencies { /* Redis */ implementation 'org.springframework.boot:spring-boot-starter-data-redis' + testImplementation group: 'org.testcontainers', name: 'testcontainers', version: '1.17.2' } def querydslDir = 'src/main/generated' @@ -37,4 +38,10 @@ tasks.withType(JavaCompile).configureEach { clean.doLast { file(querydslDir).deleteDir() +} + +configurations.configureEach { + exclude group: 'commons-logging', module: 'commons-logging' + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' + exclude group: 'ch.qos.logback', module: 'logback-classic' } \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationRepository.java new file mode 100644 index 000000000..9afcb5643 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationRepository.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.domain.common.redis.phone; + +import kr.co.pennyway.domain.common.annotation.DomainRedisTemplate; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Objects; + +@Repository +public class PhoneVerificationRepository { + private final RedisTemplate redisTemplate; + + public PhoneVerificationRepository(@DomainRedisTemplate RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public LocalDateTime save(String phone, String code, PhoneVerificationType codeType) { + LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(5); + redisTemplate.opsForValue().set(codeType.getPrefix() + ":" + phone, code, Duration.between(LocalDateTime.now(), expiresAt)); + return expiresAt; + } + + public String findCodeByPhone(String phone, PhoneVerificationType codeType) throws NullPointerException { + return Objects.requireNonNull(redisTemplate.opsForValue().get(codeType.getPrefix() + ":" + phone)).toString(); + } + + public void extendTimeToLeave(String phone, PhoneVerificationType codeType) { + redisTemplate.expire(codeType.getPrefix() + ":" + phone, Duration.ofMinutes(5)); + } + + public void delete(String phone, PhoneVerificationType codeType) { + redisTemplate.opsForValue().getAndDelete(codeType.getPrefix() + ":" + phone); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationService.java new file mode 100644 index 000000000..3ac886b46 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationService.java @@ -0,0 +1,65 @@ +package kr.co.pennyway.domain.common.redis.phone; + +import kr.co.pennyway.common.annotation.DomainService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class PhoneVerificationService { + private final PhoneVerificationRepository phoneVerificationRepository; + + /** + * 휴대폰 번호와 코드를 저장한다. (5분간 유효) + *
+ * redis에 저장되는 key는 codeType:phone, value는 code이다. + * + * @param phone String : 휴대폰 번호 + * @param code String : 6자리 정수 코드 + * @param codeType {@link PhoneVerificationType} : 코드 타입 + * @return LocalDateTime : 만료 시간 + */ + public LocalDateTime create(String phone, String code, PhoneVerificationType codeType) { + return phoneVerificationRepository.save(phone, code, codeType); + } + + /** + * 휴대폰 번호로 저장된 코드를 조회한다. + * + * @param phone String : 휴대폰 번호 + * @param codeType {@link PhoneVerificationType} : 코드 타입 + * @return String : 6자리 정수 코드 + * @throws IllegalArgumentException : 코드가 없을 경우 + */ + public String readByPhone(String phone, PhoneVerificationType codeType) throws IllegalArgumentException { + try { + return phoneVerificationRepository.findCodeByPhone(phone, codeType); + } catch (NullPointerException e) { + log.error("{}:{}에 해당하는 키가 존재하지 않습니다.", phone, codeType); + throw new IllegalArgumentException(e); + } + } + + /** + * 휴대폰 번호로 저장된 데이터의 ttl을 5분으로 연장(롤백)한다. + * + * @param phone String : 휴대폰 번호 + * @param codeType {@link PhoneVerificationType} : 코드 타입 + */ + public void extendTimeToLeave(String phone, PhoneVerificationType codeType) { + phoneVerificationRepository.extendTimeToLeave(phone, codeType); + } + + /** + * 휴대폰 번호로 저장된 코드를 삭제한다. + * + * @param phone String : 휴대폰 번호 + * @param codeType {@link PhoneVerificationType} : 코드 타입 + */ + public void delete(String phone, PhoneVerificationType codeType) { + phoneVerificationRepository.delete(phone, codeType); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationType.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationType.java new file mode 100644 index 000000000..601633c21 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationType.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.domain.common.redis.phone; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PhoneVerificationType { + SIGN_UP("signUp"), OAUTH_SIGN_UP("oauthSignUp"), FIND_USERNAME("username"), FIND_PASSWORD("password"); + + private final String prefix; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java index d09fb95d4..cd445db49 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java @@ -7,6 +7,7 @@ import org.springframework.cache.CacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; @@ -50,6 +51,7 @@ public RedisConnectionFactory redisConnectionFactory() { } @Bean + @Primary @DomainRedisTemplate public RedisTemplate redisTemplate() { RedisTemplate template = new RedisTemplate<>(); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java index fe2f86413..edd688346 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java @@ -9,27 +9,27 @@ @Embeddable @NoArgsConstructor(access = AccessLevel.PROTECTED) @DynamicInsert -@ToString(of = {"accountBookNotify", "feedNotify", "feedCommentNotify"}) +@ToString(of = {"accountBookNotify", "feedNotify", "chatNotify"}) public class NotifySetting { @ColumnDefault("true") private Boolean accountBookNotify; @ColumnDefault("true") private Boolean feedNotify; @ColumnDefault("true") - private Boolean feedCommentNotify; + private Boolean chatNotify; @Builder - private NotifySetting(Boolean accountBookNotify, Boolean feedNotify, Boolean feedCommentNotify) { + private NotifySetting(Boolean accountBookNotify, Boolean feedNotify, Boolean chatNotify) { this.accountBookNotify = accountBookNotify; this.feedNotify = feedNotify; - this.feedCommentNotify = feedCommentNotify; + this.chatNotify = chatNotify; } - public static NotifySetting of(Boolean accountBookNotify, Boolean feedNotify, Boolean feedCommentNotify) { + public static NotifySetting of(Boolean accountBookNotify, Boolean feedNotify, Boolean chatNotify) { return NotifySetting.builder() .accountBookNotify(accountBookNotify) .feedNotify(feedNotify) - .feedCommentNotify(feedCommentNotify) + .chatNotify(chatNotify) .build(); } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java index 439c9e160..c969b4cc6 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java @@ -3,5 +3,8 @@ import kr.co.pennyway.domain.domains.user.domain.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface UserRepository extends JpaRepository { + Optional findByPhone(String phone); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java index 12a75c703..a3026d91e 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java @@ -23,6 +23,11 @@ public User readUser(Long id) { return userRepository.findById(id).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); } + @Transactional(readOnly = true) + public User readUserByPhone(String phone) { + return userRepository.findByPhone(phone).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + } + @Transactional(readOnly = true) public boolean isExistUser(Long id) { return userRepository.existsById(id); diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/phone/PhoneValidationDaoTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/phone/PhoneValidationDaoTest.java new file mode 100644 index 000000000..1ff6c010b --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/phone/PhoneValidationDaoTest.java @@ -0,0 +1,83 @@ +package kr.co.pennyway.domain.common.redis.phone; + +import kr.co.pennyway.domain.config.ContainerRedisTestConfig; +import kr.co.pennyway.domain.config.RedisConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("휴대폰 검증 Redis 서비스 테스트") +@SpringBootTest(classes = {PhoneVerificationRepository.class, RedisConfig.class}) +@ActiveProfiles("local") +public class PhoneValidationDaoTest extends ContainerRedisTestConfig { + @Autowired + private PhoneVerificationRepository phoneVerificationRepository; + private String phone; + private String code; + private PhoneVerificationType codeType; + + @BeforeEach + void setUp() { + phone = "01012345678"; + code = "123456"; + codeType = PhoneVerificationType.SIGN_UP; + } + + @AfterEach + void tearDown() { + phoneVerificationRepository.delete(phone, codeType); + } + + @Test + @DisplayName("Redis에 데이터를 저장하면 {'codeType:phone':code}로 데이터가 저장된다.") + void codeSaveTest() { + // given + phoneVerificationRepository.save(phone, code, codeType); + + // when + String savedCode = phoneVerificationRepository.findCodeByPhone(phone, codeType); + + // then + assertEquals(code, savedCode); + System.out.println("savedCode = " + savedCode); + } + + @Test + @DisplayName("Redis에 'codeType:phone'에 해당하는 값이 없으면 NullPointerException이 발생한다.") + void codeReadError() { + // given + phoneVerificationRepository.delete(phone, codeType); + String wrongPhone = "01087654321"; + + // when - then + assertThrows(NullPointerException.class, () -> phoneVerificationRepository.findCodeByPhone(wrongPhone, codeType)); + } + + @Test + @DisplayName("Redis에 저장된 데이터를 삭제하면 해당 데이터가 삭제된다.") + void codeRemoveTest() { + // given + phoneVerificationRepository.save(phone, code, codeType); + + // when + phoneVerificationRepository.delete(phone, codeType); + + // then + assertThrows(NullPointerException.class, () -> phoneVerificationRepository.findCodeByPhone(phone, codeType)); + } + + @Test + @DisplayName("저장되지 않은 데이터를 삭제해도 에러가 발생하지 않는다.") + void codeRemoveError() { + // when - thengi + assertThrows(NullPointerException.class, () -> phoneVerificationRepository.findCodeByPhone(phone, codeType)); + phoneVerificationRepository.delete(phone, codeType); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerRedisTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerRedisTestConfig.java new file mode 100644 index 000000000..f21f88613 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerRedisTestConfig.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.domain.config; + +import org.junit.jupiter.api.DisplayName; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +@DisplayName("Container Redis 설정") +public abstract class ContainerRedisTestConfig { + private static final String REDIS_CONTAINER_NAME = "redis:7.2.4-alpine"; + private static final GenericContainer REDIS_CONTAINER; + + static { + REDIS_CONTAINER = + new GenericContainer<>(DockerImageName.parse(REDIS_CONTAINER_NAME)) + .withExposedPorts(6379) + .withReuse(true); + + REDIS_CONTAINER.start(); + } + + @DynamicPropertySource + public static void setRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(6379))); + } +} diff --git a/pennyway-domain/src/test/resources/application-domain.yml b/pennyway-domain/src/test/resources/application-domain.yml new file mode 100644 index 000000000..f77483637 --- /dev/null +++ b/pennyway-domain/src/test/resources/application-domain.yml @@ -0,0 +1,8 @@ +spring: + config: + activate: + on-profile: test + + data.redis: + host: 127.0.0.1 + port: 6379 \ No newline at end of file diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/PennywayInfraApplication.java b/pennyway-infra/src/main/java/kr/co/pennyway/PennywayInfraApplication.java new file mode 100644 index 000000000..8adc2e7b2 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/PennywayInfraApplication.java @@ -0,0 +1,7 @@ +package kr.co.pennyway; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PennywayInfraApplication { +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/AwsSmsProvider.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/AwsSmsProvider.java new file mode 100644 index 000000000..11100e1a0 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/AwsSmsProvider.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.infra.client.aws.sms; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.concurrent.ThreadLocalRandom; + +// TODO: AWS SNS 인프라 설정 후 내부 구현 +@Slf4j +@Component +@RequiredArgsConstructor +public class AwsSmsProvider implements SmsProvider { + @Override + public SmsDto.Info sendCode(SmsDto.To dto) { + String code = issueVerificationCode(); + SmsDto.Response response = SmsDto.Response.builder().requestAt(LocalDateTime.now()).build(); + return SmsDto.Info.from(response, code); + } + + private String issueVerificationCode() { + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < 6; i++) { + sb.append(ThreadLocalRandom.current().nextInt(0, 10)); + } + return sb.toString(); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsDto.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsDto.java new file mode 100644 index 000000000..9822143f1 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsDto.java @@ -0,0 +1,100 @@ +package kr.co.pennyway.infra.client.aws.sms; + +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; + +// FIXME: Naver Cloud Platform Snes 기준 DTO. AWS SNS 요청, 응답 포맷에 맞게 수정 필요 +public class SmsDto { + public record To( + String phone + ) { + /** + * @param phone String : SMS 인증 요청을 할 전화번호 + */ + public static To of(String phone) { + return new To(phone); + } + } + + @Builder + public record Request( + String type, + String contentType, + String countryCode, + String from, + String content, + List messages + ) { + /** + * AWS SNS API 요청 객체 생성 + * + * @param type String : SMS | LMS | MMS + * @param contentType String : COMM | AD + * @param countryCode String : 국가번호 + * @param from String : 발신번호 + * @param content String : 메시지 내용 + * @param messages List<{@link To}> : 메시지 정보 (to: 수신번호, subject: 개별 메시지 제목, content: 개별 메시지 내용) + */ + public static Request of(String type, String contentType, String countryCode, String from, String content, List messages) { + return Request.builder() + .type(type) + .contentType(contentType) + .countryCode(countryCode) + .from(from) + .content(content) + .messages(messages) + .build(); + } + } + + /** + * AWS SNS API 요청에 대한 응답 객체 + * + * @param requestId String : 요청 ID + * @param requestAt LocalDateTime : 요청 시간 + * @param statusCode String : 응답 코드 + * @param statusName String : 응답 상태 + */ + @Builder + public record Response( + String requestId, + LocalDateTime requestAt, + String statusCode, + String statusName + ) { + } + + /** + * 인증번호 전송 정보를 확인할 수 있는 DTO + * + * @param requestId String : 요청 ID (NCP SMS API 요청 시 발급된 요청 ID) + * @param code String : 발급된 인증번호 정수 6자리 문자열 + * @param requestAt LocalDateTime : 요청 시간 + * @param statusCode String : 응답 코드 + * @param statusName String : 응답 상태 + */ + @Builder + public record Info( + String requestId, + String code, + LocalDateTime requestAt, + String statusCode, + String statusName + ) { + /** + * @param request {@link Response} + * @param code String : 인증 코드 정수 6자리 문자열 + */ + public static Info from(Response request, String code) { + return Info.builder() + .requestId(request.requestId()) + .code(code) + .requestAt(request.requestAt()) + .statusCode(request.statusCode()) + .statusName(request.statusName()) + .build(); + } + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsProvider.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsProvider.java new file mode 100644 index 000000000..41e9b0959 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsProvider.java @@ -0,0 +1,11 @@ +package kr.co.pennyway.infra.client.aws.sms; + +public interface SmsProvider { + /** + * 인증번호를 수신자에게 SMS로 전송 + * + * @param dto {@link SmsDto.To} : 수신자 번호 + * @return {@link SmsDto.Info} : SNS 전송 정보 + */ + SmsDto.Info sendCode(SmsDto.To dto); +} diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/annotation/InfraRedisConnectionFactory.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/annotation/InfraRedisConnectionFactory.java similarity index 88% rename from pennyway-infra/src/main/java/kr/co/infra/common/annotation/InfraRedisConnectionFactory.java rename to pennyway-infra/src/main/java/kr/co/pennyway/infra/common/annotation/InfraRedisConnectionFactory.java index 64aaa4015..1de2931bb 100644 --- a/pennyway-infra/src/main/java/kr/co/infra/common/annotation/InfraRedisConnectionFactory.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/annotation/InfraRedisConnectionFactory.java @@ -1,4 +1,4 @@ -package kr.co.infra.common.annotation; +package kr.co.pennyway.infra.common.annotation; import org.springframework.beans.factory.annotation.Qualifier; diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/annotation/OidcCacheManager.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/annotation/OidcCacheManager.java similarity index 87% rename from pennyway-infra/src/main/java/kr/co/infra/common/annotation/OidcCacheManager.java rename to pennyway-infra/src/main/java/kr/co/pennyway/infra/common/annotation/OidcCacheManager.java index 4f8f149e7..58eb56c39 100644 --- a/pennyway-infra/src/main/java/kr/co/infra/common/annotation/OidcCacheManager.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/annotation/OidcCacheManager.java @@ -1,4 +1,4 @@ -package kr.co.infra.common.annotation; +package kr.co.pennyway.infra.common.annotation; import org.springframework.beans.factory.annotation.Qualifier; diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/annotation/SecurityUserCacheManager.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/annotation/SecurityUserCacheManager.java similarity index 88% rename from pennyway-infra/src/main/java/kr/co/infra/common/annotation/SecurityUserCacheManager.java rename to pennyway-infra/src/main/java/kr/co/pennyway/infra/common/annotation/SecurityUserCacheManager.java index 24e30ecc5..a58130ba8 100644 --- a/pennyway-infra/src/main/java/kr/co/infra/common/annotation/SecurityUserCacheManager.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/annotation/SecurityUserCacheManager.java @@ -1,4 +1,4 @@ -package kr.co.infra.common.annotation; +package kr.co.pennyway.infra.common.annotation; import org.springframework.beans.factory.annotation.Qualifier; diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/exception/DomainErrorCode.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/DomainErrorCode.java similarity index 88% rename from pennyway-infra/src/main/java/kr/co/infra/common/exception/DomainErrorCode.java rename to pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/DomainErrorCode.java index 4eb812545..4261463b1 100644 --- a/pennyway-infra/src/main/java/kr/co/infra/common/exception/DomainErrorCode.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/DomainErrorCode.java @@ -1,4 +1,4 @@ -package kr.co.infra.common.exception; +package kr.co.pennyway.infra.common.exception; import kr.co.pennyway.common.exception.DomainCode; import lombok.RequiredArgsConstructor; diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/exception/FieldErrorCode.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/FieldErrorCode.java similarity index 88% rename from pennyway-infra/src/main/java/kr/co/infra/common/exception/FieldErrorCode.java rename to pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/FieldErrorCode.java index 19d444bf9..beaacb45f 100644 --- a/pennyway-infra/src/main/java/kr/co/infra/common/exception/FieldErrorCode.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/FieldErrorCode.java @@ -1,4 +1,4 @@ -package kr.co.infra.common.exception; +package kr.co.pennyway.infra.common.exception; import kr.co.pennyway.common.exception.FieldCode; import lombok.RequiredArgsConstructor; diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java similarity index 98% rename from pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java rename to pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java index c632d2b5d..4568bd417 100644 --- a/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorCode.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java @@ -1,4 +1,4 @@ -package kr.co.infra.common.exception; +package kr.co.pennyway.infra.common.exception; import kr.co.pennyway.common.exception.BaseErrorCode; import kr.co.pennyway.common.exception.CausedBy; diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorException.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorException.java similarity index 91% rename from pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorException.java rename to pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorException.java index 831ccc499..cbc243e03 100644 --- a/pennyway-infra/src/main/java/kr/co/infra/common/exception/JwtErrorException.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorException.java @@ -1,4 +1,4 @@ -package kr.co.infra.common.exception; +package kr.co.pennyway.infra.common.exception; import kr.co.pennyway.common.exception.CausedBy; import kr.co.pennyway.common.exception.GlobalErrorException; diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/jwt/AuthConstants.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/jwt/AuthConstants.java similarity index 77% rename from pennyway-infra/src/main/java/kr/co/infra/common/jwt/AuthConstants.java rename to pennyway-infra/src/main/java/kr/co/pennyway/infra/common/jwt/AuthConstants.java index 7ce4c4aa2..8fa357c30 100644 --- a/pennyway-infra/src/main/java/kr/co/infra/common/jwt/AuthConstants.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/jwt/AuthConstants.java @@ -1,4 +1,4 @@ -package kr.co.infra.common.jwt; +package kr.co.pennyway.infra.common.jwt; import lombok.Getter; @@ -12,7 +12,8 @@ public enum AuthConstants { this.value = value; } - @Override public String toString() { + @Override + public String toString() { return "AuthConstants(value=" + this.value + ")"; } } diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/jwt/JwtClaims.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/jwt/JwtClaims.java similarity index 67% rename from pennyway-infra/src/main/java/kr/co/infra/common/jwt/JwtClaims.java rename to pennyway-infra/src/main/java/kr/co/pennyway/infra/common/jwt/JwtClaims.java index f04ba8e6f..900cfdb85 100644 --- a/pennyway-infra/src/main/java/kr/co/infra/common/jwt/JwtClaims.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/jwt/JwtClaims.java @@ -1,4 +1,4 @@ -package kr.co.infra.common.jwt; +package kr.co.pennyway.infra.common.jwt; import java.util.Map; diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/jwt/JwtProvider.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/jwt/JwtProvider.java similarity index 95% rename from pennyway-infra/src/main/java/kr/co/infra/common/jwt/JwtProvider.java rename to pennyway-infra/src/main/java/kr/co/pennyway/infra/common/jwt/JwtProvider.java index 9798985d2..1e08f721a 100644 --- a/pennyway-infra/src/main/java/kr/co/infra/common/jwt/JwtProvider.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/jwt/JwtProvider.java @@ -1,4 +1,4 @@ -package kr.co.infra.common.jwt; +package kr.co.pennyway.infra.common.jwt; import io.jsonwebtoken.Claims; @@ -9,6 +9,7 @@ public interface JwtProvider { /** * 헤더로부터 토큰을 추출하고 유효성을 검사하는 메서드 + * * @param authHeader : 메시지 헤더 * @return 값이 있다면 토큰, 없다면 빈 문자열 (빈 문자열을 반환하는 경우 예외 처리를 해주어야 한다.) */ @@ -21,6 +22,7 @@ default String resolveToken(String authHeader) { /** * 토큰을 생성하는 메서드 + * * @param subs {@link JwtClaims} : 토큰 payload에 담을 정보 * @return String : 토큰 */ @@ -28,6 +30,7 @@ default String resolveToken(String authHeader) { /** * 토큰으로 부터 payload를 추출하여 JwtClaims 객체로 반환하는 메서드 + * * @param token String : 토큰 * @return {@link JwtClaims} : 사용자 정보 */ @@ -35,6 +38,7 @@ default String resolveToken(String authHeader) { /** * 토큰의 만료일을 추출하는 메서드 + * * @param token String : 토큰 * @return LocalDateTime : 만료일 */ @@ -42,12 +46,14 @@ default String resolveToken(String authHeader) { /** * 토큰의 만료 여부를 검사하는 메서드 + * * @param token String : 토큰 */ boolean isTokenExpired(String token); /** * 토큰으로부터 payload 정보를 추출하는 메서드 + * * @param token String : 토큰 * @return Claims : 사용자 정보 */ diff --git a/pennyway-infra/src/main/java/kr/co/infra/common/util/JwtErrorCodeUtil.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/util/JwtErrorCodeUtil.java similarity index 91% rename from pennyway-infra/src/main/java/kr/co/infra/common/util/JwtErrorCodeUtil.java rename to pennyway-infra/src/main/java/kr/co/pennyway/infra/common/util/JwtErrorCodeUtil.java index 15819d82d..84fd997d7 100644 --- a/pennyway-infra/src/main/java/kr/co/infra/common/util/JwtErrorCodeUtil.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/util/JwtErrorCodeUtil.java @@ -1,11 +1,11 @@ -package kr.co.infra.common.util; +package kr.co.pennyway.infra.common.util; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.security.SignatureException; -import kr.co.infra.common.exception.JwtErrorCode; -import kr.co.infra.common.exception.JwtErrorException; +import kr.co.pennyway.infra.common.exception.JwtErrorCode; +import kr.co.pennyway.infra.common.exception.JwtErrorException; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -27,7 +27,7 @@ public class JwtErrorCodeUtil { /** * 예외에 해당하는 오류 코드를 반환하거나 기본 오류 코드를 반환합니다. * - * @param exception {@link Exception} : 발생한 예외 + * @param exception {@link Exception} : 발생한 예외 * @param defaultErrorCode {@link JwtErrorCode} : 기본 오류 코드 * @return {@link JwtErrorException} */ diff --git a/pennyway-infra/src/main/java/kr/co/infra/config/CacheConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java similarity index 95% rename from pennyway-infra/src/main/java/kr/co/infra/config/CacheConfig.java rename to pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java index bf207b54f..4c51e7a59 100644 --- a/pennyway-infra/src/main/java/kr/co/infra/config/CacheConfig.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java @@ -1,8 +1,8 @@ -package kr.co.infra.config; +package kr.co.pennyway.infra.config; -import kr.co.infra.common.annotation.InfraRedisConnectionFactory; -import kr.co.infra.common.annotation.OidcCacheManager; -import kr.co.infra.common.annotation.SecurityUserCacheManager; +import kr.co.pennyway.infra.common.annotation.InfraRedisConnectionFactory; +import kr.co.pennyway.infra.common.annotation.OidcCacheManager; +import kr.co.pennyway.infra.common.annotation.SecurityUserCacheManager; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; From 1b41db4a25aaf00aebcf446b7fe5b5ff0157bb1f Mon Sep 17 00:00:00 2001 From: Jinwoo Lee Date: Tue, 26 Mar 2024 14:47:59 +0900 Subject: [PATCH 029/152] =?UTF-8?q?CI/CD=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EA=B5=AC=EC=B6=95=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ci workflow 작성 * feat: cd workflow 작성 * fix: mysql actions step 삭제 * fix: docker image 태그 제거 * fix: cd 파이프라인 trigger 브랜치명 수정(develop->dev) --- .github/workflows/deploy.yml | 68 ++++++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 49 ++++++++++++++++++++++++++ gradlew | 0 3 files changed, 117 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/test.yml mode change 100644 => 100755 gradlew diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..4fe85d304 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,68 @@ +name: Continuous Deployment + +on: + push: + branches: [ "dev" ] + workflow_dispatch: + inputs: + logLevel: + description: 'Log level' + required: true + default: 'warning' + type: choice + options: + - info + - warning + - debug + tags: + description: 'Test scenario tags' + required: false + type: boolean + environment: + description: 'Environment to run tests against' + type: environment + required: false + +permissions: + contents: read + +jobs: + deployment: + runs-on: ubuntu-20.04 + + steps: + # 1. Compare branch 코드 내려 받기 + - name: Checkout PR + uses: actions/checkout@v3 + with: + ref: ${{ github.event.push.base_ref }} + + # 2. 자바 환경 설정 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + # 3. Docker 이미지 build 및 push + - name: docker build and push + run: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker build -t jinlee1703/pennyway-was . + docker push jinlee1703/pennyway-was + + # 4. AWS SSM을 통한 Run-Command (Docker 이미지 pull 후 docker-compose를 통한 실행) + - name: AWS SSM Send-Command + uses: peterkimzz/aws-ssm-send-command@master + id: ssm + with: + aws-region: ${{ secrets.AWS_REGION }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + instance-ids: ${{ secrets.AWS_DEV_INSTANCE_ID }} + working-directory: /home/ubuntu + command: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker system prune -a -f + docker pull jinlee1703/pennyway-was + docker-compose up -d \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..200219117 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,49 @@ +name: Continuous Integration + +on: + pull_request: + branches: [ "dev" ] + workflow_dispatch: + inputs: + logLevel: + description: 'Log level' + required: true + default: 'warning' + type: choice + options: + - info + - warning + - debug + tags: + description: 'Test scenario tags' + required: false + type: boolean + environment: + description: 'Environment to run tests against' + type: environment + required: false + +permissions: + contents: read + +jobs: + testing: + runs-on: ubuntu-20.04 + + steps: + # 1. Compare branch 코드 내려 받기 + - name: Checkout PR + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.ref }} + + # 2. 자바 환경 설정 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + # 3. Gradle Test 실행 + - name: Test with Gradle + run: ./gradlew --info test \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From e09ab66d38e76d6240b1bf07f3854b3b3a27c122 Mon Sep 17 00:00:00 2001 From: Jinwoo Lee Date: Tue, 26 Mar 2024 23:18:55 +0900 Subject: [PATCH 030/152] =?UTF-8?q?CD=20Workflow=20=EC=88=98=EC=A0=95=20(#?= =?UTF-8?q?21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ci workflow 작성 * feat: cd workflow 작성 * fix: mysql actions step 삭제 * fix: docker image 태그 제거 * fix: cd 파이프라인 trigger 브랜치명 수정(develop->dev) * fix: gradle build 과정 추가 * fix: gradle build 과정 추가 * fix: cd 파이프라인 임시 수정 * fix: cd 파이프라인 임시 수정 사항 삭제 * fix: gradlew 권한 수정 * fix: 테스트 실패 오류 해결 --- .github/workflows/deploy.yml | 11 +- .github/workflows/test.yml | 4 +- build.gradle | 81 ++-- .../api/apis/auth/helper/UserSyncHelper.java | 44 +- .../AuthControllerValidationTest.java | 395 +++++++++--------- .../apis/auth/helper/UserSyncHelperTest.java | 84 ++-- .../common/response/SuccessResponseTest.java | 133 +++--- .../co/pennyway/PennywayInfraApplication.java | 4 +- 8 files changed, 396 insertions(+), 360 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4fe85d304..afaf055b2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -44,14 +44,21 @@ jobs: java-version: '17' distribution: 'temurin' - # 3. Docker 이미지 build 및 push + # 3. Build Gradle + - name: Build Gradle + run: | + chmod +x ./gradlew + ./gradlew build --stacktrace --info -x test + shell: bash + + # 4. Docker 이미지 build 및 push - name: docker build and push run: | docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} docker build -t jinlee1703/pennyway-was . docker push jinlee1703/pennyway-was - # 4. AWS SSM을 통한 Run-Command (Docker 이미지 pull 후 docker-compose를 통한 실행) + # 5. AWS SSM을 통한 Run-Command (Docker 이미지 pull 후 docker-compose를 통한 실행) - name: AWS SSM Send-Command uses: peterkimzz/aws-ssm-send-command@master id: ssm diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 200219117..db9a9519d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,4 +46,6 @@ jobs: # 3. Gradle Test 실행 - name: Test with Gradle - run: ./gradlew --info test \ No newline at end of file + run: | + chmod +x ./gradlew + ./gradlew --info test \ No newline at end of file diff --git a/build.gradle b/build.gradle index 39cad6583..1ccc1e45d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,50 +1,61 @@ buildscript { - repositories { - mavenCentral() - } + repositories { + mavenCentral() + } } plugins { - id 'java' - id 'org.springframework.boot' version '3.2.3' - id 'io.spring.dependency-management' version '1.1.4' + id 'java' + id 'org.springframework.boot' version '3.2.3' + id 'io.spring.dependency-management' version '1.1.4' } group = 'kr.co' version = '0.0.1-SNAPSHOT' -bootJar {enabled = false} -jar {enabled = true} +bootJar { enabled = false } +jar { enabled = true } allprojects { - group = 'kr.co' - version = '0.0.1-SNAPSHOT' - sourceCompatibility = '17' + group = 'kr.co' + version = '0.0.1-SNAPSHOT' + sourceCompatibility = '17' } subprojects { - apply plugin: "java" - apply plugin: 'java-library' - apply plugin: "io.spring.dependency-management" - apply plugin: "org.springframework.boot" - - repositories { - mavenCentral() - } - - configurations { - compileOnly { - extendsFrom annotationProcessor - } - } - - dependencies { - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" - - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testCompileOnly 'org.projectlombok:lombok' - testAnnotationProcessor 'org.projectlombok:lombok' - } + apply plugin: "java" + apply plugin: 'java-library' + apply plugin: "io.spring.dependency-management" + apply plugin: "org.springframework.boot" + + repositories { + mavenCentral() + } + + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } + + dependencies { + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + } + + test { + useJUnitPlatform() + testLogging { + showStandardStreams = true + showCauses = true + showExceptions = true + showStackTraces = true + exceptionFormat = 'full' + } + } } \ No newline at end of file diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java index 8dce3c4fe..1fd1dc45d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java @@ -14,29 +14,31 @@ @Helper @RequiredArgsConstructor public class UserSyncHelper { - private final UserService userService; - /** - * 일반 회원가입 시 이미 가입된 회원인지 확인 - * - * @param phone String : 전화번호 - * @return Pair : 이미 가입된 회원인지 여부 (TRUE: 가입되지 않은 회원, FALSE: 가입된 회원), 가입된 회원인 경우 회원 ID 반환 - * @throws UserErrorException : 이미 일반 회원가입을 한 유저인 경우 - */ - public Pair isSignedUserWhenGeneral(String phone) { - User user; - try { - user = userService.readUserByPhone(phone); - } catch (GlobalErrorException e) { - log.info("User not found. phone: {}", phone); - return Pair.of(Boolean.TRUE, null); - } + private final UserService userService; - if (user.getPassword() != null) { - log.warn("User already exists. phone: {}", phone); - throw new UserErrorException(UserErrorCode.ALREADY_SIGNUP); - } + /** + * 일반 회원가입 시 이미 가입된 회원인지 확인 + * + * @param phone String : 전화번호 + * @return Pair : 이미 가입된 회원인지 여부 (TRUE: 가입되지 않은 회원, FALSE: 가입된 회원), 가입된 회원인 경우 회원 + * ID 반환 + * @throws UserErrorException : 이미 일반 회원가입을 한 유저인 경우 + */ + public Pair isSignedUserWhenGeneral(String phone) { + User user; + try { + user = userService.readUserByPhone(phone); + } catch (GlobalErrorException e) { + log.info("User not found. phone: {}", phone); + return Pair.of(Boolean.FALSE, null); + } - return Pair.of(Boolean.FALSE, user.getUsername()); + if (user.getPassword() != null) { + log.warn("User already exists. phone: {}", phone); + throw new UserErrorException(UserErrorCode.ALREADY_SIGNUP); } + + return Pair.of(Boolean.TRUE, user.getUsername()); + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java index 832be89eb..ddf906023 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java @@ -1,6 +1,14 @@ package kr.co.pennyway.api.apis.auth.controller; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; import kr.co.pennyway.api.apis.auth.usecase.AuthUseCase; import kr.co.pennyway.api.common.security.jwt.Jwts; @@ -17,199 +25,202 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; -import java.time.Duration; - -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - @WebMvcTest(controllers = {AuthController.class}) @ActiveProfiles("local") public class AuthControllerValidationTest { - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @MockBean - private AuthUseCase authUseCase; - - @MockBean - private CookieUtil cookieUtil; - - @DisplayName("[1] 아이디, 이름, 비밀번호, 전화번호, 인증번호 필수 입력") - @Test - void requiredInputError() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("", "", "", "", ""); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.username").exists()) - .andExpect(jsonPath("$.fieldErrors.name").exists()) - .andExpect(jsonPath("$.fieldErrors.password").exists()) - .andExpect(jsonPath("$.fieldErrors.phone").exists()) - .andExpect(jsonPath("$.fieldErrors.code").exists()) - .andDo(print()); - } - - @DisplayName("[2] 아이디는 5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.") - @Test - void idValidError() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("#pennyway", "페니웨이", "pennyway1234", "010-1234-5678", "123456"); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.username").value("5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.")) - .andDo(print()); - } - - @DisplayName("[3] 이름은 2~20자의 한글, 영문 대/소문자만 사용 가능합니다.") - @Test - void nameValidError() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이1", "pennyway1234", "010-1234-5678", "123456"); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.name").value("2~20자의 한글, 영문 대/소문자만 사용 가능합니다.")) - .andDo(print()); - } - - @DisplayName("[4] 비밀번호는 8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)") - @Test - void passwordValidError() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway", "010-1234-5678", "123456"); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.password").value("8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)")) - .andDo(print()); - } - - @DisplayName("[5] 전화번호는 010 혹은 011로 시작하는, 010-0000-0000 형식이어야 합니다.") - @Test - void phoneValidError() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", "01012345673", "123456"); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.phone").value("전화번호 형식이 올바르지 않습니다.")) - .andDo(print()); - } - - @DisplayName("[6] 인증번호는 6자리 숫자여야 합니다.") - @Test - void codeValidError() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", "010-1234-5678", "12345"); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.code").value("인증번호는 6자리 숫자여야 합니다.")) - .andDo(print()); - } - - @DisplayName("[7] 일부 필드 누락") - @Test - void someFieldMissingError() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", "010-1234-5678", "123456"); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request) - .replace("\"username\":\"pennyway\",", "") - .replace("\"phone\":\"010-1234-5678\",", ""))); - - // then - resultActions - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.username").value("아이디를 입력해주세요")) - .andExpect(jsonPath("$.fieldErrors.phone").value("전화번호를 입력해주세요")) - .andDo(print()); - } - - @DisplayName("[8] 정상적인 회원가입 요청 - 쿠키/인증 헤더와 회원 pk 반환") - @Test - void signUp() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", "010-1234-5678", "123456"); - ResponseCookie expectedCookie = ResponseCookie.from("refreshToken", "refreshToken").maxAge(Duration.ofDays(7).toSeconds()).httpOnly(true).path("/").build(); - - given(authUseCase.signUp(request)) - .willReturn(Pair.of(1L, Jwts.of("accessToken", "refreshToken"))); - given(cookieUtil.createCookie("refreshToken", "refreshToken", Duration.ofDays(7).toSeconds())) - .willReturn(expectedCookie); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(header().string("Set-Cookie", expectedCookie.toString())) - .andExpect(header().string("Authorization", "accessToken")) - .andExpect(jsonPath("$.data.user.id").value(1)) - .andDo(print()); - } + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private AuthUseCase authUseCase; + + @MockBean + private CookieUtil cookieUtil; + + @DisplayName("[1] 아이디, 이름, 비밀번호, 전화번호, 인증번호 필수 입력") + @Test + void requiredInputError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("", "", "", "", ""); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.username").exists()) + .andExpect(jsonPath("$.fieldErrors.name").exists()) + .andExpect(jsonPath("$.fieldErrors.password").exists()) + .andExpect(jsonPath("$.fieldErrors.phone").exists()) + .andExpect(jsonPath("$.fieldErrors.code").exists()) + .andDo(print()); + } + + @DisplayName("[2] 아이디는 5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.") + @Test + void idValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("#pennyway", "페니웨이", "pennyway1234", + "010-1234-5678", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.username").value("5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.")) + .andDo(print()); + } + + @DisplayName("[3] 이름은 2~20자의 한글, 영문 대/소문자만 사용 가능합니다.") + @Test + void nameValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이1", "pennyway1234", + "010-1234-5678", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.name").value("2~20자의 한글, 영문 대/소문자만 사용 가능합니다.")) + .andDo(print()); + } + + @DisplayName("[4] 비밀번호는 8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)") + @Test + void passwordValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway", + "010-1234-5678", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.password").value( + "8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)")) + .andDo(print()); + } + + @DisplayName("[5] 전화번호는 010 혹은 011로 시작하는, 010-0000-0000 형식이어야 합니다.") + @Test + void phoneValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", + "01012345673", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.phone").value("전화번호 형식이 올바르지 않습니다.")) + .andDo(print()); + } + + @DisplayName("[6] 인증번호는 6자리 숫자여야 합니다.") + @Test + void codeValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", + "010-1234-5678", "12345"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.code").value("인증번호는 6자리 숫자여야 합니다.")) + .andDo(print()); + } + + @DisplayName("[7] 일부 필드 누락") + @Test + void someFieldMissingError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", + "010-1234-5678", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request) + .replace("\"username\":\"pennyway\",", "") + .replace("\"phone\":\"010-1234-5678\",", ""))); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.username").value("아이디를 입력해주세요")) + .andExpect(jsonPath("$.fieldErrors.phone").value("전화번호를 입력해주세요")) + .andDo(print()); + } + + @DisplayName("[8] 정상적인 회원가입 요청 - 쿠키/인증 헤더와 회원 pk 반환") + @Test + void signUp() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", + "010-1234-5678", "123456"); + ResponseCookie expectedCookie = ResponseCookie.from("refreshToken", "refreshToken") + .maxAge(Duration.ofDays(7).toSeconds()).httpOnly(true).path("/").build(); + + given(authUseCase.signUp(request)) + .willReturn(Pair.of(1L, Jwts.of("accessToken", "refreshToken"))); + given(cookieUtil.createCookie("refreshToken", "refreshToken", Duration.ofDays(7).toSeconds())) + .willReturn(expectedCookie); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().string("Authorization", "accessToken")) + .andExpect(jsonPath("$.data.user.id").value(1)) + .andDo(print()); + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java index 12f01b378..4e04a32ba 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java @@ -1,5 +1,8 @@ package kr.co.pennyway.api.apis.auth.helper; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.BDDMockito.given; + import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; @@ -11,55 +14,56 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.BDDMockito.given; - @ExtendWith(MockitoExtension.class) public class UserSyncHelperTest { - private final String phone = "010-1234-5678"; - private UserSyncHelper userSyncHelper; - @Mock - private UserService userService; - @BeforeEach - void setUp() { - userSyncHelper = new UserSyncHelper(userService); - } + private final String phone = "010-1234-5678"; + private UserSyncHelper userSyncHelper; + @Mock + private UserService userService; + + @BeforeEach + void setUp() { + userSyncHelper = new UserSyncHelper(userService); + } - @DisplayName("일반 회원가입 시, 회원 정보가 없으면 FALSE를 반환한다.") - @Test - void isSignedUserWhenGeneralReturnFalse() { - // given - given(userService.readUserByPhone(phone)).willThrow(new UserErrorException(UserErrorCode.NOT_FOUND)); + @DisplayName("일반 회원가입 시, 회원 정보가 없으면 FALSE를 반환한다.") + @Test + void isSignedUserWhenGeneralReturnFalse() { + // given + given(userService.readUserByPhone(phone)).willThrow( + new UserErrorException(UserErrorCode.NOT_FOUND)); - // when - Boolean result = userSyncHelper.isSignedUserWhenGeneral(phone); + // when + Boolean result = userSyncHelper.isSignedUserWhenGeneral(phone).getKey(); - // then - assertEquals(result, Boolean.FALSE); - } + // then + assertEquals(result, Boolean.FALSE); + } - @DisplayName("일반 회원가입 시, oauth 회원 정보가 있으면 TRUE를 반환한다.") - @Test - void isSignedUserWhenGeneralReturnTrue() { - // given - given(userService.readUserByPhone(phone)).willReturn(User.builder().password(null).build()); + @DisplayName("일반 회원가입 시, oauth 회원 정보가 있으면 TRUE를 반환한다.") + @Test + void isSignedUserWhenGeneralReturnTrue() { + // given + given(userService.readUserByPhone(phone)).willReturn(User.builder().password(null).build()); - // when - Boolean result = userSyncHelper.isSignedUserWhenGeneral(phone); + // when + Boolean result = userSyncHelper.isSignedUserWhenGeneral(phone).getKey(); - // then - assertEquals(result, Boolean.TRUE); - } + // then + assertEquals(result, Boolean.TRUE); + } - @DisplayName("일반 회원가입 시, 이미 일반회원 가입된 회원인 경우 UserErrorException을 발생시킨다.") - @Test - void isSignedUserWhenGeneralThrowUserErrorException() { - // given - given(userService.readUserByPhone(phone)).willReturn(User.builder().password("password").build()); + @DisplayName("일반 회원가입 시, 이미 일반회원 가입된 회원인 경우 UserErrorException을 발생시킨다.") + @Test + void isSignedUserWhenGeneralThrowUserErrorException() { + // given + given(userService.readUserByPhone(phone)).willReturn( + User.builder().password("password").build()); - // when - then - UserErrorException exception = org.junit.jupiter.api.Assertions.assertThrows(UserErrorException.class, () -> userSyncHelper.isSignedUserWhenGeneral(phone)); - System.out.println(exception.getExplainError()); - } + // when - then + UserErrorException exception = org.junit.jupiter.api.Assertions.assertThrows( + UserErrorException.class, () -> userSyncHelper.isSignedUserWhenGeneral(phone)); + System.out.println(exception.getExplainError()); + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/response/SuccessResponseTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/response/SuccessResponseTest.java index 8e9e44ba9..28fdf2b69 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/response/SuccessResponseTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/response/SuccessResponseTest.java @@ -1,77 +1,78 @@ package kr.co.pennyway.api.common.response; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; - @ExtendWith(MockitoExtension.class) public class SuccessResponseTest { - private TestDto dto; - - @BeforeEach - void setUp() { - dto = new TestDto("test", 1); - } - - @Test - @DisplayName("SuccessResponse.from() - data가 존재하는 성공 응답") - public void successResponseWithData() { - // Given - String key = "example"; - String value = "data"; - - // When - SuccessResponse response = SuccessResponse.from(key, value); - - // Then - assertEquals("2000000", response.getCode()); - assertEquals(Map.of(key, value), response.getData()); - } - - @Test - @DisplayName("SuccessResponse.from() - data가 존재하는 성공 응답") - public void successResponseWithNoContent() { - // When - SuccessResponse response = SuccessResponse.noContent(); - - // Then - assertEquals("2000000", response.getCode()); - assertEquals(Map.of(), response.getData()); - } - - @Test - @DisplayName("SuccessResponse.from() - DTO를 통한 성공 응답") - public void successResponseFromDto() { - // When - SuccessResponse response = SuccessResponse.from(dto); - - // Then - assertEquals("2000000", response.getCode()); - assertEquals(dto, response.getData()); - System.out.println(response); - } - - @Test - @DisplayName("SuccessResponse.from() - key와 DTO를 통한 성공 응답") - public void successResponseFromDtoWithKey() { - // Given - String key = "test"; - - // When - SuccessResponse> response = SuccessResponse.from(key, dto); - - // Then - assertEquals("2000000", response.getCode()); - assertEquals(Map.of(key, dto), response.getData()); - System.out.println(response); - } - - private record TestDto(String name, int age) { - } + + private TestDto dto; + + @BeforeEach + void setUp() { + dto = new TestDto("test", 1); + } + + @Test + @DisplayName("SuccessResponse.from() - data가 존재하는 성공 응답") + public void successResponseWithData() { + // Given + String key = "example"; + String value = "data"; + + // When + SuccessResponse response = SuccessResponse.from(key, value); + + // Then + assertEquals("2000", response.getCode()); + assertEquals(Map.of(key, value), response.getData()); + } + + @Test + @DisplayName("SuccessResponse.from() - data가 존재하는 성공 응답") + public void successResponseWithNoContent() { + // When + SuccessResponse response = SuccessResponse.noContent(); + + // Then + assertEquals("2000", response.getCode()); + assertEquals(Map.of(), response.getData()); + } + + @Test + @DisplayName("SuccessResponse.from() - DTO를 통한 성공 응답") + public void successResponseFromDto() { + // When + SuccessResponse response = SuccessResponse.from(dto); + + // Then + assertEquals("2000", response.getCode()); + assertEquals(dto, response.getData()); + System.out.println(response); + } + + @Test + @DisplayName("SuccessResponse.from() - key와 DTO를 통한 성공 응답") + public void successResponseFromDtoWithKey() { + // Given + String key = "test"; + + // When + SuccessResponse> response = SuccessResponse.from(key, dto); + + // Then + assertEquals("2000", response.getCode()); + assertEquals(Map.of(key, dto), response.getData()); + System.out.println(response); + } + + private record TestDto(String name, int age) { + + } } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/PennywayInfraApplication.java b/pennyway-infra/src/main/java/kr/co/pennyway/PennywayInfraApplication.java index 8adc2e7b2..0bc66b4bb 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/PennywayInfraApplication.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/PennywayInfraApplication.java @@ -1,7 +1,5 @@ package kr.co.pennyway; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication public class PennywayInfraApplication { + } From 39f97e9013b13ab18e33802b7dce6eeab7f4aaf8 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 27 Mar 2024 10:19:30 +0900 Subject: [PATCH 031/152] =?UTF-8?q?=E2=9C=A8=20Spring=20Security=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EC=84=A4=EC=A0=95=20(+=20Test=20case=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EA=B4=80=EB=A0=A8)=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: external-api 모듈 spring boot starter security 의존성 주입 * chore: security config 설정 * chore: method security config 설정 * fix: 기존 api 인가 권한 is-anonymous로 제한 * fix: security config 인증, 인가 예외 필터 제거 (로그인 작업 시 추가) * fix: user sync helper oauth 반환 수정 * test: user sync helper 메서드 반환 타입 수정 * test: username 반환 검증 추가 * fix: pennyway infra application @spring boot application 어노테이션 제거 * test: 성공 응답 객체 code 값 2000으로 수정 * chore: spring security test 의존성 주입 * test: auth controller 성공 응답 set cookie 헤더 존재 여부 판단으로 수정 * chore: sub project test 블럭 추가 * feat: security user details & service 정의 * chore: local 환경 내 logging level 정보 추가 * fix: user sync helper transaction 제거 --- pennyway-app-external-api/build.gradle | 8 +- .../apis/auth/controller/AuthController.java | 7 +- .../api/apis/auth/helper/UserSyncHelper.java | 45 +- .../authentication/SecurityUserDetails.java | 84 ++++ .../authentication/UserDetailServiceImpl.java | 25 ++ .../config/security/MethodSecurityConfig.java | 17 + .../api/config/security/SecurityConfig.java | 64 +++ .../AuthControllerValidationTest.java | 415 +++++++++--------- .../apis/auth/helper/UserSyncHelperTest.java | 87 ++-- .../common/response/SuccessResponseTest.java | 133 +++--- .../src/main/resources/application-domain.yml | 7 + 11 files changed, 552 insertions(+), 340 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/UserDetailServiceImpl.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/MethodSecurityConfig.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java diff --git a/pennyway-app-external-api/build.gradle b/pennyway-app-external-api/build.gradle index f482cc3fe..dd868716b 100644 --- a/pennyway-app-external-api/build.gradle +++ b/pennyway-app-external-api/build.gradle @@ -2,8 +2,8 @@ plugins { id 'java' } -bootJar {enabled = true} -jar {enabled = false} +bootJar { enabled = true } +jar { enabled = false } group = 'kr.co' version = 'unspecified' @@ -17,6 +17,10 @@ dependencies { implementation project(':pennyway-domain') implementation project(':pennyway-infra') + /* Security */ + implementation 'org.springframework.boot:spring-boot-starter-security:3.2.4' + testImplementation 'org.springframework.security:spring-security-test:6.2.3' + /* Swagger */ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0' diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java index ef18d27b0..30268cfd7 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java @@ -14,6 +14,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -34,21 +35,21 @@ public class AuthController { @Operation(summary = "인증번호 전송") @PostMapping("/phone") - // TODO: Spring Security 설정 후 @PreAuthorize("permitAll()") 추가 && ip 당 횟수 제한 + @PreAuthorize("isAnonymous()") public ResponseEntity sendCode(@RequestBody @Validated PhoneVerificationDto.PushCodeReq request) { return ResponseEntity.ok(SuccessResponse.from("sms", authUseCase.sendCode(request))); } @Operation(summary = "인증번호 검증") @PostMapping("/phone/verification") - // TODO: Spring Security 설정 후 @PreAuthorize("permitAll()") 추가 + @PreAuthorize("isAnonymous()") public ResponseEntity verifyCode(@RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request) { return ResponseEntity.ok(SuccessResponse.from("sms", authUseCase.verifyCode(request))); } @Operation(summary = "일반 회원가입") @PostMapping("/sign-up") - // TODO: Spring Security 설정 후 @PreAuthorize("isAnonymous()") 추가 + @PreAuthorize("isAnonymous()") public ResponseEntity signUp(@RequestBody @Validated SignUpReq.General request) { Pair jwts = authUseCase.signUp(request); ResponseCookie cookie = cookieUtil.createCookie("refreshToken", jwts.getValue().refreshToken(), Duration.ofDays(7).toSeconds()); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java index 1fd1dc45d..31c6fee42 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java @@ -14,31 +14,30 @@ @Helper @RequiredArgsConstructor public class UserSyncHelper { + private final UserService userService; - private final UserService userService; + /** + * 일반 회원가입 시 이미 가입된 회원인지 확인 + * + * @param phone String : 전화번호 + * @return Pair : 이미 가입된 회원인지 여부 (TRUE: 가입되지 않은 회원, FALSE: 가입된 회원), 가입된 회원인 경우 회원 + * ID 반환 + * @throws UserErrorException : 이미 일반 회원가입을 한 유저인 경우 + */ + public Pair isSignedUserWhenGeneral(String phone) { + User user; + try { + user = userService.readUserByPhone(phone); + } catch (GlobalErrorException e) { + log.info("User not found. phone: {}", phone); + return Pair.of(Boolean.FALSE, null); + } - /** - * 일반 회원가입 시 이미 가입된 회원인지 확인 - * - * @param phone String : 전화번호 - * @return Pair : 이미 가입된 회원인지 여부 (TRUE: 가입되지 않은 회원, FALSE: 가입된 회원), 가입된 회원인 경우 회원 - * ID 반환 - * @throws UserErrorException : 이미 일반 회원가입을 한 유저인 경우 - */ - public Pair isSignedUserWhenGeneral(String phone) { - User user; - try { - user = userService.readUserByPhone(phone); - } catch (GlobalErrorException e) { - log.info("User not found. phone: {}", phone); - return Pair.of(Boolean.FALSE, null); - } + if (user.getPassword() != null) { + log.warn("User already exists. phone: {}", phone); + throw new UserErrorException(UserErrorCode.ALREADY_SIGNUP); + } - if (user.getPassword() != null) { - log.warn("User already exists. phone: {}", phone); - throw new UserErrorException(UserErrorCode.ALREADY_SIGNUP); + return Pair.of(Boolean.TRUE, user.getUsername()); } - - return Pair.of(Boolean.TRUE, user.getUsername()); - } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java new file mode 100644 index 000000000..808d3c39f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java @@ -0,0 +1,84 @@ +package kr.co.pennyway.api.common.security.authentication; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.Builder; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Arrays; +import java.util.Collection; + +@Getter +public class SecurityUserDetails implements UserDetails { + private final Long userId; + private final String username; + private final Collection authorities; + private final boolean accountNonLocked; + + @JsonIgnore + private boolean enabled; + @JsonIgnore + private String password; + @JsonIgnore + private boolean credentialsNonExpired; + @JsonIgnore + private boolean accountNonExpired; + + @Builder + private SecurityUserDetails(Long userId, String username, Collection authorities, boolean accountNonLocked) { + this.userId = userId; + this.username = username; + this.authorities = authorities; + this.accountNonLocked = accountNonLocked; + } + + public static UserDetails from(User user) { + return SecurityUserDetails.builder() + .userId(user.getId()) + .username(user.getUsername()) + .authorities(Arrays.stream(Role.values()) + .filter(roleType -> roleType == user.getRole()) + .map(roleType -> (GrantedAuthority) roleType::getType) + .toList()) + .accountNonLocked(user.getLocked()) + .build(); + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + throw new UnsupportedOperationException(); + } + + @Override + public String getUsername() { + return username; + } + + @Override + public boolean isAccountNonExpired() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isAccountNonLocked() { + return accountNonLocked; + } + + @Override + public boolean isCredentialsNonExpired() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isEnabled() { + throw new UnsupportedOperationException(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/UserDetailServiceImpl.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/UserDetailServiceImpl.java new file mode 100644 index 000000000..463fe4b8f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/UserDetailServiceImpl.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.api.common.security.authentication; + +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserDetailServiceImpl implements UserDetailsService { + private final UserService userService; + + @Override + @Cacheable(value = "securityUser", key = "#userId", unless = "#result == null", cacheManager = "securityUserCacheManager") + public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException { + try { + return SecurityUserDetails.from(userService.readUser(Long.parseLong(userId))); + } catch (Exception e) { + return null; + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/MethodSecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/MethodSecurityConfig.java new file mode 100644 index 000000000..eac03b3e7 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/MethodSecurityConfig.java @@ -0,0 +1,17 @@ +package kr.co.pennyway.api.config.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; +import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; + +@Configuration +@EnableMethodSecurity(securedEnabled = true) +public class MethodSecurityConfig { + @Bean + protected MethodSecurityExpressionHandler createExpressionHandler() { + DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); + return expressionHandler; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java new file mode 100644 index 000000000..7db0010bc --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java @@ -0,0 +1,64 @@ +package kr.co.pennyway.api.config.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private static final String[] publicReadOnlyPublicEndpoints = { + "/favicon.ico", + // Swagger + "/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger", + }; + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .cors((cors) -> cors.configurationSource(corsConfigurationSource())) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .sessionManagement((sessionManagement) -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests( + auth -> auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .requestMatchers(HttpMethod.OPTIONS, "*").permitAll() + .requestMatchers(HttpMethod.GET, publicReadOnlyPublicEndpoints).permitAll() + .anyRequest().permitAll() + ); + return http.build(); + } + + // TODO: dev, test, prod 환경이 정해지면 수정 필요. + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.addAllowedOrigin("http://localhost:3000"); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "OPTIONS", "PUT", "PATCH", "DELETE")); + configuration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java index ddf906023..0447af6cb 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java @@ -1,19 +1,12 @@ package kr.co.pennyway.api.apis.auth.controller; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - import com.fasterxml.jackson.databind.ObjectMapper; -import java.time.Duration; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; import kr.co.pennyway.api.apis.auth.usecase.AuthUseCase; import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.api.common.util.CookieUtil; import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -24,203 +17,221 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.time.Duration; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(controllers = {AuthController.class}) @ActiveProfiles("local") public class AuthControllerValidationTest { - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @MockBean - private AuthUseCase authUseCase; - - @MockBean - private CookieUtil cookieUtil; - - @DisplayName("[1] 아이디, 이름, 비밀번호, 전화번호, 인증번호 필수 입력") - @Test - void requiredInputError() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("", "", "", "", ""); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.username").exists()) - .andExpect(jsonPath("$.fieldErrors.name").exists()) - .andExpect(jsonPath("$.fieldErrors.password").exists()) - .andExpect(jsonPath("$.fieldErrors.phone").exists()) - .andExpect(jsonPath("$.fieldErrors.code").exists()) - .andDo(print()); - } - - @DisplayName("[2] 아이디는 5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.") - @Test - void idValidError() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("#pennyway", "페니웨이", "pennyway1234", - "010-1234-5678", "123456"); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.username").value("5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.")) - .andDo(print()); - } - - @DisplayName("[3] 이름은 2~20자의 한글, 영문 대/소문자만 사용 가능합니다.") - @Test - void nameValidError() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이1", "pennyway1234", - "010-1234-5678", "123456"); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.name").value("2~20자의 한글, 영문 대/소문자만 사용 가능합니다.")) - .andDo(print()); - } - - @DisplayName("[4] 비밀번호는 8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)") - @Test - void passwordValidError() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway", - "010-1234-5678", "123456"); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.password").value( - "8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)")) - .andDo(print()); - } - - @DisplayName("[5] 전화번호는 010 혹은 011로 시작하는, 010-0000-0000 형식이어야 합니다.") - @Test - void phoneValidError() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", - "01012345673", "123456"); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.phone").value("전화번호 형식이 올바르지 않습니다.")) - .andDo(print()); - } - - @DisplayName("[6] 인증번호는 6자리 숫자여야 합니다.") - @Test - void codeValidError() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", - "010-1234-5678", "12345"); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.code").value("인증번호는 6자리 숫자여야 합니다.")) - .andDo(print()); - } - - @DisplayName("[7] 일부 필드 누락") - @Test - void someFieldMissingError() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", - "010-1234-5678", "123456"); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request) - .replace("\"username\":\"pennyway\",", "") - .replace("\"phone\":\"010-1234-5678\",", ""))); - - // then - resultActions - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.username").value("아이디를 입력해주세요")) - .andExpect(jsonPath("$.fieldErrors.phone").value("전화번호를 입력해주세요")) - .andDo(print()); - } - - @DisplayName("[8] 정상적인 회원가입 요청 - 쿠키/인증 헤더와 회원 pk 반환") - @Test - void signUp() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", - "010-1234-5678", "123456"); - ResponseCookie expectedCookie = ResponseCookie.from("refreshToken", "refreshToken") - .maxAge(Duration.ofDays(7).toSeconds()).httpOnly(true).path("/").build(); - - given(authUseCase.signUp(request)) - .willReturn(Pair.of(1L, Jwts.of("accessToken", "refreshToken"))); - given(cookieUtil.createCookie("refreshToken", "refreshToken", Duration.ofDays(7).toSeconds())) - .willReturn(expectedCookie); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(header().exists("Set-Cookie")) - .andExpect(header().string("Authorization", "accessToken")) - .andExpect(jsonPath("$.data.user.id").value(1)) - .andDo(print()); - } + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private AuthUseCase authUseCase; + + @MockBean + private CookieUtil cookieUtil; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(post("/**").with(csrf())) + .build(); + } + + @DisplayName("[1] 아이디, 이름, 비밀번호, 전화번호, 인증번호 필수 입력") + @Test + void requiredInputError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("", "", "", "", ""); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.username").exists()) + .andExpect(jsonPath("$.fieldErrors.name").exists()) + .andExpect(jsonPath("$.fieldErrors.password").exists()) + .andExpect(jsonPath("$.fieldErrors.phone").exists()) + .andExpect(jsonPath("$.fieldErrors.code").exists()) + .andDo(print()); + } + + @DisplayName("[2] 아이디는 5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.") + @Test + void idValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("#pennyway", "페니웨이", "pennyway1234", + "010-1234-5678", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.username").value("5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.")) + .andDo(print()); + } + + @DisplayName("[3] 이름은 2~20자의 한글, 영문 대/소문자만 사용 가능합니다.") + @Test + void nameValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이1", "pennyway1234", + "010-1234-5678", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.name").value("2~20자의 한글, 영문 대/소문자만 사용 가능합니다.")) + .andDo(print()); + } + + @DisplayName("[4] 비밀번호는 8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)") + @Test + void passwordValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway", + "010-1234-5678", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.password").value( + "8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)")) + .andDo(print()); + } + + @DisplayName("[5] 전화번호는 010 혹은 011로 시작하는, 010-0000-0000 형식이어야 합니다.") + @Test + void phoneValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", + "01012345673", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.phone").value("전화번호 형식이 올바르지 않습니다.")) + .andDo(print()); + } + + @DisplayName("[6] 인증번호는 6자리 숫자여야 합니다.") + @Test + void codeValidError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", + "010-1234-5678", "12345"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.code").value("인증번호는 6자리 숫자여야 합니다.")) + .andDo(print()); + } + + @DisplayName("[7] 일부 필드 누락") + @Test + void someFieldMissingError() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", + "010-1234-5678", "123456"); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request) + .replace("\"username\":\"pennyway\",", "") + .replace("\"phone\":\"010-1234-5678\",", ""))); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.username").value("아이디를 입력해주세요")) + .andExpect(jsonPath("$.fieldErrors.phone").value("전화번호를 입력해주세요")) + .andDo(print()); + } + + @DisplayName("[8] 정상적인 회원가입 요청 - 쿠키/인증 헤더와 회원 pk 반환") + @Test + void signUp() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", + "010-1234-5678", "123456"); + ResponseCookie expectedCookie = ResponseCookie.from("refreshToken", "refreshToken") + .maxAge(Duration.ofDays(7).toSeconds()).httpOnly(true).path("/").build(); + + given(authUseCase.signUp(request)) + .willReturn(Pair.of(1L, Jwts.of("accessToken", "refreshToken"))); + given(cookieUtil.createCookie("refreshToken", "refreshToken", Duration.ofDays(7).toSeconds())) + .willReturn(expectedCookie); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().string("Authorization", "accessToken")) + .andExpect(jsonPath("$.data.user.id").value(1)) + .andDo(print()); + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java index 4e04a32ba..bc9a13aa4 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java @@ -1,8 +1,5 @@ package kr.co.pennyway.api.apis.auth.helper; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.BDDMockito.given; - import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; @@ -14,56 +11,58 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.BDDMockito.given; + @ExtendWith(MockitoExtension.class) public class UserSyncHelperTest { + private final String phone = "010-1234-5678"; + private UserSyncHelper userSyncHelper; + @Mock + private UserService userService; - private final String phone = "010-1234-5678"; - private UserSyncHelper userSyncHelper; - @Mock - private UserService userService; - - @BeforeEach - void setUp() { - userSyncHelper = new UserSyncHelper(userService); - } + @BeforeEach + void setUp() { + userSyncHelper = new UserSyncHelper(userService); + } - @DisplayName("일반 회원가입 시, 회원 정보가 없으면 FALSE를 반환한다.") - @Test - void isSignedUserWhenGeneralReturnFalse() { - // given - given(userService.readUserByPhone(phone)).willThrow( - new UserErrorException(UserErrorCode.NOT_FOUND)); + @DisplayName("일반 회원가입 시, 회원 정보가 없으면 FALSE를 반환한다.") + @Test + void isSignedUserWhenGeneralReturnFalse() { + // given + given(userService.readUserByPhone(phone)).willThrow( + new UserErrorException(UserErrorCode.NOT_FOUND)); - // when - Boolean result = userSyncHelper.isSignedUserWhenGeneral(phone).getKey(); + // when + Boolean result = userSyncHelper.isSignedUserWhenGeneral(phone).getKey(); - // then - assertEquals(result, Boolean.FALSE); - } + // then + assertEquals(result, Boolean.FALSE); + } - @DisplayName("일반 회원가입 시, oauth 회원 정보가 있으면 TRUE를 반환한다.") - @Test - void isSignedUserWhenGeneralReturnTrue() { - // given - given(userService.readUserByPhone(phone)).willReturn(User.builder().password(null).build()); + @DisplayName("일반 회원가입 시, oauth 회원 정보가 있으면 TRUE를 반환한다.") + @Test + void isSignedUserWhenGeneralReturnTrue() { + // given + given(userService.readUserByPhone(phone)).willReturn(User.builder().password(null).build()); - // when - Boolean result = userSyncHelper.isSignedUserWhenGeneral(phone).getKey(); + // when + Boolean result = userSyncHelper.isSignedUserWhenGeneral(phone).getKey(); - // then - assertEquals(result, Boolean.TRUE); - } + // then + assertEquals(result, Boolean.TRUE); + } - @DisplayName("일반 회원가입 시, 이미 일반회원 가입된 회원인 경우 UserErrorException을 발생시킨다.") - @Test - void isSignedUserWhenGeneralThrowUserErrorException() { - // given - given(userService.readUserByPhone(phone)).willReturn( - User.builder().password("password").build()); + @DisplayName("일반 회원가입 시, 이미 일반회원 가입된 회원인 경우 UserErrorException을 발생시킨다.") + @Test + void isSignedUserWhenGeneralThrowUserErrorException() { + // given + given(userService.readUserByPhone(phone)).willReturn( + User.builder().password("password").build()); - // when - then - UserErrorException exception = org.junit.jupiter.api.Assertions.assertThrows( - UserErrorException.class, () -> userSyncHelper.isSignedUserWhenGeneral(phone)); - System.out.println(exception.getExplainError()); - } + // when - then + UserErrorException exception = org.junit.jupiter.api.Assertions.assertThrows( + UserErrorException.class, () -> userSyncHelper.isSignedUserWhenGeneral(phone)); + System.out.println(exception.getExplainError()); + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/response/SuccessResponseTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/response/SuccessResponseTest.java index 28fdf2b69..508cc77fd 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/response/SuccessResponseTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/response/SuccessResponseTest.java @@ -1,78 +1,79 @@ package kr.co.pennyway.api.common.response; -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + @ExtendWith(MockitoExtension.class) public class SuccessResponseTest { - private TestDto dto; - - @BeforeEach - void setUp() { - dto = new TestDto("test", 1); - } - - @Test - @DisplayName("SuccessResponse.from() - data가 존재하는 성공 응답") - public void successResponseWithData() { - // Given - String key = "example"; - String value = "data"; - - // When - SuccessResponse response = SuccessResponse.from(key, value); - - // Then - assertEquals("2000", response.getCode()); - assertEquals(Map.of(key, value), response.getData()); - } - - @Test - @DisplayName("SuccessResponse.from() - data가 존재하는 성공 응답") - public void successResponseWithNoContent() { - // When - SuccessResponse response = SuccessResponse.noContent(); - - // Then - assertEquals("2000", response.getCode()); - assertEquals(Map.of(), response.getData()); - } - - @Test - @DisplayName("SuccessResponse.from() - DTO를 통한 성공 응답") - public void successResponseFromDto() { - // When - SuccessResponse response = SuccessResponse.from(dto); - - // Then - assertEquals("2000", response.getCode()); - assertEquals(dto, response.getData()); - System.out.println(response); - } - - @Test - @DisplayName("SuccessResponse.from() - key와 DTO를 통한 성공 응답") - public void successResponseFromDtoWithKey() { - // Given - String key = "test"; - - // When - SuccessResponse> response = SuccessResponse.from(key, dto); - - // Then - assertEquals("2000", response.getCode()); - assertEquals(Map.of(key, dto), response.getData()); - System.out.println(response); - } - - private record TestDto(String name, int age) { - - } + private TestDto dto; + + @BeforeEach + void setUp() { + dto = new TestDto("test", 1); + } + + @Test + @DisplayName("SuccessResponse.from() - data가 존재하는 성공 응답") + public void successResponseWithData() { + // Given + String key = "example"; + String value = "data"; + + // When + SuccessResponse response = SuccessResponse.from(key, value); + + // Then + assertEquals("2000", response.getCode()); + assertEquals(Map.of(key, value), response.getData()); + } + + @Test + @DisplayName("SuccessResponse.from() - data가 존재하는 성공 응답") + public void successResponseWithNoContent() { + // When + SuccessResponse response = SuccessResponse.noContent(); + + // Then + assertEquals("2000", response.getCode()); + assertEquals(Map.of(), response.getData()); + } + + @Test + @DisplayName("SuccessResponse.from() - DTO를 통한 성공 응답") + public void successResponseFromDto() { + // When + SuccessResponse response = SuccessResponse.from(dto); + + // Then + assertEquals("2000", response.getCode()); + assertEquals(dto, response.getData()); + System.out.println(response); + } + + @Test + @DisplayName("SuccessResponse.from() - key와 DTO를 통한 성공 응답") + public void successResponseFromDtoWithKey() { + // Given + String key = "test"; + + // When + SuccessResponse> response = SuccessResponse.from(key, dto); + + // Then + assertEquals("2000", response.getCode()); + assertEquals(Map.of(key, dto), response.getData()); + System.out.println(response); + } + + private record TestDto(String name, int age) { + + } } diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml index fcf90ab18..42d2dc101 100644 --- a/pennyway-domain/src/main/resources/application-domain.yml +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -34,9 +34,16 @@ spring: logging: level: + ROOT: INFO + org.hibernate: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE org.hibernate.sql: debug org.hibernate.type: trace com.zaxxer.hikari.HikariConfig: DEBUG + org.springframework.orm: TRACE + org.springframework.transaction: TRACE + com.zaxxer.hikari: TRACE + com.mysql.cj.jdbc: TRACE --- spring: From f39cdb79b7e858b069c0b7b50aeb2de118be6b11 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 27 Mar 2024 17:31:59 +0900 Subject: [PATCH 032/152] =?UTF-8?q?=E2=9C=A8=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20API=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 일반 로그인 요청 dto 작성 * feat: user repository find-by-username 메서드 추가 * feat: user service read-user-by-username 메서드 구현 * feat: 유저 비밀번호 예외 추가 * feat: user sync helper read-user-if-valid 메서드 * feat: auth user case내 sign in 메서드 추가 * feat: sign in api 추가 * test: sign in test case 추가 * fix: sign in dto 정규표현식 검사 제거 --- .../apis/auth/controller/AuthController.java | 15 ++++++ .../pennyway/api/apis/auth/dto/SignInReq.java | 19 ++++++++ .../api/apis/auth/helper/UserSyncHelper.java | 25 ++++++++++ .../api/apis/auth/usecase/AuthUseCase.java | 9 ++++ .../apis/auth/helper/UserSyncHelperTest.java | 47 ++++++++++++++++++- .../domains/user/exception/UserErrorCode.java | 1 + .../user/repository/UserRepository.java | 2 + .../domains/user/service/UserService.java | 5 ++ 8 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java index 30268cfd7..7d96501b5 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.apis.auth.dto.SignInReq; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; import kr.co.pennyway.api.apis.auth.usecase.AuthUseCase; import kr.co.pennyway.api.common.response.SuccessResponse; @@ -60,4 +61,18 @@ public ResponseEntity signUp(@RequestBody @Validated SignUpReq.General reques .body(SuccessResponse.from("user", Map.of("id", jwts.getKey()))) ; } + + @Operation(summary = "일반 로그인") + @PostMapping("/sign-in") + @PreAuthorize("isAnonymous()") + public ResponseEntity signIn(@RequestBody @Validated SignInReq.General request) { + Pair jwts = authUseCase.signIn(request); + ResponseCookie cookie = cookieUtil.createCookie("refreshToken", jwts.getValue().refreshToken(), Duration.ofDays(7).toSeconds()); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .header(HttpHeaders.AUTHORIZATION, jwts.getValue().accessToken()) + .body(SuccessResponse.from("user", Map.of("id", jwts.getKey()))) + ; + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java new file mode 100644 index 000000000..97cca37b8 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.api.apis.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SignInReq { + public record General( + @Schema(description = "아이디", example = "pennyway") + @NotBlank(message = "아이디를 입력해주세요") + String username, + @Schema(description = "비밀번호", example = "pennyway1234") + @NotBlank(message = "비밀번호를 입력해주세요") + String password + ) { + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java index 31c6fee42..7778f4fff 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java @@ -9,6 +9,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Helper @@ -16,6 +18,8 @@ public class UserSyncHelper { private final UserService userService; + private final PasswordEncoder bCryptPasswordEncoder; + /** * 일반 회원가입 시 이미 가입된 회원인지 확인 * @@ -40,4 +44,25 @@ public Pair isSignedUserWhenGeneral(String phone) { return Pair.of(Boolean.TRUE, user.getUsername()); } + + /** + * 로그인 시 유저가 존재하고 비밀번호가 일치하는지 확인 + */ + @Transactional(readOnly = true) + public User readUserIfValid(String username, String password) { + User user; + + try { + user = userService.readUserByUsername(username); + + if (!bCryptPasswordEncoder.matches(password, user.getPassword())) { + throw new UserErrorException(UserErrorCode.NOT_MATCHED_PASSWORD); + } + } catch (UserErrorException e) { + log.warn("request not valid : {} : {}", username, e.getExplainError()); + throw new UserErrorException(UserErrorCode.INVALID_USERNAME_OR_PASSWORD); + } + + return user; + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java index 711d5dcfb..a1693ef5b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java @@ -1,6 +1,7 @@ package kr.co.pennyway.api.apis.auth.usecase; import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.apis.auth.dto.SignInReq; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; import kr.co.pennyway.api.apis.auth.helper.UserSyncHelper; import kr.co.pennyway.api.apis.auth.mapper.JwtAuthMapper; @@ -28,6 +29,7 @@ public class AuthUseCase { private final PhoneVerificationMapper phoneVerificationMapper; private final PhoneVerificationService phoneVerificationService; + public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeReq request) { return phoneVerificationMapper.sendCode(request, PhoneVerificationType.SIGN_UP); } @@ -51,6 +53,13 @@ public Pair signUp(SignUpReq.General request) { return Pair.of(user.getId(), jwtAuthMapper.createToken(user)); } + @Transactional(readOnly = true) + public Pair signIn(SignInReq.General request) { + User user = userSyncHelper.readUserIfValid(request.username(), request.password()); + + return Pair.of(user.getId(), jwtAuthMapper.createToken(user)); + } + private Pair checkOauthUser(String phone) { try { return userSyncHelper.isSignedUserWhenGeneral(phone); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java index bc9a13aa4..4e28c2cfe 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java @@ -10,8 +10,10 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.BDDMockito.given; @ExtendWith(MockitoExtension.class) @@ -20,10 +22,12 @@ public class UserSyncHelperTest { private UserSyncHelper userSyncHelper; @Mock private UserService userService; + @Mock + private BCryptPasswordEncoder bCryptPasswordEncoder; @BeforeEach void setUp() { - userSyncHelper = new UserSyncHelper(userService); + userSyncHelper = new UserSyncHelper(userService, bCryptPasswordEncoder); } @DisplayName("일반 회원가입 시, 회원 정보가 없으면 FALSE를 반환한다.") @@ -65,4 +69,45 @@ void isSignedUserWhenGeneralThrowUserErrorException() { UserErrorException.class, () -> userSyncHelper.isSignedUserWhenGeneral(phone)); System.out.println(exception.getExplainError()); } + + @DisplayName("로그인 시, 유저가 존재하고 비밀번호가 일치하면 User를 반환한다.") + @Test + void readUserIfValidReturnUser() { + // given + User user = User.builder().username("pennyway").password("password").build(); + given(userService.readUserByUsername("pennyway")).willReturn(user); + given(bCryptPasswordEncoder.matches("password", user.getPassword())).willReturn(true); + + // when + User result = userSyncHelper.readUserIfValid("pennyway", "password"); + + // then + assertEquals(result, user); + } + + @DisplayName("로그인 시, username에 해당하는 유저가 존재하지 않으면 UserErrorException을 발생시킨다.") + @Test + void readUserIfNotFound() { + // given + User user = User.builder().username("pennyway").password("password").build(); + given(userService.readUserByUsername("pennyway")).willThrow( + new UserErrorException(UserErrorCode.NOT_FOUND)); + + // when - then + UserErrorException exception = assertThrows(UserErrorException.class, () -> userSyncHelper.readUserIfValid("pennyway", "password")); + System.out.println(exception.getExplainError()); + } + + @DisplayName("로그인 시, 비밀번호가 일치하지 않으면 UserErrorException을 발생시킨다.") + @Test + void readUserIfNotMatchedPassword() { + // given + User user = User.builder().username("pennyway").password("password").build(); + given(userService.readUserByUsername("pennyway")).willReturn(user); + given(bCryptPasswordEncoder.matches("password", user.getPassword())).willReturn(false); + + // when - then + UserErrorException exception = assertThrows(UserErrorException.class, () -> userSyncHelper.readUserIfValid("pennyway", "password")); + System.out.println(exception.getExplainError()); + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java index 4a6a0443e..4d8bc86c9 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java @@ -12,6 +12,7 @@ public enum UserErrorCode implements BaseErrorCode { ALREADY_SIGNUP(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "이미 회원가입한 유저입니다."), /* 401 UNAUTHORIZED */ + NOT_MATCHED_PASSWORD(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "비밀번호가 일치하지 않습니다."), INVALID_USERNAME_OR_PASSWORD(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "유효하지 않은 아이디 또는 비밀번호입니다."), /* 403 FORBIDDEN */ diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java index c969b4cc6..f695b5be9 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java @@ -7,4 +7,6 @@ public interface UserRepository extends JpaRepository { Optional findByPhone(String phone); + + Optional findByUsername(String username); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java index a3026d91e..91a991df2 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java @@ -28,6 +28,11 @@ public User readUserByPhone(String phone) { return userRepository.findByPhone(phone).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); } + @Transactional(readOnly = true) + public User readUserByUsername(String username) { + return userRepository.findByUsername(username).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + } + @Transactional(readOnly = true) public boolean isExistUser(Long id) { return userRepository.existsById(id); From 44145e77e6293ab7a1c5aeee4f9781e5a0540641 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 28 Mar 2024 11:13:04 +0900 Subject: [PATCH 033/152] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20API=20=EA=B0=9C=EC=84=A0=20(+=20Domain=20S?= =?UTF-8?q?ervice=20Runtime=20=EC=98=88=EC=99=B8=20=EB=B0=9C=EC=83=9D=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0)=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: sign up dto -> phone verification dto from 메서드 추가 * fix: helper 클래스 책임과 역할 분리 * rename: user sync helper 메서드명 명시적으로 수정 * fix: sign up dto password 암호화 후 entity 생성 * fix: 일반 회원가입, 로그인 시나리오 helper 클래스 분리 * fix: 일반 회원가입, oauth 계정 연동 시나리오에 맞게 dto 분리 후 info 클래스로 통합 * fix: oauth 연동 dto -> phone, code 필드 추가 * fix: sign up api 회원가입 요청 인자 수정 * fix: 서명 헬퍼 클래스 매개변수 수정 * feat: user domain 비밀번호 업데이트 메서드 추가 * fix: dto에서 유저 생성 시, password update at 갱신 * feat: 일반 회원가입 도우미 메서드 분기 처리 * rename: helper, mapper 클래스 재지정 * fix: 전화번호 요청 코드 정적 메서드 매개변수 타입 변경 * rename: user general sign mapper 메서드 create -> save(생성 혹은 수정 기능) * fix: sync with oauth dto의 to info 메서드 인자 제거 * feat: 기존 소셜 계정 연동 api 추가 && 인증 응답 생성 도우미 메서드 분리 * feat: 회원가입 시나리오 개선 * test: user sync mapper test 변경 사항 반영 * test: auth controller validation test 변경 사항 반영 * test: user general sign mapper test 분리 * fix: domain service layer 예외 처리 제거 * refactor: user sync mapper 예외 처리 로직 수정 * refactor: user details service imple 예외 처리 로직 수정 * refactor: user general sign mapper 예외 처리 로직 수정 * refactor: user sync mapper에서 비검사 예외 발생 제거 * fix: user sync mapper 선언적 transaction 추가 * refactor: auth use case 비검사 예외 핸들링 제거 * test: test optional 반환 적용 * rename: user sync mapper 주석 수정 --- .../apis/auth/controller/AuthController.java | 24 ++-- .../apis/auth/dto/PhoneVerificationDto.java | 3 + .../pennyway/api/apis/auth/dto/SignUpReq.java | 56 +++++++-- .../JwtAuthHelper.java} | 10 +- .../api/apis/auth/helper/UserSyncHelper.java | 68 ----------- .../auth/mapper/UserGeneralSignMapper.java | 71 +++++++++++ .../api/apis/auth/mapper/UserSyncMapper.java | 47 ++++++++ .../api/apis/auth/usecase/AuthUseCase.java | 45 +++---- .../authentication/UserDetailServiceImpl.java | 8 +- .../AuthControllerValidationTest.java | 2 +- .../apis/auth/helper/UserSyncHelperTest.java | 113 ------------------ .../mapper/UserGeneralSignMapperTest.java | 73 +++++++++++ .../apis/auth/mapper/UserSyncMapperTest.java | 68 +++++++++++ .../domain/domains/user/domain/User.java | 6 +- .../domains/user/service/UserService.java | 16 +-- 15 files changed, 368 insertions(+), 242 deletions(-) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/{mapper/JwtAuthMapper.java => helper/JwtAuthHelper.java} (92%) delete mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java delete mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapperTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapperTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java index 7d96501b5..e88c90981 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java @@ -52,27 +52,29 @@ public ResponseEntity verifyCode(@RequestBody @Validated PhoneVerificationDto @PostMapping("/sign-up") @PreAuthorize("isAnonymous()") public ResponseEntity signUp(@RequestBody @Validated SignUpReq.General request) { - Pair jwts = authUseCase.signUp(request); - ResponseCookie cookie = cookieUtil.createCookie("refreshToken", jwts.getValue().refreshToken(), Duration.ofDays(7).toSeconds()); + return createAuthenticatedResponse(authUseCase.signUp(request.toInfo())); + } - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, cookie.toString()) - .header(HttpHeaders.AUTHORIZATION, jwts.getValue().accessToken()) - .body(SuccessResponse.from("user", Map.of("id", jwts.getKey()))) - ; + @Operation(summary = "기존 소셜 계정에 일반 계정을 연동하는 회원가입") + @PostMapping("/link-oauth") + @PreAuthorize("isAnonymous()") + public ResponseEntity linkOauth(@RequestBody @Validated SignUpReq.SyncWithOauth request) { + return createAuthenticatedResponse(authUseCase.signUp(request.toInfo())); } @Operation(summary = "일반 로그인") @PostMapping("/sign-in") @PreAuthorize("isAnonymous()") public ResponseEntity signIn(@RequestBody @Validated SignInReq.General request) { - Pair jwts = authUseCase.signIn(request); - ResponseCookie cookie = cookieUtil.createCookie("refreshToken", jwts.getValue().refreshToken(), Duration.ofDays(7).toSeconds()); + return createAuthenticatedResponse(authUseCase.signIn(request)); + } + private ResponseEntity createAuthenticatedResponse(Pair userInfo) { + ResponseCookie cookie = cookieUtil.createCookie("refreshToken", userInfo.getValue().refreshToken(), Duration.ofDays(7).toSeconds()); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, cookie.toString()) - .header(HttpHeaders.AUTHORIZATION, jwts.getValue().accessToken()) - .body(SuccessResponse.from("user", Map.of("id", jwts.getKey()))) + .header(HttpHeaders.AUTHORIZATION, userInfo.getValue().accessToken()) + .body(SuccessResponse.from("user", Map.of("id", userInfo.getKey()))) ; } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java index 989a6e0ad..08975e749 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java @@ -56,6 +56,9 @@ public record VerifyCodeReq( @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") String code ) { + public static VerifyCodeReq from(SignUpReq.Info request) { + return new VerifyCodeReq(request.phone(), request.code()); + } } @Schema(title = "인증번호 검증 응답 DTO") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java index 8fe879f90..fe234eddf 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java @@ -7,6 +7,9 @@ import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; import kr.co.pennyway.domain.domains.user.type.Role; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDateTime; /** * 회원가입 요청 Dto @@ -14,6 +17,29 @@ * 일반 회원가입 시엔 General, 소셜 회원가입 시엔 Oauth를 사용합니다. */ public class SignUpReq { + public record Info(String username, String name, String password, String phone, String code) { + public String password(PasswordEncoder passwordEncoder) { + return passwordEncoder.encode(password); + } + + public User toEntity(PasswordEncoder bCryptPasswordEncoder) { + return User.builder() + .username(username) + .name(name) + .password(bCryptPasswordEncoder.encode(password)) + .passwordUpdatedAt(LocalDateTime.now()) + .phone(phone) + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .build(); + } + + @Override + public String password() { + throw new UnsupportedOperationException("Not supported yet."); + } + } + @Schema(title = "일반 회원가입 요청 DTO") public record General( @Schema(description = "아이디", example = "pennyway") @@ -37,17 +63,29 @@ public record General( @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") String code ) { - public User toEntity() { - return User.builder() - .username(username) - .name(name) - .password(password) - .phone(phone) - .role(Role.USER) - .profileVisibility(ProfileVisibility.PUBLIC) - .build(); + public Info toInfo() { + return new Info(username, name, password, phone, code); } + } + @Schema(title = "일반 회원가입(소셜 계정 존재) 요청 DTO") + public record SyncWithOauth( + @Schema(description = "비밀번호", example = "pennyway1234") + @NotBlank(message = "비밀번호를 입력해주세요") + @Password(message = "8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)") + String password, + @Schema(description = "전화번호", example = "010-1234-5678") + @NotBlank(message = "전화번호를 입력해주세요") + @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phone, + @Schema(description = "6자리 정수 인증번호", example = "123456") + @NotBlank(message = "인증번호는 필수입니다.") + @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") + String code + ) { + public Info toInfo() { + return new Info(null, null, password, phone, code); + } } @Schema(title = "소셜 회원가입 요청 DTO") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/JwtAuthMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java similarity index 92% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/JwtAuthMapper.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java index 4d1ab9fbf..2cbd4fbdf 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/JwtAuthMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java @@ -1,11 +1,11 @@ -package kr.co.pennyway.api.apis.auth.mapper; +package kr.co.pennyway.api.apis.auth.helper; import kr.co.pennyway.api.common.annotation.AccessTokenStrategy; import kr.co.pennyway.api.common.annotation.RefreshTokenStrategy; import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaim; -import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.common.annotation.Helper; import kr.co.pennyway.domain.common.redis.refresh.RefreshToken; import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; import kr.co.pennyway.domain.domains.user.domain.User; @@ -16,13 +16,13 @@ import java.time.LocalDateTime; @Slf4j -@Mapper -public class JwtAuthMapper { +@Helper +public class JwtAuthHelper { private final JwtProvider accessTokenProvider; private final JwtProvider refreshTokenProvider; private final RefreshTokenService refreshTokenService; - public JwtAuthMapper( + public JwtAuthHelper( @AccessTokenStrategy JwtProvider accessTokenProvider, @RefreshTokenStrategy JwtProvider refreshTokenProvider, RefreshTokenService refreshTokenService diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java deleted file mode 100644 index 7778f4fff..000000000 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelper.java +++ /dev/null @@ -1,68 +0,0 @@ -package kr.co.pennyway.api.apis.auth.helper; - -import kr.co.pennyway.common.annotation.Helper; -import kr.co.pennyway.common.exception.GlobalErrorException; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; -import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.transaction.annotation.Transactional; - -@Slf4j -@Helper -@RequiredArgsConstructor -public class UserSyncHelper { - private final UserService userService; - - private final PasswordEncoder bCryptPasswordEncoder; - - /** - * 일반 회원가입 시 이미 가입된 회원인지 확인 - * - * @param phone String : 전화번호 - * @return Pair : 이미 가입된 회원인지 여부 (TRUE: 가입되지 않은 회원, FALSE: 가입된 회원), 가입된 회원인 경우 회원 - * ID 반환 - * @throws UserErrorException : 이미 일반 회원가입을 한 유저인 경우 - */ - public Pair isSignedUserWhenGeneral(String phone) { - User user; - try { - user = userService.readUserByPhone(phone); - } catch (GlobalErrorException e) { - log.info("User not found. phone: {}", phone); - return Pair.of(Boolean.FALSE, null); - } - - if (user.getPassword() != null) { - log.warn("User already exists. phone: {}", phone); - throw new UserErrorException(UserErrorCode.ALREADY_SIGNUP); - } - - return Pair.of(Boolean.TRUE, user.getUsername()); - } - - /** - * 로그인 시 유저가 존재하고 비밀번호가 일치하는지 확인 - */ - @Transactional(readOnly = true) - public User readUserIfValid(String username, String password) { - User user; - - try { - user = userService.readUserByUsername(username); - - if (!bCryptPasswordEncoder.matches(password, user.getPassword())) { - throw new UserErrorException(UserErrorCode.NOT_MATCHED_PASSWORD); - } - } catch (UserErrorException e) { - log.warn("request not valid : {} : {}", username, e.getExplainError()); - throw new UserErrorException(UserErrorCode.INVALID_USERNAME_OR_PASSWORD); - } - - return user; - } -} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapper.java new file mode 100644 index 000000000..0ac3ea083 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapper.java @@ -0,0 +1,71 @@ +package kr.co.pennyway.api.apis.auth.mapper; + +import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * 일반 회원가입, 로그인 시나리오 도우미 클래스 + * + * @author YANG JAESEO + */ +@Slf4j +@Mapper +@RequiredArgsConstructor +public class UserGeneralSignMapper { + private final UserService userService; + + private final PasswordEncoder bCryptPasswordEncoder; + + /** + * 일반 회원가입이라면 새롭게 유저를 생성하고, 기존 Oauth 유저라면 비밀번호를 업데이트한다. + * + * @param request {@link SignUpReq.Info} + */ + @Transactional + public User saveUserWithEncryptedPassword(SignUpReq.Info request, Pair isOauthUser) { + User user; + + if (isOauthUser.getLeft().equals(Boolean.TRUE)) { + user = userService.readUserByUsername(isOauthUser.getRight()) + .orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + user.updatePassword(request.password(bCryptPasswordEncoder)); + } else { + user = userService.createUser(request.toEntity(bCryptPasswordEncoder)); + } + + return user; + } + + /** + * 로그인 시 유저가 존재하고 비밀번호가 일치하는지 확인 + * + * @throws UserErrorException : 유저가 존재하지 않거나 비밀번호가 일치하지 않는 경우 + */ + @Transactional(readOnly = true) + public User readUserIfValid(String username, String password) { + Optional user = userService.readUserByUsername(username); + + if (user.isEmpty()) { + log.warn("해당 유저가 존재하지 않습니다. username: {}", username); + throw new UserErrorException(UserErrorCode.INVALID_USERNAME_OR_PASSWORD); + } + + if (!bCryptPasswordEncoder.matches(password, user.get().getPassword())) { + log.warn("비밀번호가 일치하지 않습니다. username: {}", username); + throw new UserErrorException(UserErrorCode.INVALID_USERNAME_OR_PASSWORD); + } + + return user.get(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java new file mode 100644 index 000000000..e04b9a551 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java @@ -0,0 +1,47 @@ +package kr.co.pennyway.api.apis.auth.mapper; + +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * 일반 회원가입, Oauth 회원가입 시나리오를 제어하여 유저 정보를 동기화하는 클래스 + * + * @author YANG JAESEO + */ +@Slf4j +@Mapper +@RequiredArgsConstructor +public class UserSyncMapper { + private final UserService userService; + + /** + * 일반 회원가입이 가능한 유저인지 확인 + * + * @param phone String : 전화번호 + * @return Pair : 이미 가입된 회원인지 여부 (TRUE: 가입되지 않은 회원, FALSE: 가입된 회원), 가입된 회원인 경우 회원 + * ID 반환. 단, 이미 일반 회원가입을 한 유저인 경우에는 null을 반환한다. + */ + @Transactional(readOnly = true) + public Pair isGeneralSignUpAllowed(String phone) { + Optional user = userService.readUserByPhone(phone); + + if (user.isEmpty()) { + log.info("회원가입 이력이 없는 사용자입니다. phone: {}", phone); + return Pair.of(Boolean.FALSE, null); + } + + if (user.get().getPassword() != null) { + log.warn("이미 회원가입된 사용자입니다. phone: {}", phone); + return null; + } + + return Pair.of(Boolean.TRUE, user.get().getUsername()); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java index a1693ef5b..2086c3bed 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java @@ -3,16 +3,17 @@ import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.dto.SignInReq; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; -import kr.co.pennyway.api.apis.auth.helper.UserSyncHelper; -import kr.co.pennyway.api.apis.auth.mapper.JwtAuthMapper; +import kr.co.pennyway.api.apis.auth.helper.JwtAuthHelper; import kr.co.pennyway.api.apis.auth.mapper.PhoneVerificationMapper; +import kr.co.pennyway.api.apis.auth.mapper.UserGeneralSignMapper; +import kr.co.pennyway.api.apis.auth.mapper.UserSyncMapper; import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; @@ -22,50 +23,52 @@ @UseCase @RequiredArgsConstructor public class AuthUseCase { - private final UserService userService; - private final UserSyncHelper userSyncHelper; + private final UserSyncMapper userSyncMapper; + private final UserGeneralSignMapper userGeneralSignMapper; - private final JwtAuthMapper jwtAuthMapper; + private final JwtAuthHelper jwtAuthHelper; private final PhoneVerificationMapper phoneVerificationMapper; private final PhoneVerificationService phoneVerificationService; - public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeReq request) { return phoneVerificationMapper.sendCode(request, PhoneVerificationType.SIGN_UP); } public PhoneVerificationDto.VerifyCodeRes verifyCode(PhoneVerificationDto.VerifyCodeReq request) { Boolean isValidCode = phoneVerificationMapper.isValidCode(request, PhoneVerificationType.SIGN_UP); - Pair isOauthUser = checkOauthUser(request.phone()); + Pair isOauthUser = checkOauthUserNotGeneralSignUp(request.phone()); phoneVerificationService.extendTimeToLeave(request.phone(), PhoneVerificationType.SIGN_UP); - return PhoneVerificationDto.VerifyCodeRes.valueOf(isValidCode, isOauthUser.getKey(), isOauthUser.getValue()); + return PhoneVerificationDto.VerifyCodeRes.valueOf(isValidCode, isOauthUser.getLeft(), isOauthUser.getRight()); } @Transactional - public Pair signUp(SignUpReq.General request) { - // TODO: 인증 번호 확인 로직 추가 - // phoneVerificationHelper.verify(request.phone(), request.code()); + public Pair signUp(SignUpReq.Info request) { + phoneVerificationMapper.isValidCode(PhoneVerificationDto.VerifyCodeReq.from(request), PhoneVerificationType.SIGN_UP); + Pair isOauthUser = checkOauthUserNotGeneralSignUp(request.phone()); - User user = userService.createUser(request.toEntity()); + User user = userGeneralSignMapper.saveUserWithEncryptedPassword(request, isOauthUser); + phoneVerificationService.delete(request.phone(), PhoneVerificationType.SIGN_UP); - return Pair.of(user.getId(), jwtAuthMapper.createToken(user)); + return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); } @Transactional(readOnly = true) public Pair signIn(SignInReq.General request) { - User user = userSyncHelper.readUserIfValid(request.username(), request.password()); + User user = userGeneralSignMapper.readUserIfValid(request.username(), request.password()); - return Pair.of(user.getId(), jwtAuthMapper.createToken(user)); + return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); } - private Pair checkOauthUser(String phone) { - try { - return userSyncHelper.isSignedUserWhenGeneral(phone); - } catch (UserErrorException e) { + private Pair checkOauthUserNotGeneralSignUp(String phone) { + Pair isGeneralSignUpAllowed = userSyncMapper.isGeneralSignUpAllowed(phone); + + if (isGeneralSignUpAllowed == null) { phoneVerificationService.delete(phone, PhoneVerificationType.SIGN_UP); - throw e; + throw new UserErrorException(UserErrorCode.ALREADY_SIGNUP); } + + return isGeneralSignUpAllowed; } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/UserDetailServiceImpl.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/UserDetailServiceImpl.java index 463fe4b8f..659145ebc 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/UserDetailServiceImpl.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/UserDetailServiceImpl.java @@ -16,10 +16,8 @@ public class UserDetailServiceImpl implements UserDetailsService { @Override @Cacheable(value = "securityUser", key = "#userId", unless = "#result == null", cacheManager = "securityUserCacheManager") public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException { - try { - return SecurityUserDetails.from(userService.readUser(Long.parseLong(userId))); - } catch (Exception e) { - return null; - } + return userService.readUser(Long.parseLong(userId)) + .map(SecurityUserDetails::from) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")); } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java index 0447af6cb..e6f92458c 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java @@ -214,7 +214,7 @@ void signUp() throws Exception { ResponseCookie expectedCookie = ResponseCookie.from("refreshToken", "refreshToken") .maxAge(Duration.ofDays(7).toSeconds()).httpOnly(true).path("/").build(); - given(authUseCase.signUp(request)) + given(authUseCase.signUp(request.toInfo())) .willReturn(Pair.of(1L, Jwts.of("accessToken", "refreshToken"))); given(cookieUtil.createCookie("refreshToken", "refreshToken", Duration.ofDays(7).toSeconds())) .willReturn(expectedCookie); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java deleted file mode 100644 index 4e28c2cfe..000000000 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/UserSyncHelperTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package kr.co.pennyway.api.apis.auth.helper; - -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; -import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.BDDMockito.given; - -@ExtendWith(MockitoExtension.class) -public class UserSyncHelperTest { - private final String phone = "010-1234-5678"; - private UserSyncHelper userSyncHelper; - @Mock - private UserService userService; - @Mock - private BCryptPasswordEncoder bCryptPasswordEncoder; - - @BeforeEach - void setUp() { - userSyncHelper = new UserSyncHelper(userService, bCryptPasswordEncoder); - } - - @DisplayName("일반 회원가입 시, 회원 정보가 없으면 FALSE를 반환한다.") - @Test - void isSignedUserWhenGeneralReturnFalse() { - // given - given(userService.readUserByPhone(phone)).willThrow( - new UserErrorException(UserErrorCode.NOT_FOUND)); - - // when - Boolean result = userSyncHelper.isSignedUserWhenGeneral(phone).getKey(); - - // then - assertEquals(result, Boolean.FALSE); - } - - @DisplayName("일반 회원가입 시, oauth 회원 정보가 있으면 TRUE를 반환한다.") - @Test - void isSignedUserWhenGeneralReturnTrue() { - // given - given(userService.readUserByPhone(phone)).willReturn(User.builder().password(null).build()); - - // when - Boolean result = userSyncHelper.isSignedUserWhenGeneral(phone).getKey(); - - // then - assertEquals(result, Boolean.TRUE); - } - - @DisplayName("일반 회원가입 시, 이미 일반회원 가입된 회원인 경우 UserErrorException을 발생시킨다.") - @Test - void isSignedUserWhenGeneralThrowUserErrorException() { - // given - given(userService.readUserByPhone(phone)).willReturn( - User.builder().password("password").build()); - - // when - then - UserErrorException exception = org.junit.jupiter.api.Assertions.assertThrows( - UserErrorException.class, () -> userSyncHelper.isSignedUserWhenGeneral(phone)); - System.out.println(exception.getExplainError()); - } - - @DisplayName("로그인 시, 유저가 존재하고 비밀번호가 일치하면 User를 반환한다.") - @Test - void readUserIfValidReturnUser() { - // given - User user = User.builder().username("pennyway").password("password").build(); - given(userService.readUserByUsername("pennyway")).willReturn(user); - given(bCryptPasswordEncoder.matches("password", user.getPassword())).willReturn(true); - - // when - User result = userSyncHelper.readUserIfValid("pennyway", "password"); - - // then - assertEquals(result, user); - } - - @DisplayName("로그인 시, username에 해당하는 유저가 존재하지 않으면 UserErrorException을 발생시킨다.") - @Test - void readUserIfNotFound() { - // given - User user = User.builder().username("pennyway").password("password").build(); - given(userService.readUserByUsername("pennyway")).willThrow( - new UserErrorException(UserErrorCode.NOT_FOUND)); - - // when - then - UserErrorException exception = assertThrows(UserErrorException.class, () -> userSyncHelper.readUserIfValid("pennyway", "password")); - System.out.println(exception.getExplainError()); - } - - @DisplayName("로그인 시, 비밀번호가 일치하지 않으면 UserErrorException을 발생시킨다.") - @Test - void readUserIfNotMatchedPassword() { - // given - User user = User.builder().username("pennyway").password("password").build(); - given(userService.readUserByUsername("pennyway")).willReturn(user); - given(bCryptPasswordEncoder.matches("password", user.getPassword())).willReturn(false); - - // when - then - UserErrorException exception = assertThrows(UserErrorException.class, () -> userSyncHelper.readUserIfValid("pennyway", "password")); - System.out.println(exception.getExplainError()); - } -} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapperTest.java new file mode 100644 index 000000000..f59202b2a --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapperTest.java @@ -0,0 +1,73 @@ +package kr.co.pennyway.api.apis.auth.mapper; + +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +public class UserGeneralSignMapperTest { + private UserGeneralSignMapper userGeneralSignMapper; + @Mock + private UserService userService; + @Mock + private PasswordEncoder passwordEncoder; + + @BeforeEach + void setUp() { + userGeneralSignMapper = new UserGeneralSignMapper(userService, passwordEncoder); + } + + @DisplayName("로그인 시, 유저가 존재하고 비밀번호가 일치하면 User를 반환한다.") + @Test + void readUserIfValidReturnUser() { + // given + User user = User.builder().username("pennyway").password("password").build(); + given(userService.readUserByUsername("pennyway")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("password", user.getPassword())).willReturn(true); + + // when + User result = userGeneralSignMapper.readUserIfValid("pennyway", "password"); + + // then + assertEquals(result, user); + } + + @DisplayName("로그인 시, username에 해당하는 유저가 존재하지 않으면 UserErrorException을 발생시킨다.") + @Test + void readUserIfNotFound() { + // given + given(userService.readUserByUsername("pennyway")).willThrow( + new UserErrorException(UserErrorCode.NOT_FOUND)); + + // when - then + UserErrorException exception = assertThrows(UserErrorException.class, () -> userGeneralSignMapper.readUserIfValid("pennyway", "password")); + System.out.println(exception.getExplainError()); + } + + @DisplayName("로그인 시, 비밀번호가 일치하지 않으면 UserErrorException을 발생시킨다.") + @Test + void readUserIfNotMatchedPassword() { + // given + User user = User.builder().username("pennyway").password("password").build(); + given(userService.readUserByUsername("pennyway")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("password", user.getPassword())).willReturn(false); + + // when - then + UserErrorException exception = assertThrows(UserErrorException.class, () -> userGeneralSignMapper.readUserIfValid("pennyway", "password")); + System.out.println(exception.getExplainError()); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapperTest.java new file mode 100644 index 000000000..c67630530 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapperTest.java @@ -0,0 +1,68 @@ +package kr.co.pennyway.api.apis.auth.mapper; + +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +public class UserSyncMapperTest { + private final String phone = "010-1234-5678"; + private UserSyncMapper userSyncMapper; + @Mock + private UserService userService; + + @BeforeEach + void setUp() { + userSyncMapper = new UserSyncMapper(userService); + } + + @DisplayName("일반 회원가입 시, 회원 정보가 없으면 FALSE를 반환한다.") + @Test + void isSignedUserWhenGeneralReturnFalse() { + // given + given(userService.readUserByPhone(phone)).willReturn(Optional.empty()); + + // when + Boolean result = userSyncMapper.isGeneralSignUpAllowed(phone).getKey(); + + // then + assertEquals(result, Boolean.FALSE); + } + + @DisplayName("일반 회원가입 시, oauth 회원 정보가 있으면 TRUE를 반환한다.") + @Test + void isSignedUserWhenGeneralReturnTrue() { + // given + given(userService.readUserByPhone(phone)).willReturn(Optional.of(User.builder().username("pennyway").password(null).build())); + + // when + Pair result = userSyncMapper.isGeneralSignUpAllowed(phone); + + // then + assertEquals(result.getLeft(), Boolean.TRUE); + assertEquals(result.getRight(), "pennyway"); + } + + @DisplayName("일반 회원가입 시, 이미 일반회원 가입된 회원인 경우 null을 반환한다.") + @Test + void isSignedUserWhenGeneralThrowUserErrorException() { + // given + given(userService.readUserByPhone(phone)).willReturn( + Optional.of(User.builder().password("password").build())); + + // when - then + assertNull(userSyncMapper.isGeneralSignUpAllowed(phone)); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java index 41d4a6a25..e3afcc2b4 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java @@ -29,7 +29,6 @@ public class User extends DateAuditable { private String name; @ColumnDefault("NULL") private String password; - @ColumnDefault("NULL") private LocalDateTime passwordUpdatedAt; @ColumnDefault("NULL") private String profileImageUrl; @@ -59,4 +58,9 @@ private User(String username, String name, String password, LocalDateTime passwo this.locked = locked; this.deletedAt = deletedAt; } + + public void updatePassword(String password) { + this.password = password; + this.passwordUpdatedAt = LocalDateTime.now(); + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java index 91a991df2..27ee12580 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java @@ -2,12 +2,12 @@ import kr.co.pennyway.common.annotation.DomainService; import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; -import kr.co.pennyway.domain.domains.user.exception.UserErrorException; import kr.co.pennyway.domain.domains.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @DomainService @RequiredArgsConstructor public class UserService { @@ -19,18 +19,18 @@ public User createUser(User user) { } @Transactional(readOnly = true) - public User readUser(Long id) { - return userRepository.findById(id).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + public Optional readUser(Long id) { + return userRepository.findById(id); } @Transactional(readOnly = true) - public User readUserByPhone(String phone) { - return userRepository.findByPhone(phone).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + public Optional readUserByPhone(String phone) { + return userRepository.findByPhone(phone); } @Transactional(readOnly = true) - public User readUserByUsername(String username) { - return userRepository.findByUsername(username).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + public Optional readUserByUsername(String username) { + return userRepository.findByUsername(username); } @Transactional(readOnly = true) From 4b7a31361b0d17f2a90f8f589d2649b242b495d6 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 29 Mar 2024 00:22:14 +0900 Subject: [PATCH 034/152] =?UTF-8?q?=E2=9C=A8=20Jwt=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 403 에러 핸들러 작성 * feat: 401 에러 핸들러 작성 * feat: security config에 인증, 인가 필터 bean 등록 * fix: 인증, 인가 필터 로그 레벨 조정 error -> warn * feat: jwt 예외 필터 작성 * feat: forbedden token entity 정의 * feat: forbedden token repository 작성 * feat: forbidden token service 작성 * feat: jwt 인증 필터 추가 * fix: user details service 구현제 주입 -> 인터페이스 주입 * chore: security filter config 설정 * chore: jwt security config 설정 * chore: security config 커스텀 예외 핸들러 설정 * feat: security user to string() 재정의 * chore: security config 설정 * fix: access denied exception import 경로 수정 * fix: token 파싱 에러 해결 * style: 예외 로그 위치 수정 * feat: global exception handler no-resource-found-exception 핸들링 * fix: security config 불필요한 의존성 주입 제거 * feat: refresh api 개방 * fix: refresh token annotaion 빈 이름 수정 * feat: refresh token 탈취 예외 추가 * fix: refresh token 탈취 시나리오 핸들링 * fix: taken way token reason code 403의 이유 코드로 변경 * fix: jwt 인증 필터 내 메서드 명시적 final 매개변수 제거 --- .../apis/auth/controller/AuthController.java | 13 +- .../api/apis/auth/helper/JwtAuthHelper.java | 24 ++++ .../api/apis/auth/usecase/AuthUseCase.java | 4 + .../annotation/RefreshTokenStrategy.java | 2 +- .../handler/GlobalExceptionHandler.java | 22 ++- .../authentication/SecurityUserDetails.java | 10 ++ .../filter/JwtAuthenticationFilter.java | 130 ++++++++++++++++++ .../security/filter/JwtExceptionFilter.java | 42 ++++++ .../handler/JwtAccessDeniedHandler.java | 33 +++++ .../handler/JwtAuthenticationEntryPoint.java | 33 +++++ .../config/security/JwtSecurityConfig.java | 23 ++++ .../api/config/security/SecurityConfig.java | 30 ++++ .../config/security/SecurityFilterConfig.java | 33 +++++ .../redis/forbidden/ForbiddenToken.java | 42 ++++++ .../forbidden/ForbiddenTokenRepository.java | 6 + .../forbidden/ForbiddenTokenService.java | 43 ++++++ .../infra/common/exception/JwtErrorCode.java | 1 + 17 files changed, 484 insertions(+), 7 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtExceptionFilter.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/handler/JwtAccessDeniedHandler.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/handler/JwtAuthenticationEntryPoint.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/JwtSecurityConfig.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityFilterConfig.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenToken.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenService.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java index e88c90981..ecd34319c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.dto.SignInReq; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; @@ -17,10 +18,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.time.Duration; import java.util.Map; @@ -69,6 +67,13 @@ public ResponseEntity signIn(@RequestBody @Validated SignInReq.General reques return createAuthenticatedResponse(authUseCase.signIn(request)); } + @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 이용해 액세스 토큰과 리프레시 토큰을 갱신합니다.") + @GetMapping("/refresh") + @PreAuthorize("isAnonymous()") + public ResponseEntity refresh(@CookieValue("refreshToken") @Valid String refreshToken) { + return createAuthenticatedResponse(authUseCase.refresh(refreshToken)); + } + private ResponseEntity createAuthenticatedResponse(Pair userInfo) { ResponseCookie cookie = cookieUtil.createCookie("refreshToken", userInfo.getValue().refreshToken(), Duration.ofDays(7).toSeconds()); return ResponseEntity.ok() diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java index 2cbd4fbdf..34254b52f 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java @@ -5,15 +5,20 @@ import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaim; +import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys; import kr.co.pennyway.common.annotation.Helper; import kr.co.pennyway.domain.common.redis.refresh.RefreshToken; import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.infra.common.exception.JwtErrorCode; +import kr.co.pennyway.infra.common.exception.JwtErrorException; import kr.co.pennyway.infra.common.jwt.JwtProvider; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; import java.time.Duration; import java.time.LocalDateTime; +import java.util.Map; @Slf4j @Helper @@ -47,6 +52,25 @@ public Jwts createToken(User user) { return Jwts.of(accessToken, refreshToken); } + public Pair refresh(String refreshToken) { + Map claims = refreshTokenProvider.getJwtClaimsFromToken(refreshToken).getClaims(); + + Long userId = Long.parseLong((String) claims.get(RefreshTokenClaimKeys.USER_ID.getValue())); + String role = (String) claims.get(RefreshTokenClaimKeys.ROLE.getValue()); + + String newAccessToken = accessTokenProvider.generateToken(AccessTokenClaim.of(userId, role)); + RefreshToken newRefreshToken; + try { + newRefreshToken = refreshTokenService.refresh(userId, refreshToken, refreshTokenProvider.generateToken(RefreshTokenClaim.of(userId, role))); + } catch (IllegalArgumentException e) { + throw new JwtErrorException(JwtErrorCode.EXPIRED_TOKEN); + } catch (IllegalStateException e) { + throw new JwtErrorException(JwtErrorCode.TAKEN_AWAY_TOKEN); + } + + return Pair.of(userId, Jwts.of(newAccessToken, newRefreshToken.getToken())); + } + private long toSeconds(LocalDateTime expiryTime) { return Duration.between(LocalDateTime.now(), expiryTime).getSeconds(); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java index 2086c3bed..56eb0573c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java @@ -61,6 +61,10 @@ public Pair signIn(SignInReq.General request) { return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); } + public Pair refresh(String refreshToken) { + return jwtAuthHelper.refresh(refreshToken); + } + private Pair checkOauthUserNotGeneralSignUp(String phone) { Pair isGeneralSignUpAllowed = userSyncMapper.isGeneralSignUpAllowed(phone); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/RefreshTokenStrategy.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/RefreshTokenStrategy.java index 911bd2039..b29ebda68 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/RefreshTokenStrategy.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/RefreshTokenStrategy.java @@ -8,6 +8,6 @@ ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented -@Qualifier("accessTokenStrategy") +@Qualifier("refreshTokenStrategy") public @interface RefreshTokenStrategy { } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java index dbb101da3..25999c0b1 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.exc.MismatchedInputException; import kr.co.pennyway.api.common.response.ErrorResponse; +import kr.co.pennyway.common.exception.CausedBy; import kr.co.pennyway.common.exception.GlobalErrorException; import kr.co.pennyway.common.exception.ReasonCode; import kr.co.pennyway.common.exception.StatusCode; @@ -11,6 +12,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.security.access.AccessDeniedException; import org.springframework.validation.BindingResult; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingRequestHeaderException; @@ -20,8 +22,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; -import java.nio.file.AccessDeniedException; import java.util.HashMap; import java.util.Map; @@ -54,7 +56,9 @@ protected ResponseEntity handleGlobalErrorException(GlobalErrorEx @ExceptionHandler(AccessDeniedException.class) protected ErrorResponse handleAccessDeniedException(AccessDeniedException e) { log.warn("handleAccessDeniedException : {}", e.getMessage()); - return ErrorResponse.of(String.valueOf(StatusCode.FORBIDDEN.getCode()), e.getMessage()); + CausedBy causedBy = CausedBy.of(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN); + + return ErrorResponse.of(causedBy.getCode(), causedBy.getReason()); } /** @@ -156,6 +160,20 @@ protected ErrorResponse handleNoHandlerFoundException(NoHandlerFoundException e) return ErrorResponse.of(code, e.getMessage()); } + /** + * 존재하지 않는 URL 호출 시 + * + * @see NoHandlerFoundException + */ + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(NoResourceFoundException.class) + protected ErrorResponse handleNoResourceFoundException(NoResourceFoundException e) { + log.warn("handleNoResourceFoundException : {}", e.getMessage()); + + String code = String.valueOf(StatusCode.NOT_FOUND.getCode() * 10 + ReasonCode.INVALID_URL_OR_ENDPOINT.getCode()); + return ErrorResponse.of(code, e.getMessage()); + } + /** * API 호출 시 데이터를 반환할 수 없는 경우 * diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java index 808d3c39f..250d9cfc7 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java @@ -81,4 +81,14 @@ public boolean isCredentialsNonExpired() { public boolean isEnabled() { throw new UnsupportedOperationException(); } + + @Override + public String toString() { + return "SecurityUserDetails{" + + "userId=" + userId + + ", username='" + username + '\'' + + ", authorities=" + authorities + + ", accountNonLocked=" + accountNonLocked + + '}'; + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 000000000..0363fcd03 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,130 @@ +package kr.co.pennyway.api.common.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaimKeys; +import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; +import kr.co.pennyway.infra.common.exception.JwtErrorCode; +import kr.co.pennyway.infra.common.exception.JwtErrorException; +import kr.co.pennyway.infra.common.jwt.JwtClaims; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * JWT 인증 필터
+ * 만약, 유효한 액세스 토큰과 리프레시 토큰이 모두 없다면 익명 사용자로 간주한다.
+ * 인증된 유저는 SecurityContextHolder에 SecurityUser를 등록하며, Controller에서 @AuthenticationPrincipal 어노테이션을 통해 접근할 수 있다. + * + *
+ * {@code
+ *  @GetMapping("/user")
+ *  public ResponseEntity getUser(@AuthenticationPrincipal SecurityUser user) {
+ *      Long userId = user.getId();
+ *      ...
+ *  }
+ * }
+ * 
+ * + * @see org.springframework.security.core.annotation.AuthenticationPrincipal + */ +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final UserDetailsService userDetailService; + private final ForbiddenTokenService forbiddenTokenService; + + private final JwtProvider accessTokenProvider; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { + if (isAnonymousRequest(request)) { + filterChain.doFilter(request, response); + return; + } + + String accessToken = resolveAccessToken(request, response); + + UserDetails userDetails = getUserDetails(accessToken); + authenticateUser(userDetails, request); + filterChain.doFilter(request, response); + } + + /** + * AccessToken과 RefreshToken이 모두 없는 경우, 익명 사용자로 간주한다. + */ + private boolean isAnonymousRequest(HttpServletRequest request) { + String accessToken = request.getHeader(HttpHeaders.AUTHORIZATION); + String refreshToken = request.getHeader(HttpHeaders.SET_COOKIE); + + return accessToken == null && refreshToken == null; + } + + /** + * @throws ServletException : Authorization 헤더가 없거나, 금지된 토큰이거나, 토큰이 만료된 경우 예외 발생 + */ + private String resolveAccessToken(HttpServletRequest request, HttpServletResponse response) throws ServletException { + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + String token = accessTokenProvider.resolveToken(authHeader); + + if (!StringUtils.hasText(token)) { + handleAuthException(JwtErrorCode.EMPTY_ACCESS_TOKEN); + } + + if (forbiddenTokenService.isForbidden(token)) { + handleAuthException(JwtErrorCode.FORBIDDEN_ACCESS_TOKEN); + } + + if (accessTokenProvider.isTokenExpired(token)) { + handleAuthException(JwtErrorCode.EXPIRED_TOKEN); + } + + return token; + } + + /** + * UserDetailsService를 통해 SecurityUser를 가져오는 메서드 + */ + private UserDetails getUserDetails(String accessToken) { + JwtClaims claims = accessTokenProvider.getJwtClaimsFromToken(accessToken); + String userId = (String) claims.getClaims().get(AccessTokenClaimKeys.USER_ID.getValue()); + + return userDetailService.loadUserByUsername(userId); + } + + /** + * SecurityContextHolder에 SecurityUser를 등록하는 메서드 + */ + private void authenticateUser(UserDetails userDetails, HttpServletRequest request) { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + log.info("Authenticated user: {}", userDetails.getUsername()); + } + + /** + * 인증 예외가 발생했을 때, 로그를 남기고 예외를 던지는 메서드 + */ + private void handleAuthException(JwtErrorCode errorCode) throws ServletException { + log.warn("AuthErrorException(code={}, message={})", errorCode.name(), errorCode.getExplainError()); + JwtErrorException exception = new JwtErrorException(errorCode); + throw new ServletException(exception); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtExceptionFilter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtExceptionFilter.java new file mode 100644 index 000000000..574c71f7b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtExceptionFilter.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.api.common.security.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.co.pennyway.api.common.response.ErrorResponse; +import kr.co.pennyway.infra.common.exception.JwtErrorException; +import kr.co.pennyway.infra.common.util.JwtErrorCodeUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtExceptionFilter extends OncePerRequestFilter { + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (Exception e) { + log.warn("Exception caught in JwtExceptionFilter: {}", e.getMessage()); + JwtErrorException exception = JwtErrorCodeUtil.determineAuthErrorException(e); + + sendAuthError(response, exception); + } + } + + private void sendAuthError(HttpServletResponse response, JwtErrorException e) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(e.getErrorCode().getStatusCode().getCode()); + + ErrorResponse errorResponse = ErrorResponse.of(e.causedBy().getCode(), e.causedBy().getReason()); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/handler/JwtAccessDeniedHandler.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/handler/JwtAccessDeniedHandler.java new file mode 100644 index 000000000..46198b10d --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.api.common.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.co.pennyway.api.common.response.ErrorResponse; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + log.warn("handle error: {}", accessDeniedException.getMessage()); + CausedBy causedBy = CausedBy.of(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN); + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(causedBy.statusCode().getCode()); + ErrorResponse errorResponse = ErrorResponse.of(causedBy.getCode(), causedBy.getReason()); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/handler/JwtAuthenticationEntryPoint.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 000000000..d2003546b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.api.common.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.co.pennyway.api.common.response.ErrorResponse; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + log.warn("commence error: {}", authException.getMessage()); + CausedBy causedBy = CausedBy.of(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS); + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(causedBy.statusCode().getCode()); + ErrorResponse errorResponse = ErrorResponse.of(causedBy.getCode(), causedBy.getReason()); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/JwtSecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/JwtSecurityConfig.java new file mode 100644 index 000000000..70133bf1f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/JwtSecurityConfig.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.api.config.security; + +import kr.co.pennyway.api.common.security.filter.JwtAuthenticationFilter; +import kr.co.pennyway.api.common.security.filter.JwtExceptionFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +public class JwtSecurityConfig extends SecurityConfigurerAdapter { + private final JwtExceptionFilter jwtExceptionFilter; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Override + public void configure(HttpSecurity http) throws Exception { + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java index 7db0010bc..c1ae423d3 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java @@ -1,16 +1,25 @@ package kr.co.pennyway.api.config.security; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.common.security.handler.JwtAccessDeniedHandler; +import kr.co.pennyway.api.common.security.handler.JwtAuthenticationEntryPoint; import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity; +import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -19,6 +28,7 @@ @Configuration @EnableWebSecurity +@ConditionalOnDefaultWebSecurity @RequiredArgsConstructor public class SecurityConfig { private static final String[] publicReadOnlyPublicEndpoints = { @@ -26,6 +36,8 @@ public class SecurityConfig { // Swagger "/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger", }; + private final ObjectMapper objectMapper; + private final JwtSecurityConfig jwtSecurityConfig; @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { @@ -33,6 +45,17 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() { } @Bean + public AccessDeniedHandler accessDeniedHandler() { + return new JwtAccessDeniedHandler(objectMapper); + } + + @Bean + public AuthenticationEntryPoint authenticationEntryPoint() { + return new JwtAuthenticationEntryPoint(objectMapper); + } + + @Bean + @Order(SecurityProperties.BASIC_AUTH_ORDER) public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) @@ -41,12 +64,19 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .logout(AbstractHttpConfigurer::disable) .sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .with(jwtSecurityConfig, Customizer.withDefaults()) .authorizeHttpRequests( auth -> auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() .requestMatchers(HttpMethod.OPTIONS, "*").permitAll() .requestMatchers(HttpMethod.GET, publicReadOnlyPublicEndpoints).permitAll() .anyRequest().permitAll() + ) + .exceptionHandling( + exception -> exception + .accessDeniedHandler(accessDeniedHandler()) + .authenticationEntryPoint(authenticationEntryPoint()) ); + return http.build(); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityFilterConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityFilterConfig.java new file mode 100644 index 000000000..fb32425a6 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityFilterConfig.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.api.config.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.common.security.filter.JwtAuthenticationFilter; +import kr.co.pennyway.api.common.security.filter.JwtExceptionFilter; +import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.userdetails.UserDetailsService; + +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@Configuration +public class SecurityFilterConfig { + private final UserDetailsService userDetailServiceImpl; + private final ForbiddenTokenService forbiddenTokenService; + + private final JwtProvider accessTokenProvider; + + private final ObjectMapper objectMapper; + + @Bean + public JwtExceptionFilter jwtExceptionFilter() { + return new JwtExceptionFilter(objectMapper); + } + + @Bean + public JwtAuthenticationFilter jwtAuthorizationFilter() { + return new JwtAuthenticationFilter(userDetailServiceImpl, forbiddenTokenService, accessTokenProvider); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenToken.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenToken.java new file mode 100644 index 000000000..84dc4cd93 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenToken.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.domain.common.redis.forbidden; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@RedisHash("forbiddenToken") +public class ForbiddenToken { + @Id + private final String accessToken; + private final Long userId; + @TimeToLive + private final long ttl; + + @Builder + private ForbiddenToken(String accessToken, Long userId, long ttl) { + this.accessToken = accessToken; + this.userId = userId; + this.ttl = ttl; + } + + public static ForbiddenToken of(String accessToken, Long userId, long ttl) { + return new ForbiddenToken(accessToken, userId, ttl); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ForbiddenToken that)) return false; + return accessToken.equals(that.accessToken) && userId.equals(that.userId); + } + + @Override + public int hashCode() { + int result = accessToken.hashCode(); + result = ((1 << 5) - 1) * result + userId.hashCode(); + return result; + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenRepository.java new file mode 100644 index 000000000..83db477af --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenRepository.java @@ -0,0 +1,6 @@ +package kr.co.pennyway.domain.common.redis.forbidden; + +import org.springframework.data.repository.CrudRepository; + +public interface ForbiddenTokenRepository extends CrudRepository { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenService.java new file mode 100644 index 000000000..12ea5d41b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenService.java @@ -0,0 +1,43 @@ +package kr.co.pennyway.domain.common.redis.forbidden; + +import kr.co.pennyway.common.annotation.DomainService; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.Duration; +import java.time.LocalDateTime; + +@Slf4j +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@DomainService +public class ForbiddenTokenService { + private final ForbiddenTokenRepository forbiddenTokenRepository; + + /** + * 토큰을 블랙 리스트에 등록합니다. + * + * @param accessToken String : 블랙 리스트에 등록할 액세스 토큰 + * @param userId Long : 블랙 리스트에 등록할 유저 아이디 + * @param expiresAt LocalDateTime : 블랙 리스트에 등록할 토큰의 만료 시간 (등록할 access token의 만료시간을 추출한 값) + */ + public void createForbiddenToken(String accessToken, Long userId, LocalDateTime expiresAt) { + final LocalDateTime now = LocalDateTime.now(); + final long timeToLive = Duration.between(now, expiresAt).toSeconds(); + + log.info("forbidden token ttl : {}", timeToLive); + + ForbiddenToken forbiddenToken = ForbiddenToken.of(accessToken, userId, timeToLive); + forbiddenTokenRepository.save(forbiddenToken); + log.info("forbidden token registered. about User : {}", forbiddenToken.getUserId()); + } + + /** + * 토큰이 블랙 리스트에 등록되어 있는지 확인합니다. + * + * @return : 블랙 리스트에 등록되어 있으면 true, 아니면 false + */ + public boolean isForbidden(String accessToken) { + return forbiddenTokenRepository.existsById(accessToken); + } +} \ No newline at end of file diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java index 4568bd417..862f03bfc 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java @@ -33,6 +33,7 @@ public enum JwtErrorCode implements BaseErrorCode { * 403 FORBIDDEN: 인증된 클라이언트가 권한이 없는 자원에 접근 */ FORBIDDEN_ACCESS_TOKEN(FORBIDDEN, ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "해당 토큰에는 엑세스 권한이 없습니다"), + TAKEN_AWAY_TOKEN(FORBIDDEN, ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "탈취당한 토큰입니다. 다시 로그인 해주세요."), SUSPENDED_OR_BANNED_TOKEN(FORBIDDEN, USER_ACCOUNT_SUSPENDED_OR_BANNED, "사용자 계정이 정지되었습니다"), /** From 6334e72288b087531b551fd96c8299d8eb522cbb Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 29 Mar 2024 14:07:09 +0900 Subject: [PATCH 035/152] =?UTF-8?q?=F0=9F=93=91=20Readme=20v0.0.2=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: erd 추가 * docs: 라이브러리 버전 수정 * docs: version 관리 설명 추가 --- README.md | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index cf6bbf055..42eade435 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ ## 💰 Pennyway + > 지출 관리 SNS 플랫폼 -| Version # | Revision Date | Description | Author | -|:---------:|:-------------:|:--------------|:------:| -| v0.0.1 | 2024.03.07 | 프로젝트 기본 설명 작성 | 양재서 | +| Version # | Revision Date | Description | Author | +|:---------:|:-------------:|:------------------------------|:------:| +| v0.0.1 | 2024.03.07 | 프로젝트 기본 설명 작성 | 양재서 | +| v0.0.2 | 2024.03.29 | ERD 추가, 라이브러리 버전 수정, Infra 추가 | 양재서 |
@@ -38,7 +40,9 @@
## 🌳 Branch Convention + > 💡 Git-Flow 전략을 사용합니다. + - main - 배포 가능한 상태의 코드만을 관리하는 프로덕션용 브랜치 - PM(양재서)의 승인 후 병합 가능 @@ -55,7 +59,9 @@
## 🤝 Commit Convention + > 💡 angular commit convention + - feat: 신규 기능 추가 - fix: 버그 수정 - docs: 문서 수정 @@ -68,6 +74,7 @@
## 📌 Architecture + ### 1️⃣ System Architecture
@@ -89,35 +96,45 @@ ### 4️⃣ ERD
- +

## 📗 Tech Stack + ### 1️⃣ Framework & Library + - JDK 17 - SpringBoot 3.2.3 -- SpringBoot Security 6.2.2 +- Spring Boot Starter Security 3.2.4 - Spring Data JPA 3.2.3 -- Spring Doc Open API 2.3.0 +- QueryDsl 5.0.0 +- Spring Doc Open API 2.4.0 - Lombok 1.18.30 - [JUnit 5](https://junit.org/junit5/docs/current/user-guide/) -- jjwt 0.11.5 +- jjwt 0.12.5 - httpclient5 5.2.25.RELEASE - OpenFeign 4.0.6 -### 2️⃣ Infrastructure Architecture +### 2️⃣ Build Tools + - Gradle 7.6.4 -### 3️⃣ Multi Module Architecture +### 3️⃣ Database + - MySQL 8 -- Redis 7.0 +- Redis 7.2.4 -### 4️⃣ ERD -- AWS EC2 (for Build Server) -- AWS GW +### 4️⃣ Infra + +- AWS EC2 (for Build Server, Bastion Server) +- AWS NAT Gateway - AWS S3 +- AWS Route53 +- AWS VPC +- AWS Elastic Load Balancer +- AWS SNS - Docker & Docker-compose - Ngnix - GitHub Actions From f9251609ee596bb2ed3516b7ae0d0b38d0908081 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sun, 31 Mar 2024 23:43:07 +0900 Subject: [PATCH 036/152] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20Swagger=20+=20Secu?= =?UTF-8?q?rity=20=EC=88=98=EC=A0=95=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * rename: 로그인 요청 dto @schema 추가 * chore: server domain 환경 설정 external-api -> infra * chore: server domain property bean 등록 * chore: application-infra server 블럭 수정 * fix: swagger server url 환경변수 경로 수정 * chore: cors 설정 추가 * refactor: cors 설정 파일 분리 * rename: jwt security config -> security adpater config * chore: bcryptpasswordencoder -> passwordencoder * fix: 동일한 클래스명의 DTO @Schema name 속성 설정 * refactor: 운영 환경 별 security filter chain 설정 분리 * chore: external api 모듈 내 jackson nullable module 종속성 추가 * refactor: security auth config 분리 * refactor: security config swagger endpoint 프로필 별 설정 분리 * feat: simple granted authority 역직렬화 이슈로 custom granted authority 클래스 선언 * chore: external-api 내 jackson config 설정 * fix: security user details 역직렬화 문제 해결 * fix: security config 개발 환경 옵션 수정 * fix: custom granted authority equals 수정 * chore: docker hub 경로 수정 --- .github/workflows/deploy.yml | 6 +- pennyway-app-external-api/build.gradle | 3 + .../pennyway/api/apis/auth/dto/SignInReq.java | 4 +- .../pennyway/api/apis/auth/dto/SignUpReq.java | 2 +- .../CustomGrantedAuthority.java | 48 ++++++++++ .../authentication/SecurityUserDetails.java | 26 +++--- .../co/pennyway/api/config/JacksonConfig.java | 20 +++++ .../co/pennyway/api/config/SwaggerConfig.java | 4 +- .../api/config/security/CorsConfig.java | 33 +++++++ ...Config.java => SecurityAdapterConfig.java} | 6 +- .../config/security/SecurityAuthConfig.java | 52 +++++++++++ .../api/config/security/SecurityConfig.java | 88 +++++++++---------- .../src/main/resources/application.yml | 5 -- .../common/properties/ServerProperties.java | 13 +++ .../config/ConfigurationPropertiesConfig.java | 12 +++ .../src/main/resources/application-infra.yml | 6 ++ 16 files changed, 254 insertions(+), 74 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/CustomGrantedAuthority.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/JacksonConfig.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/CorsConfig.java rename pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/{JwtSecurityConfig.java => SecurityAdapterConfig.java} (75%) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityAuthConfig.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ServerProperties.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/config/ConfigurationPropertiesConfig.java diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index afaf055b2..724c74584 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -55,8 +55,8 @@ jobs: - name: docker build and push run: | docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} - docker build -t jinlee1703/pennyway-was . - docker push jinlee1703/pennyway-was + docker build -t pennyway/pennyway-was . + docker push pennyway/pennyway-was # 5. AWS SSM을 통한 Run-Command (Docker 이미지 pull 후 docker-compose를 통한 실행) - name: AWS SSM Send-Command @@ -71,5 +71,5 @@ jobs: command: | docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} docker system prune -a -f - docker pull jinlee1703/pennyway-was + docker pull pennyway/pennyway-was docker-compose up -d \ No newline at end of file diff --git a/pennyway-app-external-api/build.gradle b/pennyway-app-external-api/build.gradle index dd868716b..2ae563a35 100644 --- a/pennyway-app-external-api/build.gradle +++ b/pennyway-app-external-api/build.gradle @@ -24,6 +24,9 @@ dependencies { /* Swagger */ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0' + /* jackson */ + implementation group: 'org.openapitools', name: 'jackson-databind-nullable', version: '0.2.6' + implementation 'org.springframework.boot:spring-boot-starter-web:3.2.3' implementation 'org.springframework.boot:spring-boot-starter-validation:3.2.3' } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java index 97cca37b8..64e4f4668 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java @@ -2,11 +2,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; -@NoArgsConstructor(access = AccessLevel.PRIVATE) public class SignInReq { + @Schema(name = "signInReqGeneral", title = "로그인 요청") public record General( @Schema(description = "아이디", example = "pennyway") @NotBlank(message = "아이디를 입력해주세요") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java index fe234eddf..2ce7ad446 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java @@ -40,7 +40,7 @@ public String password() { } } - @Schema(title = "일반 회원가입 요청 DTO") + @Schema(name = "signUpReqGeneral", title = "일반 회원가입 요청 DTO") public record General( @Schema(description = "아이디", example = "pennyway") @NotBlank(message = "아이디를 입력해주세요") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/CustomGrantedAuthority.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/CustomGrantedAuthority.java new file mode 100644 index 000000000..f6fc3848a --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/CustomGrantedAuthority.java @@ -0,0 +1,48 @@ +package kr.co.pennyway.api.common.security.authentication; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.util.Assert; + +import java.io.Serial; +import java.io.Serializable; + +public final class CustomGrantedAuthority implements GrantedAuthority, Serializable { + @Serial + private static final long serialVersionUID = 1L; + + private final String role; + + @JsonCreator + public CustomGrantedAuthority(@JsonProperty("authority") String role) { + Assert.hasText(role, "role must not be empty"); + this.role = role; + } + + @Override + public String getAuthority() { + return role; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj instanceof CustomGrantedAuthority cga) { + return this.role.equals(cga.getAuthority()); + } + return false; + } + + @Override + public int hashCode() { + return this.role.hashCode(); + } + + @Override + public String toString() { + return this.role; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java index 250d9cfc7..f5dc6ac68 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java @@ -2,21 +2,27 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import java.util.Arrays; +import java.io.Serial; import java.util.Collection; +import java.util.List; @Getter -public class SecurityUserDetails implements UserDetails { - private final Long userId; - private final String username; - private final Collection authorities; - private final boolean accountNonLocked; +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class SecurityUserDetails implements UserDetails { + @Serial + private static final long serialVersionUID = 1L; + + private Long userId; + private String username; + private Collection authorities; + private boolean accountNonLocked; @JsonIgnore private boolean enabled; @@ -39,10 +45,7 @@ public static UserDetails from(User user) { return SecurityUserDetails.builder() .userId(user.getId()) .username(user.getUsername()) - .authorities(Arrays.stream(Role.values()) - .filter(roleType -> roleType == user.getRole()) - .map(roleType -> (GrantedAuthority) roleType::getType) - .toList()) + .authorities(List.of(new CustomGrantedAuthority(user.getRole().getType()))) .accountNonLocked(user.getLocked()) .build(); } @@ -91,4 +94,5 @@ public String toString() { ", accountNonLocked=" + accountNonLocked + '}'; } + } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/JacksonConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/JacksonConfig.java new file mode 100644 index 000000000..d2d1e035b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/JacksonConfig.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.api.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.RequiredArgsConstructor; +import org.openapitools.jackson.nullable.JsonNullableModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +@Configuration +@RequiredArgsConstructor +public class JacksonConfig { + @Bean + Jackson2ObjectMapperBuilder jackson2ObjectMapperBuilder() { + return new Jackson2ObjectMapperBuilder() + .serializationInclusion(JsonInclude.Include.ALWAYS) + .modulesToInstall(new JsonNullableModule()) + .indentOutput(true); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java index ab6515cd3..174c3bcbf 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java @@ -17,8 +17,8 @@ @Configuration @OpenAPIDefinition( servers = { - @Server(url = "${pennyway.domain.local}", description = "Local Server"), - @Server(url = "${pennyway.domain.dev}", description = "Develop Server") + @Server(url = "${pennyway.server.domain.local}", description = "Local Server"), + @Server(url = "${pennyway.server.domain.dev}", description = "Develop Server") } ) @RequiredArgsConstructor diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/CorsConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/CorsConfig.java new file mode 100644 index 000000000..e0aaf5925 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/CorsConfig.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.api.config.security; + +import kr.co.pennyway.infra.common.properties.ServerProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class CorsConfig { + private final ServerProperties serverProperties; + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of(serverProperties.getLocal(), serverProperties.getDev())); + configuration.setAllowedMethods(List.of("GET", "POST", "OPTIONS", "PUT", "PATCH", "DELETE")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setExposedHeaders(List.of(HttpHeaders.AUTHORIZATION, HttpHeaders.SET_COOKIE)); + configuration.setMaxAge(3600L); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/JwtSecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityAdapterConfig.java similarity index 75% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/JwtSecurityConfig.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityAdapterConfig.java index 70133bf1f..93bed2abf 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/JwtSecurityConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityAdapterConfig.java @@ -4,6 +4,7 @@ import kr.co.pennyway.api.common.security.filter.JwtExceptionFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.SecurityConfigurerAdapter; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.DefaultSecurityFilterChain; @@ -11,12 +12,15 @@ @Configuration @RequiredArgsConstructor -public class JwtSecurityConfig extends SecurityConfigurerAdapter { +public class SecurityAdapterConfig extends SecurityConfigurerAdapter { + private final DaoAuthenticationProvider daoAuthenticationProvider; + private final JwtExceptionFilter jwtExceptionFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter; @Override public void configure(HttpSecurity http) throws Exception { + http.authenticationProvider(daoAuthenticationProvider); http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); http.addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityAuthConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityAuthConfig.java new file mode 100644 index 000000000..367dc6090 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityAuthConfig.java @@ -0,0 +1,52 @@ +package kr.co.pennyway.api.config.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.common.security.authentication.UserDetailServiceImpl; +import kr.co.pennyway.api.common.security.handler.JwtAccessDeniedHandler; +import kr.co.pennyway.api.common.security.handler.JwtAuthenticationEntryPoint; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; + +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@Configuration +public class SecurityAuthConfig { + private final UserDetailServiceImpl userDetailsService; + private final ObjectMapper objectMapper; + + @Bean + public PasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AccessDeniedHandler accessDeniedHandler() { + return new JwtAccessDeniedHandler(objectMapper); + } + + @Bean + public AuthenticationEntryPoint authenticationEntryPoint() { + return new JwtAuthenticationEntryPoint(objectMapper); + } + + @Bean + public DaoAuthenticationProvider daoAuthenticationProvider() { + DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); + daoAuthenticationProvider.setUserDetailsService(userDetailsService); + daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder()); + return daoAuthenticationProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java index c1ae423d3..024f787cc 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java @@ -1,94 +1,86 @@ package kr.co.pennyway.api.config.security; -import com.fasterxml.jackson.databind.ObjectMapper; -import kr.co.pennyway.api.common.security.handler.JwtAccessDeniedHandler; -import kr.co.pennyway.api.common.security.handler.JwtAuthenticationEntryPoint; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity; import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.AbstractRequestMatcherRegistry; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandler; -import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import java.util.Arrays; @Configuration @EnableWebSecurity @ConditionalOnDefaultWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private static final String[] publicReadOnlyPublicEndpoints = { + private static final String[] READ_ONLY_PUBLIC_ENDPOINTS = { "/favicon.ico", // Swagger "/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger", }; - private final ObjectMapper objectMapper; - private final JwtSecurityConfig jwtSecurityConfig; + private static final String[] ANONYMOUS_ENDPOINTS = {"/v1/auth/**"}; - @Bean - public BCryptPasswordEncoder bCryptPasswordEncoder() { - return new BCryptPasswordEncoder(); - } + private final SecurityAdapterConfig securityAdapterConfig; + private final CorsConfigurationSource corsConfigurationSource; + private final AccessDeniedHandler accessDeniedHandler; + private final AuthenticationEntryPoint authenticationEntryPoint; @Bean - public AccessDeniedHandler accessDeniedHandler() { - return new JwtAccessDeniedHandler(objectMapper); + @Profile({"local", "dev", "test"}) + @Order(SecurityProperties.BASIC_AUTH_ORDER) + public SecurityFilterChain filterChainDev(HttpSecurity http) throws Exception { + return defaultSecurity(http) + .cors((cors) -> cors.configurationSource(corsConfigurationSource)) + .authorizeHttpRequests( + auth -> defaultAuthorizeHttpRequests(auth) + .requestMatchers(READ_ONLY_PUBLIC_ENDPOINTS).permitAll() + .anyRequest().authenticated() + ).build(); } @Bean - public AuthenticationEntryPoint authenticationEntryPoint() { - return new JwtAuthenticationEntryPoint(objectMapper); + @Profile({"prod"}) + @Order(SecurityProperties.BASIC_AUTH_ORDER) + public SecurityFilterChain filterChainProd(HttpSecurity http) throws Exception { + return defaultSecurity(http) + .cors(AbstractHttpConfigurer::disable) + .authorizeHttpRequests( + auth -> defaultAuthorizeHttpRequests(auth).anyRequest().authenticated() + ).build(); } - @Bean - @Order(SecurityProperties.BASIC_AUTH_ORDER) - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.csrf(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .cors((cors) -> cors.configurationSource(corsConfigurationSource())) - .formLogin(AbstractHttpConfigurer::disable) - .logout(AbstractHttpConfigurer::disable) + private HttpSecurity defaultSecurity(HttpSecurity http) throws Exception { + return http.httpBasic(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) .sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .with(jwtSecurityConfig, Customizer.withDefaults()) - .authorizeHttpRequests( - auth -> auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() - .requestMatchers(HttpMethod.OPTIONS, "*").permitAll() - .requestMatchers(HttpMethod.GET, publicReadOnlyPublicEndpoints).permitAll() - .anyRequest().permitAll() - ) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .with(securityAdapterConfig, Customizer.withDefaults()) .exceptionHandling( exception -> exception - .accessDeniedHandler(accessDeniedHandler()) - .authenticationEntryPoint(authenticationEntryPoint()) + .accessDeniedHandler(accessDeniedHandler) + .authenticationEntryPoint(authenticationEntryPoint) ); - - return http.build(); } - // TODO: dev, test, prod 환경이 정해지면 수정 필요. - @Bean - CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.addAllowedOrigin("http://localhost:3000"); - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "OPTIONS", "PUT", "PATCH", "DELETE")); - configuration.setAllowCredentials(true); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; + private AbstractRequestMatcherRegistry.AuthorizedUrl> defaultAuthorizeHttpRequests( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth) { + return auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .requestMatchers(HttpMethod.OPTIONS, "*").permitAll() + .requestMatchers(ANONYMOUS_ENDPOINTS).anonymous(); } } diff --git a/pennyway-app-external-api/src/main/resources/application.yml b/pennyway-app-external-api/src/main/resources/application.yml index d13f5062a..2d360b542 100644 --- a/pennyway-app-external-api/src/main/resources/application.yml +++ b/pennyway-app-external-api/src/main/resources/application.yml @@ -4,11 +4,6 @@ spring: local: common, domain, infra dev: common, domain, infra -pennyway: - domain: - local: ${PENNYWAY_DOMAIN_LOCAL} - dev: ${PENNYWAY_DOMAIN_DEV} - jwt: secret-key: access-token: ${JWT_ACCESS_SECRET_KEY} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ServerProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ServerProperties.java new file mode 100644 index 000000000..10fb35942 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/ServerProperties.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.infra.common.properties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "pennyway.server.domain") +public class ServerProperties { + private final String local; + private final String dev; +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/ConfigurationPropertiesConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/ConfigurationPropertiesConfig.java new file mode 100644 index 000000000..63c859194 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/ConfigurationPropertiesConfig.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.infra.config; + +import kr.co.pennyway.infra.common.properties.ServerProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@EnableConfigurationProperties({ + ServerProperties.class +}) +@Configuration +public class ConfigurationPropertiesConfig { +} diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index ee08bd17e..f9c75be13 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -4,6 +4,12 @@ spring: local: common dev: common +pennyway: + server: + domain: + local: ${PENNYWAY_DOMAIN_LOCAL} + dev: ${PENNYWAY_DOMAIN_DEV} + --- spring: config: From b6943e49a416f8d5f4c976e05e98c5486f1852a8 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 1 Apr 2024 20:27:59 +0900 Subject: [PATCH 037/152] =?UTF-8?q?=E2=9C=A8=20=EB=8B=89=EB=84=A4=EC=9E=84?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=EA=B2=80=EC=82=AC=20API=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * rename: auth controller '일반 회원 가입' 전화번호 인증 swagger 문서 상 명시 * feat: 닉네임 중복 검사 domain service 메서드 추가 * feat: username 중복 검사 api 개방 * fix: 중복 검사 체크 url을 anonymous endpoints에 추가 * fix: swagger endpoints와 read only public endpoints 분리 * fix: 닉네임 중복 검사 인가 기준 permit-all로 변경 * rename: is-exist-nickname -> is-exist-username * rename: auth check controller 매개변수명 username으로 수정 --- .../auth/controller/AuthCheckController.java | 31 +++++++++++++++++++ .../apis/auth/controller/AuthController.java | 4 +-- .../apis/auth/usecase/AuthCheckUseCase.java | 19 ++++++++++++ .../api/config/security/SecurityConfig.java | 10 +++--- .../user/repository/UserRepository.java | 2 ++ .../domains/user/service/UserService.java | 5 +++ 6 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java new file mode 100644 index 000000000..1e1d826e5 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.api.apis.auth.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.auth.usecase.AuthCheckUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@Tag(name = "[계정 검사 API]") +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/duplicate") +public class AuthCheckController { + private final AuthCheckUseCase authCheckUseCase; + + @Operation(summary = "닉네임 중복 검사") + @GetMapping("/username") + @PreAuthorize("permitAll()") + public ResponseEntity checkUsername(@RequestParam @Validated String username) { + return ResponseEntity.ok(SuccessResponse.from("isDuplicate", authCheckUseCase.checkUsernameDuplicate(username))); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java index ecd34319c..d27787b8c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java @@ -32,14 +32,14 @@ public class AuthController { private final AuthUseCase authUseCase; private final CookieUtil cookieUtil; - @Operation(summary = "인증번호 전송") + @Operation(summary = "일반 회원가입 인증번호 전송") @PostMapping("/phone") @PreAuthorize("isAnonymous()") public ResponseEntity sendCode(@RequestBody @Validated PhoneVerificationDto.PushCodeReq request) { return ResponseEntity.ok(SuccessResponse.from("sms", authUseCase.sendCode(request))); } - @Operation(summary = "인증번호 검증") + @Operation(summary = "일반 회원가입 인증번호 검증") @PostMapping("/phone/verification") @PreAuthorize("isAnonymous()") public ResponseEntity verifyCode(@RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request) { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java new file mode 100644 index 000000000..13ba7064d --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.api.apis.auth.usecase; + +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class AuthCheckUseCase { + private final UserService userService; + + @Transactional(readOnly = true) + public boolean checkUsernameDuplicate(String username) { + return userService.isExistUsername(username); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java index 024f787cc..8fd2aa71d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java @@ -26,12 +26,9 @@ @ConditionalOnDefaultWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private static final String[] READ_ONLY_PUBLIC_ENDPOINTS = { - "/favicon.ico", - // Swagger - "/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger", - }; + private static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/v1/duplicate/**"}; private static final String[] ANONYMOUS_ENDPOINTS = {"/v1/auth/**"}; + private static final String[] SWAGGER_ENDPOINTS = {"/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger",}; private final SecurityAdapterConfig securityAdapterConfig; private final CorsConfigurationSource corsConfigurationSource; @@ -46,7 +43,7 @@ public SecurityFilterChain filterChainDev(HttpSecurity http) throws Exception { .cors((cors) -> cors.configurationSource(corsConfigurationSource)) .authorizeHttpRequests( auth -> defaultAuthorizeHttpRequests(auth) - .requestMatchers(READ_ONLY_PUBLIC_ENDPOINTS).permitAll() + .requestMatchers(SWAGGER_ENDPOINTS).permitAll() .anyRequest().authenticated() ).build(); } @@ -81,6 +78,7 @@ private AbstractRequestMatcherRegistry.AuthorizationManagerRequestMatcherRegistry auth) { return auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() .requestMatchers(HttpMethod.OPTIONS, "*").permitAll() + .requestMatchers(HttpMethod.GET, READ_ONLY_PUBLIC_ENDPOINTS).permitAll() .requestMatchers(ANONYMOUS_ENDPOINTS).anonymous(); } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java index f695b5be9..a16ccdb8e 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java @@ -9,4 +9,6 @@ public interface UserRepository extends JpaRepository { Optional findByPhone(String phone); Optional findByUsername(String username); + + boolean existsByUsername(String username); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java index 27ee12580..66726392e 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java @@ -37,4 +37,9 @@ public Optional readUserByUsername(String username) { public boolean isExistUser(Long id) { return userRepository.existsById(id); } + + @Transactional(readOnly = true) + public boolean isExistUsername(String username) { + return userRepository.existsByUsername(username); + } } From 9d910ab2e27cc96b7eda8b88d1e7758afc429ffe Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 3 Apr 2024 10:40:24 +0900 Subject: [PATCH 038/152] =?UTF-8?q?=E2=9C=A8=20OIDC=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=ED=99=94=20(+?= =?UTF-8?q?=20component=20scan=EC=97=90=20=EB=8C=80=ED=95=9C=20=EA=B3=A0?= =?UTF-8?q?=EC=B0=B0)=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: infra 모듈 내 feign 의존성 주입 * feat: oidc dto 정의 * feat: oidc public key response 객체 정의 * feat: oauth oidc client 인터페이스 정의 * feat: oidc token parsing provider 정의 * feat: oidc provider 환경 변수 정보를 가져올 인터페이스 정의 * rename: oidc 카멜케이스로 변경 * chore: provider 별 jwks-uri 및 secret 환경변수 주입 * feat: apple, google, kakao oidc 환경 변수 주입 * feat: oidc configuration properties config 세팅 * feat: default feign config 설정 * feat: common module 내 map utils 작성 * rename: oidc cache manager 빈 이름 오타 수정 * fix: oidc properties 필드 final 변경 * fix: infra properties 설정 api 모듈로 이전 * feat: provider 별 feign interface 정의 * chore: infra application 패키지 경로 수정 * chore: cache config @configuration 어노테이션 재삽입 * chore: infra config -> api 모듈에서 사용할 infra 모듈의 properties 명시 * feat: infra config maker 인터페이스 및 열거 타입 생성 * feat: infra 모듈 confg import selector 정의 * feat: infra를 의존하는 모듈에서 동적으로 인프라 구성을 명시적으로 선택하기 위한 어노테이션 작성 * feat: oidc 도우미 클래스 작성 * fix: cache config 클래스 마커 인터페이스 구현 제거 * fix: client-secret -> secret 필드 변경 * chore: feign config 설정 * fix: cache config @configuration 어노테이션 복구 --- .../api/apis/auth/helper/OauthOidcHelper.java | 41 +++++++ .../co/pennyway/api/config/InfraConfig.java | 18 +++ .../kr/co/pennyway/common/util/MapUtils.java | 20 ++++ pennyway-infra/build.gradle | 9 +- .../co/pennyway/PennywayInfraApplication.java | 5 - .../infra/PennywayInfraApplication.java | 8 ++ .../client/apple/oidc/AppleOidcClient.java | 22 ++++ .../client/google/oidc/GoogleOidcClient.java | 22 ++++ .../client/kakao/oidc/KakaoOidcClient.java | 21 ++++ .../importer/EnablePennywayInfraConfig.java | 15 +++ .../common/importer/PennywayInfraConfig.java | 7 ++ .../importer/PennywayInfraConfigGroup.java | 12 ++ .../PennywayInfraConfigImportSelector.java | 25 +++++ .../infra/common/oidc/OauthOidcClient.java | 5 + .../oidc/OauthOidcClientProperties.java | 7 ++ .../infra/common/oidc/OauthOidcProvider.java | 24 ++++ .../common/oidc/OauthOidcProviderImpl.java | 105 ++++++++++++++++++ .../infra/common/oidc/OidcDecodePayload.java | 12 ++ .../infra/common/oidc/OidcPublicKey.java | 11 ++ .../common/oidc/OidcPublicKeyResponse.java | 19 ++++ .../properties/AppleOidcProperties.java | 14 +++ .../properties/GoogleOidcProperties.java | 14 +++ .../properties/KakaoOidcProperties.java | 14 +++ .../co/pennyway/infra/config/CacheConfig.java | 2 +- .../config/ConfigurationPropertiesConfig.java | 12 -- .../infra/config/DefaultFeignConfig.java | 18 +++ .../co/pennyway/infra/config/FeignConfig.java | 13 +++ .../src/main/resources/application-infra.yml | 13 +++ 28 files changed, 488 insertions(+), 20 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/util/MapUtils.java delete mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/PennywayInfraApplication.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/PennywayInfraApplication.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/apple/oidc/AppleOidcClient.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/oidc/GoogleOidcClient.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/kakao/oidc/KakaoOidcClient.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/EnablePennywayInfraConfig.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfig.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigGroup.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigImportSelector.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClient.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClientProperties.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProvider.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OidcDecodePayload.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OidcPublicKey.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OidcPublicKeyResponse.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/AppleOidcProperties.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/GoogleOidcProperties.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/KakaoOidcProperties.java delete mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/config/ConfigurationPropertiesConfig.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/config/DefaultFeignConfig.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/config/FeignConfig.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java new file mode 100644 index 000000000..9ed00f006 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java @@ -0,0 +1,41 @@ +package kr.co.pennyway.api.apis.auth.helper; + +import kr.co.pennyway.common.annotation.Helper; +import kr.co.pennyway.infra.common.oidc.OauthOidcProvider; +import kr.co.pennyway.infra.common.oidc.OidcDecodePayload; +import kr.co.pennyway.infra.common.oidc.OidcPublicKey; +import kr.co.pennyway.infra.common.oidc.OidcPublicKeyResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Helper +@RequiredArgsConstructor +@Slf4j +public class OauthOidcHelper { + private final OauthOidcProvider oauthOIDCProvider; + + /** + * ID Token의 payload를 추출하는 메서드
+ * OAuth 2.0 spec에 따라 ID Token의 유효성 검사 수행
+ * + * @param token : idToken + * @param iss : ID Token을 발급한 provider의 URL + * @param aud : ID Token이 발급된 앱의 앱 키 + * @param nonce : 인증 서버 로그인 요청 시 전달한 임의의 문자열 + * @param response : 공개키 목록 + * @return OIDCDecodePayload : ID Token의 payload + */ + public OidcDecodePayload getPayloadFromIdToken(String token, String iss, String aud, String nonce, OidcPublicKeyResponse response) { + String kid = getKidFromUnsignedIdToken(token, iss, aud, nonce); + + OidcPublicKey key = response.getKeys().stream() + .filter(k -> k.kid().equals(kid)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No matching key found")); + return oauthOIDCProvider.getOIDCTokenBody(token, key.n(), key.e()); + } + + private String getKidFromUnsignedIdToken(String token, String iss, String aud, String nonce) { + return oauthOIDCProvider.getKidFromUnsignedTokenHeader(token, iss, aud, nonce); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java new file mode 100644 index 000000000..e62ecea67 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java @@ -0,0 +1,18 @@ +package kr.co.pennyway.api.config; + +import kr.co.pennyway.infra.common.properties.AppleOidcProperties; +import kr.co.pennyway.infra.common.properties.GoogleOidcProperties; +import kr.co.pennyway.infra.common.properties.KakaoOidcProperties; +import kr.co.pennyway.infra.common.properties.ServerProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({ + ServerProperties.class, + AppleOidcProperties.class, + GoogleOidcProperties.class, + KakaoOidcProperties.class +}) +public class InfraConfig { +} diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/util/MapUtils.java b/pennyway-common/src/main/java/kr/co/pennyway/common/util/MapUtils.java new file mode 100644 index 000000000..773e6dd2a --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/util/MapUtils.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.common.util; + +import java.util.Map; + +/** + * Map 관련 유틸리티 클래스 + * + * @author YANG JAESEO + */ +public class MapUtils { + /** + * key에 해당하는 값을 반환하고, key가 없을 경우 defaultValue를 반환한다. + */ + public static V getObject(Map map, K key, V defaultValue) { + if (map == null || key == null) { + return defaultValue; + } + return map.getOrDefault(key, defaultValue); + } +} \ No newline at end of file diff --git a/pennyway-infra/build.gradle b/pennyway-infra/build.gradle index f2e1c0b01..8631172c7 100644 --- a/pennyway-infra/build.gradle +++ b/pennyway-infra/build.gradle @@ -1,5 +1,5 @@ -bootJar {enabled = false} -jar {enabled = true} +bootJar { enabled = false } +jar { enabled = true } dependencies { implementation project(':pennyway-common') @@ -11,4 +11,9 @@ dependencies { /* redis */ api 'org.springframework.boot:spring-boot-starter-data-redis' + + /* feign */ + implementation platform("org.springframework.cloud:spring-cloud-dependencies:2023.0.1") + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.1.1' + implementation 'io.github.openfeign:feign-okhttp:13.2' } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/PennywayInfraApplication.java b/pennyway-infra/src/main/java/kr/co/pennyway/PennywayInfraApplication.java deleted file mode 100644 index 0bc66b4bb..000000000 --- a/pennyway-infra/src/main/java/kr/co/pennyway/PennywayInfraApplication.java +++ /dev/null @@ -1,5 +0,0 @@ -package kr.co.pennyway; - -public class PennywayInfraApplication { - -} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/PennywayInfraApplication.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/PennywayInfraApplication.java new file mode 100644 index 000000000..b4f57535d --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/PennywayInfraApplication.java @@ -0,0 +1,8 @@ +package kr.co.pennyway.infra; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PennywayInfraApplication { + +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/apple/oidc/AppleOidcClient.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/apple/oidc/AppleOidcClient.java new file mode 100644 index 000000000..6c7078132 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/apple/oidc/AppleOidcClient.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.infra.client.apple.oidc; + +import kr.co.pennyway.infra.common.oidc.OauthOidcClient; +import kr.co.pennyway.infra.common.oidc.OidcPublicKeyResponse; +import kr.co.pennyway.infra.config.DefaultFeignConfig; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +@FeignClient( + name = "AppleOauthClient", + url = "${oauth2.client.provider.apple.jwks-uri}", + configuration = DefaultFeignConfig.class, + qualifiers = "appleOauthClient", + primary = false +) +public interface AppleOidcClient extends OauthOidcClient { + @Override + @Cacheable(value = "AppleOauth", cacheManager = "oidcCacheManager") + @GetMapping("/auth/keys") + OidcPublicKeyResponse getOIDCPublicKey(); +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/oidc/GoogleOidcClient.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/oidc/GoogleOidcClient.java new file mode 100644 index 000000000..297fdea98 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/oidc/GoogleOidcClient.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.infra.client.google.oidc; + +import kr.co.pennyway.infra.common.oidc.OauthOidcClient; +import kr.co.pennyway.infra.common.oidc.OidcPublicKeyResponse; +import kr.co.pennyway.infra.config.DefaultFeignConfig; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +@FeignClient( + name = "GoogleOauthClient", + url = "${oauth2.client.provider.google.jwks-uri}", + configuration = DefaultFeignConfig.class, + qualifiers = "googleOauthClient", + primary = false +) +public interface GoogleOidcClient extends OauthOidcClient { + @Override + @Cacheable(value = "GoogleOauth", cacheManager = "oidcCacheManager") + @GetMapping("/oauth2/v3/certs") + OidcPublicKeyResponse getOIDCPublicKey(); +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/kakao/oidc/KakaoOidcClient.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/kakao/oidc/KakaoOidcClient.java new file mode 100644 index 000000000..b9603d3bd --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/kakao/oidc/KakaoOidcClient.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.infra.client.kakao.oidc; + +import kr.co.pennyway.infra.common.oidc.OauthOidcClient; +import kr.co.pennyway.infra.common.oidc.OidcPublicKeyResponse; +import kr.co.pennyway.infra.config.DefaultFeignConfig; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +@FeignClient( + name = "KakaoOauthClient", + url = "${oauth2.client.provider.kakao.jwks-uri}", + configuration = DefaultFeignConfig.class, + qualifiers = "kakaoOauthClient" +) +public interface KakaoOidcClient extends OauthOidcClient { + @Override + @Cacheable(value = "KakaoOauth", cacheManager = "oidcCacheManager") + @GetMapping("/.well-known/jwks.json") + OidcPublicKeyResponse getOIDCPublicKey(); +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/EnablePennywayInfraConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/EnablePennywayInfraConfig.java new file mode 100644 index 000000000..ce58cee3f --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/EnablePennywayInfraConfig.java @@ -0,0 +1,15 @@ +package kr.co.pennyway.infra.common.importer; + +import org.springframework.context.annotation.Import; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Import(PennywayInfraConfigImportSelector.class) +public @interface EnablePennywayInfraConfig { + PennywayInfraConfigGroup[] value(); +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfig.java new file mode 100644 index 000000000..8a7398e5a --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfig.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.infra.common.importer; + +/** + * Pennyway Infra의 Configurations를 나타내는 Marker Interface + */ +public interface PennywayInfraConfig { +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigGroup.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigGroup.java new file mode 100644 index 000000000..44cd79d98 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigGroup.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.infra.common.importer; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PennywayInfraConfigGroup { + ; + + private final Class configClass; +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigImportSelector.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigImportSelector.java new file mode 100644 index 000000000..05e9cc0cb --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigImportSelector.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.infra.common.importer; + +import kr.co.pennyway.common.util.MapUtils; +import org.jetbrains.annotations.NotNull; +import org.springframework.context.annotation.DeferredImportSelector; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.lang.NonNull; + +import java.util.Arrays; +import java.util.Map; + +public class PennywayInfraConfigImportSelector implements DeferredImportSelector { + @NotNull + @Override + public String[] selectImports(@NonNull AnnotationMetadata metadata) { + return Arrays.stream(getGroups(metadata)) + .map(v -> v.getConfigClass().getName()) + .toArray(String[]::new); + } + + private PennywayInfraConfigGroup[] getGroups(AnnotationMetadata metadata) { + Map attributes = metadata.getAnnotationAttributes(EnablePennywayInfraConfig.class.getName()); + return (PennywayInfraConfigGroup[]) MapUtils.getObject(attributes, "value", new PennywayInfraConfigGroup[]{}); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClient.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClient.java new file mode 100644 index 000000000..0c4d217a1 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClient.java @@ -0,0 +1,5 @@ +package kr.co.pennyway.infra.common.oidc; + +public interface OauthOidcClient { + OidcPublicKeyResponse getOIDCPublicKey(); +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClientProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClientProperties.java new file mode 100644 index 000000000..8b03875a6 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClientProperties.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.infra.common.oidc; + +public interface OauthOidcClientProperties { + String getJwksUri(); + + String getSecret(); +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProvider.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProvider.java new file mode 100644 index 000000000..57bd3938f --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProvider.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.infra.common.oidc; + +public interface OauthOidcProvider { + /** + * ID Token의 header에서 kid를 추출하는 메서드 + * + * @param token : idToken + * @param iss : ID Token을 발급한 OAuth 2.0 제공자의 URL + * @param aud : ID Token이 발급된 앱의 앱 키 + * @param nonce : 인증 서버 로그인 요청 시 전달한 임의의 문자열 + * @return kid : ID Token의 서명에 사용된 공개키의 ID + */ + String getKidFromUnsignedTokenHeader(String token, String iss, String aud, String nonce); + + /** + * ID Token의 payload를 추출하는 메서드 + * + * @param token : idToken + * @param modulus : 공개키 모듈(n) + * @param exponent : 공개키 지수(e) + * @return OIDCDecodePayload : ID Token의 payload + */ + OidcDecodePayload getOIDCTokenBody(String token, String modulus, String exponent); +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java new file mode 100644 index 000000000..2010d14b2 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java @@ -0,0 +1,105 @@ +package kr.co.pennyway.infra.common.oidc; + +import io.jsonwebtoken.*; +import kr.co.pennyway.infra.common.exception.JwtErrorCode; +import kr.co.pennyway.infra.common.exception.JwtErrorException; +import kr.co.pennyway.infra.common.util.JwtErrorCodeUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; + +@Slf4j +@Component +public class OauthOidcProviderImpl implements OauthOidcProvider { + private static final String KID = "kid"; + private static final String RSA = "RSA"; + + @Override + public String getKidFromUnsignedTokenHeader(String token, String iss, String aud, String nonce) { + return (String) getUnsignedTokenClaims(token, iss, aud, nonce).getHeader().get(KID); + } + + @Override + public OidcDecodePayload getOIDCTokenBody(String token, String modulus, String exponent) { + Claims body = getOIDCTokenJws(token, modulus, exponent).getPayload(); + String aud = body.getAudience().iterator().next(); // TODO: 이전 버전과 다르게 aud가 Set으로 변경되어 있음. 테스트 필요 + log.debug("aud : {}", aud); + + return new OidcDecodePayload( + body.getIssuer(), + aud, + body.getSubject(), + body.get("email", String.class)); + } + + /** + * ID Token의 header와 body를 Base64 방식으로 디코딩하는 메서드
+ * payload의 iss, aud, exp, nonce를 검증하고, 실패시 예외 처리 + */ + private Jwt getUnsignedTokenClaims(String token, String iss, String aud, String nonce) { + try { + return Jwts.parser() + .requireAudience(aud) + .requireIssuer(iss) +// .require("nonce", nonce) // 현재는 nonce를 사용하지 않음 + .build() + .parseUnsecuredClaims(getUnsignedToken(token)); // TODO: 기존 방식은 parseClaimsJwt(getUnsignedToken(token)); -> 변경한 코드 정상 동작 여부 확인 필요 + } catch (JwtException e) { + final JwtErrorCode errorCode = JwtErrorCodeUtil.determineErrorCode(e, JwtErrorCode.FAILED_AUTHENTICATION); + + log.warn("Error code : {}, Error - {}, {}", errorCode, e.getClass(), e.getMessage()); + throw new JwtErrorException(errorCode); + } + } + + /** + * Token의 signature를 제거하는 메서드 + */ + private String getUnsignedToken(String token) { + String[] splitToken = token.split("\\."); + if (splitToken.length != 3) throw new JwtErrorException(JwtErrorCode.MALFORMED_TOKEN); + return splitToken[0] + "." + splitToken[1] + "."; + } + + /** + * 공개키로 서명을 검증하는 메서드 + */ + private Jws getOIDCTokenJws(String token, String modulus, String exponent) { + try { + log.info("token : {}", token); + return Jwts.parser() + .verifyWith(getRSAPublicKey(modulus, exponent)) + .build() + .parseSignedClaims(token); + } catch (JwtException e) { + final JwtErrorCode errorCode = JwtErrorCodeUtil.determineErrorCode(e, JwtErrorCode.FAILED_AUTHENTICATION); + + log.warn("Error code : {}, Error - {}, {}", errorCode, e.getClass(), e.getMessage()); + throw new JwtErrorException(errorCode); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + log.warn("Error - {}, {}", e.getClass(), e.getMessage()); + throw new JwtErrorException(JwtErrorCode.MALFORMED_TOKEN); + } + } + + /** + * n, e 조합으로 공개키를 생성하는 메서드 + */ + private PublicKey getRSAPublicKey(String modulus, String exponent) throws NoSuchAlgorithmException, InvalidKeySpecException { + KeyFactory keyFactory = KeyFactory.getInstance(RSA); + byte[] decodeN = Base64.getUrlDecoder().decode(modulus); + byte[] decodeE = Base64.getUrlDecoder().decode(exponent); + BigInteger n = new BigInteger(1, decodeN); + BigInteger e = new BigInteger(1, decodeE); + + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); + return keyFactory.generatePublic(publicKeySpec); + } +} \ No newline at end of file diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OidcDecodePayload.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OidcDecodePayload.java new file mode 100644 index 000000000..7049056fb --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OidcDecodePayload.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.infra.common.oidc; + +public record OidcDecodePayload( + /* issuer */ + String iss, + /* client id */ + String aud, + /* aouth provider account unique id */ + String sub, + String email +) { +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OidcPublicKey.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OidcPublicKey.java new file mode 100644 index 000000000..0fe4011f1 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OidcPublicKey.java @@ -0,0 +1,11 @@ +package kr.co.pennyway.infra.common.oidc; + +public record OidcPublicKey( + String kid, + String kty, + String alg, + String use, + String n, + String e +) { +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OidcPublicKeyResponse.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OidcPublicKeyResponse.java new file mode 100644 index 000000000..15a392c67 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OidcPublicKeyResponse.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.infra.common.oidc; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class OidcPublicKeyResponse { + List keys; + + @Override + public String toString() { + return "OIDCPublicKeyResponse{" + + "keys=" + keys + + '}'; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/AppleOidcProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/AppleOidcProperties.java new file mode 100644 index 000000000..d953b2285 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/AppleOidcProperties.java @@ -0,0 +1,14 @@ +package kr.co.pennyway.infra.common.properties; + +import kr.co.pennyway.infra.common.oidc.OauthOidcClientProperties; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "oauth2.client.provider.apple") +public class AppleOidcProperties implements OauthOidcClientProperties { + private final String jwksUri; + private final String secret; +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/GoogleOidcProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/GoogleOidcProperties.java new file mode 100644 index 000000000..b61e61078 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/GoogleOidcProperties.java @@ -0,0 +1,14 @@ +package kr.co.pennyway.infra.common.properties; + +import kr.co.pennyway.infra.common.oidc.OauthOidcClientProperties; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "oauth2.client.provider.google") +public class GoogleOidcProperties implements OauthOidcClientProperties { + private final String jwksUri; + private final String secret; +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/KakaoOidcProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/KakaoOidcProperties.java new file mode 100644 index 000000000..e7d3f90a2 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/KakaoOidcProperties.java @@ -0,0 +1,14 @@ +package kr.co.pennyway.infra.common.properties; + +import kr.co.pennyway.infra.common.oidc.OauthOidcClientProperties; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "oauth2.client.provider.kakao") +public class KakaoOidcProperties implements OauthOidcClientProperties { + private final String jwksUri; + private final String secret; +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java index 4c51e7a59..a84c56992 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java @@ -100,7 +100,7 @@ public CacheManager securityUserCacheManager(@InfraRedisConnectionFactory RedisC @Bean @OidcCacheManager - public CacheManager oidcCacheManger(@InfraRedisConnectionFactory RedisConnectionFactory cf) { + public CacheManager oidcCacheManager(@InfraRedisConnectionFactory RedisConnectionFactory cf) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith( RedisSerializationContext.SerializationPair.fromSerializer( diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/ConfigurationPropertiesConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/ConfigurationPropertiesConfig.java deleted file mode 100644 index 63c859194..000000000 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/ConfigurationPropertiesConfig.java +++ /dev/null @@ -1,12 +0,0 @@ -package kr.co.pennyway.infra.config; - -import kr.co.pennyway.infra.common.properties.ServerProperties; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -@EnableConfigurationProperties({ - ServerProperties.class -}) -@Configuration -public class ConfigurationPropertiesConfig { -} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/DefaultFeignConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/DefaultFeignConfig.java new file mode 100644 index 000000000..07ee11df4 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/DefaultFeignConfig.java @@ -0,0 +1,18 @@ +package kr.co.pennyway.infra.config; + +import feign.codec.Encoder; +import feign.form.FormEncoder; +import feign.okhttp.OkHttpClient; +import org.springframework.context.annotation.Bean; + +public class DefaultFeignConfig { + @Bean + public OkHttpClient client() { + return new OkHttpClient(); + } + + @Bean + Encoder formEncoder() { + return new FormEncoder(); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/FeignConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/FeignConfig.java new file mode 100644 index 000000000..564fd5057 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/FeignConfig.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.infra.config; + +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.cloud.openfeign.FeignAutoConfiguration; +import org.springframework.cloud.openfeign.clientconfig.HttpClient5FeignConfiguration; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableFeignClients(basePackages = "kr.co.pennyway.infra") +@ImportAutoConfiguration({FeignAutoConfiguration.class, HttpClient5FeignConfiguration.class}) +public class FeignConfig { +} diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index f9c75be13..5b963497d 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -10,6 +10,19 @@ pennyway: local: ${PENNYWAY_DOMAIN_LOCAL} dev: ${PENNYWAY_DOMAIN_DEV} +oauth2: + client: + provider: + kakao: + jwks-uri: ${KAKAO_JWKS_URI} + secret: ${KAKAO_CLIENT_SECRET} + google: + jwks-uri: ${GOOGLE_JWKS_URI} + secret: ${GOOGLE_CLIENT_SECRET} + apple: + jwks-uri: ${APPLE_JWKS_URI} + secret: ${APPLE_CLIENT_SECRET} + --- spring: config: From 9b7bde219644a6a4fa6d7abf3f34a7e82d1033d7 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 4 Apr 2024 11:58:59 +0900 Subject: [PATCH 039/152] =?UTF-8?q?=E2=9C=A8=20OAuth=20OIDC=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20API=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: provider enum 클래스 정의 * feat: provider converter 정의 * feat: oauth domain 정의 * feat: provider exception 정의 * feat: provider request converter 정의 * feat: web config에 provider converter 등록 * chore: infra 모듈 httpclient 라이브러리 의존성 추가 * feat: oauth 로그인&회원가입 dto 정의 * rename: getter 메서드명 중 oidc 소문자화 * fix: oauth 로그인 시 oauth id 필드 추가 * fix: oauth oidc helper 메서드 추가 * rename: provider exception -> oauth exception * feat: oauth id 불일치 예외 추가 * feat: oauth repository 정의 * feat: oauth repository 소셜 아이디 & 제공자 탐색 메서드 선언 * feat: oauth domain service 정의 * rename: provider converter 예외 이름 수정 * rename: oauth service get -> read * style: oauth exception api 모듈 -> domain 모듈 이전 * rename: oauth error code 주석 포맷 변경 * feat: oauth 매퍼클래스 - 로그인 분기처리 * feat: oauth 로그인 use case 구현 * feat: oauth 로그인 컨트롤러 정의 * fix: cache config 내 불필요한 설정 추가 제거 * chore: infra 모듈 redis 환경 변수 주입 * rename: oauth controller 스웨거 문서 설명 추가 * feat: oauth API 설계 * fix: 전화번호 인증 oauth provider 구분 * fix: 전화 번호 인증 열거 타입 oauth provider 추론 메서드 static으로 변경 * fix: 전화번호 인증 코드 응답 객체 general, oauth 정적 팩토리 분리 * fix: oauth 분기 시나리오에 따른 dto 구분 * fix: oauth api 설계 수정 * fix: aouth use case의 verify code 메서드 반환 타입 verify code res로 수정 * rename: 인증 코드 검증 dto 생성 메서드 변경 * feat: oauth provider signup된 정보 존재 시 반환하는 에러코드 추가 * feat: 사용자 아이디 & provider 기반 데이터 존재 여부 검증 도메인 서비스 메서드 추가 * feat: oauth 회원가입 요청을 phone 검증 dto로 변환하는 정적 팩토리 메서드 추가 * fix: 소셜 회원가입 시 phone, code 필수 입력 필드 추가 * feat: user sync mapper 클래스 내 oauth 회원가입 분기 결정 메서드 추가 * feat: oauth use case 메서드 작성 * feat: 소셜 회원가입 분기 등록 로직 구현 * feat: 소셜 회원가입 Use case 작성 * rename: oauth controller 주석 제거 * rename: oauth api 1, 3번 상세한 설명을 위한 swagger 어노테이션 추가 * style: 프로그래밍 코드와 문서 주석 분리 * docs: 인증 코드 검증 예외 문서 수정 * fix: 휴대폰 만료 혹은 미등록 예외 reason code 401->404 변경 * docs: 전화번호 인증 응답 포맷 수정 --- .../pennyway/api/apis/auth/api/OauthApi.java | 140 ++++++++++++++++++ .../apis/auth/controller/OauthController.java | 81 ++++++++++ .../apis/auth/dto/PhoneVerificationDto.java | 30 +++- .../pennyway/api/apis/auth/dto/SignInReq.java | 11 ++ .../pennyway/api/apis/auth/dto/SignUpReq.java | 44 +++++- .../api/apis/auth/helper/OauthOidcHelper.java | 63 ++++++-- .../apis/auth/mapper/UserOauthSignMapper.java | 59 ++++++++ .../api/apis/auth/mapper/UserSyncMapper.java | 30 +++- .../api/apis/auth/usecase/AuthUseCase.java | 2 +- .../api/apis/auth/usecase/OauthUseCase.java | 86 +++++++++++ .../common/converter/ProviderConverter.java | 17 +++ .../exception/PhoneVerificationErrorCode.java | 4 +- .../kr/co/pennyway/api/config/WebConfig.java | 14 ++ .../apis/auth/mapper/UserSyncMapperTest.java | 5 +- .../common/converter/ProviderConverter.java | 13 ++ .../redis/phone/PhoneVerificationType.java | 16 +- .../domain/domains/oauth/domain/Oauth.java | 58 ++++++++ .../oauth/exception/OauthErrorCode.java | 35 +++++ .../oauth/exception/OauthException.java | 22 +++ .../oauth/repository/OauthRepository.java | 13 ++ .../domains/oauth/service/OauthService.java | 26 ++++ .../domain/domains/oauth/type/Provider.java | 36 +++++ .../domain/domains/user/domain/User.java | 1 + pennyway-infra/build.gradle | 5 + .../client/apple/oidc/AppleOidcClient.java | 2 +- .../client/google/oidc/GoogleOidcClient.java | 2 +- .../client/kakao/oidc/KakaoOidcClient.java | 2 +- .../infra/common/oidc/OauthOidcClient.java | 2 +- .../co/pennyway/infra/config/CacheConfig.java | 2 - .../src/main/resources/application-infra.yml | 5 + 30 files changed, 797 insertions(+), 29 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/OauthController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/ProviderConverter.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProviderConverter.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthException.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/type/Provider.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java new file mode 100644 index 000000000..6021be5d1 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java @@ -0,0 +1,140 @@ +package kr.co.pennyway.api.apis.auth.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.apis.auth.dto.SignInReq; +import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "[소셜 인증 API]") +public interface OauthApi { + @Operation(summary = "[1] 소셜 로그인", description = "기존에 Provider로 가입한 사용자는 로그인, 가입하지 않은 사용자는 전화번호 인증으로 이동") + @Parameter(name = "provider", description = "소셜 제공자", examples = { + @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") + }, required = true, in = ParameterIn.QUERY) + @ApiResponses({ + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "성공 - 기존 계정 있음", value = """ + { + "code": "2000", + "data": { + "user": { + "id": 1 + } + } + } + """), + @ExampleObject(name = "성공 - 기존 계정 없음 (id -1인 경우) - [2]로 진행", value = """ + { + "code": "2000", + "data": { + "user": { + "id": -1 + } + } + } + """) + })), + @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "실패 - 유효하지 않은 idToken", value = """ + { + "code": "4013", + "message": "비정상적인 토큰입니다" + } + """) + })) + }) + ResponseEntity signIn(@RequestParam Provider provider, @RequestBody @Validated SignInReq.Oauth request); + + @Operation(summary = "[2] 인증번호 발송", description = "전화번호 입력 후 인증번호 발송") + @Parameter(name = "provider", description = "소셜 제공자", examples = { + @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") + }, required = true, in = ParameterIn.QUERY) + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "발신 성공", value = """ + { + "code": "2000", + "data": { + "sms": { + "to": "010-1234-5678", + "sendAt": "2024-04-04 00:31:57", + "expiresAt": "2024-04-04 00:36:57" + } + } + } + """) + })) + ResponseEntity sendCode(@RequestParam Provider provider, @RequestBody @Validated PhoneVerificationDto.PushCodeReq request); + + @Operation(summary = "[3] 전화번호 인증", description = "전화번호 인증 후 이미 계정이 존재하면 연동, 없으면 회원가입") + @Parameter(name = "provider", description = "소셜 제공자", examples = { + @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") + }, required = true, in = ParameterIn.QUERY) + @ApiResponses({ + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "성공 - 기존 계정 있음 - [4-1]로 진행", value = """ + { + "code": "2000", + "data": { + "sms": { + "code": true, + "existUser": true, + "username": "pennyway" + } + } + } + """), + @ExampleObject(name = "성공 - 기존 계정 없음 - [4-2]로 진행", value = """ + { + "code": "2000", + "data": { + "sms": { + "code": true, + "existUser": false + } + } + } + """) + })), + @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "인증코드 불일치", value = """ + { + "code": "4010", + "message": "인증코드가 일치하지 않습니다." + } + """) + })), + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "만료 혹은 등록되지 않은 휴대폰", value = """ + { + "code": "4042", + "message": "만료되었거나 등록되지 않은 휴대폰 정보입니다." + } + """), + })) + }) + ResponseEntity verifyCode(@RequestParam Provider provider, @RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request); + + @Operation(summary = "[4-1] 계정 연동", description = "일반 혹은 소셜 계정이 존재하는 경우 연동") + @Parameter(name = "provider", description = "소셜 제공자", examples = { + @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") + }, required = true, in = ParameterIn.QUERY) + ResponseEntity linkAuth(@RequestParam Provider provider, @RequestBody @Validated SignUpReq.SyncWithAuth request); + + @Operation(summary = "[4-2] 소셜 회원가입", description = "회원 정보 입력 후 회원가입") + @Parameter(name = "provider", description = "소셜 제공자", examples = { + @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") + }, required = true, in = ParameterIn.QUERY) + ResponseEntity signUp(@RequestParam Provider provider, @RequestBody @Validated SignUpReq.Oauth request); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/OauthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/OauthController.java new file mode 100644 index 000000000..404ebe937 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/OauthController.java @@ -0,0 +1,81 @@ +package kr.co.pennyway.api.apis.auth.controller; + +import kr.co.pennyway.api.apis.auth.api.OauthApi; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.apis.auth.dto.SignInReq; +import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.api.apis.auth.usecase.OauthUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.jwt.Jwts; +import kr.co.pennyway.api.common.util.CookieUtil; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; +import java.util.Map; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/auth/oauth") +public class OauthController implements OauthApi { + private final OauthUseCase oauthUseCase; + private final CookieUtil cookieUtil; + + @Override + @PostMapping("/sign-in") + @PreAuthorize("isAnonymous()") + public ResponseEntity signIn(@RequestParam Provider provider, @RequestBody @Validated SignInReq.Oauth request) { + Pair userInfo = oauthUseCase.signIn(provider, request); + + if (userInfo.getLeft().equals(-1L)) { + return ResponseEntity.ok(SuccessResponse.from("user", Map.of("id", -1))); + } + return createAuthenticatedResponse(userInfo); + } + + @Override + @PostMapping("/phone") + @PreAuthorize("isAnonymous()") + public ResponseEntity sendCode(@RequestParam Provider provider, @RequestBody @Validated PhoneVerificationDto.PushCodeReq request) { + return ResponseEntity.ok(SuccessResponse.from("sms", oauthUseCase.sendCode(provider, request))); + } + + @Override + @PostMapping("/phone/verification") + @PreAuthorize("isAnonymous()") + public ResponseEntity verifyCode(@RequestParam Provider provider, @RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request) { + return ResponseEntity.ok(SuccessResponse.from("sms", oauthUseCase.verifyCode(provider, request))); + } + + @Override + @PostMapping("/link-auth") + @PreAuthorize("isAnonymous()") + public ResponseEntity linkAuth(@RequestParam Provider provider, @RequestBody @Validated SignUpReq.SyncWithAuth request) { + return createAuthenticatedResponse(oauthUseCase.signUp(provider, request.toOauthInfo())); + } + + @Override + @PostMapping("/sign-up") + @PreAuthorize("isAnonymous()") + public ResponseEntity signUp(@RequestParam Provider provider, @RequestBody @Validated SignUpReq.Oauth request) { + return createAuthenticatedResponse(oauthUseCase.signUp(provider, request.toOauthInfo())); + } + + private ResponseEntity createAuthenticatedResponse(Pair userInfo) { + ResponseCookie cookie = cookieUtil.createCookie("refreshToken", userInfo.getValue().refreshToken(), Duration.ofDays(7).toSeconds()); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .header(HttpHeaders.AUTHORIZATION, userInfo.getValue().accessToken()) + .body(SuccessResponse.from("user", Map.of("id", userInfo.getKey()))) + ; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java index 08975e749..a220231a9 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java @@ -13,7 +13,7 @@ public class PhoneVerificationDto { @Schema(title = "인증번호 요청 DTO", description = "전화번호로 인증번호 송신 요청을 위한 DTO") public record PushCodeReq( - @Schema(description = "전화번호", example = "01012345678") + @Schema(description = "전화번호", example = "010-1234-5678") @NotBlank(message = "전화번호는 필수입니다.") @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") String phone @@ -59,20 +59,42 @@ public record VerifyCodeReq( public static VerifyCodeReq from(SignUpReq.Info request) { return new VerifyCodeReq(request.phone(), request.code()); } + + public static VerifyCodeReq from(SignUpReq.OauthInfo request) { + return new VerifyCodeReq(request.phone(), request.code()); + } } @Schema(title = "인증번호 검증 응답 DTO") public record VerifyCodeRes( @Schema(description = "코드 일치 여부 : 일치하지 않으면 예외이므로 성공하면 언제나 true", example = "true") Boolean code, - @Schema(description = "oauth 사용자 여부", example = "true") + @Schema(description = "oauth 사용자 여부. true면 sync, false면 회원가입으로 진행 (일반 회원가입 시 필수값)", example = "true") + @JsonInclude(JsonInclude.Include.NON_NULL) Boolean oauth, + @Schema(description = "기존 계정 존재 여부. true면 sync, false면 회원가입 (oauth 회원가입 시 필수값)", example = "true") + @JsonInclude(JsonInclude.Include.NON_NULL) + Boolean existsUser, @Schema(description = "기존 사용자 아이디", example = "pennyway") @JsonInclude(JsonInclude.Include.NON_NULL) String username ) { - public static VerifyCodeRes valueOf(Boolean isValidCode, Boolean isOauthUser, String username) { - return new VerifyCodeRes(isValidCode, isOauthUser, username); + /** + * 일반 회원가입 시 인증 코드 응답 객체 생성 + * + * @param isOauthUser Boolean : oauth 사용자 여부. true면 sync, false면 회원가입으로 진행 + */ + public static VerifyCodeRes valueOfGeneral(Boolean isValidCode, Boolean isOauthUser, String username) { + return new VerifyCodeRes(isValidCode, isOauthUser, null, username); + } + + /** + * oauth 회원가입 시 인증 코드 응답 객체 생성 + * + * @param existsUser Boolean : 기존 계정 존재 여부. true면 sync, false면 회원가입으로 진행 + */ + public static VerifyCodeRes valueOfOauth(Boolean isValidCode, Boolean existsUser, String username) { + return new VerifyCodeRes(isValidCode, null, existsUser, username); } } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java index 64e4f4668..9c56621f0 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java @@ -14,4 +14,15 @@ public record General( String password ) { } + + @Schema(name = "signInReqOauth", title = "소셜 로그인 요청") + public record Oauth( + @Schema(description = "OAuth id") + @NotBlank(message = "OAuth id는 필수 입력값입니다.") + String oauthId, + @Schema(description = "OIDC 토큰") + @NotBlank(message = "OIDC 토큰은 필수 입력값입니다.") + String idToken + ) { + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java index 2ce7ad446..ca1a25bdf 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java @@ -40,6 +40,9 @@ public String password() { } } + public record OauthInfo(String idToken, String name, String username, String phone, String code) { + } + @Schema(name = "signUpReqGeneral", title = "일반 회원가입 요청 DTO") public record General( @Schema(description = "아이디", example = "pennyway") @@ -90,8 +93,47 @@ public Info toInfo() { @Schema(title = "소셜 회원가입 요청 DTO") public record Oauth( - + @Schema(description = "OIDC 토큰") + @NotBlank(message = "OIDC 토큰은 필수 입력값입니다.") + String idToken, + @Schema(description = "이름", example = "페니웨이") + @NotBlank(message = "이름을 입력해주세요") + @Pattern(regexp = "^[가-힣a-zA-Z]{2,20}$", message = "2~20자의 한글, 영문 대/소문자만 사용 가능합니다.") + String name, + @Schema(description = "아이디", example = "pennyway") + @NotBlank(message = "아이디를 입력해주세요") + @Pattern(regexp = "^[a-z-_.]{5,20}$", message = "5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.") + String username, + @Schema(description = "전화번호", example = "010-1234-5678") + @NotBlank(message = "전화번호를 입력해주세요") + @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phone, + @Schema(description = "6자리 정수 인증번호", example = "123456") + @NotBlank(message = "인증번호는 필수입니다.") + @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") + String code ) { + public OauthInfo toOauthInfo() { + return new OauthInfo(idToken, name, username, phone, code); + } + } + @Schema(title = "소셜 회원가입(기존 계정 존재) 요청 DTO") + public record SyncWithAuth( + @Schema(description = "OIDC 토큰") + @NotBlank(message = "OIDC 토큰은 필수 입력값입니다.") + String idToken, + @Schema(description = "전화번호", example = "010-1234-5678") + @NotBlank(message = "전화번호를 입력해주세요") + @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phone, + @Schema(description = "6자리 정수 인증번호", example = "123456") + @NotBlank(message = "인증번호는 필수입니다.") + @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") + String code + ) { + public OauthInfo toOauthInfo() { + return new OauthInfo(idToken, null, null, phone, code); + } } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java index 9ed00f006..de6c777e1 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java @@ -1,41 +1,78 @@ package kr.co.pennyway.api.apis.auth.helper; import kr.co.pennyway.common.annotation.Helper; -import kr.co.pennyway.infra.common.oidc.OauthOidcProvider; -import kr.co.pennyway.infra.common.oidc.OidcDecodePayload; -import kr.co.pennyway.infra.common.oidc.OidcPublicKey; -import kr.co.pennyway.infra.common.oidc.OidcPublicKeyResponse; -import lombok.RequiredArgsConstructor; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.infra.client.apple.oidc.AppleOidcClient; +import kr.co.pennyway.infra.client.google.oidc.GoogleOidcClient; +import kr.co.pennyway.infra.client.kakao.oidc.KakaoOidcClient; +import kr.co.pennyway.infra.common.oidc.*; +import kr.co.pennyway.infra.common.properties.AppleOidcProperties; +import kr.co.pennyway.infra.common.properties.GoogleOidcProperties; +import kr.co.pennyway.infra.common.properties.KakaoOidcProperties; import lombok.extern.slf4j.Slf4j; +import java.util.Map; + @Helper -@RequiredArgsConstructor @Slf4j public class OauthOidcHelper { - private final OauthOidcProvider oauthOIDCProvider; + private final OauthOidcProvider oauthOidcProvider; + private final Map> oauthOidcClients; + + public OauthOidcHelper( + OauthOidcProvider oauthOidcProvider, + KakaoOidcClient kakaoOauthClient, + GoogleOidcClient googleOauthClient, + AppleOidcClient appleOauthClient, + KakaoOidcProperties kakaoOauthClientProperties, + GoogleOidcProperties googleOauthClientProperties, + AppleOidcProperties appleOauthClientProperties + ) { + this.oauthOidcProvider = oauthOidcProvider; + oauthOidcClients = Map.of( + Provider.KAKAO, Map.of(kakaoOauthClient, kakaoOauthClientProperties), + Provider.GOOGLE, Map.of(googleOauthClient, googleOauthClientProperties), + Provider.APPLE, Map.of(appleOauthClient, appleOauthClientProperties) + ); + } + + /** + * Provider에 따라 Client와 Properties를 선택하고 Odic public key 정보를 가져와서 ID Token의 payload를 추출하는 메서드 + * + * @param provider : {@link Provider} + * @param idToken : idToken + * @return OIDCDecodePayload : ID Token의 payload + */ + public OidcDecodePayload getPayload(Provider provider, String idToken) { + OauthOidcClient client = oauthOidcClients.get(provider).keySet().iterator().next(); + OauthOidcClientProperties properties = oauthOidcClients.get(provider).values().iterator().next(); + OidcPublicKeyResponse response = client.getOidcPublicKey(); + + return getPayloadFromIdToken(idToken, properties.getJwksUri(), properties.getSecret(), null, response); + } /** * ID Token의 payload를 추출하는 메서드
* OAuth 2.0 spec에 따라 ID Token의 유효성 검사 수행
* - * @param token : idToken + * @param idToken : idToken * @param iss : ID Token을 발급한 provider의 URL * @param aud : ID Token이 발급된 앱의 앱 키 - * @param nonce : 인증 서버 로그인 요청 시 전달한 임의의 문자열 + * @param nonce : 인증 서버 로그인 요청 시 전달한 임의의 문자열 (Optional, 현재는 사용하지 않음) * @param response : 공개키 목록 * @return OIDCDecodePayload : ID Token의 payload */ - public OidcDecodePayload getPayloadFromIdToken(String token, String iss, String aud, String nonce, OidcPublicKeyResponse response) { - String kid = getKidFromUnsignedIdToken(token, iss, aud, nonce); + private OidcDecodePayload getPayloadFromIdToken(String idToken, String iss, String aud, String nonce, OidcPublicKeyResponse response) { + String kid = getKidFromUnsignedIdToken(idToken, iss, aud, nonce); OidcPublicKey key = response.getKeys().stream() .filter(k -> k.kid().equals(kid)) .findFirst() .orElseThrow(() -> new IllegalArgumentException("No matching key found")); - return oauthOIDCProvider.getOIDCTokenBody(token, key.n(), key.e()); + return oauthOidcProvider.getOIDCTokenBody(idToken, key.n(), key.e()); } private String getKidFromUnsignedIdToken(String token, String iss, String aud, String nonce) { - return oauthOIDCProvider.getKidFromUnsignedTokenHeader(token, iss, aud, nonce); + return oauthOidcProvider.getKidFromUnsignedTokenHeader(token, iss, aud, nonce); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java new file mode 100644 index 000000000..49e4c71d3 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java @@ -0,0 +1,59 @@ +package kr.co.pennyway.api.apis.auth.mapper; + +import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Mapper +@RequiredArgsConstructor +public class UserOauthSignMapper { + private final UserService userService; + private final OauthService oauthService; + + @Transactional(readOnly = true) + public User readUser(String oauthId, Provider provider) { + Optional oauth = oauthService.readOauthByOauthIdAndProvider(oauthId, provider); + + return oauth.map(Oauth::getUser).orElse(null); + } + + /** + * 기존 계정이 존재하면 Oauth 계정을 생성하여 연동하고, 존재하지 않으면 새로운 계정을 생성한다. + * + * @param request {@link SignUpReq.OauthInfo} + */ + @Transactional + public User saveUser(SignUpReq.OauthInfo request, Pair isSignUpUser, Provider provider, String oauthId) { + User user; + + if (isSignUpUser.getLeft().equals(Boolean.TRUE)) { + user = userService.readUserByUsername(isSignUpUser.getRight()) + .orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + Oauth.of(provider, oauthId, user); + } else { + user = User.builder() + .username(request.username()) + .name(request.name()) + .phone(request.phone()) + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC).build(); + userService.createUser(user); + Oauth.of(provider, oauthId, user); + } + + return user; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java index e04b9a551..f12e17ef8 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java @@ -1,6 +1,8 @@ package kr.co.pennyway.api.apis.auth.mapper; import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.oauth.type.Provider; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; @@ -20,11 +22,11 @@ @RequiredArgsConstructor public class UserSyncMapper { private final UserService userService; + private final OauthService oauthService; /** * 일반 회원가입이 가능한 유저인지 확인 * - * @param phone String : 전화번호 * @return Pair : 이미 가입된 회원인지 여부 (TRUE: 가입되지 않은 회원, FALSE: 가입된 회원), 가입된 회원인 경우 회원 * ID 반환. 단, 이미 일반 회원가입을 한 유저인 경우에는 null을 반환한다. */ @@ -44,4 +46,30 @@ public Pair isGeneralSignUpAllowed(String phone) { return Pair.of(Boolean.TRUE, user.get().getUsername()); } + + /** + * Oauth 회원가입 시나리오를 결정한다. + * + * @return Pair : 이미 가입된 회원인지 여부 (TRUE: 계정 연동, FALSE: 소셜 회원가입) + * 단, 이미 동일한 Provider로 가입된 회원이 있는 경우에는 해당 회원의 ID를 반환한다. + */ + @Transactional(readOnly = true) + public Pair isOauthSignUpAllowed(Provider provider, String phone) { + Optional user = userService.readUserByPhone(phone); + + // user 정보 없으면 Pair.of(Boolean.FALSE, null) 반환 + if (user.isEmpty()) { + log.info("회원가입 이력이 없는 사용자입니다. phone: {}", phone); + return Pair.of(Boolean.FALSE, null); + } + + // 같은 provider로 가입한 정보가 있는지 확인 + if (oauthService.isExistOauthAccount(user.get().getId(), provider)) { + log.info("이미 동일한 Provider로 가입된 사용자입니다. phone: {}, provider: {}", phone, provider); + return null; + } + + // user 정보 있으면 Pair.of(Boolean.TRUE, user.get().getUsername()) 반환 + return Pair.of(Boolean.TRUE, user.get().getUsername()); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java index 56eb0573c..614f2e275 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java @@ -40,7 +40,7 @@ public PhoneVerificationDto.VerifyCodeRes verifyCode(PhoneVerificationDto.Verify phoneVerificationService.extendTimeToLeave(request.phone(), PhoneVerificationType.SIGN_UP); - return PhoneVerificationDto.VerifyCodeRes.valueOf(isValidCode, isOauthUser.getLeft(), isOauthUser.getRight()); + return PhoneVerificationDto.VerifyCodeRes.valueOfGeneral(isValidCode, isOauthUser.getLeft(), isOauthUser.getRight()); } @Transactional diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java new file mode 100644 index 000000000..d19732670 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java @@ -0,0 +1,86 @@ +package kr.co.pennyway.api.apis.auth.usecase; + +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.apis.auth.dto.SignInReq; +import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.api.apis.auth.helper.JwtAuthHelper; +import kr.co.pennyway.api.apis.auth.helper.OauthOidcHelper; +import kr.co.pennyway.api.apis.auth.mapper.PhoneVerificationMapper; +import kr.co.pennyway.api.apis.auth.mapper.UserOauthSignMapper; +import kr.co.pennyway.api.apis.auth.mapper.UserSyncMapper; +import kr.co.pennyway.api.common.security.jwt.Jwts; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; +import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; +import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; +import kr.co.pennyway.domain.domains.oauth.exception.OauthException; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.infra.common.oidc.OidcDecodePayload; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class OauthUseCase { + private final OauthOidcHelper oauthOidcHelper; + private final PhoneVerificationMapper phoneVerificationMapper; + private final PhoneVerificationService phoneVerificationService; + private final JwtAuthHelper jwtAuthHelper; + private final UserOauthSignMapper userOauthSignMapper; + private final UserSyncMapper userSyncMapper; + + public Pair signIn(Provider provider, SignInReq.Oauth request) { + OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken()); + log.info("payload : {}", payload); + + if (!request.oauthId().equals(payload.sub())) + throw new OauthException(OauthErrorCode.NOT_MATCHED_OAUTH_ID); + User user = userOauthSignMapper.readUser(request.oauthId(), provider); + + return (user != null) ? Pair.of(user.getId(), jwtAuthHelper.createToken(user)) : Pair.of(-1L, null); + } + + public PhoneVerificationDto.PushCodeRes sendCode(Provider provider, PhoneVerificationDto.PushCodeReq request) { + return phoneVerificationMapper.sendCode(request, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + } + + @Transactional(readOnly = true) + public PhoneVerificationDto.VerifyCodeRes verifyCode(Provider provider, PhoneVerificationDto.VerifyCodeReq request) { + Boolean isValidCode = phoneVerificationMapper.isValidCode(request, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + Pair isSignUpUser = checkSignUpUserNotOauthByProvider(provider, request.phone()); + + phoneVerificationService.extendTimeToLeave(request.phone(), PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + + return PhoneVerificationDto.VerifyCodeRes.valueOfOauth(isValidCode, isSignUpUser.getLeft(), isSignUpUser.getRight()); + } + + @Transactional + public Pair signUp(Provider provider, SignUpReq.OauthInfo request) { + phoneVerificationMapper.isValidCode(PhoneVerificationDto.VerifyCodeReq.from(request), PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + Pair isSignUpUser = checkSignUpUserNotOauthByProvider(provider, request.phone()); + + OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken()); + User user = userOauthSignMapper.saveUser(request, isSignUpUser, provider, payload.sub()); + phoneVerificationService.delete(request.phone(), PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + + return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); + } + + /** + * Oauth 회원가입 진행 도중, Provider로 가입한 사용자인지 지속적으로 검증하기 위한 메서드 + */ + private Pair checkSignUpUserNotOauthByProvider(Provider provider, String phone) { + Pair isOauthSignUpAllowed = userSyncMapper.isOauthSignUpAllowed(provider, phone); + + if (isOauthSignUpAllowed == null) { + phoneVerificationService.delete(phone, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + throw new OauthException(OauthErrorCode.ALREADY_SIGNUP_OAUTH); + } + + return isOauthSignUpAllowed; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/ProviderConverter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/ProviderConverter.java new file mode 100644 index 000000000..1246e7888 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/ProviderConverter.java @@ -0,0 +1,17 @@ +package kr.co.pennyway.api.common.converter; + +import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; +import kr.co.pennyway.domain.domains.oauth.exception.OauthException; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import org.springframework.core.convert.converter.Converter; + +public class ProviderConverter implements Converter { + @Override + public Provider convert(String provider) { + try { + return Provider.valueOf(provider.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new OauthException(OauthErrorCode.INVALID_PROVIDER); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationErrorCode.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationErrorCode.java index 4537aba77..316eb9e13 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationErrorCode.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationErrorCode.java @@ -11,8 +11,10 @@ @RequiredArgsConstructor public enum PhoneVerificationErrorCode implements BaseErrorCode { // 401 Unauthorized - EXPIRED_OR_INVALID_PHONE(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "만료되었거나 등록되지 않은 휴대폰 정보입니다."), IS_NOT_VALID_CODE(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "인증코드가 일치하지 않습니다."), + + // 404 Not Found + EXPIRED_OR_INVALID_PHONE(StatusCode.NOT_FOUND, ReasonCode.RESOURCE_DELETED_OR_MOVED, "만료되었거나 등록되지 않은 휴대폰 정보입니다."), ; private final StatusCode statusCode; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java new file mode 100644 index 000000000..69bc2c599 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java @@ -0,0 +1,14 @@ +package kr.co.pennyway.api.config; + +import kr.co.pennyway.api.common.converter.ProviderConverter; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Override + public void addFormatters(FormatterRegistry registrar) { + registrar.addConverter(new ProviderConverter()); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapperTest.java index c67630530..0d6ff35ad 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapperTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapperTest.java @@ -1,5 +1,6 @@ package kr.co.pennyway.api.apis.auth.mapper; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; import org.apache.commons.lang3.tuple.Pair; @@ -22,10 +23,12 @@ public class UserSyncMapperTest { private UserSyncMapper userSyncMapper; @Mock private UserService userService; + @Mock + private OauthService oauthService; @BeforeEach void setUp() { - userSyncMapper = new UserSyncMapper(userService); + userSyncMapper = new UserSyncMapper(userService, oauthService); } @DisplayName("일반 회원가입 시, 회원 정보가 없으면 FALSE를 반환한다.") diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProviderConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProviderConverter.java new file mode 100644 index 000000000..1ef15c1c6 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProviderConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.oauth.type.Provider; + +@Converter +public class ProviderConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "제공자"; + + public ProviderConverter() { + super(Provider.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationType.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationType.java index 601633c21..7c35d8a62 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationType.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationType.java @@ -1,12 +1,26 @@ package kr.co.pennyway.domain.common.redis.phone; +import kr.co.pennyway.domain.domains.oauth.type.Provider; import lombok.Getter; import lombok.RequiredArgsConstructor; @Getter @RequiredArgsConstructor public enum PhoneVerificationType { - SIGN_UP("signUp"), OAUTH_SIGN_UP("oauthSignUp"), FIND_USERNAME("username"), FIND_PASSWORD("password"); + SIGN_UP("signUp"), + OAUTH_SIGN_UP_KAKAO("oauthSignUp:kakao"), + OAUTH_SIGN_UP_GOOGLE("oauthSignUp:google"), + OAUTH_SIGN_UP_APPLE("oauthSignUp:apple"), + FIND_USERNAME("username"), + FIND_PASSWORD("password"); private final String prefix; + + public static PhoneVerificationType getOauthSignUpTypeByProvider(Provider provider) { + return switch (provider) { + case KAKAO -> OAUTH_SIGN_UP_KAKAO; + case GOOGLE -> OAUTH_SIGN_UP_GOOGLE; + case APPLE -> OAUTH_SIGN_UP_APPLE; + }; + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java new file mode 100644 index 000000000..cde953be3 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java @@ -0,0 +1,58 @@ +package kr.co.pennyway.domain.domains.oauth.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "oauth") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +@DynamicInsert +public class Oauth { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Provider provider; + private String oauthId; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @ColumnDefault("NULL") + private LocalDateTime deletedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Builder(access = AccessLevel.PRIVATE) + private Oauth(Provider provider, String oauthId, LocalDateTime createdAt, LocalDateTime deletedAt, User user) { + this.provider = provider; + this.oauthId = oauthId; + this.createdAt = createdAt; + this.deletedAt = deletedAt; + this.user = user; + } + + public static Oauth of(Provider provider, String oauthId, User user) { + return Oauth.builder() + .provider(provider) + .oauthId(oauthId) + .user(user) + .build(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java new file mode 100644 index 000000000..017897b7c --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.domain.domains.oauth.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum OauthErrorCode implements BaseErrorCode { + /* 400 Bad Request */ + ALREADY_SIGNUP_OAUTH(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "이미 해당 제공자로 가입된 사용자입니다."), + + /* 401 Unauthorized */ + NOT_MATCHED_OAUTH_ID(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "OAuth ID가 일치하지 않습니다."), + + /* 422 Unprocessable Entity */ + INVALID_PROVIDER(StatusCode.UNPROCESSABLE_CONTENT, ReasonCode.TYPE_MISMATCH_ERROR_IN_REQUEST_BODY, "유효하지 않은 제공자입니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthException.java new file mode 100644 index 000000000..2276a4478 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthException.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.domain.domains.oauth.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class OauthException extends GlobalErrorException { + private final OauthErrorCode errorCode; + + public OauthException(OauthErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + @Override + public CausedBy causedBy() { + return errorCode.causedBy(); + } + + public String getExplainError() { + return errorCode.getExplainError(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java new file mode 100644 index 000000000..09c519a55 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.domains.oauth.repository; + +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface OauthRepository extends JpaRepository { + Optional findByOauthIdAndProvider(String oauthId, Provider provider); + + boolean existsByUser_IdAndProvider(Long userId, Provider provider); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java new file mode 100644 index 000000000..967d632f1 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.domain.domains.oauth.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.repository.OauthRepository; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@DomainService +@RequiredArgsConstructor +public class OauthService { + private final OauthRepository oauthRepository; + + @Transactional(readOnly = true) + public Optional readOauthByOauthIdAndProvider(String oauthId, Provider provider) { + return oauthRepository.findByOauthIdAndProvider(oauthId, provider); + } + + @Transactional(readOnly = true) + public boolean isExistOauthAccount(Long userId, Provider provider) { + return oauthRepository.existsByUser_IdAndProvider(userId, provider); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/type/Provider.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/type/Provider.java new file mode 100644 index 000000000..e405919ae --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/type/Provider.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.domain.domains.oauth.type; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum Provider implements LegacyCommonType { + KAKAO("1", "카카오"), + GOOGLE("2", "구글"), + APPLE("3", "애플"); + + private final String code; + private final String type; + + @JsonCreator + public Provider fromString(String type) { + return valueOf(type.toUpperCase()); + } + + @Override + public String getCode() { + return code; + } + + @JsonValue + public String getType() { + return type; + } + + @Override + public String toString() { + return name().toLowerCase(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java index e3afcc2b4..f527796b3 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java @@ -29,6 +29,7 @@ public class User extends DateAuditable { private String name; @ColumnDefault("NULL") private String password; + @ColumnDefault("NULL") private LocalDateTime passwordUpdatedAt; @ColumnDefault("NULL") private String profileImageUrl; diff --git a/pennyway-infra/build.gradle b/pennyway-infra/build.gradle index 8631172c7..a214aa707 100644 --- a/pennyway-infra/build.gradle +++ b/pennyway-infra/build.gradle @@ -9,6 +9,11 @@ dependencies { runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.12.5' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.12.5' + /* httpclient */ + implementation 'org.apache.httpcomponents:httpclient:4.5.14' + implementation 'org.apache.httpcomponents.client5:httpclient5:5.3.1' + implementation 'org.apache.httpcomponents:httpcore:4.4.16' + /* redis */ api 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/apple/oidc/AppleOidcClient.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/apple/oidc/AppleOidcClient.java index 6c7078132..729acc0ac 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/apple/oidc/AppleOidcClient.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/apple/oidc/AppleOidcClient.java @@ -18,5 +18,5 @@ public interface AppleOidcClient extends OauthOidcClient { @Override @Cacheable(value = "AppleOauth", cacheManager = "oidcCacheManager") @GetMapping("/auth/keys") - OidcPublicKeyResponse getOIDCPublicKey(); + OidcPublicKeyResponse getOidcPublicKey(); } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/oidc/GoogleOidcClient.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/oidc/GoogleOidcClient.java index 297fdea98..418a413c9 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/oidc/GoogleOidcClient.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/oidc/GoogleOidcClient.java @@ -18,5 +18,5 @@ public interface GoogleOidcClient extends OauthOidcClient { @Override @Cacheable(value = "GoogleOauth", cacheManager = "oidcCacheManager") @GetMapping("/oauth2/v3/certs") - OidcPublicKeyResponse getOIDCPublicKey(); + OidcPublicKeyResponse getOidcPublicKey(); } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/kakao/oidc/KakaoOidcClient.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/kakao/oidc/KakaoOidcClient.java index b9603d3bd..5887b060e 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/kakao/oidc/KakaoOidcClient.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/kakao/oidc/KakaoOidcClient.java @@ -17,5 +17,5 @@ public interface KakaoOidcClient extends OauthOidcClient { @Override @Cacheable(value = "KakaoOauth", cacheManager = "oidcCacheManager") @GetMapping("/.well-known/jwks.json") - OidcPublicKeyResponse getOIDCPublicKey(); + OidcPublicKeyResponse getOidcPublicKey(); } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClient.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClient.java index 0c4d217a1..4a21a3fbc 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClient.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClient.java @@ -1,5 +1,5 @@ package kr.co.pennyway.infra.common.oidc; public interface OauthOidcClient { - OidcPublicKeyResponse getOIDCPublicKey(); + OidcPublicKeyResponse getOidcPublicKey(); } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java index a84c56992..6ebf369a6 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java @@ -111,13 +111,11 @@ public CacheManager oidcCacheManager(@InfraRedisConnectionFactory RedisConnectio new GenericJackson2JsonRedisSerializer() )) .entryTtl(Duration.ofDays(oidcCacheTtlDay)); - Map redisCacheConfigurationMap = Map.of("oidcConfig", config); return RedisCacheManager .RedisCacheManagerBuilder .fromConnectionFactory(cf) .cacheDefaults(config) - .withInitialCacheConfigurations(redisCacheConfigurationMap) .build(); } } diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index 5b963497d..e0ff311d1 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -4,6 +4,11 @@ spring: local: common dev: common + data.redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + pennyway: server: domain: From 23a413f95a5de6387ce1ed111615cb947e07edbd Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 5 Apr 2024 13:03:54 +0900 Subject: [PATCH 040/152] =?UTF-8?q?=E2=9C=A8=20External-api=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EA=B5=AC=EC=B6=95=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: test profile yml 작성 * chore: external-api test 패키지 logback-test.xml 추가 * chore: testcontainer + redis, mysql 의존성 주입 * chore: 통합 테스트 db 컨테이너 환경 정의 * chore: api 통합 테스트 프로필 resolver 정의 * chore: common 패키지 application 클래스 정의 * chore: api 통합 테스트 base package classes 정의 * chore: api 통합 테스트 어노테이션 정의 * rename: api test application.yml -> application-test.yml * test: auth api 유효성 검사 profile locat -> test * test: api 통합 테스트 실행 테스트 * test: 통합 테스트 어노테이션에 profile=test 추가 * chore: test application 파일 제거 * chore: 환경변수 기본값 정의 * test: api 테스트 프로필 test -> local * chore: test ci gradlew test --peraller 옵션 추가 * chore: external api db config @container 제거 * chore: application 파일 내 test 프로필 추가 * test: api 통합 테스트 프로필 local -> test * test: 회원가입 유저 시나리오 통합 테스트 (동작 확인용) --- .github/workflows/test.yml | 2 +- pennyway-app-external-api/build.gradle | 7 ++ .../src/main/resources/application.yml | 16 ++-- .../AuthControllerIntegrationTest.java | 75 +++++++++++++++++++ .../api/config/ExternalApiDBTestConfig.java | 44 +++++++++++ ...ExternalApiIntegrationProfileResolver.java | 12 +++ .../config/ExternalApiIntegrationTest.java | 14 ++++ .../ExternalApiIntegrationTestConfig.java | 20 +++++ .../src/test/resources/application.yml | 0 .../src/test/resources/logback-test.xml | 7 ++ .../common/PennywayCommonApplication.java | 7 ++ .../src/main/resources/application-common.yml | 8 +- .../src/main/resources/application-domain.yml | 29 +++++-- .../src/test/resources/application-domain.yml | 8 -- .../src/main/resources/application-infra.yml | 30 +++++--- 15 files changed, 246 insertions(+), 33 deletions(-) create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationProfileResolver.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTestConfig.java delete mode 100644 pennyway-app-external-api/src/test/resources/application.yml create mode 100644 pennyway-app-external-api/src/test/resources/logback-test.xml create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/PennywayCommonApplication.java delete mode 100644 pennyway-domain/src/test/resources/application-domain.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db9a9519d..8a2decae2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,4 +48,4 @@ jobs: - name: Test with Gradle run: | chmod +x ./gradlew - ./gradlew --info test \ No newline at end of file + ./gradlew --info --parallel test \ No newline at end of file diff --git a/pennyway-app-external-api/build.gradle b/pennyway-app-external-api/build.gradle index 2ae563a35..fedb57393 100644 --- a/pennyway-app-external-api/build.gradle +++ b/pennyway-app-external-api/build.gradle @@ -29,4 +29,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web:3.2.3' implementation 'org.springframework.boot:spring-boot-starter-validation:3.2.3' + + /* testcontainer */ + testImplementation "org.junit.jupiter:junit-jupiter:5.8.1" + testImplementation "org.testcontainers:testcontainers:1.19.7" + testImplementation "org.testcontainers:junit-jupiter:1.19.7" + testImplementation "org.testcontainers:mysql:1.19.7" + testImplementation "com.redis.testcontainers:testcontainers-redis-junit:1.6.4" } diff --git a/pennyway-app-external-api/src/main/resources/application.yml b/pennyway-app-external-api/src/main/resources/application.yml index 2d360b542..98ffa47a6 100644 --- a/pennyway-app-external-api/src/main/resources/application.yml +++ b/pennyway-app-external-api/src/main/resources/application.yml @@ -6,12 +6,12 @@ spring: jwt: secret-key: - access-token: ${JWT_ACCESS_SECRET_KEY} - refresh-token: ${JWT_REFRESH_SECRET_KEY} + access-token: ${JWT_ACCESS_SECRET_KEY:exampleSecretKeyForPennywaySystemAccessSecretKeyTestForPadding} + refresh-token: ${JWT_REFRESH_SECRET_KEY:exampleSecretKeyForPennywaySystemRefreshSecretKeyTestForPadding} expiration-time: # milliseconds 단위 - access-token: ${JWT_ACCESS_EXPIRATION_TIME} # 30m (30 * 60 * 1000) - refresh-token: ${JWT_REFRESH_EXPIRATION_TIME} # 7d (7 * 24 * 60 * 60 * 1000) + access-token: ${JWT_ACCESS_EXPIRATION_TIME:1800000} # 30m (30 * 60 * 1000) + refresh-token: ${JWT_REFRESH_EXPIRATION_TIME:604800000} # 7d (7 * 24 * 60 * 60 * 1000) --- spring: @@ -47,4 +47,10 @@ springdoc: operations-sorter: alpha api-docs: groups: - enabled: true \ No newline at end of file + enabled: true + +--- +spring: + config: + activate: + on-profile: test \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java new file mode 100644 index 000000000..ad124abf5 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java @@ -0,0 +1,75 @@ +package kr.co.pennyway.api.apis.auth.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; +import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExternalApiIntegrationTest +@AutoConfigureMockMvc +public class AuthControllerIntegrationTest extends ExternalApiDBTestConfig { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private PhoneVerificationService phoneVerificationService; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(post("/**").with(csrf())) + .build(); + } + + @Test + @DisplayName("컨테이너 실행 테스트") + void containerTest() { + System.out.println("컨테이너 실행 테스트"); + } + + @Test + @WithAnonymousUser + @DisplayName("회원가입 통합 테스트") + void controllerTest() throws Exception { + // given + SignUpReq.General request = new SignUpReq.General("pennyway", "jayang", "dkssudgktpdy1", "010-1234-5678", "050505"); + phoneVerificationService.create("010-1234-5678", "050505", PhoneVerificationType.SIGN_UP); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().exists("Authorization")) + .andExpect(jsonPath("$.data.user.id").value(1)) + .andDo(print()); + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java new file mode 100644 index 000000000..b03255832 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java @@ -0,0 +1,44 @@ +package kr.co.pennyway.api.config; + +import com.redis.testcontainers.RedisContainer; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@ActiveProfiles("test") +public abstract class ExternalApiDBTestConfig { + private static final String REDIS_CONTAINER_IMAGE = "redis:7.2.4-alpine"; + private static final String MYSQL_CONTAINER_IMAGE = "mysql:8.0.26"; + + private static final RedisContainer REDIS_CONTAINER; + private static final MySQLContainer MYSQL_CONTAINER; + + static { + REDIS_CONTAINER = + new RedisContainer(DockerImageName.parse(REDIS_CONTAINER_IMAGE)) + .withExposedPorts(6379) + .withReuse(true); + MYSQL_CONTAINER = + new MySQLContainer<>(DockerImageName.parse(MYSQL_CONTAINER_IMAGE)) + .withDatabaseName("pennyway") + .withUsername("root") + .withPassword("testpass") + .withReuse(true); + + REDIS_CONTAINER.start(); + MYSQL_CONTAINER.start(); + } + + @DynamicPropertySource + public static void setRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(6379))); + registry.add("spring.datasource.url", () -> String.format("jdbc:mysql://%s:%s/pennyway?serverTimezone=UTC&characterEncoding=utf8", MYSQL_CONTAINER.getHost(), MYSQL_CONTAINER.getMappedPort(3306))); + registry.add("spring.datasource.username", () -> "root"); + registry.add("spring.datasource.password", () -> "testpass"); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationProfileResolver.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationProfileResolver.java new file mode 100644 index 000000000..689c2eef8 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationProfileResolver.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.api.config; + +import org.springframework.lang.NonNull; +import org.springframework.test.context.ActiveProfilesResolver; + +public class ExternalApiIntegrationProfileResolver implements ActiveProfilesResolver { + @Override + @NonNull + public String[] resolve(@NonNull Class testClass) { + return new String[]{"common", "infra", "domain"}; + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTest.java new file mode 100644 index 000000000..294f35ea4 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTest.java @@ -0,0 +1,14 @@ +package kr.co.pennyway.api.config; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest(classes = ExternalApiIntegrationTestConfig.class) +@ActiveProfiles(profiles = {"test"}, resolver = ExternalApiIntegrationProfileResolver.class) +@Documented +public @interface ExternalApiIntegrationTest { +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTestConfig.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTestConfig.java new file mode 100644 index 000000000..50d654bfe --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTestConfig.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.api.config; + +import kr.co.pennyway.PennywayExternalApiApplication; +import kr.co.pennyway.common.PennywayCommonApplication; +import kr.co.pennyway.domain.DomainPackageLocation; +import kr.co.pennyway.infra.PennywayInfraApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan( + basePackageClasses = { + PennywayExternalApiApplication.class, + PennywayInfraApplication.class, + DomainPackageLocation.class, + PennywayCommonApplication.class + } +) +public class ExternalApiIntegrationTestConfig { +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/resources/application.yml b/pennyway-app-external-api/src/test/resources/application.yml deleted file mode 100644 index e69de29bb..000000000 diff --git a/pennyway-app-external-api/src/test/resources/logback-test.xml b/pennyway-app-external-api/src/test/resources/logback-test.xml new file mode 100644 index 000000000..198192602 --- /dev/null +++ b/pennyway-app-external-api/src/test/resources/logback-test.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/PennywayCommonApplication.java b/pennyway-common/src/main/java/kr/co/pennyway/common/PennywayCommonApplication.java new file mode 100644 index 000000000..983dac2f3 --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/PennywayCommonApplication.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.common; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PennywayCommonApplication { +} diff --git a/pennyway-common/src/main/resources/application-common.yml b/pennyway-common/src/main/resources/application-common.yml index b1e7bbcd9..aecc35354 100644 --- a/pennyway-common/src/main/resources/application-common.yml +++ b/pennyway-common/src/main/resources/application-common.yml @@ -8,4 +8,10 @@ spring: spring: config: activate: - on-profile: dev \ No newline at end of file + on-profile: dev + +--- +spring: + config: + activate: + on-profile: test \ No newline at end of file diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml index 42d2dc101..6f00d4f7a 100644 --- a/pennyway-domain/src/main/resources/application-domain.yml +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -5,15 +5,15 @@ spring: dev: common datasource: - url: ${DB_URL} - username: ${DB_USER_NAME} - password: ${DB_PASSWORD} + url: ${DB_URL:jdbc:mysql://localhost:3300/pennyway?serverTimezone=UTC&characterEncoding=utf8} + username: ${DB_USER_NAME:root} + password: ${DB_PASSWORD:password} driver-class-name: com.mysql.cj.jdbc.Driver data.redis: - host: ${REDIS_HOST} - port: ${REDIS_PORT} - password: ${REDIS_PASSWORD} + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} --- spring: @@ -58,6 +58,23 @@ spring: hibernate: ddl-auto: none show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + +--- +spring: + config: + activate: + on-profile: test + + jpa: + database: MySQL + open-in-view: false + generate-ddl: true + hibernate: + ddl-auto: create + show-sql: false properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect \ No newline at end of file diff --git a/pennyway-domain/src/test/resources/application-domain.yml b/pennyway-domain/src/test/resources/application-domain.yml deleted file mode 100644 index f77483637..000000000 --- a/pennyway-domain/src/test/resources/application-domain.yml +++ /dev/null @@ -1,8 +0,0 @@ -spring: - config: - activate: - on-profile: test - - data.redis: - host: 127.0.0.1 - port: 6379 \ No newline at end of file diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index e0ff311d1..50b03b1e3 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -5,28 +5,28 @@ spring: dev: common data.redis: - host: ${REDIS_HOST} - port: ${REDIS_PORT} - password: ${REDIS_PASSWORD} + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} pennyway: server: domain: - local: ${PENNYWAY_DOMAIN_LOCAL} - dev: ${PENNYWAY_DOMAIN_DEV} + local: ${PENNYWAY_DOMAIN_LOCAL:127.0.0.1:8080} + dev: ${PENNYWAY_DOMAIN_DEV:127.0.0.1:8080} oauth2: client: provider: kakao: - jwks-uri: ${KAKAO_JWKS_URI} - secret: ${KAKAO_CLIENT_SECRET} + jwks-uri: ${KAKAO_JWKS_URI:https://kauth.kakao.com} + secret: ${KAKAO_CLIENT_SECRET:liuhil5068l2j5o0912} google: - jwks-uri: ${GOOGLE_JWKS_URI} - secret: ${GOOGLE_CLIENT_SECRET} + jwks-uri: ${GOOGLE_JWKS_URI:https://www.googleapis.com} + secret: ${GOOGLE_CLIENT_SECRET:123456789012-67hm9vokrt6ukmiwtvd8ak67oflecm.apps.googleusercontent.com} apple: - jwks-uri: ${APPLE_JWKS_URI} - secret: ${APPLE_CLIENT_SECRET} + jwks-uri: ${APPLE_JWKS_URI:https://appleid.apple.com} + secret: ${APPLE_CLIENT_SECRET:pennyway-jayang-was} --- spring: @@ -38,4 +38,10 @@ spring: spring: config: activate: - on-profile: dev \ No newline at end of file + on-profile: dev + +--- +spring: + config: + activate: + on-profile: test \ No newline at end of file From afd13b6b92e835899bdb6803dd50832c364bde30 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sat, 6 Apr 2024 17:44:24 +0900 Subject: [PATCH 041/152] =?UTF-8?q?=F0=9F=90=9B=20OIDC=20signature=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=EC=97=86=EC=9D=B4=20header,=20payload=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * rename: oauth oidc provider imple 에러 메서드 명시 * fix: token header, payload 추출 메서드 jjwt 라이브러리 의존 제거 * rename: get oidc token body 메서드 내 aud 로그 제거 * fix: oidc provider log 제거 및 get unsignedtoken() token 마지막에 . 제거 --- .../common/oidc/OauthOidcProviderImpl.java | 52 ++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java index 2010d14b2..7b4244431 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java @@ -1,11 +1,18 @@ package kr.co.pennyway.infra.common.oidc; -import io.jsonwebtoken.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; import kr.co.pennyway.infra.common.exception.JwtErrorCode; import kr.co.pennyway.infra.common.exception.JwtErrorException; import kr.co.pennyway.infra.common.util.JwtErrorCodeUtil; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.util.Assert; import java.math.BigInteger; import java.security.KeyFactory; @@ -14,23 +21,25 @@ import java.security.spec.InvalidKeySpecException; import java.security.spec.RSAPublicKeySpec; import java.util.Base64; +import java.util.Map; @Slf4j @Component +@RequiredArgsConstructor public class OauthOidcProviderImpl implements OauthOidcProvider { private static final String KID = "kid"; private static final String RSA = "RSA"; + private final ObjectMapper objectMapper; @Override public String getKidFromUnsignedTokenHeader(String token, String iss, String aud, String nonce) { - return (String) getUnsignedTokenClaims(token, iss, aud, nonce).getHeader().get(KID); + return getUnsignedTokenClaims(token, iss, aud, nonce).get("header").get(KID); } @Override public OidcDecodePayload getOIDCTokenBody(String token, String modulus, String exponent) { Claims body = getOIDCTokenJws(token, modulus, exponent).getPayload(); - String aud = body.getAudience().iterator().next(); // TODO: 이전 버전과 다르게 aud가 Set으로 변경되어 있음. 테스트 필요 - log.debug("aud : {}", aud); + String aud = body.getAudience().iterator().next(); // aud가 여러개일 경우 첫 번째 aud를 사용 return new OidcDecodePayload( body.getIssuer(), @@ -43,19 +52,26 @@ public OidcDecodePayload getOIDCTokenBody(String token, String modulus, String e * ID Token의 header와 body를 Base64 방식으로 디코딩하는 메서드
* payload의 iss, aud, exp, nonce를 검증하고, 실패시 예외 처리 */ - private Jwt getUnsignedTokenClaims(String token, String iss, String aud, String nonce) { + private Map> getUnsignedTokenClaims(String token, String iss, String aud, String nonce) { try { - return Jwts.parser() - .requireAudience(aud) - .requireIssuer(iss) -// .require("nonce", nonce) // 현재는 nonce를 사용하지 않음 - .build() - .parseUnsecuredClaims(getUnsignedToken(token)); // TODO: 기존 방식은 parseClaimsJwt(getUnsignedToken(token)); -> 변경한 코드 정상 동작 여부 확인 필요 - } catch (JwtException e) { - final JwtErrorCode errorCode = JwtErrorCodeUtil.determineErrorCode(e, JwtErrorCode.FAILED_AUTHENTICATION); + Base64.Decoder decoder = Base64.getUrlDecoder(); - log.warn("Error code : {}, Error - {}, {}", errorCode, e.getClass(), e.getMessage()); - throw new JwtErrorException(errorCode); + String unsignedToken = getUnsignedToken(token); + String headerJson = new String(decoder.decode(unsignedToken.split("\\.")[0])); + String payloadJson = new String(decoder.decode(unsignedToken.split("\\.")[1])); + + @SuppressWarnings("unchecked") + Map header = objectMapper.readValue(headerJson, Map.class); + @SuppressWarnings("unchecked") + Map payload = objectMapper.readValue(payloadJson, Map.class); + + Assert.isTrue(payload.get("aud").equals(aud), "aud is not matched. expected : " + aud + ", actual : " + payload.get("aud")); + Assert.isTrue(payload.get("iss").equals(iss), "iss is not matched. expected : " + iss + ", actual : " + payload.get("iss")); + + return Map.of("header", header, "payload", payload); + } catch (JsonProcessingException e) { + log.warn("getUnsignedTokenClaims : Error - {}, {}", e.getClass(), e.getMessage()); + throw new RuntimeException(e); } } @@ -65,7 +81,7 @@ private Jwt getUnsignedTokenClaims(String token, String iss, Str private String getUnsignedToken(String token) { String[] splitToken = token.split("\\."); if (splitToken.length != 3) throw new JwtErrorException(JwtErrorCode.MALFORMED_TOKEN); - return splitToken[0] + "." + splitToken[1] + "."; + return splitToken[0] + "." + splitToken[1]; } /** @@ -81,10 +97,10 @@ private Jws getOIDCTokenJws(String token, String modulus, String exponen } catch (JwtException e) { final JwtErrorCode errorCode = JwtErrorCodeUtil.determineErrorCode(e, JwtErrorCode.FAILED_AUTHENTICATION); - log.warn("Error code : {}, Error - {}, {}", errorCode, e.getClass(), e.getMessage()); + log.warn("getOIDCTokenJws : Error code : {}, Error - {}, {}", errorCode, e.getClass(), e.getMessage()); throw new JwtErrorException(errorCode); } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { - log.warn("Error - {}, {}", e.getClass(), e.getMessage()); + log.warn("getOIDCTokenJws : Error - {}, {}", e.getClass(), e.getMessage()); throw new JwtErrorException(JwtErrorCode.MALFORMED_TOKEN); } } From e000d6e22ea8f4a5218226fcbdf2018a044400bc Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 9 Apr 2024 19:27:47 +0900 Subject: [PATCH 042/152] =?UTF-8?q?=E2=9C=A8=20=EC=9D=B8=EC=A6=9D=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20SMS=20=EC=A0=84=EC=86=A1=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: infra 모듈 내 aws sdk, sns 의존성 주입 * chore: aws sns 환경변수 설정 * chore: aws sns config 설정 * feat: sms dto에서 sms 전송을 위한 전화번호 파싱 메서드 추가 * rename: pheon 파싱 메서드명 수정 * fix: sms request record 제거 * fix: sms dto response 객체 제거 및 불필요한 필드 정보 제거 * feat: phone 인증 event 등록 * fix: sms dto to 레코드명 변경 및 code 필드 추가 * rename: phone verification event -> push code event * feat: push code 이벤트 핸들러 등록 * rename: event명 변경으로 인한 수정 * fix: 인증 코드 생성 로직 sms provider -> mapper로 변경 * chore: infra 환경 설정 기본값 null 제거 -> 더미값 주입 --- .../auth/mapper/PhoneVerificationMapper.java | 25 +++-- pennyway-infra/build.gradle | 4 + .../infra/client/aws/sms/AwsSmsProvider.java | 31 +++--- .../pennyway/infra/client/aws/sms/SmsDto.java | 95 ++++--------------- .../infra/client/aws/sms/SmsProvider.java | 4 +- .../infra/common/event/PushCodeEvent.java | 12 +++ .../common/event/PushCodeEventHandling.java | 29 ++++++ .../pennyway/infra/config/AwsSnsConfig.java | 42 ++++++++ .../src/main/resources/application-infra.yml | 9 ++ 9 files changed, 157 insertions(+), 94 deletions(-) create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/PushCodeEvent.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/PushCodeEventHandling.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AwsSnsConfig.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/PhoneVerificationMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/PhoneVerificationMapper.java index ff1cb8502..1b26768e4 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/PhoneVerificationMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/PhoneVerificationMapper.java @@ -6,19 +6,20 @@ import kr.co.pennyway.common.annotation.Mapper; import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; -import kr.co.pennyway.infra.client.aws.sms.SmsDto; -import kr.co.pennyway.infra.client.aws.sms.SmsProvider; +import kr.co.pennyway.infra.common.event.PushCodeEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import java.time.LocalDateTime; +import java.util.concurrent.ThreadLocalRandom; @Slf4j @Mapper @RequiredArgsConstructor public class PhoneVerificationMapper { private final PhoneVerificationService phoneVerificationService; - private final SmsProvider smsProvider; + private final ApplicationEventPublisher eventPublisher; /** * 휴대폰 번호로 인증 코드를 발송하고 캐싱한다. (5분간 유효) @@ -28,9 +29,12 @@ public class PhoneVerificationMapper { * @return {@link PhoneVerificationDto.PushCodeRes} */ public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeReq request, PhoneVerificationType codeType) { - SmsDto.Info info = smsProvider.sendCode(SmsDto.To.of(request.phone())); - LocalDateTime expiresAt = phoneVerificationService.create(request.phone(), info.code(), codeType); - return PhoneVerificationDto.PushCodeRes.of(request.phone(), info.requestAt(), expiresAt); + String code = issueVerificationCode(); + LocalDateTime expiresAt = phoneVerificationService.create(request.phone(), code, codeType); + + eventPublisher.publishEvent(PushCodeEvent.of(request.phone(), code)); + + return PhoneVerificationDto.PushCodeRes.of(request.phone(), LocalDateTime.now(), expiresAt); } /** @@ -53,4 +57,13 @@ public Boolean isValidCode(PhoneVerificationDto.VerifyCodeReq request, PhoneVeri throw new PhoneVerificationException(PhoneVerificationErrorCode.IS_NOT_VALID_CODE); return Boolean.TRUE; } + + private String issueVerificationCode() { + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < 6; i++) { + sb.append(ThreadLocalRandom.current().nextInt(0, 10)); + } + return sb.toString(); + } } diff --git a/pennyway-infra/build.gradle b/pennyway-infra/build.gradle index a214aa707..30c5a749a 100644 --- a/pennyway-infra/build.gradle +++ b/pennyway-infra/build.gradle @@ -21,4 +21,8 @@ dependencies { implementation platform("org.springframework.cloud:spring-cloud-dependencies:2023.0.1") implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.1.1' implementation 'io.github.openfeign:feign-okhttp:13.2' + + /* aws */ + implementation platform("software.amazon.awssdk:bom:2.25.26") + implementation 'software.amazon.awssdk:sns:2.25.26' } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/AwsSmsProvider.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/AwsSmsProvider.java index 11100e1a0..faffc974a 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/AwsSmsProvider.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/AwsSmsProvider.java @@ -3,28 +3,37 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.PublishRequest; +import software.amazon.awssdk.services.sns.model.PublishResponse; +import software.amazon.awssdk.services.sns.model.SnsException; import java.time.LocalDateTime; -import java.util.concurrent.ThreadLocalRandom; -// TODO: AWS SNS 인프라 설정 후 내부 구현 @Slf4j @Component @RequiredArgsConstructor public class AwsSmsProvider implements SmsProvider { + private final SnsClient snsClient; + @Override - public SmsDto.Info sendCode(SmsDto.To dto) { - String code = issueVerificationCode(); - SmsDto.Response response = SmsDto.Response.builder().requestAt(LocalDateTime.now()).build(); - return SmsDto.Info.from(response, code); + public SmsDto.Info sendCode(SmsDto.Request dto) { + PublishResponse response = publishCodeSms(dto.parsePhone(), dto.code()); + + return SmsDto.Info.of(response.messageId(), dto.code(), LocalDateTime.now()); } - private String issueVerificationCode() { - StringBuilder sb = new StringBuilder(); + private PublishResponse publishCodeSms(String phone, String code) { + PublishRequest request = PublishRequest.builder() + .message("[Pennyway] 인증번호 [" + code + "]를 입력해주세요.") + .phoneNumber(phone) + .build(); - for (int i = 0; i < 6; i++) { - sb.append(ThreadLocalRandom.current().nextInt(0, 10)); + try { + return snsClient.publish(request); + } catch (SnsException e) { + log.error("SMS 전송 실패: {}", e.getMessage()); + throw new RuntimeException("SMS 전송에 실패했습니다."); } - return sb.toString(); } } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsDto.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsDto.java index 9822143f1..22b066e38 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsDto.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsDto.java @@ -1,100 +1,45 @@ package kr.co.pennyway.infra.client.aws.sms; +import kr.co.pennyway.infra.common.event.PushCodeEvent; import lombok.Builder; import java.time.LocalDateTime; -import java.util.List; +import java.util.Objects; -// FIXME: Naver Cloud Platform Snes 기준 DTO. AWS SNS 요청, 응답 포맷에 맞게 수정 필요 public class SmsDto { - public record To( - String phone + public record Request( + String phone, + String code ) { - /** - * @param phone String : SMS 인증 요청을 할 전화번호 - */ - public static To of(String phone) { - return new To(phone); + public static Request from(PushCodeEvent e) { + return new Request(e.phone(), e.code()); } - } - @Builder - public record Request( - String type, - String contentType, - String countryCode, - String from, - String content, - List messages - ) { - /** - * AWS SNS API 요청 객체 생성 - * - * @param type String : SMS | LMS | MMS - * @param contentType String : COMM | AD - * @param countryCode String : 국가번호 - * @param from String : 발신번호 - * @param content String : 메시지 내용 - * @param messages List<{@link To}> : 메시지 정보 (to: 수신번호, subject: 개별 메시지 제목, content: 개별 메시지 내용) - */ - public static Request of(String type, String contentType, String countryCode, String from, String content, List messages) { - return Request.builder() - .type(type) - .contentType(contentType) - .countryCode(countryCode) - .from(from) - .content(content) - .messages(messages) - .build(); + public String parsePhone() { + return "+82" + phone.replaceAll("-", ""); } } /** * AWS SNS API 요청에 대한 응답 객체 * - * @param requestId String : 요청 ID - * @param requestAt LocalDateTime : 요청 시간 - * @param statusCode String : 응답 코드 - * @param statusName String : 응답 상태 - */ - @Builder - public record Response( - String requestId, - LocalDateTime requestAt, - String statusCode, - String statusName - ) { - } - - /** - * 인증번호 전송 정보를 확인할 수 있는 DTO - * - * @param requestId String : 요청 ID (NCP SMS API 요청 시 발급된 요청 ID) - * @param code String : 발급된 인증번호 정수 6자리 문자열 - * @param requestAt LocalDateTime : 요청 시간 - * @param statusCode String : 응답 코드 - * @param statusName String : 응답 상태 + * @param requestId String : 요청 ID + * @param requestAt LocalDateTime : 요청 시간 */ @Builder public record Info( String requestId, String code, - LocalDateTime requestAt, - String statusCode, - String statusName + LocalDateTime requestAt ) { - /** - * @param request {@link Response} - * @param code String : 인증 코드 정수 6자리 문자열 - */ - public static Info from(Response request, String code) { - return Info.builder() - .requestId(request.requestId()) - .code(code) - .requestAt(request.requestAt()) - .statusCode(request.statusCode()) - .statusName(request.statusName()) - .build(); + public Info { + Objects.requireNonNull(requestId); + Objects.requireNonNull(code); + Objects.requireNonNull(requestAt); + } + + public static Info of(String requestId, String code, LocalDateTime requestAt) { + return new Info(requestId, code, requestAt); } } } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsProvider.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsProvider.java index 41e9b0959..4f111223c 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsProvider.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/sms/SmsProvider.java @@ -4,8 +4,8 @@ public interface SmsProvider { /** * 인증번호를 수신자에게 SMS로 전송 * - * @param dto {@link SmsDto.To} : 수신자 번호 + * @param dto {@link SmsDto.Request} : 수신자 번호 * @return {@link SmsDto.Info} : SNS 전송 정보 */ - SmsDto.Info sendCode(SmsDto.To dto); + SmsDto.Info sendCode(SmsDto.Request dto); } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/PushCodeEvent.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/PushCodeEvent.java new file mode 100644 index 000000000..aa0224333 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/PushCodeEvent.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.infra.common.event; + +import java.util.Objects; + +public record PushCodeEvent(String phone, String code) { + public static PushCodeEvent of(String phone, String code) { + Objects.requireNonNull(phone); + Objects.requireNonNull(code); + + return new PushCodeEvent(phone, code); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/PushCodeEventHandling.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/PushCodeEventHandling.java new file mode 100644 index 000000000..23298877e --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/PushCodeEventHandling.java @@ -0,0 +1,29 @@ +package kr.co.pennyway.infra.common.event; + +import kr.co.pennyway.infra.client.aws.sms.SmsDto; +import kr.co.pennyway.infra.client.aws.sms.SmsProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PushCodeEventHandling { + private final SmsProvider awsSmsProvider; + + /** + * 사용자의 전화번호로 인증코드를 발신합니다. + *
+ * {@link EventListener}를 통해 이벤트를 받아서 SMS 메시지를 전송합니다. + * + * @param event {@link PushCodeEvent} + */ + @EventListener + public void handlePhoneVerificationEvent(PushCodeEvent event) { + log.debug("handlePhoneVerificationEvent: {}", event); + SmsDto.Info result = awsSmsProvider.sendCode(SmsDto.Request.from(event)); + log.info("Successfully sent SMS message - sent id: {}", result.requestId()); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AwsSnsConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AwsSnsConfig.java new file mode 100644 index 000000000..034090a22 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AwsSnsConfig.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.infra.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.sns.SnsClient; + +@Getter +@Configuration +public class AwsSnsConfig { + private final String accessKey; + private final String secretKey; + private final String region; + + public AwsSnsConfig( + @Value("${spring.cloud.aws.sns.credentials.access-key}") String accessKey, + @Value("${spring.cloud.aws.sns.credentials.secret-key}") String secretKey, + @Value("${spring.cloud.aws.sns.region.static}") String region + ) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.region = region; + } + + @Bean + public AwsCredentials awsSnsCredentials() { + return AwsBasicCredentials.create(accessKey, secretKey); + } + + @Bean + public SnsClient awsSnsClient() { + return SnsClient.builder() + .credentialsProvider(StaticCredentialsProvider.create(awsSnsCredentials())) + .region(Region.of(region)) + .build(); + } +} diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index 50b03b1e3..d71d5e076 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -9,6 +9,15 @@ spring: port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} + cloud: + aws: + sns: + credentials: + access-key: ${AWS_SNS_ACCESS_KEY:access-key} + secret-key: ${AWS_SNS_SECRET_KEY:secret-key} + region: + static: ${AWS_SNS_REGION:republic-of-korea-1} + pennyway: server: domain: From 0a3c1a613937fcd020f7f67d9ffc5c8af555f504 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 10 Apr 2024 19:33:00 +0900 Subject: [PATCH 043/152] =?UTF-8?q?=F0=9F=93=9D=20Swagger=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=9D=91=EB=8B=B5=20=EB=AC=B8=EC=84=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * rename: oauth 전화번호 인증 시, existUser -> existsUser * rename: 인증번호 검증 phone 필드 example에 하이픈 추가 * feat: json view에 적용할 구분용 클래스 생성 * rename: error response 응답 코드 설명 수정 * fix: 공통 예외 핸들러 주석 & 순서 수정 및 400 예외 핸들러 추가 * fix: auth api 문서 주석 분리 및 상세 내용 추가 * rename: 일반 회원가입 인증번호 검증에서 성공 응답 여부에 따른 분기 이동지점 명시 --- .../pennyway/api/apis/auth/api/AuthApi.java | 174 ++++++++++++++++++ .../pennyway/api/apis/auth/api/OauthApi.java | 4 +- .../apis/auth/controller/AuthController.java | 12 +- .../apis/auth/dto/PhoneVerificationDto.java | 2 +- .../api/common/response/ErrorResponse.java | 9 +- .../handler/GlobalExceptionHandler.java | 122 +++++++----- .../api/common/swagger/CustomJsonView.java | 9 + 7 files changed, 274 insertions(+), 58 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/swagger/CustomJsonView.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java new file mode 100644 index 000000000..bd80ab184 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java @@ -0,0 +1,174 @@ +package kr.co.pennyway.api.apis.auth.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.apis.auth.dto.SignInReq; +import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "[인증 API]") +public interface AuthApi { + @Operation(summary = "[1] 일반 회원가입 인증번호 전송", description = "전화번호로 인증번호를 전송합니다. 미인증 사용자만 가능합니다.") + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "발신 성공", value = """ + { + "code": "2000", + "data": { + "sms": { + "to": "010-1234-5678", + "sendAt": "2024-04-04 00:31:57", + "expiresAt": "2024-04-04 00:36:57" + } + } + } + """) + })) + ResponseEntity sendCode(@RequestBody @Validated PhoneVerificationDto.PushCodeReq request); + + @Operation(summary = "[2] 일반 회원가입 인증번호 검증", description = "인증번호를 검증합니다. 미인증 사용자만 가능합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "검증 성공 - 기존에 등록한 소셜 계정 없음 - [3-1]로 진행", value = """ + { + "code": "2000", + "data": { + "sms": { + "code": true, + "oauth": false + } + } + } + """), + @ExampleObject(name = "검증 성공 - 기존에 등록한 소셜 계정 있음 - [3-2]로 진행", value = """ + { + "code": "2000", + "data": { + "sms": { + "code": true, + "oauth": true, + "username": "pennyway" + } + } + } + """) + })), + @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "검증 실패", value = """ + { + "code": "4010", + "message": "인증번호가 일치하지 않습니다." + } + """) + })), + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "검증 실패 - 인증번호 만료", value = """ + { + "code": "4042", + "message": "만료되었거나 등록되지 않은 휴대폰 정보입니다." + } + """) + })) + }) + ResponseEntity verifyCode(@RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request); + + @Operation(summary = "[3-1] 일반 회원가입", description = "일반 회원가입을 진행합니다. 미인증 사용자만 가능합니다.") + @ApiResponse(responseCode = "200", description = "로그인 성공", + headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰", schema = @Schema(type = "string"), required = true), + @Header(name = "Authorization", description = "액세스 토큰", schema = @Schema(type = "string", format = "jwt"), required = true) + }, + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "성공", value = """ + { + "code": "2000", + "data": { + "user": { + "id": 1 + } + } + } + """) + })) + ResponseEntity signUp(@RequestBody @Validated SignUpReq.General request); + + @Operation(summary = "[3-2] 기존 소셜 계정에 일반 계정을 연동하는 회원가입", description = "소셜 계정과 연동할 일반 계정 정보를 입력합니다. 미인증 사용자만 가능합니다.") + @ApiResponse(responseCode = "200", description = "로그인 성공", + headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰", schema = @Schema(type = "string"), required = true), + @Header(name = "Authorization", description = "액세스 토큰", schema = @Schema(type = "string", format = "jwt"), required = true) + }, + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "성공", value = """ + { + "code": "2000", + "data": { + "user": { + "id": 1 + } + } + } + """) + })) + ResponseEntity linkOauth(@RequestBody @Validated SignUpReq.SyncWithOauth request); + + @Operation(summary = "[4] 일반 로그인", description = "아이디와 비밀번호로 로그인합니다. 미인증 사용자만 가능합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그인 성공", + headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰", schema = @Schema(type = "string"), required = true), + @Header(name = "Authorization", description = "액세스 토큰", schema = @Schema(type = "string", format = "jwt"), required = true) + }, + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "성공", value = """ + { + "code": "2000", + "data": { + "user": { + "id": 1 + } + } + } + """) + })), + @ApiResponse(responseCode = "401", description = "로그인 실패", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "실패 - 유효하지 않은 아이디/비밀번호", value = """ + { + "code": "4010", + "message": "유효하지 않은 아이디 또는 비밀번호입니다." + } + """) + })) + }) + ResponseEntity signIn(@RequestBody @Validated SignInReq.General request); + + @Operation(summary = "[5] 토큰 갱신", description = "리프레시 토큰을 이용해 액세스 토큰과 리프레시 토큰을 갱신합니다.") + @ApiResponse(responseCode = "200", description = "로그인 성공", + headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰", schema = @Schema(type = "string"), required = true), + @Header(name = "Authorization", description = "액세스 토큰", schema = @Schema(type = "string", format = "jwt"), required = true) + }, + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "성공", value = """ + { + "code": "2000", + "data": { + "user": { + "id": 1 + } + } + } + """) + })) + ResponseEntity refresh(@CookieValue("refreshToken") @Valid String refreshToken); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java index 6021be5d1..29be15dda 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java @@ -89,7 +89,7 @@ public interface OauthApi { "data": { "sms": { "code": true, - "existUser": true, + "existsUser": true, "username": "pennyway" } } @@ -101,7 +101,7 @@ public interface OauthApi { "data": { "sms": { "code": true, - "existUser": false + "existsUser": false } } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java index d27787b8c..c26544ab2 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java @@ -1,8 +1,7 @@ package kr.co.pennyway.api.apis.auth.controller; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import kr.co.pennyway.api.apis.auth.api.AuthApi; import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.dto.SignInReq; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; @@ -24,50 +23,43 @@ import java.util.Map; @Slf4j -@Tag(name = "[인증 API]") @RestController @RequiredArgsConstructor @RequestMapping("/v1/auth") -public class AuthController { +public class AuthController implements AuthApi { private final AuthUseCase authUseCase; private final CookieUtil cookieUtil; - @Operation(summary = "일반 회원가입 인증번호 전송") @PostMapping("/phone") @PreAuthorize("isAnonymous()") public ResponseEntity sendCode(@RequestBody @Validated PhoneVerificationDto.PushCodeReq request) { return ResponseEntity.ok(SuccessResponse.from("sms", authUseCase.sendCode(request))); } - @Operation(summary = "일반 회원가입 인증번호 검증") @PostMapping("/phone/verification") @PreAuthorize("isAnonymous()") public ResponseEntity verifyCode(@RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request) { return ResponseEntity.ok(SuccessResponse.from("sms", authUseCase.verifyCode(request))); } - @Operation(summary = "일반 회원가입") @PostMapping("/sign-up") @PreAuthorize("isAnonymous()") public ResponseEntity signUp(@RequestBody @Validated SignUpReq.General request) { return createAuthenticatedResponse(authUseCase.signUp(request.toInfo())); } - @Operation(summary = "기존 소셜 계정에 일반 계정을 연동하는 회원가입") @PostMapping("/link-oauth") @PreAuthorize("isAnonymous()") public ResponseEntity linkOauth(@RequestBody @Validated SignUpReq.SyncWithOauth request) { return createAuthenticatedResponse(authUseCase.signUp(request.toInfo())); } - @Operation(summary = "일반 로그인") @PostMapping("/sign-in") @PreAuthorize("isAnonymous()") public ResponseEntity signIn(@RequestBody @Validated SignInReq.General request) { return createAuthenticatedResponse(authUseCase.signIn(request)); } - @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 이용해 액세스 토큰과 리프레시 토큰을 갱신합니다.") @GetMapping("/refresh") @PreAuthorize("isAnonymous()") public ResponseEntity refresh(@CookieValue("refreshToken") @Valid String refreshToken) { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java index a220231a9..82c5ad1be 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java @@ -47,7 +47,7 @@ public static PushCodeRes of(String to, LocalDateTime sendAt, LocalDateTime expi @Schema(title = "인증번호 검증 DTO", description = "전화번호로 인증번호 검증 요청을 위한 DTO") public record VerifyCodeReq( - @Schema(description = "전화번호", example = "01012345678") + @Schema(description = "전화번호", example = "010-1234-5678") @NotBlank(message = "전화번호는 필수입니다.") @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") String phone, diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/ErrorResponse.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/ErrorResponse.java index 3f0d7ab51..f2ad2960f 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/ErrorResponse.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/ErrorResponse.java @@ -1,7 +1,9 @@ package kr.co.pennyway.api.common.response; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonView; import io.swagger.v3.oas.annotations.media.Schema; +import kr.co.pennyway.api.common.swagger.CustomJsonView; import kr.co.pennyway.common.exception.ReasonCode; import kr.co.pennyway.common.exception.StatusCode; import lombok.AccessLevel; @@ -15,15 +17,18 @@ import java.util.Map; @Getter -@Schema(description = "API 응답 - 실패 및 에러") +@Schema(title = "API 응답 - 실패 및 에러") @NoArgsConstructor(access = AccessLevel.PRIVATE) public class ErrorResponse { - @Schema(description = "응답 코드", defaultValue = "4000") + @Schema(description = "응답 코드", example = "4자리 정수형 문자열 (상태 코드(3자리) + 에러 코드(1자리))", pattern = "\\d{4}") + @JsonView(CustomJsonView.Common.class) private String code; @Schema(description = "응답 메시지", example = "에러 이유") + @JsonView(CustomJsonView.Common.class) private String message; @Schema(description = "에러 상세", example = "{\"field\":\"reason\"}") @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonView(CustomJsonView.Hidden.class) private Object fieldErrors; @Builder diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java index 25999c0b1..d022d0017 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java @@ -1,7 +1,12 @@ package kr.co.pennyway.api.common.response.handler; +import com.fasterxml.jackson.annotation.JsonView; import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import kr.co.pennyway.api.common.response.ErrorResponse; +import kr.co.pennyway.api.common.swagger.CustomJsonView; import kr.co.pennyway.common.exception.CausedBy; import kr.co.pennyway.common.exception.GlobalErrorException; import kr.co.pennyway.common.exception.ReasonCode; @@ -20,6 +25,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException; @@ -44,9 +50,56 @@ public class GlobalExceptionHandler { protected ResponseEntity handleGlobalErrorException(GlobalErrorException e) { log.warn("handleGlobalErrorException : {}", e.getMessage()); ErrorResponse response = ErrorResponse.of(e.getBaseErrorCode().causedBy().getCode(), e.getBaseErrorCode().getExplainError()); + return ResponseEntity.status(e.getBaseErrorCode().causedBy().statusCode().getCode()).body(response); } + /** + * API 호출 시 'Header' 내에 데이터 값이 유효하지 않은 경우 + * + * @see MissingRequestHeaderException + */ + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MissingRequestHeaderException.class) + @JsonView(CustomJsonView.Common.class) + protected ErrorResponse handleMissingRequestHeaderException(MissingRequestHeaderException e) { + log.warn("handleMissingRequestHeaderException : {}", e.getMessage()); + String code = String.valueOf(StatusCode.BAD_REQUEST.getCode() * 10 + ReasonCode.MISSING_REQUIRED_PARAMETER.getCode()); + + return ErrorResponse.of(code, e.getMessage()); + } + + /** + * API 호출 시 'Parameter' 내에 데이터 값이 존재하지 않은 경우 + * + * @see MissingServletRequestParameterException + */ + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MissingServletRequestParameterException.class) + @JsonView(CustomJsonView.Common.class) + protected ErrorResponse handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { + log.warn("handleMissingServletRequestParameterException : {}", e.getMessage()); + String code = String.valueOf(StatusCode.BAD_REQUEST.getCode() * 10 + ReasonCode.MISSING_REQUIRED_PARAMETER.getCode()); + + return ErrorResponse.of(code, e.getMessage()); + } + + /** + * API 호출 시 외부 서버와 통신 중 예외가 발생한 경우 + * + * @see HttpClientErrorException + */ + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(HttpClientErrorException.class) + @ApiResponse(responseCode = "400", description = "BAD_REQUEST", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + @JsonView(CustomJsonView.Common.class) + protected ErrorResponse handleHttpClientErrorException(HttpClientErrorException e) { + log.warn("handleHttpClientErrorException : {}", e.getMessage()); + String code = String.valueOf(StatusCode.BAD_REQUEST.getCode() * 10 + ReasonCode.INVALID_REQUEST.getCode()); + + return ErrorResponse.of(code, e.getMessage()); + } + /** * API 호출 시 인가 관련 예외를 처리하는 메서드 * @@ -54,6 +107,8 @@ protected ResponseEntity handleGlobalErrorException(GlobalErrorEx */ @ResponseStatus(HttpStatus.FORBIDDEN) @ExceptionHandler(AccessDeniedException.class) + @ApiResponse(responseCode = "403", description = "FORBIDDEN", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + @JsonView(CustomJsonView.Common.class) protected ErrorResponse handleAccessDeniedException(AccessDeniedException e) { log.warn("handleAccessDeniedException : {}", e.getMessage()); CausedBy causedBy = CausedBy.of(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN); @@ -66,12 +121,14 @@ protected ErrorResponse handleAccessDeniedException(AccessDeniedException e) { * * @see MethodArgumentNotValidException */ + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) @ExceptionHandler(MethodArgumentNotValidException.class) - protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + @JsonView(CustomJsonView.Hidden.class) + protected ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { log.warn("handleMethodArgumentNotValidException: {}", e.getMessage()); BindingResult bindingResult = e.getBindingResult(); - ErrorResponse response = ErrorResponse.failure(bindingResult, ReasonCode.REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY); - return ResponseEntity.unprocessableEntity().body(response); + + return ErrorResponse.failure(bindingResult, ReasonCode.REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY); } /** @@ -79,8 +136,11 @@ protected ResponseEntity handleMethodArgumentNotValidException(Me * * @see MethodArgumentTypeMismatchException */ + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) @ExceptionHandler(MethodArgumentTypeMismatchException.class) - protected ResponseEntity handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) { + @ApiResponse(responseCode = "422", description = "UNPROCESSABLE_ENTITY", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + @JsonView(CustomJsonView.Hidden.class) + protected ErrorResponse handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) { log.warn("handleMethodArgumentTypeMismatchException: {}", e.getMessage()); Class type = e.getRequiredType(); @@ -94,22 +154,7 @@ protected ResponseEntity handleMethodArgumentTypeMismatchExceptio } String code = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + TYPE_MISMATCH_ERROR_IN_REQUEST_BODY.getCode()); - ErrorResponse response = ErrorResponse.failure(code, TYPE_MISMATCH_ERROR_IN_REQUEST_BODY.name(), fieldErrors); - return ResponseEntity.unprocessableEntity().body(response); - } - - /** - * API 호출 시 'Header' 내에 데이터 값이 유효하지 않은 경우 - * - * @see MissingRequestHeaderException - */ - @ExceptionHandler(MissingRequestHeaderException.class) - protected ResponseEntity handleMissingRequestHeaderException(MissingRequestHeaderException e) { - log.warn("handleMissingRequestHeaderException : {}", e.getMessage()); - - String code = String.valueOf(StatusCode.BAD_REQUEST.getCode() * 10 + ReasonCode.MISSING_REQUIRED_PARAMETER.getCode()); - ErrorResponse response = ErrorResponse.of(code, e.getMessage()); - return ResponseEntity.badRequest().body(response); + return ErrorResponse.failure(code, TYPE_MISMATCH_ERROR_IN_REQUEST_BODY.name(), fieldErrors); } /** @@ -117,33 +162,18 @@ protected ResponseEntity handleMissingRequestHeaderException(Miss * * @see HttpMessageNotReadableException */ - @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(HttpMessageNotReadableException.class) - protected ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + protected ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { log.warn("handleHttpMessageNotReadableException : {}", e.getMessage()); String code; if (e.getCause() instanceof MismatchedInputException mismatchedInputException) { code = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + TYPE_MISMATCH_ERROR_IN_REQUEST_BODY.getCode()); - return ErrorResponse.of(code, mismatchedInputException.getPath().get(0).getFieldName() + " 필드의 값이 유효하지 않습니다."); + return ResponseEntity.unprocessableEntity().body(ErrorResponse.of(code, mismatchedInputException.getPath().get(0).getFieldName() + " 필드의 값이 유효하지 않습니다.")); } code = String.valueOf(StatusCode.BAD_REQUEST.getCode() * 10 + ReasonCode.MALFORMED_REQUEST_BODY.getCode()); - return ErrorResponse.of(code, e.getMessage()); - } - - /** - * API 호출 시 'Parameter' 내에 데이터 값이 존재하지 않은 경우 - * - * @see MissingServletRequestParameterException - */ - @ResponseStatus(HttpStatus.BAD_REQUEST) - @ExceptionHandler(MissingServletRequestParameterException.class) - protected ErrorResponse handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { - log.warn("handleMissingServletRequestParameterException : {}", e.getMessage()); - - String code = String.valueOf(StatusCode.BAD_REQUEST.getCode() * 10 + ReasonCode.MISSING_REQUIRED_PARAMETER.getCode()); - return ErrorResponse.of(code, e.getMessage()); + return ResponseEntity.badRequest().body(ErrorResponse.of(code, e.getMessage())); } /** @@ -153,10 +183,11 @@ protected ErrorResponse handleMissingServletRequestParameterException(MissingSer */ @ResponseStatus(HttpStatus.NOT_FOUND) @ExceptionHandler(NoHandlerFoundException.class) + @JsonView(CustomJsonView.Common.class) protected ErrorResponse handleNoHandlerFoundException(NoHandlerFoundException e) { log.warn("handleNoHandlerFoundException : {}", e.getMessage()); - String code = String.valueOf(StatusCode.NOT_FOUND.getCode() * 10 + ReasonCode.INVALID_URL_OR_ENDPOINT.getCode()); + return ErrorResponse.of(code, e.getMessage()); } @@ -167,10 +198,11 @@ protected ErrorResponse handleNoHandlerFoundException(NoHandlerFoundException e) */ @ResponseStatus(HttpStatus.NOT_FOUND) @ExceptionHandler(NoResourceFoundException.class) + @JsonView(CustomJsonView.Common.class) protected ErrorResponse handleNoResourceFoundException(NoResourceFoundException e) { log.warn("handleNoResourceFoundException : {}", e.getMessage()); - String code = String.valueOf(StatusCode.NOT_FOUND.getCode() * 10 + ReasonCode.INVALID_URL_OR_ENDPOINT.getCode()); + return ErrorResponse.of(code, e.getMessage()); } @@ -181,10 +213,11 @@ protected ErrorResponse handleNoResourceFoundException(NoResourceFoundException */ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ExceptionHandler(HttpMessageNotWritableException.class) + @JsonView(CustomJsonView.Common.class) protected ErrorResponse handleHttpMessageNotWritableException(HttpMessageNotWritableException e) { log.warn("handleHttpMessageNotWritableException : {}", e.getMessage()); - String code = String.valueOf(StatusCode.INTERNAL_SERVER_ERROR.getCode() * 10 + ReasonCode.UNEXPECTED_ERROR.getCode()); + return ErrorResponse.of(code, e.getMessage()); } @@ -195,11 +228,12 @@ protected ErrorResponse handleHttpMessageNotWritableException(HttpMessageNotWrit */ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ExceptionHandler(NullPointerException.class) + @JsonView(CustomJsonView.Common.class) protected ErrorResponse handleNullPointerException(NullPointerException e) { log.warn("handleNullPointerException : {}", e.getMessage()); e.printStackTrace(); - String code = String.valueOf(StatusCode.INTERNAL_SERVER_ERROR.getCode() * 10 + ReasonCode.UNEXPECTED_ERROR.getCode()); + return ErrorResponse.of(code, StatusCode.INTERNAL_SERVER_ERROR.name()); } @@ -213,11 +247,13 @@ protected ErrorResponse handleNullPointerException(NullPointerException e) { */ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ExceptionHandler(Exception.class) + @ApiResponse(responseCode = "500", description = "INTERNAL_SERVER_ERROR", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + @JsonView(CustomJsonView.Common.class) protected ErrorResponse handleException(Exception e) { log.warn("{} : handleException : {}", e.getClass(), e.getMessage()); e.printStackTrace(); - String code = String.valueOf(StatusCode.INTERNAL_SERVER_ERROR.getCode() * 10 + ReasonCode.UNEXPECTED_ERROR.getCode()); + return ErrorResponse.of(code, StatusCode.INTERNAL_SERVER_ERROR.name()); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/swagger/CustomJsonView.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/swagger/CustomJsonView.java new file mode 100644 index 000000000..a948ad8ff --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/swagger/CustomJsonView.java @@ -0,0 +1,9 @@ +package kr.co.pennyway.api.common.swagger; + +public class CustomJsonView { + public static class Common { + } + + public static class Hidden extends Common { + } +} From 5a68de67a409c089b6b5834031da35e4d1a5a373 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 10 Apr 2024 23:17:12 +0900 Subject: [PATCH 044/152] =?UTF-8?q?=F0=9F=90=9B=20Google=20id=20token=20is?= =?UTF-8?q?suer=20mismatch=20=EC=9D=B4=EC=8A=88=20=ED=95=B4=EA=B2=B0=20(#4?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 공개키 서명 검증 메서드에서 token 로그 출력 제거 * fix: oauth oidc client properties 인터페이스 get issuer 메서드 추가 * chore: infra 모듈 provider issuer 환경 변수 추가 * fix: apple, kakao 환경 get issuer 메서드 수정 * chore: kakao, apple issuer 제거 * fix: iss 인자에 get_jwks() -> get_issuer() 메서드로 삽입 --- .../co/pennyway/api/apis/auth/helper/OauthOidcHelper.java | 2 +- .../infra/common/oidc/OauthOidcClientProperties.java | 2 ++ .../pennyway/infra/common/oidc/OauthOidcProviderImpl.java | 1 - .../infra/common/properties/AppleOidcProperties.java | 5 +++++ .../infra/common/properties/GoogleOidcProperties.java | 1 + .../infra/common/properties/KakaoOidcProperties.java | 5 +++++ pennyway-infra/src/main/resources/application-infra.yml | 7 ++++--- 7 files changed, 18 insertions(+), 5 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java index de6c777e1..eeccae84e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java @@ -48,7 +48,7 @@ public OidcDecodePayload getPayload(Provider provider, String idToken) { OauthOidcClientProperties properties = oauthOidcClients.get(provider).values().iterator().next(); OidcPublicKeyResponse response = client.getOidcPublicKey(); - return getPayloadFromIdToken(idToken, properties.getJwksUri(), properties.getSecret(), null, response); + return getPayloadFromIdToken(idToken, properties.getIssuer(), properties.getSecret(), null, response); } /** diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClientProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClientProperties.java index 8b03875a6..dc6583d56 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClientProperties.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcClientProperties.java @@ -4,4 +4,6 @@ public interface OauthOidcClientProperties { String getJwksUri(); String getSecret(); + + String getIssuer(); } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java index 7b4244431..d9858f444 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java @@ -89,7 +89,6 @@ private String getUnsignedToken(String token) { */ private Jws getOIDCTokenJws(String token, String modulus, String exponent) { try { - log.info("token : {}", token); return Jwts.parser() .verifyWith(getRSAPublicKey(modulus, exponent)) .build() diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/AppleOidcProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/AppleOidcProperties.java index d953b2285..71a45aeaa 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/AppleOidcProperties.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/AppleOidcProperties.java @@ -11,4 +11,9 @@ public class AppleOidcProperties implements OauthOidcClientProperties { private final String jwksUri; private final String secret; + + @Override + public String getIssuer() { + return jwksUri; + } } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/GoogleOidcProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/GoogleOidcProperties.java index b61e61078..73cbfb070 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/GoogleOidcProperties.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/GoogleOidcProperties.java @@ -11,4 +11,5 @@ public class GoogleOidcProperties implements OauthOidcClientProperties { private final String jwksUri; private final String secret; + private final String issuer; } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/KakaoOidcProperties.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/KakaoOidcProperties.java index e7d3f90a2..06d3b0f29 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/KakaoOidcProperties.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/properties/KakaoOidcProperties.java @@ -11,4 +11,9 @@ public class KakaoOidcProperties implements OauthOidcClientProperties { private final String jwksUri; private final String secret; + + @Override + public String getIssuer() { + return jwksUri; + } } diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index d71d5e076..3750504ed 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -28,13 +28,14 @@ oauth2: client: provider: kakao: - jwks-uri: ${KAKAO_JWKS_URI:https://kauth.kakao.com} + jwks-uri: ${KAKAO_JWKS_URI:https://kakao.com} secret: ${KAKAO_CLIENT_SECRET:liuhil5068l2j5o0912} google: - jwks-uri: ${GOOGLE_JWKS_URI:https://www.googleapis.com} + jwks-uri: ${GOOGLE_JWKS_URI:https://google.com} secret: ${GOOGLE_CLIENT_SECRET:123456789012-67hm9vokrt6ukmiwtvd8ak67oflecm.apps.googleusercontent.com} + issuer: ${GOOGLE_ISSUER:https://google.com} apple: - jwks-uri: ${APPLE_JWKS_URI:https://appleid.apple.com} + jwks-uri: ${APPLE_JWKS_URI:https://apple.com} secret: ${APPLE_CLIENT_SECRET:pennyway-jayang-was} --- From 596aaf755202461192f04bbb2065b1978e1a390a Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 12 Apr 2024 11:24:10 +0900 Subject: [PATCH 045/152] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20User=20name=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EA=B8=B0=EC=A4=80=20=EB=B3=80=EA=B2=BD=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: get_unsigned_token_claims 500 error -> 401 error 변환 * fix: oauth_usecase 내 payload 로그 레벨 info -> debug * fix: name 필드 정규 표현식 변경 (한글 6자, 영어 10자) * fix: name 필드 정규 표현식 변경 (한글 & 영어 소문자 8자) * fix: name 필드 한글, 영문 소문자 2~8자로 제한 * test: 인증 시 name 예외 문구 수정 --- .../main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java | 4 ++-- .../kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java | 2 +- .../apis/auth/controller/AuthControllerValidationTest.java | 4 ++-- .../co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java | 3 +++ 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java index ca1a25bdf..545c30c6e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java @@ -51,7 +51,7 @@ public record General( String username, @Schema(description = "이름", example = "페니웨이") @NotBlank(message = "이름을 입력해주세요") - @Pattern(regexp = "^[가-힣a-zA-Z]{2,20}$", message = "2~20자의 한글, 영문 대/소문자만 사용 가능합니다.") + @Pattern(regexp = "^[가-힣a-z]{2,8}$", message = "2~8자의 한글, 영문 소문자만 사용 가능합니다.") String name, @Schema(description = "비밀번호", example = "pennyway1234") @NotBlank(message = "비밀번호를 입력해주세요") @@ -98,7 +98,7 @@ public record Oauth( String idToken, @Schema(description = "이름", example = "페니웨이") @NotBlank(message = "이름을 입력해주세요") - @Pattern(regexp = "^[가-힣a-zA-Z]{2,20}$", message = "2~20자의 한글, 영문 대/소문자만 사용 가능합니다.") + @Pattern(regexp = "^[가-힣a-z]{2,8}$", message = "2~8자의 한글, 영문 소문자만 사용 가능합니다.") String name, @Schema(description = "아이디", example = "pennyway") @NotBlank(message = "아이디를 입력해주세요") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java index d19732670..401b8d432 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java @@ -35,7 +35,7 @@ public class OauthUseCase { public Pair signIn(Provider provider, SignInReq.Oauth request) { OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken()); - log.info("payload : {}", payload); + log.debug("payload : {}", payload); if (!request.oauthId().equals(payload.sub())) throw new OauthException(OauthErrorCode.NOT_MATCHED_OAUTH_ID); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java index e6f92458c..e2d44e040 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java @@ -97,7 +97,7 @@ void idValidError() throws Exception { .andDo(print()); } - @DisplayName("[3] 이름은 2~20자의 한글, 영문 대/소문자만 사용 가능합니다.") + @DisplayName("[3] 이름은 2~8자의 한글, 영문 소문자만 사용 가능합니다.") @Test void nameValidError() throws Exception { // given @@ -114,7 +114,7 @@ void nameValidError() throws Exception { // then resultActions .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.name").value("2~20자의 한글, 영문 대/소문자만 사용 가능합니다.")) + .andExpect(jsonPath("$.fieldErrors.name").value("2~8자의 한글, 영문 소문자만 사용 가능합니다.")) .andDo(print()); } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java index d9858f444..8ab41e347 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java @@ -69,6 +69,9 @@ private Map> getUnsignedTokenClaims(String token, St Assert.isTrue(payload.get("iss").equals(iss), "iss is not matched. expected : " + iss + ", actual : " + payload.get("iss")); return Map.of("header", header, "payload", payload); + } catch (IllegalArgumentException e) { + log.warn("getUnsignedTokenClaims : Error - {}, {}", e.getClass(), e.getMessage()); + throw new JwtErrorException(JwtErrorCode.FAILED_AUTHENTICATION); } catch (JsonProcessingException e) { log.warn("getUnsignedTokenClaims : Error - {}, {}", e.getClass(), e.getMessage()); throw new RuntimeException(e); From 5f585c33351051f04bb991cee94aa9cc9e98d30b Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 12 Apr 2024 14:08:47 +0900 Subject: [PATCH 046/152] =?UTF-8?q?=E2=9C=A8=20User,=20Oauth=20Entity=20So?= =?UTF-8?q?ft=20Delete=20=EB=B0=98=EC=98=81=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: user domain soft delete와 where 추가 * feat: oauth domain soft delete와 where 추가 * chore: domain module 내 mysql testcontainer 의존성 추가 * test: domain 모듈 mysql container 환경 설정 * chore: logback 설정 수정 * feat: user domain tostring 재정의 * feat: user domain service delete 메서드 추가 * test: soft delete 확인 테스트 * fix: oauth domain sql delete 쿼리 수정 * test: user soft delete test case 추가 --- pennyway-domain/build.gradle | 11 +- .../domain/domains/oauth/domain/Oauth.java | 4 + .../domain/domains/user/domain/User.java | 15 +++ .../domains/user/service/UserService.java | 5 + .../config/ContainerMySqlTestConfig.java | 34 +++++ .../user/repository/UserSoftDeleteTest.java | 119 ++++++++++++++++++ .../src/test/resources/logback-test.xml | 7 ++ 7 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java create mode 100644 pennyway-domain/src/test/resources/logback-test.xml diff --git a/pennyway-domain/build.gradle b/pennyway-domain/build.gradle index 36e6a3717..c7b559878 100644 --- a/pennyway-domain/build.gradle +++ b/pennyway-domain/build.gradle @@ -19,7 +19,10 @@ dependencies { /* Redis */ implementation 'org.springframework.boot:spring-boot-starter-data-redis' - testImplementation group: 'org.testcontainers', name: 'testcontainers', version: '1.17.2' + testImplementation "org.testcontainers:junit-jupiter:1.19.7" + testImplementation "org.testcontainers:testcontainers:1.19.7" + testImplementation "org.testcontainers:mysql:1.19.7" + testImplementation "com.redis.testcontainers:testcontainers-redis-junit:1.6.4" } def querydslDir = 'src/main/generated' @@ -38,10 +41,4 @@ tasks.withType(JavaCompile).configureEach { clean.doLast { file(querydslDir).deleteDir() -} - -configurations.configureEach { - exclude group: 'commons-logging', module: 'commons-logging' - exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' - exclude group: 'ch.qos.logback', module: 'logback-classic' } \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java index cde953be3..eec8ad267 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java @@ -9,6 +9,8 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -20,6 +22,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @EntityListeners(AuditingEntityListener.class) @DynamicInsert +@SQLRestriction("deleted_at IS NULL") +@SQLDelete(sql = "UPDATE oauth SET deleted_at = NOW() WHERE id = ?") public class Oauth { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java index f527796b3..7c198833a 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java @@ -12,6 +12,8 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; import java.time.LocalDateTime; @@ -20,6 +22,8 @@ @Table(name = "user") @NoArgsConstructor(access = AccessLevel.PROTECTED) @DynamicInsert +@SQLRestriction("deleted_at IS NULL") +@SQLDelete(sql = "UPDATE user SET deleted_at = NOW() WHERE id = ?") public class User extends DateAuditable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -64,4 +68,15 @@ public void updatePassword(String password) { this.password = password; this.passwordUpdatedAt = LocalDateTime.now(); } + + @Override + public String toString() { + return "User{" + + "id=" + id + + ", username='" + username + '\'' + + ", name='" + name + '\'' + + ", role=" + role + + ", deletedAt=" + deletedAt + + '}'; + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java index 66726392e..8051472f9 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java @@ -42,4 +42,9 @@ public boolean isExistUser(Long id) { public boolean isExistUsername(String username) { return userRepository.existsByUsername(username); } + + @Transactional + public void deleteUser(User user) { + userRepository.delete(user); + } } diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java new file mode 100644 index 000000000..3a2511284 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java @@ -0,0 +1,34 @@ +package kr.co.pennyway.domain.config; + +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@ActiveProfiles("test") +public class ContainerMySqlTestConfig { + private static final String MYSQL_CONTAINER_IMAGE = "mysql:8.0.26"; + + private static final MySQLContainer MYSQL_CONTAINER; + + static { + MYSQL_CONTAINER = + new MySQLContainer<>(DockerImageName.parse(MYSQL_CONTAINER_IMAGE)) + .withDatabaseName("pennyway") + .withUsername("root") + .withPassword("testpass") + .withReuse(true); + + MYSQL_CONTAINER.start(); + } + + @DynamicPropertySource + public static void setRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", () -> String.format("jdbc:mysql://%s:%s/pennyway?serverTimezone=UTC&characterEncoding=utf8", MYSQL_CONTAINER.getHost(), MYSQL_CONTAINER.getMappedPort(3306))); + registry.add("spring.datasource.username", () -> "root"); + registry.add("spring.datasource.password", () -> "testpass"); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java new file mode 100644 index 000000000..a535d29b1 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java @@ -0,0 +1,119 @@ +package kr.co.pennyway.domain.domains.user.repository; + +import jakarta.persistence.EntityManager; +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.util.AssertionErrors.*; + +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") +@ContextConfiguration(classes = {JpaConfig.class, UserService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +public class UserSoftDeleteTest extends ContainerMySqlTestConfig { + @Autowired + private UserService userService; + + @Autowired + private EntityManager em; + + private User user; + + @BeforeEach + public void setUp() { + user = User.builder() + .username("test") + .name("pannyway") + .password("test") + .phone("01012345678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .build(); + } + + @Test + @DisplayName("[명제] em.createNativeQuery를 사용해도 영속성 컨텍스트에 저장된 엔티티를 조회할 수 있다.") + @Transactional + public void findByEntityMangerUsingNativeQuery() { + // given + User savedUser = userService.createUser(user); + Long userId = savedUser.getId(); + + // when + Object foundUser = em.createNativeQuery("SELECT * FROM user WHERE id = ?", User.class) + .setParameter(1, userId) + .getSingleResult(); + + // then + assertNotNull("foundUser는 nll이 아니어야 한다.", foundUser); + assertEquals("동등성 보장에 성공해야 한다.", savedUser, foundUser); + assertTrue("동일성 보장에 성공해야 한다.", savedUser == foundUser); + System.out.println("foundUser = " + foundUser); + } + + @Test + @DisplayName("유저가 삭제되면 deletedAt이 업데이트된다.") + @Transactional + public void deleteUser() { + // given + User savedUser = userService.createUser(user); + Long userId = savedUser.getId(); + + // when + userService.deleteUser(savedUser); + Object deletedUser = em.createNativeQuery("SELECT * FROM user WHERE id = ?", User.class) + .setParameter(1, userId) + .getSingleResult(); + + // then + assertNotNull("유저가 삭제되면 deletedAt이 업데이트된다. ", ((User) deletedUser).getDeletedAt()); + System.out.println("deletedUser = " + deletedUser); + } + + @Test + @DisplayName("유저가 삭제되면 findBy와 existsBy로 조회할 수 없다.") + @Transactional + public void deleteUserAndFindById() { + // given + User savedUser = userService.createUser(user); + Long userId = savedUser.getId(); + + // when + userService.deleteUser(savedUser); + + // then + assertFalse("유저가 삭제되면 existsById로 조회할 수 없다. ", userService.isExistUser(userId)); + assertNull("유저가 삭제되면 findById로 조회할 수 없다. ", userService.readUser(userId).orElse(null)); + System.out.println("after delete: savedUser = " + savedUser); + } + + @Test + @DisplayName("유저가 삭제되지 않으면 findById로 조회할 수 있다.") + @Transactional + public void findUserNotDeleted() { + // given + User savedUser = userService.createUser(user); + Long userId = savedUser.getId(); + + // when + User foundUser = userService.readUser(userId).orElse(null); + + // then + assertNotNull("foundUser는 null이 아니어야 한다.", foundUser); + assertEquals("foundUser는 savedUser와 같아야 한다.", savedUser, foundUser); + System.out.println("foundUser = " + foundUser); + } +} diff --git a/pennyway-domain/src/test/resources/logback-test.xml b/pennyway-domain/src/test/resources/logback-test.xml new file mode 100644 index 000000000..198192602 --- /dev/null +++ b/pennyway-domain/src/test/resources/logback-test.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file From 2434130b8f18123dac99aab30ac664ba7a8f6099 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sun, 14 Apr 2024 00:27:29 +0900 Subject: [PATCH 047/152] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20OAuth=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=97=B0=EB=8F=99=20=EC=8B=A4=ED=8C=A8=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * rename: user sync mapper 불필요한 주석 제거 * feat: oauth service create 메서드 추가 * feat: oauth sign mapper 내에서 entity 생성 메서드 호출 * rename: auth api 일반 회원가입 이력 존재 시 예외 문서 추가 * rename: oauth api 소셜 로그인 이력 존재 시 예외 문서 추가 * test: [2] 소셜 로그인 이력이 있는 경우, 200 ok를 반환하고 oauth 필드가 true고 username 필드가 존재 * rename: 소셜인증 회원가입, 계정 연동 시 성공 응답 반환 문서 추가 * test: [3-1] 일반 회원가입 테스트 * test: [3-2] 소셜 계정 연동 회원가입 테스트 * test: 일반 회원가입 전화 검증 api 예외 테스트 케이스 추가 * test: url별로 inner 클래스로 테스트 분리 * test: auth test order 지정 * chore: test 환경에서 sql log 출력 옵션 true로 변경 * test: oauth controller 통합 테스트 내부 클래스 구분 * chore: wiremock 의존성 추가 * test: feign mock test 적용 (실제로 사용은 안 함) * test: [1] 소셜 로그인 통합 테스트 * fix: oauth link 시, 기존 계정 없는 경우 예외 처리 * test: [4-1] 소셜 회원가입 계정 연동 * feat: oauth entity tostring 재정의 * fix: oauth 회원가입 시, 기존 계정이 하나라도 존재하면 예외 처리 * test: [4-2] 소셜 회원가입 * test: 저장된 oauth 정보 조회 추가 --- pennyway-app-external-api/build.gradle | 1 + .../pennyway/api/apis/auth/api/AuthApi.java | 8 + .../pennyway/api/apis/auth/api/OauthApi.java | 44 ++ .../apis/auth/mapper/UserOauthSignMapper.java | 5 +- .../api/apis/auth/mapper/UserSyncMapper.java | 3 - .../api/apis/auth/usecase/OauthUseCase.java | 5 + .../AuthControllerIntegrationTest.java | 329 +++++++++- .../OAuthControllerIntegrationTest.java | 615 ++++++++++++++++++ .../test/resources/payload/oidc-response.json | 20 + .../domain/domains/oauth/domain/Oauth.java | 12 + .../oauth/exception/OauthErrorCode.java | 1 + .../domains/oauth/service/OauthService.java | 5 + .../src/main/resources/application-domain.yml | 2 +- .../src/main/resources/application-infra.yml | 6 +- 14 files changed, 1015 insertions(+), 41 deletions(-) create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java create mode 100644 pennyway-app-external-api/src/test/resources/payload/oidc-response.json diff --git a/pennyway-app-external-api/build.gradle b/pennyway-app-external-api/build.gradle index fedb57393..6e36e0521 100644 --- a/pennyway-app-external-api/build.gradle +++ b/pennyway-app-external-api/build.gradle @@ -36,4 +36,5 @@ dependencies { testImplementation "org.testcontainers:junit-jupiter:1.19.7" testImplementation "org.testcontainers:mysql:1.19.7" testImplementation "com.redis.testcontainers:testcontainers-redis-junit:1.6.4" + testImplementation "org.springframework.cloud:spring-cloud-contract-wiremock:4.1.2" } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java index bd80ab184..063e089c1 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java @@ -63,6 +63,14 @@ public interface AuthApi { } """) })), + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "일반 회원가입 계정이 이미 존재함", value = """ + { + "code": "4004", + "message": "이미 회원가입한 유저입니다." + } + """) + })), @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = { @ExampleObject(name = "검증 실패", value = """ { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java index 29be15dda..520eaa6ff 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java @@ -3,8 +3,10 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; @@ -107,6 +109,14 @@ public interface OauthApi { } """) })), + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "해당 provider로 로그인한 이력이 이미 존재함", value = """ + { + "code": "4004", + "message": "이미 해당 제공자로 가입된 사용자입니다." + } + """) + })), @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = { @ExampleObject(name = "인증코드 불일치", value = """ { @@ -130,11 +140,45 @@ public interface OauthApi { @Parameter(name = "provider", description = "소셜 제공자", examples = { @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") }, required = true, in = ParameterIn.QUERY) + @ApiResponse(responseCode = "200", description = "로그인 성공", + headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰", schema = @Schema(type = "string"), required = true), + @Header(name = "Authorization", description = "액세스 토큰", schema = @Schema(type = "string", format = "jwt"), required = true) + }, + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "성공", value = """ + { + "code": "2000", + "data": { + "user": { + "id": 1 + } + } + } + """) + })) ResponseEntity linkAuth(@RequestParam Provider provider, @RequestBody @Validated SignUpReq.SyncWithAuth request); @Operation(summary = "[4-2] 소셜 회원가입", description = "회원 정보 입력 후 회원가입") @Parameter(name = "provider", description = "소셜 제공자", examples = { @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") }, required = true, in = ParameterIn.QUERY) + @ApiResponse(responseCode = "200", description = "로그인 성공", + headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰", schema = @Schema(type = "string"), required = true), + @Header(name = "Authorization", description = "액세스 토큰", schema = @Schema(type = "string", format = "jwt"), required = true) + }, + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "성공", value = """ + { + "code": "2000", + "data": { + "user": { + "id": 1 + } + } + } + """) + })) ResponseEntity signUp(@RequestParam Provider provider, @RequestBody @Validated SignUpReq.Oauth request); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java index 49e4c71d3..9ad3d4c99 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java @@ -42,7 +42,6 @@ public User saveUser(SignUpReq.OauthInfo request, Pair isSignUp if (isSignUpUser.getLeft().equals(Boolean.TRUE)) { user = userService.readUserByUsername(isSignUpUser.getRight()) .orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); - Oauth.of(provider, oauthId, user); } else { user = User.builder() .username(request.username()) @@ -51,9 +50,11 @@ public User saveUser(SignUpReq.OauthInfo request, Pair isSignUp .role(Role.USER) .profileVisibility(ProfileVisibility.PUBLIC).build(); userService.createUser(user); - Oauth.of(provider, oauthId, user); } + Oauth oauth = Oauth.of(provider, oauthId, user); + oauthService.createOauth(oauth); + return user; } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java index f12e17ef8..c90183720 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java @@ -57,19 +57,16 @@ public Pair isGeneralSignUpAllowed(String phone) { public Pair isOauthSignUpAllowed(Provider provider, String phone) { Optional user = userService.readUserByPhone(phone); - // user 정보 없으면 Pair.of(Boolean.FALSE, null) 반환 if (user.isEmpty()) { log.info("회원가입 이력이 없는 사용자입니다. phone: {}", phone); return Pair.of(Boolean.FALSE, null); } - // 같은 provider로 가입한 정보가 있는지 확인 if (oauthService.isExistOauthAccount(user.get().getId(), provider)) { log.info("이미 동일한 Provider로 가입된 사용자입니다. phone: {}, provider: {}", phone, provider); return null; } - // user 정보 있으면 Pair.of(Boolean.TRUE, user.get().getUsername()) 반환 return Pair.of(Boolean.TRUE, user.get().getUsername()); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java index 401b8d432..29d982c53 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java @@ -63,6 +63,11 @@ public Pair signUp(Provider provider, SignUpReq.OauthInfo request) { phoneVerificationMapper.isValidCode(PhoneVerificationDto.VerifyCodeReq.from(request), PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); Pair isSignUpUser = checkSignUpUserNotOauthByProvider(provider, request.phone()); + if (isSignUpUser.getLeft().equals(Boolean.FALSE) && request.username() == null) + throw new OauthException(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST); + if (isSignUpUser.getLeft().equals(Boolean.TRUE) && request.username() != null) + throw new OauthException(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST); + OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken()); User user = userOauthSignMapper.saveUser(request, isSignUpUser, provider, payload.sub()); phoneVerificationService.delete(request.phone(), PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java index ad124abf5..913b01e4a 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java @@ -1,23 +1,38 @@ package kr.co.pennyway.api.apis.auth.controller; import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithAnonymousUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.context.WebApplicationContext; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -25,15 +40,23 @@ @ExternalApiIntegrationTest @AutoConfigureMockMvc +@TestClassOrder(ClassOrderer.OrderAnnotation.class) public class AuthControllerIntegrationTest extends ExternalApiDBTestConfig { + private final String expectedUsername = "jayang"; + private final String expectedPhone = "010-1234-5678"; + private final String expectedCode = "123456"; + private final String expectedOauthId = "oauthId"; + @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper objectMapper; - - @Autowired + @SpyBean private PhoneVerificationService phoneVerificationService; + @SpyBean + private UserService userService; + @Autowired + private OauthService oauthService; @BeforeEach void setUp(WebApplicationContext webApplicationContext) { @@ -43,33 +66,275 @@ void setUp(WebApplicationContext webApplicationContext) { .build(); } - @Test - @DisplayName("컨테이너 실행 테스트") - void containerTest() { - System.out.println("컨테이너 실행 테스트"); + /** + * 일반 회원가입 유저 생성 + */ + private User createGeneralSignedUser() { + return User.builder() + .name("페니웨이") + .username(expectedUsername) + .password("dkssudgktpdy1") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .build(); + } + + /** + * OAuth로 가입한 유저 생성 (password가 NULL) + */ + private User createOauthSignedUser() { + return User.builder() + .name("페니웨이") + .username(expectedUsername) + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .build(); + } + + /** + * User에 연결된 Oauth 생성 + */ + private Oauth createOauthAccount(User user) { + return Oauth.of(Provider.KAKAO, expectedOauthId, user); + } + + @Nested + @Order(1) + @DisplayName("[2] 전화번호 검증 테스트") + class GeneralSignUpPhoneVerifyTest { + @Test + @WithAnonymousUser + @DisplayName("일반 회원가입 이력이 있는 경우 400 BAD_REQUEST를 반환하고, 인증 코드 캐시 데이터가 제거된다.") + void generalSignUpFailBecauseAlreadyGeneralSignUp() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.of(createGeneralSignedUser())); + + // when + ResultActions resultActions = performPhoneVerificationRequest(expectedCode); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(UserErrorCode.ALREADY_SIGNUP.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(UserErrorCode.ALREADY_SIGNUP.getExplainError())) + .andDo(print()); + assertThrows(IllegalArgumentException.class, () -> phoneVerificationService.readByPhone(expectedPhone, PhoneVerificationType.SIGN_UP)); + } + + @Test + @WithAnonymousUser + @DisplayName("인증 번호가 일치하지 않는 경우 401 UNAUTHORIZED를 반환한다.") + void generalSignUpFailBecauseInvalidCode() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.empty()); + String invalidCode = "111111"; + + // when + ResultActions resultActions = performPhoneVerificationRequest(invalidCode); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.getExplainError())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @DisplayName("일치하는 전화번호 혹은 인증 번호가 없는 경우 404 NOT_FOUND를 반환한다.") + void generalSignUpFailBecauseNotFound() throws Exception { + // given + given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.empty()); + + // when + ResultActions resultActions = performPhoneVerificationRequest(expectedCode); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PhoneVerificationErrorCode.EXPIRED_OR_INVALID_PHONE.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(PhoneVerificationErrorCode.EXPIRED_OR_INVALID_PHONE.getExplainError())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @DisplayName("소셜 로그인 이력이 없는 경우, 200 OK를 반환하고 oauth 필드가 false이다.") + void generalSignUpSuccess() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.empty()); + + // when + ResultActions resultActions = performPhoneVerificationRequest(expectedCode); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sms.code").value(true)) + .andExpect(jsonPath("$.data.sms.oauth").value(false)) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @DisplayName("소셜 로그인 이력이 있는 경우, 200 OK를 반환하고 oauth 필드가 true고 username 필드가 존재한다.") + void generalSignUpSuccessWithOauth() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.of(createOauthSignedUser())); + + // when + ResultActions resultActions = performPhoneVerificationRequest(expectedCode); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sms.code").value(true)) + .andExpect(jsonPath("$.data.sms.oauth").value(true)) + .andExpect(jsonPath("$.data.sms.username").value(expectedUsername)) + .andDo(print()); + } + + @AfterEach + void tearDown() { + phoneVerificationService.delete(expectedPhone, PhoneVerificationType.SIGN_UP); + } + + private ResultActions performPhoneVerificationRequest(String expectedCode) throws Exception { + PhoneVerificationDto.VerifyCodeReq request = new PhoneVerificationDto.VerifyCodeReq(expectedPhone, expectedCode); + return mockMvc.perform( + post("/v1/auth/phone/verification") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + } + } + + @Nested + @Order(2) + @DisplayName("[3-1] 일반 회원가입 테스트") + class GeneralSignUpTest { + @Test + @WithAnonymousUser + @DisplayName("인증번호가 일치하지 않는 경우 401 UNAUTHORIZED를 반환한다.") + void generalSignUpFailBecauseInvalidCode() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + String invalidCode = "111111"; + + // when + ResultActions resultActions = performGeneralSignUpRequest(invalidCode); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.getExplainError())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("인증번호가 일치하는 경우 200 OK를 반환하고, 회원가입이 완료된다.") + void generalSignUpSuccess() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.empty()); + + // when + ResultActions resultActions = performGeneralSignUpRequest(expectedCode); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().exists("Authorization")) + .andExpect(jsonPath("$.data.user.id").value(1)) + .andDo(print()); + } + + private ResultActions performGeneralSignUpRequest(String code) throws Exception { + SignUpReq.General request = new SignUpReq.General(expectedUsername, "pennyway", "dkssudgktpdy1", expectedPhone, code); + return mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + } + + @AfterEach + void tearDown() { + phoneVerificationService.delete(expectedPhone, PhoneVerificationType.SIGN_UP); + } } - @Test - @WithAnonymousUser - @DisplayName("회원가입 통합 테스트") - void controllerTest() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("pennyway", "jayang", "dkssudgktpdy1", "010-1234-5678", "050505"); - phoneVerificationService.create("010-1234-5678", "050505", PhoneVerificationType.SIGN_UP); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(header().exists("Set-Cookie")) - .andExpect(header().exists("Authorization")) - .andExpect(jsonPath("$.data.user.id").value(1)) - .andDo(print()); + @Nested + @Order(3) + @DisplayName("[3-2] 소셜 계정 연동 회원가입 테스트") + class SyncWithOauthSignUpTest { + @Test + @WithAnonymousUser + @DisplayName("인증번호가 일치하지 않는 경우 401 UNAUTHORIZED를 반환한다.") + void syncWithOauthSignUpFailBecauseInvalidCode() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + String invalidCode = "111111"; + + // when + ResultActions resultActions = performSyncWithOauthSignUpRequest(invalidCode); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.getExplainError())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("인증번호가 일치하는 경우 200 OK를 반환하고, 기존의 소셜 계정과 연동된 회원가입이 완료된다.") + void syncWithOauthSignUpSuccess() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + User user = createOauthSignedUser(); + userService.createUser(user); + oauthService.createOauth(createOauthAccount(user)); + + // when + ResultActions resultActions = performSyncWithOauthSignUpRequest(expectedCode); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().exists("Authorization")) + .andExpect(jsonPath("$.data.user.id").value(user.getId())) + .andDo(print()); + assertNotNull(oauthService.readOauthByOauthIdAndProvider("oauthId", Provider.KAKAO)); + assertNotNull(user.getPassword()); + } + + private ResultActions performSyncWithOauthSignUpRequest(String code) throws Exception { + SignUpReq.SyncWithOauth request = new SignUpReq.SyncWithOauth("dkssudgktpdy1", expectedPhone, code); + return mockMvc.perform( + post("/v1/auth/link-oauth") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + } + + @AfterEach + void tearDown() { + phoneVerificationService.delete(expectedPhone, PhoneVerificationType.SIGN_UP); + } } } \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java new file mode 100644 index 000000000..09acb9e9c --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java @@ -0,0 +1,615 @@ +package kr.co.pennyway.api.apis.auth.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.apis.auth.dto.SignInReq; +import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.api.apis.auth.helper.OauthOidcHelper; +import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; +import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import kr.co.pennyway.infra.common.oidc.OidcDecodePayload; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ResourceUtils; +import org.springframework.web.context.WebApplicationContext; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExternalApiIntegrationTest +@AutoConfigureMockMvc +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +@AutoConfigureWireMock(port = 0) +@TestPropertySource(properties = "oauth2.client.provider.kakao.jwks-uri=http://localhost:${wiremock.server.port}") +public class OAuthControllerIntegrationTest extends ExternalApiDBTestConfig { + private final String expectedUsername = "jayang"; + + private final String expectedOauthId = "testOauthId"; + private final String expectedIdToken = "testIdToken"; + private final String expectedPhone = "010-1234-5678"; + private final String expectedCode = "123456"; + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private OauthOidcHelper oauthOidcHelper; + @SpyBean + private PhoneVerificationService phoneVerificationService; + @Autowired + private UserService userService; + @Autowired + private OauthService oauthService; + + /** + * 일반 회원가입 유저 생성 + */ + private User createGeneralSignedUser() { + return User.builder() + .name("페니웨이") + .username(expectedUsername) + .password("dkssudgktpdy1") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .build(); + } + + /** + * OAuth로 가입한 유저 생성 (password가 NULL) + */ + private User createOauthSignedUser() { + return User.builder() + .name("페니웨이") + .username(expectedUsername) + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .build(); + } + + /** + * User에 연결된 Oauth 생성 + */ + private Oauth createOauthAccount(User user, Provider provider) { + return Oauth.of(provider, expectedOauthId, user); + } + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) throws IOException { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(post("/**").with(csrf())) + .build(); + Path path = ResourceUtils.getFile("classpath:payload/oidc-response.json").toPath(); + stubFor( + get(urlPathEqualTo("/.well-known/jwks.json")) + .willReturn( + aResponse() + .withStatus(HttpStatus.OK.value()) + .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .withBody(Files.readAllBytes(path)) + ) + ); + } + + @Nested + @Order(1) + @DisplayName("[1] 소셜 로그인") + class OauthSignInTest { + @Test + @WithAnonymousUser + @Transactional + @DisplayName("provider로 로그인한 소셜 계정이 있으면 로그인에 성공한다.") + void signInWithOauth() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + userService.createUser(user); + oauthService.createOauth(createOauthAccount(user, provider)); + + // when + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + + // then + result + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().exists("Authorization")) + .andExpect(jsonPath("$.data.user.id").value(user.getId())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("다른 provider로 로그인한 소셜 계정이 있으면 user id가 -1로 반환된다.") + void signInWithDifferentProvider() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + userService.createUser(user); + oauthService.createOauth(createOauthAccount(user, Provider.GOOGLE)); + + // when + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.id").value(-1)) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("일반 회원가입 이력만 존재하는 경우에는 user id가 -1로 반환된다.") + void signInWithGeneralSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createGeneralSignedUser(); + + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + userService.createUser(user); + + // when + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.id").value(-1)) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("회원 가입 이력이 없는 사용자의 경우에 user id가 -1로 반환된다.") + void signInWithNoSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.id").value(-1)) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("OAuth id와 payload의 sub가 다른 경우에는 NOT_MATCHED_OAUTH_ID 에러가 발생한다.") + void signInWithNotMatchedOauthId() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", "differentOauthId", "email")); + userService.createUser(user); + oauthService.createOauth(createOauthAccount(user, provider)); + + // when + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + + // then + result + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.NOT_MATCHED_OAUTH_ID.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.NOT_MATCHED_OAUTH_ID.getExplainError())) + .andDo(print()); + } + + private ResultActions performOauthSignIn(Provider provider, String oauthId, String idToken) throws Exception { + SignInReq.Oauth request = new SignInReq.Oauth(oauthId, idToken); + + return mockMvc.perform(post("/v1/auth/oauth/sign-in") + .param("provider", provider.name()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + } + } + + @Nested + @Order(2) + @DisplayName("[3] 소셜 회원가입 전화번호 인증") + class OauthSignUpPhoneVerificationTest { + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 일반 회원가입 이력이 있으면, existsUser가 true고 username이 반환된다.") + void signUpWithGeneralSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createGeneralSignedUser(); + + userService.createUser(user); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + + // when + ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andExpect(jsonPath("$.data.sms.code").value(true)) + .andExpect(jsonPath("$.data.sms.existsUser").value(true)) + .andExpect(jsonPath("$.data.sms.username").value(expectedUsername)) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 다른 provider OAuth 회원가입 이력이 있으면, existsUser가 true고 username이 반환된다.") + void signUpWithDifferentProvider() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + Oauth oauth = createOauthAccount(user, Provider.GOOGLE); + + userService.createUser(user); + oauthService.createOauth(oauth); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(Provider.KAKAO)); + + // when + ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andExpect(jsonPath("$.data.sms.code").value(true)) + .andExpect(jsonPath("$.data.sms.existsUser").value(true)) + .andExpect(jsonPath("$.data.sms.username").value(expectedUsername)) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 회원가입 이력이 없으면 existsUser가 false고 username 필드가 없다.") + void signUpWithNoSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + + // when + ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andExpect(jsonPath("$.data.sms.code").value(true)) + .andExpect(jsonPath("$.data.sms.existsUser").value(false)) + .andExpect(jsonPath("$.data.sms.username").doesNotExist()) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("같은 provider로 OAuth 회원가입 이력이 있으면 400 에러가 발생한다.") + void signUpWithSameProvider() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + Oauth oauth = createOauthAccount(user, provider); + + userService.createUser(user); + oauthService.createOauth(oauth); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + + // when + ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); + + // then + result + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.ALREADY_SIGNUP_OAUTH.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.ALREADY_SIGNUP_OAUTH.getExplainError())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("인증 코드를 요청한 provider와 다른 provider로 인증 코드를 입력하면 404 에러가 발생한다.") + void signUpWithDifferentProviderCode() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(Provider.KAKAO)); + + // when + ResultActions result = performOauthSignUpPhoneVerification(Provider.GOOGLE, expectedCode); + + // then + result + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PhoneVerificationErrorCode.EXPIRED_OR_INVALID_PHONE.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(PhoneVerificationErrorCode.EXPIRED_OR_INVALID_PHONE.getExplainError())) + .andDo(print()); + } + + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("인증 코드가 틀리면 401 에러가 발생한다.") + void signUpWithInvalidCode() throws Exception { + // given + Provider provider = Provider.KAKAO; + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + + // when + ResultActions result = performOauthSignUpPhoneVerification(provider, "123457"); + + // then + result + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.getExplainError())) + .andDo(print()); + } + + private ResultActions performOauthSignUpPhoneVerification(Provider provider, String code) throws Exception { + PhoneVerificationDto.VerifyCodeReq request = new PhoneVerificationDto.VerifyCodeReq(expectedPhone, code); + return mockMvc.perform(post("/v1/auth/oauth/phone/verification") + .param("provider", provider.name()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + } + } + + @Nested + @Order(3) + @DisplayName("[4-1] 소셜 회원가입 계정 연동") + class OauthSignUpAccountLinkingTest { + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 일반 회원가입 이력이 있으면, 해당 user entity에 OAuth 정보가 추가되고 로그인에 성공한다.") + void signUpWithGeneralSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createGeneralSignedUser(); + + userService.createUser(user); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); + + // then + result + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().exists("Authorization")) + .andExpect(jsonPath("$.data.user.id").value(user.getId())) + .andDo(print()); + Oauth savedOauth = oauthService.readOauthByOauthIdAndProvider(expectedOauthId, provider).get(); + assertEquals(savedOauth.getUser().getId(), user.getId()); + System.out.println("oauth : " + savedOauth); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 다른 provider OAuth 회원가입 이력이 있으면, user entity에 OAuth 정보가 추가되고 로그인에 성공한다.") + void signUpWithDifferentProvider() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + Oauth oauth = createOauthAccount(user, Provider.GOOGLE); + + userService.createUser(user); + oauthService.createOauth(oauth); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); + + // then + result + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().exists("Authorization")) + .andExpect(jsonPath("$.data.user.id").value(user.getId())) + .andDo(print()); + Oauth savedOauth = oauthService.readOauthByOauthIdAndProvider(expectedOauthId, provider).get(); + assertEquals(savedOauth.getUser().getId(), user.getId()); + System.out.println("oauth : " + savedOauth); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 회원가입 이력이 없으면, 동기화 요청 실패 후 400 에러가 발생한다.") + void signUpWithNoSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); + + // then + result + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST.getExplainError())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("같은 provider로 OAuth 회원가입 이력이 있으면 400 에러가 발생한다.") + void signUpWithSameProvider() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + Oauth oauth = createOauthAccount(user, provider); + + userService.createUser(user); + oauthService.createOauth(oauth); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); + + // then + result + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.ALREADY_SIGNUP_OAUTH.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.ALREADY_SIGNUP_OAUTH.getExplainError())) + .andDo(print()); + } + + private ResultActions performOauthSignUpAccountLinking(Provider provider, String code) throws Exception { + SignUpReq.SyncWithAuth request = new SignUpReq.SyncWithAuth(expectedIdToken, expectedPhone, code); + return mockMvc.perform(post("/v1/auth/oauth/link-auth") + .param("provider", provider.name()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + } + } + + @Nested + @Order(4) + @DisplayName("[4-2] 소셜 회원가입") + class OauthSignUpTest { + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 회원가입 이력이 없으면 새로운 회원가입이 성공하고 로그인 응답을 반환한다.") + void signUpWithNoSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignUp(provider, expectedCode); + + // then + result + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().exists("Authorization")) + .andExpect(jsonPath("$.data.user.id").isNumber()) + .andDo(print()); + Oauth savedOauth = oauthService.readOauthByOauthIdAndProvider(expectedOauthId, provider).get(); + assertNotNull(savedOauth.getUser().getId()); + System.out.println("oauth : " + savedOauth); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 일반 회원가입 이력이 있으면, 400 BAD_REQUEST 응답을 반환한다.") + void signUpWithGeneralSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createGeneralSignedUser(); + + userService.createUser(user); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignUp(provider, expectedCode); + + // then + result + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST.getExplainError())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("OAuth로 가입한 유저가 이미 존재하면 400 에러가 발생한다.") + void signUpWithOauthSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + Oauth oauth = createOauthAccount(user, Provider.GOOGLE); + + userService.createUser(user); + oauthService.createOauth(oauth); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignUp(provider, expectedCode); + + // then + result + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST.getExplainError())) + .andDo(print()); + } + + private ResultActions performOauthSignUp(Provider provider, String code) throws Exception { + SignUpReq.Oauth request = new SignUpReq.Oauth(expectedIdToken, "jayang", expectedUsername, expectedPhone, code); + return mockMvc.perform(post("/v1/auth/oauth/sign-up") + .param("provider", provider.name()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + } + } +} diff --git a/pennyway-app-external-api/src/test/resources/payload/oidc-response.json b/pennyway-app-external-api/src/test/resources/payload/oidc-response.json new file mode 100644 index 000000000..c000e6d8b --- /dev/null +++ b/pennyway-app-external-api/src/test/resources/payload/oidc-response.json @@ -0,0 +1,20 @@ +{ + "keys": [ + { + "kid": "3f96980381e451efad0d2ddd30e3d3", + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": "q8zZ0b_MNaLd6Ny8wd4cjFomilLfFIZcmhNSc1ttx_oQdJJZt5CDHB8WWwPGBUDUyY8AmfglS9Y1qA0_fxxs-ZUWdt45jSbUxghKNYgEwSutfM5sROh3srm5TiLW4YfOvKytGW1r9TQEdLe98ork8-rNRYPybRI3SKoqpci1m1QOcvUg4xEYRvbZIWku24DNMSeheytKUz6Ni4kKOVkzfGN11rUj1IrlRR-LNA9V9ZYmeoywy3k066rD5TaZHor5bM5gIzt1B4FmUuFITpXKGQZS5Hn_Ck8Bgc8kLWGAU8TzmOzLeROosqKE0eZJ4ESLMImTb2XSEZuN1wFyL0VtJw", + "e": "AQAB" + }, + { + "kid": "9f252dadd5f233f93d2fa528d12fea", + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": "qGWf6RVzV2pM8YqJ6by5exoixIlTvdXDfYj2v7E6xkoYmesAjp_1IYL7rzhpUYqIkWX0P4wOwAsg-Ud8PcMHggfwUNPOcqgSk1hAIHr63zSlG8xatQb17q9LrWny2HWkUVEU30PxxHsLcuzmfhbRx8kOrNfJEirIuqSyWF_OBHeEgBgYjydd_c8vPo7IiH-pijZn4ZouPsEg7wtdIX3-0ZcXXDbFkaDaqClfqmVCLNBhg3DKYDQOoyWXrpFKUXUFuk2FTCqWaQJ0GniO4p_ppkYIf4zhlwUYfXZEhm8cBo6H2EgukntDbTgnoha8kNunTPekxWTDhE5wGAt6YpT4Yw", + "e": "AQAB" + } + ] +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java index eec8ad267..b3fbc64a4 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java @@ -59,4 +59,16 @@ public static Oauth of(Provider provider, String oauthId, User user) { .user(user) .build(); } + + @Override + public String toString() { + return "Oauth{" + + "id=" + id + + ", provider=" + provider + + ", oauthId='" + oauthId + '\'' + + ", createdAt=" + createdAt + + ", deletedAt=" + deletedAt + + ", user=" + user + + '}'; + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java index 017897b7c..b372d38ed 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java @@ -12,6 +12,7 @@ public enum OauthErrorCode implements BaseErrorCode { /* 400 Bad Request */ ALREADY_SIGNUP_OAUTH(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "이미 해당 제공자로 가입된 사용자입니다."), + INVALID_OAUTH_SYNC_REQUEST(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "Oauth 동기화 요청이 잘못되었습니다."), /* 401 Unauthorized */ NOT_MATCHED_OAUTH_ID(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "OAuth ID가 일치하지 않습니다."), diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java index 967d632f1..f340c371b 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java @@ -14,6 +14,11 @@ public class OauthService { private final OauthRepository oauthRepository; + @Transactional + public void createOauth(Oauth oauth) { + oauthRepository.save(oauth); + } + @Transactional(readOnly = true) public Optional readOauthByOauthIdAndProvider(String oauthId, Provider provider) { return oauthRepository.findByOauthIdAndProvider(oauthId, provider); diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml index 6f00d4f7a..afa6c4277 100644 --- a/pennyway-domain/src/main/resources/application-domain.yml +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -74,7 +74,7 @@ spring: generate-ddl: true hibernate: ddl-auto: create - show-sql: false + show-sql: true properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect \ No newline at end of file diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index 3750504ed..78e2b188a 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -28,14 +28,14 @@ oauth2: client: provider: kakao: - jwks-uri: ${KAKAO_JWKS_URI:https://kakao.com} + jwks-uri: ${KAKAO_JWKS_URI:http://localhost} secret: ${KAKAO_CLIENT_SECRET:liuhil5068l2j5o0912} google: - jwks-uri: ${GOOGLE_JWKS_URI:https://google.com} + jwks-uri: ${GOOGLE_JWKS_URI:http://localhost} secret: ${GOOGLE_CLIENT_SECRET:123456789012-67hm9vokrt6ukmiwtvd8ak67oflecm.apps.googleusercontent.com} issuer: ${GOOGLE_ISSUER:https://google.com} apple: - jwks-uri: ${APPLE_JWKS_URI:https://apple.com} + jwks-uri: ${APPLE_JWKS_URI:http://localhost} secret: ${APPLE_CLIENT_SECRET:pennyway-jayang-was} --- From faf79d79366561d431b82e834b40f8f70078836b Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 15 Apr 2024 13:39:57 +0900 Subject: [PATCH 048/152] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EC=A0=84=EC=86=A1=20api=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 전화번호 인증 타입 정의 * feat: sms 인증코드 전송 api 분리 * feat: security config anonymous url에 /v1/phone 추가 * docs: sms api swagger 작성 * fix: send code param value 지정 * feat: phone verification error code 400 두 가지 경우 추가 * feat: send code 시, type == oauth이면 provider null일 때 예외 처리 * feat: verification type converter 추가 * docs: 인증코드 swagger 상 type의 value 수정 * feat: verification type converter web config 등록 * docs: 예시 전화번호 수정 * docs: 인증, 소셜 인증 api deprecated 처리 --- .../pennyway/api/apis/auth/api/AuthApi.java | 3 +- .../pennyway/api/apis/auth/api/OauthApi.java | 3 +- .../co/pennyway/api/apis/auth/api/SmsApi.java | 45 +++++++++++++++++++ .../apis/auth/controller/SmsController.java | 34 ++++++++++++++ .../apis/auth/dto/PhoneVerificationDto.java | 4 +- .../converter/VerificationTypeConverter.java | 17 +++++++ .../exception/PhoneVerificationErrorCode.java | 4 ++ .../api/common/query/VerificationType.java | 26 +++++++++++ .../kr/co/pennyway/api/config/WebConfig.java | 3 ++ .../api/config/security/SecurityConfig.java | 2 +- 10 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/SmsApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/SmsController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/VerificationTypeConverter.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java index 063e089c1..f95d885fe 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java @@ -19,7 +19,8 @@ @Tag(name = "[인증 API]") public interface AuthApi { - @Operation(summary = "[1] 일반 회원가입 인증번호 전송", description = "전화번호로 인증번호를 전송합니다. 미인증 사용자만 가능합니다.") + @Deprecated + @Operation(summary = "[1] 일반 회원가입 인증번호 전송", description = "deprecated된 API입니다. [인증코드 SMS 요청]의 /v1/phone API를 사용해주세요.", deprecated = true) @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { @ExampleObject(name = "발신 성공", value = """ { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java index 520eaa6ff..12162c0b3 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java @@ -59,7 +59,8 @@ public interface OauthApi { }) ResponseEntity signIn(@RequestParam Provider provider, @RequestBody @Validated SignInReq.Oauth request); - @Operation(summary = "[2] 인증번호 발송", description = "전화번호 입력 후 인증번호 발송") + @Deprecated + @Operation(summary = "[2] 인증번호 발송", description = "deprecated된 API입니다. [인증코드 SMS 요청]의 /v1/phone API를 사용해주세요.", deprecated = true) @Parameter(name = "provider", description = "소셜 제공자", examples = { @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") }, required = true, in = ParameterIn.QUERY) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/SmsApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/SmsApi.java new file mode 100644 index 000000000..1e789beb1 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/SmsApi.java @@ -0,0 +1,45 @@ +package kr.co.pennyway.api.apis.auth.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.common.query.VerificationType; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "[인증코드 SMS 요청]", description = "SMS 인증 관련 API") +public interface SmsApi { + @Operation(summary = "전화번호로 인증코드 전송", description = "전화번호로 인증번호를 전송합니다. 미인증 사용자만 가능합니다.") + @Parameters({ + @Parameter(name = "type", description = "인증 타입", required = true, examples = { + @ExampleObject(name = "일반 회원가입", value = "general"), @ExampleObject(name = "소셜 회원가입", value = "oauth"), @ExampleObject(name = "아이디 찾기", value = "username"), @ExampleObject(name = "비밀번호 찾기", value = "password") + }, in = ParameterIn.QUERY), + @Parameter(name = "provider", description = "소셜 로그인 제공자. type이 oauth인 경우 반드시 포함되어야 한다.", required = false, examples = { + @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") + }, in = ParameterIn.QUERY) + }) + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "발신 성공", value = """ + { + "code": "2000", + "data": { + "sms": { + "to": "010-2629-4624", + "sendAt": "2024-04-04 00:31:57", + "expiresAt": "2024-04-04 00:36:57" + } + } + } + """) + })) + ResponseEntity sendCode(@RequestParam(value = "type") VerificationType type, @RequestParam(name = "provider", required = false) Provider provider, @RequestBody @Validated PhoneVerificationDto.PushCodeReq request); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/SmsController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/SmsController.java new file mode 100644 index 000000000..ead329d5f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/SmsController.java @@ -0,0 +1,34 @@ +package kr.co.pennyway.api.apis.auth.controller; + +import kr.co.pennyway.api.apis.auth.api.SmsApi; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.apis.auth.mapper.PhoneVerificationMapper; +import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; +import kr.co.pennyway.api.common.exception.PhoneVerificationException; +import kr.co.pennyway.api.common.query.VerificationType; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/phone") +public class SmsController implements SmsApi { + private final PhoneVerificationMapper phoneVerificationMapper; + + @Override + @PostMapping("") + @PreAuthorize("isAnonymous()") + public ResponseEntity sendCode(@RequestParam(value = "type") VerificationType type, @RequestParam(name = "provider", required = false) Provider provider, @RequestBody @Validated PhoneVerificationDto.PushCodeReq request) { + if (type.equals(VerificationType.OAUTH) && provider == null) { + throw new PhoneVerificationException(PhoneVerificationErrorCode.PROVIDER_IS_REQUIRED); + } + return ResponseEntity.ok(SuccessResponse.from("sms", phoneVerificationMapper.sendCode(request, type.toPhoneVerificationType(provider)))); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java index 82c5ad1be..573da8a13 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java @@ -13,7 +13,7 @@ public class PhoneVerificationDto { @Schema(title = "인증번호 요청 DTO", description = "전화번호로 인증번호 송신 요청을 위한 DTO") public record PushCodeReq( - @Schema(description = "전화번호", example = "010-1234-5678") + @Schema(description = "전화번호", example = "010-2629-4624") @NotBlank(message = "전화번호는 필수입니다.") @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") String phone @@ -47,7 +47,7 @@ public static PushCodeRes of(String to, LocalDateTime sendAt, LocalDateTime expi @Schema(title = "인증번호 검증 DTO", description = "전화번호로 인증번호 검증 요청을 위한 DTO") public record VerifyCodeReq( - @Schema(description = "전화번호", example = "010-1234-5678") + @Schema(description = "전화번호", example = "010-2629-4624") @NotBlank(message = "전화번호는 필수입니다.") @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") String phone, diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/VerificationTypeConverter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/VerificationTypeConverter.java new file mode 100644 index 000000000..d021abd17 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/VerificationTypeConverter.java @@ -0,0 +1,17 @@ +package kr.co.pennyway.api.common.converter; + +import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; +import kr.co.pennyway.api.common.exception.PhoneVerificationException; +import kr.co.pennyway.api.common.query.VerificationType; +import org.springframework.core.convert.converter.Converter; + +public class VerificationTypeConverter implements Converter { + @Override + public VerificationType convert(String source) { + try { + return VerificationType.valueOf(source.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new PhoneVerificationException(PhoneVerificationErrorCode.INVALID_VERIFICATION_TYPE); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationErrorCode.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationErrorCode.java index 316eb9e13..58714834e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationErrorCode.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/exception/PhoneVerificationErrorCode.java @@ -10,6 +10,10 @@ @Getter @RequiredArgsConstructor public enum PhoneVerificationErrorCode implements BaseErrorCode { + // 400 Bad Request + INVALID_VERIFICATION_TYPE(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "유효하지 않은 인증 타입입니다."), + PROVIDER_IS_REQUIRED(StatusCode.BAD_REQUEST, ReasonCode.MISSING_REQUIRED_PARAMETER, "type이 OAUTH인 경우 provider는 필수입니다."), + // 401 Unauthorized IS_NOT_VALID_CODE(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "인증코드가 일치하지 않습니다."), diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java new file mode 100644 index 000000000..88a34e5af --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.api.common.query; + +import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; +import kr.co.pennyway.domain.domains.oauth.type.Provider; + +public enum VerificationType { + GENERAL("general"), + OAUTH("oauth"), + USERNAME("username"), + PASSWORD("password"); + + private final String type; + + VerificationType(String type) { + this.type = type; + } + + public PhoneVerificationType toPhoneVerificationType(Provider provider) { + return switch (this) { + case OAUTH -> PhoneVerificationType.getOauthSignUpTypeByProvider(provider); + case USERNAME -> PhoneVerificationType.FIND_USERNAME; + case PASSWORD -> PhoneVerificationType.FIND_PASSWORD; + default -> PhoneVerificationType.SIGN_UP; + }; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java index 69bc2c599..0f8c97480 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java @@ -1,6 +1,7 @@ package kr.co.pennyway.api.config; import kr.co.pennyway.api.common.converter.ProviderConverter; +import kr.co.pennyway.api.common.converter.VerificationTypeConverter; import org.springframework.context.annotation.Configuration; import org.springframework.format.FormatterRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -9,6 +10,8 @@ public class WebConfig implements WebMvcConfigurer { @Override public void addFormatters(FormatterRegistry registrar) { + registrar.addConverter(new ProviderConverter()); + registrar.addConverter(new VerificationTypeConverter()); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java index 8fd2aa71d..a2b008752 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java @@ -27,7 +27,7 @@ @RequiredArgsConstructor public class SecurityConfig { private static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/v1/duplicate/**"}; - private static final String[] ANONYMOUS_ENDPOINTS = {"/v1/auth/**"}; + private static final String[] ANONYMOUS_ENDPOINTS = {"/v1/auth/**", "/v1/phone/**"}; private static final String[] SWAGGER_ENDPOINTS = {"/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger",}; private final SecurityAdapterConfig securityAdapterConfig; From 26a4231c6ddac38592175ef48c8e4bf91482c90a Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 15 Apr 2024 18:30:43 +0900 Subject: [PATCH 049/152] =?UTF-8?q?=F0=9F=93=9D=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=B3=84=20README.md=20=EC=B6=94=EA=B0=80=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: readme.md .gitignore 제거 * docs: external api readme 추가 * docs: external api 패키지 경로 수정 * docs: infra 모듈 readme 추가 * docs: domain 모듈 readme 추가 * docs: common 모듈 readme 추가 --- .gitignore | 1 - pennyway-app-external-api/README.md | 34 ++++++++++++++++++++ pennyway-common/README.md | 26 ++++++++++++++++ pennyway-domain/README.md | 48 +++++++++++++++++++++++++++++ pennyway-infra/README.md | 29 +++++++++++++++++ 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 pennyway-app-external-api/README.md create mode 100644 pennyway-common/README.md create mode 100644 pennyway-domain/README.md create mode 100644 pennyway-infra/README.md diff --git a/.gitignore b/.gitignore index d992032ad..4a7a30478 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -README.md .gradle build/ !gradle/wrapper/gradle-wrapper.jar diff --git a/pennyway-app-external-api/README.md b/pennyway-app-external-api/README.md new file mode 100644 index 000000000..7375e153a --- /dev/null +++ b/pennyway-app-external-api/README.md @@ -0,0 +1,34 @@ +## External-API 모듈 + +### 🤝 Rule + +- batch, worker, internal-api, external-api 등의 모듈과 묶일 수 있다. +- 사용성에 따라 다른 모든 계층에 의존성을 추가하여 사용할 수 있다. +- 웹 및 security 관련 라이브러리 의존성을 갖는다. +- Presentation Layer에 해당하는 Controller와 핵심 비즈니스 로직을 처리하는 Usecase를 포함한다. + +### 🏷️ Directory Structure + +```agsl +pennyway-common +├── src +│ ├── main +│ │ ├── java.kr.co.pennyway +│ │ │ ├── api +│ │ │ │ ├── apis +│ │ │ │ │ ├── auth # 기능 관심사 별로 패키지를 나누어 구성한다. +│ │ │ │ │ │ ├── controller +│ │ │ │ │ │ ├── dto +│ │ │ │ │ │ ├── usecase +│ │ │ │ │ │ └── … +│ │ │ │ │ └── … +│ │ │ │ ├── common +│ │ │ │ └── config +│ │ │ └── PennywayExternalApiApplication.java +│ │ └── resources +│ │ └── application.yml +│ └── test +├── build.gradle +├── README.md +└── settings.gradle +``` \ No newline at end of file diff --git a/pennyway-common/README.md b/pennyway-common/README.md new file mode 100644 index 000000000..d739634fb --- /dev/null +++ b/pennyway-common/README.md @@ -0,0 +1,26 @@ +## Common 모듈 + +### 🤝 Rule + +- 하나의 프로젝트에서 모든 모듈에서 사용될 수밖에 없는 것들 +- Type, Util 등을 정의한다. +- 가능하면 사용하지 않는다. + - common 모듈에 기능을 추가할 때는 팀원과 상의한다. +- 프로젝트 내 어떠한 모듈도 의존해서는 안 된다. + - 최대한 오픈 소스로 배포 가능한 수준을 유지한다. + +### 🏷️ Directory Structure + +```agsl +pennyway-common +├── src +│ ├── main +│ │ ├── java.kr.co.pennyway +│ │ │ └── common # 공통으로 사용되는 Type, Util 등을 기능 별로 정의한다. +│ │ └── resources +│ │ └── application-common.yml +│ └── test +├── build.gradle +├── README.md +└── settings.gradle +``` \ No newline at end of file diff --git a/pennyway-domain/README.md b/pennyway-domain/README.md new file mode 100644 index 000000000..a65f3fb62 --- /dev/null +++ b/pennyway-domain/README.md @@ -0,0 +1,48 @@ +## Domain 모듈 + +### 🤝 Rule + +- 서비스 비지니스를 모른다. +- 하나의 모듈은 최대 하나의 Infrastructure에 대한 책임만을 갖거나 가지지 않는다. +- 도메인 모듈을 조합한 더 큰 단위의 도메인 모듈이 존재할 수 있다. +- Web 라이브러리 의존성을 갖는 것은 허용하지 않는다. +- Domain + - Java Class로 표현된 도메인 Class들 +- Repository + - 도메인 조회, 저장, 수정, 삭제 + - 시스템에서 가장 보호받아야 하고 견고해야 한다. + - 구현하려는 기능이 중심 역할이라면 도메인 모듈, 아니라면 사용하는 측에서 작성하도록 만드는 것이 좋다. +- Domain Service + - Domain의 비지니스 책임 + - Domain의 비지니스가 단순하면 생기지 않을 수도 있다. + - 트랜잭션의 단위, 요청 데이터 검증, 이벤트 발생 등의 비지니스로 사용 + - Domain 모듈의 Service는 @DomainService를 사용한다. + +### 🏷️ Directory Structure + +```agsl +pennyway-common +├── src +│ ├── main +│ │ ├── java.kr.co.pennyway.domain +│ │ │ ├── domains # 도메인 별로 패키지를 나누어 구성한다. +│ │ │ │ ├── entity +│ │ │ │ │ ├── domain +│ │ │ │ │ ├── exception +│ │ │ │ │ ├── repository +│ │ │ │ │ ├── service +│ │ │ │ │ └── type +│ │ │ │ └── … +│ │ │ ├── common +│ │ │ │ ├── redis # Redis Entity, Repository, Service +│ │ │ │ │ └── … +│ │ │ │ └── … +│ │ │ ├── config +│ │ │ └── DomainPackageLocation.java +│ │ └── resources +│ │ └── application-domain.yml +│ └── test +├── build.gradle +├── README.md +└── settings.gradle +``` \ No newline at end of file diff --git a/pennyway-infra/README.md b/pennyway-infra/README.md new file mode 100644 index 000000000..b4d2ee61a --- /dev/null +++ b/pennyway-infra/README.md @@ -0,0 +1,29 @@ +## Infra 모듈 + +### 🤝 Rule + +- 저장소, 도메인 외 시스템에서 필요한 모듈들 +- 핵심, 도메인 비지니스를 모른다. +- 전체적인 시스템 서포트를 위한 기능 모듈이 만들어질 수 있다. +- web, client, event-publisher 등을 처리할 때 사용한다. +- 외부 Actor와의 통신을 위한 설정 및 구현을 포함한다. + +### 🏷️ Directory Structure + +```agsl +pennyway-common +├── src +│ ├── main +│ │ ├── java.kr.co.pennyway +│ │ │ └── infra +│ │ │ ├── client # 외부 API 연동을 위한 모듈 +│ │ │ ├── common +│ │ │ ├── config +│ │ │ └── PennywayInfraApplication.java +│ │ └── resources +│ │ └── application-infra.yml +│ └── test +├── build.gradle +├── README.md +└── settings.gradle +``` \ No newline at end of file From 2b53f452a7d8d56395a92ca000026d2c6b2b25c0 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 17 Apr 2024 10:14:51 +0900 Subject: [PATCH 050/152] =?UTF-8?q?=E2=9C=A8=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20API=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: sign out use case 구현 * feat: sign out api 개방 * docs: user auth api swagger 문서에 sign out 추가 * feat: sign out pre authorize 추가 * fix: authorization header 파싱 추가 * test: 유효한 access token, 유효한 refresh token 시나리오 검증 * test: 3가지 테스트 시나리오 추가 작성(유효한 access token, 유효하지 않은 refresh token 시나리오 실패) * fix: authorization header 파싱 controller에서 수행 * refactor: 로그아웃 세부로직 jwt auth helper로 이동 * test: 유효한 access token을 가진 사용자가 다른 사용자의 유효한 refresh token을 전송할 시 실패 * feat: 소유권 없는 토큰 예외 추가 * fix: 다른 사용자의 유효한 refresh token 삭제 요청 시 예외 처리 * rename: jwt auth helper의 remove_access_token_and_refresh_token 메서드 주석 수정 * test: refresh token ttl 변환 로직 수정 * docs: sign out swagger 예외 응답 추가 * docs: sign out API 상세 설명 추가 * refactor: jwt auth helper 메서드 분리 * test: with_mock_user 어노테이션 제거 * fix: cookie 제거 응답 헤더 추가 * fix: 인증 필터 내 refresh token 체크 제거 * fix: cookie util delete cookie 메서드 수정 & 응답 헤더에 쿠키 제거용 헤더 추가 * test: refresh token 탈취 시나리오와 refresh 이전 token 전송 시나리오 추가 * test: scenario 2-3 pre-condition 수정 * fix: sign out api에서 sevlet request, response param 제거 --- .../api/apis/auth/helper/JwtAuthHelper.java | 56 ++++- .../api/apis/users/api/UserAuthApi.java | 52 +++++ .../users/controller/UserAuthController.java | 37 +++ .../apis/users/usecase/UserAuthUseCase.java | 17 ++ .../filter/JwtAuthenticationFilter.java | 3 +- .../pennyway/api/common/util/CookieUtil.java | 16 +- .../UserAuthControllerIntegrationTest.java | 212 ++++++++++++++++++ .../pennyway/common/exception/ReasonCode.java | 1 + .../infra/common/exception/JwtErrorCode.java | 1 + 9 files changed, 378 insertions(+), 17 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAuthApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAuthController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAuthUseCase.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAuthControllerIntegrationTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java index 34254b52f..e6f7c4384 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java @@ -7,11 +7,13 @@ import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaim; import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys; import kr.co.pennyway.common.annotation.Helper; +import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; import kr.co.pennyway.domain.common.redis.refresh.RefreshToken; import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.infra.common.exception.JwtErrorCode; import kr.co.pennyway.infra.common.exception.JwtErrorException; +import kr.co.pennyway.infra.common.jwt.JwtClaims; import kr.co.pennyway.infra.common.jwt.JwtProvider; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; @@ -26,15 +28,18 @@ public class JwtAuthHelper { private final JwtProvider accessTokenProvider; private final JwtProvider refreshTokenProvider; private final RefreshTokenService refreshTokenService; + private final ForbiddenTokenService forbiddenTokenService; public JwtAuthHelper( @AccessTokenStrategy JwtProvider accessTokenProvider, @RefreshTokenStrategy JwtProvider refreshTokenProvider, - RefreshTokenService refreshTokenService + RefreshTokenService refreshTokenService, + ForbiddenTokenService forbiddenTokenService ) { this.accessTokenProvider = accessTokenProvider; this.refreshTokenProvider = refreshTokenProvider; this.refreshTokenService = refreshTokenService; + this.forbiddenTokenService = forbiddenTokenService; } /** @@ -71,6 +76,55 @@ public Pair refresh(String refreshToken) { return Pair.of(userId, Jwts.of(newAccessToken, newRefreshToken.getToken())); } + /** + * access token과 refresh token을 삭제하여 로그아웃 처리하는 메서드 + * + * @param refreshToken : 삭제할 refreshToken. null이거나, 기존에 refreshToken이 없을 경우 삭제 과정을 생략한다. + * @throws JwtErrorException
+ * • {@link JwtErrorCode#EXPIRED_TOKEN} : 만료된 access token 삭제하려고 할 경우
+ * • {@link JwtErrorCode#WITHOUT_OWNERSHIP_REFRESH_TOKEN} : 다른 사용자의 refresh token을 삭제하려고 할 경우
+ * • {@link JwtErrorCode#MALFORMED_TOKEN} : refresh token이 유효하지 않을 경우 + */ + public void removeAccessTokenAndRefreshToken(Long userId, String accessToken, String refreshToken) { + JwtClaims jwtClaims = null; + if (refreshToken != null) { + try { + jwtClaims = refreshTokenProvider.getJwtClaimsFromToken(refreshToken); + } catch (JwtErrorException e) { + if (!e.getErrorCode().equals(JwtErrorCode.EXPIRED_TOKEN)) { + throw e; + } + } + } + + if (jwtClaims != null) { + deleteRefreshToken(userId, jwtClaims, refreshToken); + } + + deleteAccessToken(userId, accessToken); + } + + private void deleteRefreshToken(Long userId, JwtClaims jwtClaims, String refreshToken) { + Long refreshTokenUserId = Long.parseLong((String) jwtClaims.getClaims().get(RefreshTokenClaimKeys.USER_ID.getValue())); + log.info("로그아웃 요청 refresh token userId : {}", refreshTokenUserId); + + if (!userId.equals(refreshTokenUserId)) { + throw new JwtErrorException(JwtErrorCode.WITHOUT_OWNERSHIP_REFRESH_TOKEN); + } + + try { + refreshTokenService.delete(refreshTokenUserId, refreshToken); + } catch (IllegalArgumentException e) { + log.warn("refresh token not found. userId : {}", userId); + } + } + + private void deleteAccessToken(Long userId, String accessToken) { + LocalDateTime expiresAt = accessTokenProvider.getExpiryDate(accessToken); + log.info("로그아웃 요청 access token expiresAt : {}", expiresAt); + forbiddenTokenService.createForbiddenToken(accessToken, userId, expiresAt); + } + private long toSeconds(LocalDateTime expiryTime) { return Duration.between(LocalDateTime.now(), expiryTime).getSeconds(); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAuthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAuthApi.java new file mode 100644 index 000000000..11519c075 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAuthApi.java @@ -0,0 +1,52 @@ +package kr.co.pennyway.api.apis.users.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.ErrorResponse; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "[사용자 인증 관리 API]", description = "사용자의 인증과 관련된 UseCase(로그아웃, 소셜 계정 연동/해지 등)를 제공하는 API") +public interface UserAuthApi { + @Operation(summary = "로그아웃", description = """ + 사용자의 로그아웃을 수행한다. Access Token과 Refresh Token을 받아서 Access Token을 만료시키고, Refresh Token을 삭제한다.
+ Refresh Token이 없는 경우에는 Access Token만 만료시킨다. (만료된 refesh token이면 access token 만료만 수행)
+ 만약, Refresh Token이 유효하지 않거나 소유권이 없는 경우에는 401 에러를 반환한다.
+ access token이 인가 과정 중에 성공했어도, 삭제 등록 시 만료되면 refresh token만 제거하고 401_EXPIRED_TOKEN 응답을 반환한다. + """) + @Parameters({ + @Parameter(name = "Authorization", description = "Access Token", required = true), + @Parameter(name = "refreshToken", description = "Refresh Token", required = false) + }) + @ApiResponses({ + @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "유효하지 않은 refresh token", value = """ + { + "code": "4013", + "message": "비정상적인 토큰입니다" + } + """), + @ExampleObject(name = "소유권이 없는 refresh token 삭제 요청", value = """ + { + "code": "4014", + "message": "소유권이 없는 리프레시 토큰입니다" + } + """) + })) + }) + ResponseEntity signOut( + @RequestHeader("Authorization") String accessToken, + @CookieValue(value = "refreshToken", required = false) String refreshToken, + @AuthenticationPrincipal SecurityUserDetails user + ); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAuthController.java new file mode 100644 index 000000000..3dd6910fc --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAuthController.java @@ -0,0 +1,37 @@ +package kr.co.pennyway.api.apis.users.controller; + +import kr.co.pennyway.api.apis.users.api.UserAuthApi; +import kr.co.pennyway.api.apis.users.usecase.UserAuthUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.api.common.util.CookieUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/users") +public class UserAuthController implements UserAuthApi { + private final UserAuthUseCase userAuthUseCase; + private final CookieUtil cookieUtil; + + @GetMapping("/sign-out") + @PreAuthorize("isAuthenticated()") + public ResponseEntity signOut( + @RequestHeader("Authorization") String authHeader, + @CookieValue(value = "refreshToken", required = false) String refreshToken, + @AuthenticationPrincipal SecurityUserDetails user + ) { + String accessToken = authHeader.split(" ")[1]; + userAuthUseCase.signOut(user.getUserId(), accessToken, refreshToken); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookieUtil.deleteCookie("refreshToken").toString()) + .body(SuccessResponse.noContent()); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAuthUseCase.java new file mode 100644 index 000000000..392c6734e --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAuthUseCase.java @@ -0,0 +1,17 @@ +package kr.co.pennyway.api.apis.users.usecase; + +import kr.co.pennyway.api.apis.auth.helper.JwtAuthHelper; +import kr.co.pennyway.common.annotation.UseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class UserAuthUseCase { + private final JwtAuthHelper jwtAuthHelper; + + public void signOut(Long userId, String authHeader, String refreshToken) { + jwtAuthHelper.removeAccessTokenAndRefreshToken(userId, authHeader, refreshToken); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java index 0363fcd03..ccff4d231 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java @@ -68,9 +68,8 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht */ private boolean isAnonymousRequest(HttpServletRequest request) { String accessToken = request.getHeader(HttpHeaders.AUTHORIZATION); - String refreshToken = request.getHeader(HttpHeaders.SET_COOKIE); - return accessToken == null && refreshToken == null; + return accessToken == null; } /** diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/util/CookieUtil.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/util/CookieUtil.java index 5b4c0c8d8..b2e29f7c2 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/util/CookieUtil.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/util/CookieUtil.java @@ -2,7 +2,6 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Component; @@ -50,21 +49,10 @@ public ResponseCookie createCookie(String cookieName, String value, long maxAge) /** * cookieName에 해당하는 쿠키를 제거합니다. * - * @param request HttpServletRequest : 쿠키를 제거할 request - * @param response HttpServletResponse : 쿠키를 제거할 response - * @param cookieName String : 제거할 쿠키의 이름 * @return Optional : 쿠키가 존재하면 제거된 쿠키를, 존재하지 않으면 Optional.empty()를 반환합니다. */ - public Optional deleteCookie(HttpServletRequest request, HttpServletResponse response, String cookieName) { - Cookie[] cookies = request.getCookies(); - if (cookies == null) { - return Optional.empty(); - } - - return Arrays.stream(cookies) - .filter(cookie -> cookieName.equals(cookie.getName())) - .findAny() - .map(cookie -> createCookie(cookieName, "", 0)); + public ResponseCookie deleteCookie(String cookieName) { + return createCookie(cookieName, "", 0); } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAuthControllerIntegrationTest.java new file mode 100644 index 000000000..dea261cf5 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAuthControllerIntegrationTest.java @@ -0,0 +1,212 @@ +package kr.co.pennyway.api.apis.users.controller; + +import jakarta.servlet.http.Cookie; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenProvider; +import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaim; +import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenProvider; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; +import kr.co.pennyway.domain.common.redis.refresh.RefreshToken; +import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import kr.co.pennyway.infra.common.exception.JwtErrorCode; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.time.ZoneId; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExternalApiIntegrationTest +@AutoConfigureMockMvc +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +public class UserAuthControllerIntegrationTest extends ExternalApiDBTestConfig { + @Autowired + private MockMvc mockMvc; + + @Autowired + private AccessTokenProvider accessTokenProvider; + + @Autowired + private RefreshTokenProvider refreshTokenProvider; + + @Autowired + private RefreshTokenService refreshTokenService; + @Autowired + private ForbiddenTokenService forbiddenTokenService; + @Autowired + private UserService userService; + + + @Nested + @Order(1) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + @DisplayName("로그아웃") + class SignOut { + private String expectedAccessToken; + private String expectedRefreshToken; + private Long userId; + + @BeforeEach + void setUp() { + User user = User.builder() + .username("pennyway") + .password("password") + .profileVisibility(ProfileVisibility.PUBLIC) + .role(Role.USER) + .locked(Boolean.FALSE) + .build(); + userService.createUser(user); + userId = user.getId(); + expectedAccessToken = accessTokenProvider.generateToken(AccessTokenClaim.of(user.getId(), Role.USER.getType())); + expectedRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(user.getId(), Role.USER.getType())); + } + + @Order(1) + @Test + @DisplayName("Scenario #1 유효한 accessToken과 refreshToken이 있다면, accessToken은 forbiddenToken으로, refreshToken은 삭제한다.") + void validAccessTokenAndValidRefreshToken() throws Exception { + // given + refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + + // when + ResultActions result = mockMvc.perform(performSignOut() + .header("Authorization", "Bearer " + expectedAccessToken) + .cookie(new Cookie("refreshToken", expectedRefreshToken))); + + // then + result.andExpect(status().isOk()).andDo(print()); + assertTrue(forbiddenTokenService.isForbidden(expectedAccessToken)); + assertThrows(IllegalArgumentException.class, () -> refreshTokenService.delete(userId, expectedRefreshToken)); + } + + @Order(2) + @Test + @DisplayName("Scenario #2 유효한 accessToken만 존재한다면, accessToken만 forbiddenToken으로 만든다.") + void validAccessTokenWithoutRefreshToken() throws Exception { + // when + ResultActions result = mockMvc.perform(performSignOut().header("Authorization", "Bearer " + expectedAccessToken)); + + // then + result.andExpect(status().isOk()).andDo(print()); + assertTrue(forbiddenTokenService.isForbidden(expectedAccessToken)); + } + + @Order(3) + @Test + @DisplayName("Scenario #2-1 유효한 accessToken과 다른 사용자의 유효한 refreshToken이 있다면, 401 에러를 반환한다. accessToken이 forbidden 처리되지 않으며, 사용자와 다른 사용자의 refreshToken 정보 모두 삭제되지 않는다.") + void validAccessTokenAndWithOutOwnershipRefreshToken() throws Exception { + // given + String unexpectedRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(1000L, Role.USER.getType())); + refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + refreshTokenService.save(RefreshToken.of(1000L, unexpectedRefreshToken, refreshTokenProvider.getExpiryDate(unexpectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + + // when + ResultActions result = mockMvc + .perform(performSignOut().header("Authorization", "Bearer " + expectedAccessToken) + .cookie(new Cookie("refreshToken", unexpectedRefreshToken))); + + // then + result.andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(JwtErrorCode.WITHOUT_OWNERSHIP_REFRESH_TOKEN.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(JwtErrorCode.WITHOUT_OWNERSHIP_REFRESH_TOKEN.getExplainError())) + .andDo(print()); + assertDoesNotThrow(() -> refreshTokenService.delete(userId, expectedRefreshToken)); + assertDoesNotThrow(() -> refreshTokenService.delete(1000L, unexpectedRefreshToken)); + assertFalse(forbiddenTokenService.isForbidden(expectedAccessToken)); + } + + @Order(4) + @Test + @DisplayName("Scenario #2-2 유효한 accessToken과 유효하지 않은 refreshToken이 있다면, 401 에러를 반환한다. accessToken이 forbidden 처리되지 않으며, refreshToken 정보는 삭제되지 않는다.") + void validAccessTokenAndInvalidRefreshToken() throws Exception { + // given + long ttl = refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, ttl)); + + // when + ResultActions result = mockMvc.perform(performSignOut() + .header("Authorization", "Bearer " + expectedAccessToken) + .cookie(new Cookie("refreshToken", "invalidRefreshToken"))); + + // then + result + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(JwtErrorCode.MALFORMED_TOKEN.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(JwtErrorCode.MALFORMED_TOKEN.getExplainError())) + .andDo(print()); + assertDoesNotThrow(() -> refreshTokenService.delete(userId, expectedRefreshToken)); + assertFalse(forbiddenTokenService.isForbidden(expectedAccessToken)); + } + + @Order(5) + @Test + @DisplayName("Scenario #2-3 유효한 accessToken, 유효한 refreshToken을 가진 사용자가 refresh 하기 전의 refreshToken을 사용하는 경우, accessToken을 forbidden에 등록하고 refreshToken을 cache에서 제거한다. (refreshToken 탈취 대체 시나리오)") + void validAccessTokenAndOldRefreshToken() throws Exception { + // given + String oldRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(userId, Role.USER.getType())); + refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + + // when + ResultActions result = mockMvc.perform(performSignOut() + .header("Authorization", "Bearer " + expectedAccessToken) + .cookie(new Cookie("refreshToken", oldRefreshToken))); + + // then + result + .andExpect(status().isOk()) + .andExpect(header().exists(HttpHeaders.SET_COOKIE)) + .andDo(print()); + assertThrows(IllegalArgumentException.class, () -> refreshTokenService.delete(userId, oldRefreshToken)); + assertThrows(IllegalArgumentException.class, () -> refreshTokenService.delete(userId, expectedRefreshToken)); + assertTrue(forbiddenTokenService.isForbidden(expectedAccessToken)); + } + + @Order(6) + @Test + @DisplayName("Scenario #3 유효하지 않은 accessToken과 유효한 refreshToken이 있다면 401 에러를 반환한다.") + void invalidAccessTokenAndValidRefreshToken() throws Exception { + // given + refreshTokenService.save(RefreshToken.of(userId, expectedRefreshToken, refreshTokenProvider.getExpiryDate(expectedRefreshToken).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + + // when + ResultActions result = mockMvc.perform(performSignOut() + .header("Authorization", "Bearer invalidToken") + .cookie(new Cookie("refreshToken", expectedRefreshToken))); + + // then + result.andExpect(status().isUnauthorized()).andDo(print()); + } + + @Order(7) + @Test + @DisplayName("Scenario #4 유효하지 않은 accessToken과 유효하지 않은 refreshToken이 있다면 401 에러를 반환한다.") + void invalidAccessTokenAndInvalidRefreshToken() throws Exception { + // when + ResultActions result = mockMvc.perform(performSignOut().header("Authorization", "Bearer invalidToken")); + + // then + result.andExpect(status().isUnauthorized()).andDo(print()); + } + + private MockHttpServletRequestBuilder performSignOut() { + return get("/v1/users/sign-out") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON); + } + } +} diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java index 0780dd4c8..bfd6180f6 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java @@ -21,6 +21,7 @@ public enum ReasonCode { EXPIRED_OR_REVOKED_TOKEN(1), INSUFFICIENT_PERMISSIONS(2), TAMPERED_OR_MALFORMED_TOKEN(3), + WITHOUT_OWNERSHIP(4), /* 403_FORBIDDEN */ ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN(0), diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java index 862f03bfc..f0ffb6131 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/JwtErrorCode.java @@ -28,6 +28,7 @@ public enum JwtErrorCode implements BaseErrorCode { TAMPERED_TOKEN(UNAUTHORIZED, TAMPERED_OR_MALFORMED_TOKEN, "서명이 조작된 토큰입니다"), MALFORMED_TOKEN(UNAUTHORIZED, TAMPERED_OR_MALFORMED_TOKEN, "비정상적인 토큰입니다"), UNSUPPORTED_JWT_TOKEN(UNAUTHORIZED, TAMPERED_OR_MALFORMED_TOKEN, "지원하지 않는 토큰입니다"), + WITHOUT_OWNERSHIP_REFRESH_TOKEN(UNAUTHORIZED, WITHOUT_OWNERSHIP, "소유권이 없는 리프레시 토큰입니다"), /** * 403 FORBIDDEN: 인증된 클라이언트가 권한이 없는 자원에 접근 From bffb3b6e7363a6a217ba2b7df7d4064830b37403 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 19 Apr 2024 01:05:02 +0900 Subject: [PATCH 051/152] =?UTF-8?q?=E2=9C=A8=20Device=20Token=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D/=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C=20API=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: device token entity 정의 * feat: user entity 내 device entity 역방향 관계 지정 * feat: device entity 연관관계 도우미 메서드 추가 * feat: device repository 정의 * fix: device repoistory 상속 대상을 cure -> list crud로 변경 * feat: read_all_by_user_id() 메서드 repository 내 선언 * feat: device domain service 추가 * feat: user account controller 설계 및 코드 작성 * rename: controller -> api 네이밍 수정 * feat: device dto 정의 * fix: user account api에 device dto import * fix: user account controller 내 use case import * fix: device entity 생성자에 user 추가, 연관관계 도우미 메서드 제거, 토큰 업데이트 메서드 추가 * fix: device dto to_entity 파라미터에 user 추가 * fix: device service save 메서드 반환값 void -> device * feat: device 등록 use case 추가 * refactor: device 등록 usecase 내, flag 변수 제거 후 optional로 분기 제어 * feat: device domain service에서 user_id와 token으로 device 조회 메서드 추가 * feat: use case 내 임시 unregister_device 메서드 정의 * test: 신규 디바이스 등록 테스트 * test: 결과값으로 도출된 device에서 매핑 결과 확인 * refactor: register_device() 리턴값 long -> dto 객체 * feat: device custom exception 클래스 추가 * fix: 기존 디바이스 토큰 갱신 시, 기기 정보 불일치 예외 처리 * rename: 예외 상황 로그 수정 * test: 기존에 등록된 디바이스 토큰이 있는 경우, 디바이스 토큰을 갱신한다 * test: 서버에서 토큰을 비활성화 처리하여 존재하지 않는데, 클라이언트가 변경 요청을 보낸 경우 newToken으로 신규 디바이스를 등록한다 * test: 사용자가 유효한 토큰을 가지고 있지만 모델명이나 OS가 다른 경우 DEVICE_NOT_MATCH 에러를 반환한다 * feat: device entity 활성화 여부 필드 추가 && 정적 팩토리 메서드 추가 * fix: device 생성자 -> 정적 팩토리 메서드로 수정 * fix: device 생성자 private로 접근 제한 * feat: device not found error code 추가 * test: 활성화 필드 시나리오로 테스트 변경 * feat: device entity 활성화 여부 메서드 추가 * feat: device entity 활성화 메서드 추가 * fix: 활성화 필드 존재 이후 시나리오로 usecase 수정 * test: 토큰 비활성화 쿼리문 수정 * refactor: use case 추상화 수준을 위해 하위 service layer 추가 * style: 매개변수 순서 변경 * test: device_register_service 테스트 케이스 빈 등록 * rename: device request dto is_same_token() -> is_init_request() * refactor: 디바이스 생성 로직 구현 * test: 사용자 ID와 origin token에 매칭되는 활성 디바이스가 존재하는 경우 디바이스를 삭제한다 * fix: device token id 대신 device token 자체를 쿼리 파라미터로 받도록 수정 * fix: 디바이스 정보 제거 시, 매칭되는 디바이스가 없을 때 예외 종류 수정 * test: 디바이스 삭제 실패 테스트 * docs: device token api 스웨거 응답 문서 작성 * docs: 디바이스 등록 요청 dto의 함수가 필드에 포함되는 현상 제거 * test: 유효한 토큰 & 디바이스 정보가 다를 경우 디바이스 정보를 업데이트 * feat: device entity 내 model, os 정보 수정 메서드 추가 * fix: 사용자 기기 변경 시, 비지니스 로직 수정 * docs: 디바이스 장치 정보 미스 예외 제거 * fix: 디바이스 토큰 활성 여부 조건문 변경 * fix: not_match_device 에러 코드 제거 * feat: test용 user 상수 클래스 * feat: @authentication_principal 타입 변환을 위한 테스트용 어노테이션 작성 * test: user 디바이스 저장 요청 시, 성공 응답 포맷 테스트 * test: originToken에 대한 디바이스가 이미 존재하는 경우, 디바이스 정보 변경 사항만 업데이트하고 기존 디바이스 정보를 반환한다 * fix: device 저장 시, 매칭 디바이스 부재 여부 판단 로직 추가 * rename: 테스트 [1-1] display name 수정 * test: device fixture으로 변경 * style: device register service 메서드 순서 변경 --- .../api/apis/users/api/UserAccountApi.java | 58 +++++ .../controller/UserAccountController.java | 36 +++ .../api/apis/users/dto/DeviceDto.java | 48 ++++ .../users/service/DeviceRegisterService.java | 84 ++++++ .../users/usecase/UserAccountUseCase.java | 50 ++++ .../UserAccountControllerUnitTest.java | 69 +++++ .../users/usecase/UserAccountUseCaseTest.java | 241 ++++++++++++++++++ .../api/config/fixture/DeviceFixture.java | 33 +++ .../api/config/fixture/UserFixture.java | 55 ++++ ...hCustomMockUserSecurityContextFactory.java | 22 ++ .../supporter/WithSecurityMockUser.java | 12 + .../domain/domains/device/domain/Device.java | 67 +++++ .../device/exception/DeviceErrorCode.java | 27 ++ .../exception/DeviceErrorException.java | 20 ++ .../device/repository/DeviceRepository.java | 13 + .../domains/device/service/DeviceService.java | 43 ++++ .../domain/domains/user/domain/User.java | 6 + 17 files changed, 884 insertions(+) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceDto.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceRegisterService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceFixture.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/supporter/WithCustomMockUserSecurityContextFactory.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/supporter/WithSecurityMockUser.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorCode.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorException.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceService.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java new file mode 100644 index 000000000..3776f7cce --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java @@ -0,0 +1,58 @@ +package kr.co.pennyway.api.apis.users.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotBlank; +import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "사용자 계정 관리 API", description = "사용자 본인의 계정 관리를 위한 Usecase를 제공합니다.") +@SecurityRequirement(name = "access-token") +public interface UserAccountApi { + @Operation(summary = "디바이스 등록", description = "사용자의 디바이스 정보를 등록(originToken == newToken)하거나 갱신(originToken != newToken)합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "디바이스 등록 성공", value = """ + { + "device": { + "id": 1, + "token": "newToken" + } + } + """) + })), + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "수정 요청 시, token에 매칭하는 디바이스 정보가 없는 경우", value = """ + { + "code": "4040", + "message": "디바이스를 찾을 수 없습니다." + } + """) + })) + }) + ResponseEntity putDevice(@RequestBody @Validated DeviceDto.RegisterReq request, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "디바이스 토큰 제거", description = "사용자의 디바이스 정보와 토큰을 제거합니다.") + @Parameter(name = "token", description = "삭제할 디바이스 토큰", required = true, in = ParameterIn.QUERY) + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "수정 요청 시, token에 매칭하는 디바이스 정보가 없는 경우", value = """ + { + "code": "4040", + "message": "디바이스를 찾을 수 없습니다." + } + """) + })) + ResponseEntity deleteDevice(@RequestParam("token") @Validated @NotBlank String token, @AuthenticationPrincipal SecurityUserDetails user); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java new file mode 100644 index 000000000..f0b49900f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.api.apis.users.controller; + +import jakarta.validation.constraints.NotBlank; +import kr.co.pennyway.api.apis.users.api.UserAccountApi; +import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.api.apis.users.usecase.UserAccountUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/users/me") +public class UserAccountController implements UserAccountApi { + private final UserAccountUseCase userAccountUseCase; + + @PutMapping("/devices") + @PreAuthorize("isAuthenticated()") + public ResponseEntity putDevice(@RequestBody @Validated DeviceDto.RegisterReq request, @AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from("device", userAccountUseCase.registerDevice(user.getUserId(), request))); + } + + @DeleteMapping("/devices") + @PreAuthorize("isAuthenticated()") + public ResponseEntity deleteDevice(@RequestParam("token") @Validated @NotBlank String token, @AuthenticationPrincipal SecurityUserDetails user) { + userAccountUseCase.unregisterDevice(user.getUserId(), token); + return ResponseEntity.ok(SuccessResponse.noContent()); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceDto.java new file mode 100644 index 000000000..ea8f91278 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceDto.java @@ -0,0 +1,48 @@ +package kr.co.pennyway.api.apis.users.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import kr.co.pennyway.domain.domains.device.domain.Device; +import kr.co.pennyway.domain.domains.user.domain.User; + +public class DeviceDto { + @Schema(title = "디바이스 등록 요청") + public record RegisterReq( + @Schema(description = "기존 디바이스 토큰", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "originToken은 필수입니다.") + String originToken, + @Schema(description = "새로운 디바이스 토큰", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "newToken은 필수입니다.") + String newToken, + @Schema(description = "디바이스 모델명", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "model은 필수입니다.") + String model, + @Schema(description = "디바이스 OS", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "os는 필수입니다.") + String os + ) { + /** + * oldToken과 newToken이 같은 경우, 신규 등록 요청으로 판단 + */ + @Schema(hidden = true) + public boolean isInitRequest() { + return originToken.equals(newToken); + } + + public Device toEntity(User user) { + return Device.of(newToken, model, os, user); + } + } + + @Schema(title = "디바이스 등록 응답") + public record RegisterRes( + @Schema(title = "디바이스 ID") + Long id, + @Schema(title = "디바이스 토큰") + String token + ) { + public static RegisterRes of(Long id, String token) { + return new RegisterRes(id, token); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceRegisterService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceRegisterService.java new file mode 100644 index 000000000..f03bb435a --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceRegisterService.java @@ -0,0 +1,84 @@ +package kr.co.pennyway.api.apis.users.service; + +import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.domain.domains.device.domain.Device; +import kr.co.pennyway.domain.domains.device.exception.DeviceErrorCode; +import kr.co.pennyway.domain.domains.device.exception.DeviceErrorException; +import kr.co.pennyway.domain.domains.device.service.DeviceService; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DeviceRegisterService { + private final DeviceService deviceService; + + @Transactional + public Device createOrUpdateDevice(User user, DeviceDto.RegisterReq request) { + Optional device = deviceService.readDeviceByUserIdAndToken(user.getId(), request.originToken()); + + if (request.isInitRequest() && device.isEmpty()) { + return createDevice(user, request); + } + + Device originDevice = getDeviceOrThrow(device); + + log.info("디바이스 토큰 갱신: 사용자 {} - model {} - os {}", user, request.model(), request.os()); + return updateExistingDevice(originDevice, request); + } + + private Device createDevice(User user, DeviceDto.RegisterReq request) { + log.info("신규 디바이스 등록: 사용자 {} - model {} - os {}", user, request.model(), request.os()); + Device newDevice = request.toEntity(user); + return deviceService.createDevice(newDevice); + } + + /** + * 사용자 ID와 토큰으로 디바이스 정보를 조회한다. + * + * @throws DeviceErrorException 사용자 id와 originToken과 매칭되는 디바이스 정보가 없는 경우 + */ + private Device getDeviceOrThrow(Optional device) { + return device.orElseThrow(() -> new DeviceErrorException(DeviceErrorCode.NOT_FOUND_DEVICE)); + } + + /** + * 기존에 등록된 사용자의 디바이스 토큰을 갱신한다. + */ + private Device updateExistingDevice(Device device, DeviceDto.RegisterReq request) { + if (!isMatchOriginDeviceInfo(device, request)) { + log.warn("사용자 디바이스 정보 변경됨 : model {} - os {}", request.model(), request.os()); + device.updateDeviceInfo(request.model(), request.os()); + } + + return updateDeviceToken(device, request.newToken()); + } + + /** + * 요청한 디바이스 정보가 기존 디바이스 정보와 일치하는지 확인한다. + */ + private boolean isMatchOriginDeviceInfo(Device device, DeviceDto.RegisterReq request) { + return device.getOs().equals(request.os()) && device.getModel().equals(request.model()); + } + + /** + * 디바이스 토큰을 newToken으로 갱신하고, 만약 비활성화 토큰이라면 활성화 상태로 되돌린다. + */ + private Device updateDeviceToken(Device device, String newToken) { + log.debug("디바이스 토큰 갱신: {} -> {}", device.getToken(), newToken); + + device.updateToken(newToken); + + if (!device.isActivated()) { + device.activate(); + } + + return device; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java new file mode 100644 index 000000000..4415a6991 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java @@ -0,0 +1,50 @@ +package kr.co.pennyway.api.apis.users.usecase; + +import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.device.domain.Device; +import kr.co.pennyway.domain.domains.device.exception.DeviceErrorCode; +import kr.co.pennyway.domain.domains.device.exception.DeviceErrorException; +import kr.co.pennyway.domain.domains.device.service.DeviceService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class UserAccountUseCase { + private final UserService userService; + private final DeviceService deviceService; + + private final DeviceRegisterService deviceRegisterService; + + @Transactional + public DeviceDto.RegisterRes registerDevice(Long userId, DeviceDto.RegisterReq request) { + User user = userService.readUser(userId).orElseThrow( + () -> new UserErrorException(UserErrorCode.NOT_FOUND) + ); + + Device device = deviceRegisterService.createOrUpdateDevice(user, request); + + return DeviceDto.RegisterRes.of(device.getId(), request.newToken()); + } + + @Transactional + public void unregisterDevice(Long userId, String token) { + User user = userService.readUser(userId).orElseThrow( + () -> new UserErrorException(UserErrorCode.NOT_FOUND) + ); + + Device device = deviceService.readDeviceByUserIdAndToken(user.getId(), token).orElseThrow( + () -> new DeviceErrorException(DeviceErrorCode.NOT_FOUND_DEVICE) + ); + + deviceService.deleteDevice(device); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java new file mode 100644 index 000000000..59ff8d083 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java @@ -0,0 +1,69 @@ +package kr.co.pennyway.api.apis.users.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.api.apis.users.usecase.UserAccountUseCase; +import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = {UserAccountController.class}) +@ActiveProfiles("local") +public class UserAccountControllerUnitTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private UserAccountUseCase userAccountUseCase; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(put("/**").with(csrf())) + .defaultRequest(delete("/**").with(csrf())) + .build(); + } + + @DisplayName("[1] 디바이스가 정상적으로 저장되었을 때, 디바이스 pk와 등록된 토큰을 반환한다.") + @Test + @WithSecurityMockUser + void putDeviceSuccess() throws Exception { + // given + DeviceDto.RegisterReq request = new DeviceDto.RegisterReq("newToken", "newToken", "modelA", "Windows"); + DeviceDto.RegisterRes expectedResponse = new DeviceDto.RegisterRes(2L, "newToken"); + given(userAccountUseCase.registerDevice(1L, request)).willReturn(expectedResponse); + + // when + ResultActions result = mockMvc.perform(put("/v2/users/me/devices") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andExpect(jsonPath("$.data.device.id").value(expectedResponse.id())) + .andExpect(jsonPath("$.data.device.token").value(expectedResponse.token())) + .andDo(print()); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java new file mode 100644 index 000000000..902ae07f8 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java @@ -0,0 +1,241 @@ +package kr.co.pennyway.api.apis.users.usecase; + +import jakarta.persistence.EntityManager; +import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.fixture.DeviceFixture; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.device.domain.Device; +import kr.co.pennyway.domain.domains.device.exception.DeviceErrorCode; +import kr.co.pennyway.domain.domains.device.exception.DeviceErrorException; +import kr.co.pennyway.domain.domains.device.service.DeviceService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.util.AssertionErrors.*; + +@ExtendWith(MockitoExtension.class) +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") +@ContextConfiguration(classes = {JpaConfig.class, UserAccountUseCase.class, DeviceRegisterService.class, UserService.class, DeviceService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +class UserAccountUseCaseTest extends ExternalApiDBTestConfig { + @Autowired + private UserService userService; + + @Autowired + private DeviceService deviceService; + + @Autowired + private UserAccountUseCase userAccountUseCase; + + @Autowired + private EntityManager em; + + private User requestUser; + + @BeforeEach + void setUp() { + User user = User.builder().role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).build(); + requestUser = userService.createUser(user); + } + + @Test + @Transactional + @DisplayName("[1] originToken과 newToken이 같은 경우, 신규 디바이스를 등록한다.") + void registerNewDevice() { + // given + DeviceDto.RegisterReq request = DeviceFixture.INIT.toRegisterReq(); + + // when + DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + System.out.println("device = " + device); + }, + () -> fail("신규 디바이스가 등록되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[1-1] 저장 요청에서 originToken에 대한 디바이스가 이미 존재하는 경우, 디바이스 정보 변경 사항만 업데이트하고 기존 디바이스 정보를 반환한다.") + void registerNewDeviceWhenDeviceIsAlreadyExists() { + // given + Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(originDevice); + DeviceDto.RegisterReq request = DeviceFixture.ONLY_MODEL_AND_OS_CHANGED.toRegisterReq(); + + // when + DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertEquals("디바이스 ID가 일치해야 한다.", originDevice.getId(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); + System.out.println("device = " + device); + }, + () -> fail("신규 디바이스가 등록되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[2] originToken과 일치하는 활성화 디바이스 토큰이 존재한다면, 디바이스 토큰을 갱신한다.") + void updateActivateDeviceToken() { + // given + Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(originDevice); + + System.out.println("originDevice = " + originDevice); + DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); + + // when + DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); + System.out.println("device = " + device); + }, + () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[2-1] 기존에 등록된 비활성화 디바이스 토큰이 있고 디바이스 정보가 일치한다면, 디바이스 토큰을 갱신하고 활성화로 변경한다.") + void updateDeactivateDeviceToken() { + // given + Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(originDevice); + em.createQuery("UPDATE Device d SET d.activated = false WHERE d.id = :id AND d.token = :token") + .setParameter("id", originDevice.getId()) + .setParameter("token", originDevice.getToken()) + .executeUpdate(); // 비활성화 처리 + + System.out.println("originDevice = " + originDevice); + DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); + + // when + DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); + System.out.println("device = " + device); + }, + () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") + ); + } + + + @Test + @Transactional + @DisplayName("[2-2] 사용자가 유효한 토큰을 가지고 있지만 모델명이나 OS가 다른 경우, 디바이스 정보를 업데이트한다.") + void notMatchDevice() { + // given + Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(originDevice); + System.out.println("originDevice = " + originDevice); + DeviceDto.RegisterReq request = DeviceFixture.ALL_CHANGED.toRegisterReq(); + + // when + userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", request.newToken(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); + System.out.println("device = " + device); + }, + () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[3] 토큰 수정 요청에서 oldToken에 대한 디바이스가 존재하지 않는 경우, NOT_FOUND 에러를 반환한다.") + void registerNewDeviceWhenOldDeviceTokenIsNotExists() { + // given + DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); + + // when - then + DeviceErrorException ex = assertThrows(DeviceErrorException.class, () -> userAccountUseCase.registerDevice(requestUser.getId(), request)); + assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("[4] 사용자 ID와 origin token에 매칭되는 활성 디바이스가 존재하는 경우 디바이스를 삭제한다.") + void unregisterDevice() { + // given + Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(device); + + // when + userAccountUseCase.unregisterDevice(requestUser.getId(), device.getToken()); + + // then + Optional deletedDevice = deviceService.readDeviceByUserIdAndToken(requestUser.getId(), device.getToken()); + assertNull("디바이스가 삭제되어 있어야 한다.", deletedDevice.orElse(null)); + } + + @Test + @Transactional + @DisplayName("[5] 사용자 ID와 token에 매칭되는 디바이스가 존재하지 않는 경우 NOT_FOUND_DEVICE 에러를 반환한다.") + void unregisterDeviceWhenDeviceIsNotExists() { + // given + Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(device); + + // when - then + DeviceErrorException ex = assertThrows(DeviceErrorException.class, () -> userAccountUseCase.unregisterDevice(requestUser.getId(), "notExistsToken")); + assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode()); + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceFixture.java new file mode 100644 index 000000000..e28c537a9 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceFixture.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.api.config.fixture; + +import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.domain.domains.device.domain.Device; +import kr.co.pennyway.domain.domains.user.domain.User; + +public enum DeviceFixture { + INIT("originToken", "originToken", "modelA", "Windows 11"), + ORIGIN_DEVICE("originToken", "originToken", "modelA", "Windows 11"), + ONLY_TOKEN_CHANGED("originToken", "newToken", "modelA", "Windows 11"), + ONLY_MODEL_AND_OS_CHANGED("originToken", "originToken", "modelB", "Windows 11"), + ALL_CHANGED("originToken", "newToken", "modelB", "Mac OS X"); + + private final String originToken; + private final String newToken; + private final String model; + private final String os; + + DeviceFixture(String originToken, String newToken, String model, String os) { + this.originToken = originToken; + this.newToken = newToken; + this.model = model; + this.os = os; + } + + public Device toDevice(User user) { + return Device.of(originToken, model, os, user); + } + + public DeviceDto.RegisterReq toRegisterReq() { + return new DeviceDto.RegisterReq(originToken, newToken, model, os); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java new file mode 100644 index 000000000..8ac68a26f --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java @@ -0,0 +1,55 @@ +package kr.co.pennyway.api.config.fixture; + +import kr.co.pennyway.api.common.security.authentication.CustomGrantedAuthority; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; + +import java.util.List; + +public enum UserFixture { + GENERAL_USER(1L, "jayang", "dkssudgktpdy1", "Yang", "abc@abc.com", Role.USER, ProfileVisibility.PUBLIC, false), + ; + + private final Long id; + private final String username; + private final String password; + private final String name; + private final String phone; + private final Role role; + private final ProfileVisibility profileVisibility; + private final Boolean locked; + + UserFixture(Long id, String username, String password, String name, String phone, Role role, ProfileVisibility profileVisibility, Boolean locked) { + this.id = id; + this.username = username; + this.password = password; + this.name = name; + this.phone = phone; + this.role = role; + this.profileVisibility = profileVisibility; + this.locked = locked; + } + + public static SecurityUserDetails createSecurityUser(Long userId) { + return SecurityUserDetails.builder() + .userId(userId) + .username(GENERAL_USER.username) + .authorities(List.of(new CustomGrantedAuthority(GENERAL_USER.role.getType()))) + .accountNonLocked(false) + .build(); + } + + public User toUser() { + return User.builder() + .username(username) + .password(password) + .name(name) + .phone(phone) + .role(role) + .profileVisibility(profileVisibility) + .locked(locked) + .build(); + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/supporter/WithCustomMockUserSecurityContextFactory.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/supporter/WithCustomMockUserSecurityContextFactory.java new file mode 100644 index 000000000..f5b7fdced --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/supporter/WithCustomMockUserSecurityContextFactory.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.api.config.supporter; + +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.api.config.fixture.UserFixture; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.context.support.WithSecurityContextFactory; + +public class WithCustomMockUserSecurityContextFactory implements WithSecurityContextFactory { + @Override + public SecurityContext createSecurityContext(WithSecurityMockUser annotation) { + String userId = annotation.userId(); + + SecurityUserDetails user = UserFixture.createSecurityUser(Long.parseLong(userId)); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + return context; + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/supporter/WithSecurityMockUser.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/supporter/WithSecurityMockUser.java new file mode 100644 index 000000000..7fe56c1e1 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/supporter/WithSecurityMockUser.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.api.config.supporter; + +import org.springframework.security.test.context.support.WithSecurityContext; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = WithCustomMockUserSecurityContextFactory.class) +public @interface WithSecurityMockUser { + String userId() default "1"; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java new file mode 100644 index 000000000..7772d3a4b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java @@ -0,0 +1,67 @@ +package kr.co.pennyway.domain.domains.device.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Getter +@Table(name = "device") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Device extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String token; + private String model; + private String os; + @ColumnDefault("true") + private Boolean activated; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + private Device(String token, String model, String os, Boolean activated, User user) { + this.token = token; + this.model = model; + this.os = os; + this.activated = activated; + this.user = user; + } + + public static Device of(String token, String model, String os, User user) { + return new Device(token, model, os, Boolean.TRUE, user); + } + + public Boolean isActivated() { + return activated; + } + + public void activate() { + this.activated = Boolean.TRUE; + } + + public void updateToken(String token) { + this.token = token; + } + + public void updateDeviceInfo(String model, String os) { + this.model = model; + this.os = os; + } + + @Override + public String toString() { + return "Device{" + + "id=" + id + + ", token='" + token + '\'' + + ", model='" + model + '\'' + + ", os='" + os + '}'; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorCode.java new file mode 100644 index 000000000..e3eecec29 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorCode.java @@ -0,0 +1,27 @@ +package kr.co.pennyway.domain.domains.device.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum DeviceErrorCode implements BaseErrorCode { + /* 404 NOT_FOUND */ + NOT_FOUND_DEVICE(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "디바이스를 찾을 수 없습니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorException.java new file mode 100644 index 000000000..05455f94e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorException.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.domains.device.exception; + +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class DeviceErrorException extends GlobalErrorException { + private final DeviceErrorCode deviceErrorCode; + + public DeviceErrorException(DeviceErrorCode deviceErrorCode) { + super(deviceErrorCode); + this.deviceErrorCode = deviceErrorCode; + } + + public String getExplainError() { + return deviceErrorCode.getExplainError(); + } + + public String getErrorCode() { + return deviceErrorCode.name(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceRepository.java new file mode 100644 index 000000000..f85818449 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceRepository.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.domains.device.repository; + +import kr.co.pennyway.domain.domains.device.domain.Device; +import org.springframework.data.repository.ListCrudRepository; + +import java.util.List; +import java.util.Optional; + +public interface DeviceRepository extends ListCrudRepository { + Optional findByUser_IdAndToken(Long userId, String token); + + List findAllByUser_Id(Long userId); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceService.java new file mode 100644 index 000000000..6e3f46c69 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceService.java @@ -0,0 +1,43 @@ +package kr.co.pennyway.domain.domains.device.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.device.domain.Device; +import kr.co.pennyway.domain.domains.device.repository.DeviceRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class DeviceService { + private final DeviceRepository deviceRepository; + + @Transactional + public Device createDevice(Device device) { + return deviceRepository.save(device); + } + + @Transactional + public Optional readDeviceByUserIdAndToken(Long userId, String token) { + return deviceRepository.findByUser_IdAndToken(userId, token); + } + + @Transactional(readOnly = true) + public List readDevicesByUserId(Long userId) { + return deviceRepository.findAllByUser_Id(userId); + } + + @Transactional + public void deleteDevice(Long deviceId) { + deviceRepository.deleteById(deviceId); + } + + @Transactional + public void deleteDevice(Device device) { + deviceRepository.delete(device); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java index 7c198833a..1b8e77b98 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java @@ -4,6 +4,7 @@ import kr.co.pennyway.domain.common.converter.ProfileVisibilityConverter; import kr.co.pennyway.domain.common.converter.RoleConverter; import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.device.domain.Device; import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; import kr.co.pennyway.domain.domains.user.type.Role; import lombok.AccessLevel; @@ -16,6 +17,8 @@ import org.hibernate.annotations.SQLRestriction; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Getter @@ -49,6 +52,9 @@ public class User extends DateAuditable { @ColumnDefault("NULL") private LocalDateTime deletedAt; + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) + private List devices = new ArrayList<>(); + @Builder private User(String username, String name, String password, LocalDateTime passwordUpdatedAt, String profileImageUrl, String phone, Role role, ProfileVisibility profileVisibility, NotifySetting notifySetting, Boolean locked, LocalDateTime deletedAt) { this.username = username; From c594fd102144b894a0b100334980bcbdd95c9516 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 19 Apr 2024 02:44:35 +0900 Subject: [PATCH 052/152] =?UTF-8?q?fix:=20oauth=20entity=20=EB=82=B4=20pro?= =?UTF-8?q?vider=20converter=20=EC=A0=95=EC=9D=98=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java index b3fbc64a4..0207097a5 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java @@ -1,6 +1,7 @@ package kr.co.pennyway.domain.domains.oauth.domain; import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.ProviderConverter; import kr.co.pennyway.domain.domains.oauth.type.Provider; import kr.co.pennyway.domain.domains.user.domain.User; import lombok.AccessLevel; @@ -29,6 +30,7 @@ public class Oauth { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Convert(converter = ProviderConverter.class) private Provider provider; private String oauthId; From 42e9700123ee209351c79cead73390e4c5d830e1 Mon Sep 17 00:00:00 2001 From: DinoDeveloper <79460319+asn6878@users.noreply.github.com> Date: Sat, 20 Apr 2024 22:08:41 +0900 Subject: [PATCH 053/152] =?UTF-8?q?=E2=9C=A8=20=EB=AC=B8=EC=9D=98=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20API=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: question 엔티티, repository 작성 * feat: mail 발송 로직 임시 작성 * fix: http 메서드 수정 * feat: question 예외 작성 * feat: question service 작성 * feat: 문의 발송 api 작성 * fix: 문의 발송 내용 수정 * feat: 필요한 환경변수 설정 * docs: swagger 문서 작성 및 분리 * fix: 라이브러리 버전 명시 * docs: swagger parameter 제거 및 schema 내용 수정 * fix: 컨벤션에 따른 uri 수정 * fix: controller 메소드 인가 권한 수정 * fix: dto 필드별 schema 작성 * fix: dto email 필드 유효성 검사 추가 * fix: category(enum) 필드 유효성 검사 처리 * fix: restful 원칙에 따른 request uri 수정 * fix: dto inner class 제거 * fix: transactional 어노테이션 변경 * fix: email_error 오탈자 수정 * fix: 공통 허용 endpoint 선언 * fix: createddate 를 사용하기 위한 entitylistners 추가 * fix: domainservice 연결 * fix: swagger schema 오탈자 수정 및 enum 설명 제거 * fix: transactional 어노테이션 추가 * fix: @schema 어노테이션 제거 * fix: questioncategory enum converter 적용 * fix: sendquestion 응답 nocontent로 변경 * refactor: starter-mail 의존성 이동 * refactor: starter-mail 의존성 구성 속성 변수 이동 * refactor: 메일발송 로직 infra 모듈 이전 * fix: 임시 로그 제거 * refactor: 의존성 주입을 위한 mailconfig 수정 * refactor: transactionaleventlistener를 활용한 메일발송 이벤트처리 * test: 테스트 작성 * fix: mockbean 누락 오류 수정 * fix: 테스팅간 로직 임시 주석처리 복구 * fix: 불필요한 getter 제거 * fix: main핸들링 log 수준 변경 * feat: admin_address 환경변수 기본값 추가 * fix: 메일 발송 이벤트 실패시 로그 레벨 변경 * feat: swagger 성공 응답 명시 --- .../api/apis/question/api/QuestionApi.java | 26 ++++ .../controller/QuestionController.java | 31 +++++ .../api/apis/question/dto/QuestionReq.java | 29 ++++ .../question/usecase/QuestionUseCase.java | 27 ++++ .../api/config/security/SecurityConfig.java | 3 + .../controller/QuestionControllerTest.java | 126 ++++++++++++++++++ .../converter/QuestionCategoryConverter.java | 13 ++ .../domains/question/domain/Question.java | 41 ++++++ .../question/domain/QuestionCategory.java | 19 +++ .../question/exception/QuestionErrorCode.java | 26 ++++ .../exception/QuestionErrorException.java | 24 ++++ .../repository/QuestionRepository.java | 7 + .../question/service/QuestionService.java | 19 +++ pennyway-infra/build.gradle | 3 + .../client/google/mail/GoogleMailSender.java | 49 +++++++ .../infra/common/event/MailEvent.java | 11 ++ .../infra/common/event/MailEventHandling.java | 28 ++++ .../co/pennyway/infra/config/MailConfig.java | 46 +++++++ .../src/main/resources/application-infra.yml | 8 ++ 19 files changed, 536 insertions(+) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/api/QuestionApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/controller/QuestionController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/dto/QuestionReq.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/usecase/QuestionUseCase.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/question/controller/QuestionControllerTest.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/QuestionCategoryConverter.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/QuestionCategory.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorCode.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorException.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/repository/QuestionRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/service/QuestionService.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/mail/GoogleMailSender.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/MailEvent.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/MailEventHandling.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MailConfig.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/api/QuestionApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/api/QuestionApi.java new file mode 100644 index 000000000..ca0cbb714 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/api/QuestionApi.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.api.apis.question.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.question.dto.QuestionReq; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "[문의 API]") +public interface QuestionApi { + + @Operation(summary = "문의 전송", description = "사용자는 관리자에게 문의 메일을 발송한다.") + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "발신 성공", value = """ + { + "code": "2000", + "data": {} + } + """) + })) + ResponseEntity sendQuestion(@RequestBody @Validated QuestionReq request); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/controller/QuestionController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/controller/QuestionController.java new file mode 100644 index 000000000..3cdba1f12 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/controller/QuestionController.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.api.apis.question.controller; + +import kr.co.pennyway.api.apis.question.api.QuestionApi; +import kr.co.pennyway.api.apis.question.dto.QuestionReq; +import kr.co.pennyway.api.apis.question.usecase.QuestionUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/questions") +public class QuestionController implements QuestionApi { + private final QuestionUseCase questionUseCase; + + @Override + @PostMapping("") + @PreAuthorize("permitAll()") + public ResponseEntity sendQuestion(@RequestBody @Validated QuestionReq request) { + questionUseCase.sendQuestion(request); + return ResponseEntity.ok(SuccessResponse.noContent()); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/dto/QuestionReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/dto/QuestionReq.java new file mode 100644 index 000000000..78c0fd41b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/dto/QuestionReq.java @@ -0,0 +1,29 @@ +package kr.co.pennyway.api.apis.question.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import kr.co.pennyway.domain.domains.question.domain.Question; +import kr.co.pennyway.domain.domains.question.domain.QuestionCategory; + +public record QuestionReq( + @Schema(description = "문의자 이메일", example = "foobar@gmail.com") + @NotBlank(message = "이메일을 입력해주세요") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + @Schema(description = "문의 내용", example = "문의 내용입니다.") + @NotBlank(message = "문의 내용을 입력해주세요") + String content, + @Schema(description = "문의 카테고리", example = "UTILIZATION") + @NotNull(message = "문의 카테고리를 입력해주세요") + QuestionCategory category +) { + public Question toEntity() { + return Question.builder() + .email(email) + .content(content) + .category(category) + .build(); + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/usecase/QuestionUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/usecase/QuestionUseCase.java new file mode 100644 index 000000000..3eb2ca101 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/usecase/QuestionUseCase.java @@ -0,0 +1,27 @@ +package kr.co.pennyway.api.apis.question.usecase; + +import jakarta.transaction.Transactional; +import kr.co.pennyway.api.apis.question.dto.QuestionReq; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.question.domain.Question; +import kr.co.pennyway.domain.domains.question.service.QuestionService; +import kr.co.pennyway.infra.common.event.MailEvent; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; + +@Slf4j +@UseCase +@AllArgsConstructor +public class QuestionUseCase { + private final QuestionService questionService; + private final ApplicationEventPublisher applicationEventPublisher; + + @Transactional + public void sendQuestion(QuestionReq request) { + Question question = request.toEntity(); + + questionService.createQuestion(question); + applicationEventPublisher.publishEvent(MailEvent.of(request.email(), request.content(), request.category().getTitle())); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java index a2b008752..7660c6ada 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java @@ -27,9 +27,11 @@ @RequiredArgsConstructor public class SecurityConfig { private static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/v1/duplicate/**"}; + private static final String[] PUBLIC_ENDPOINTS = {"/v1/questions/**"}; private static final String[] ANONYMOUS_ENDPOINTS = {"/v1/auth/**", "/v1/phone/**"}; private static final String[] SWAGGER_ENDPOINTS = {"/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger",}; + private final SecurityAdapterConfig securityAdapterConfig; private final CorsConfigurationSource corsConfigurationSource; private final AccessDeniedHandler accessDeniedHandler; @@ -79,6 +81,7 @@ private AbstractRequestMatcherRegistry { + private static final String ENUM_NAME = "문의 카테고리"; + + public QuestionCategoryConverter() { + super(QuestionCategory.class, false, ENUM_NAME); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java new file mode 100644 index 000000000..f9b57ad2b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java @@ -0,0 +1,41 @@ +package kr.co.pennyway.domain.domains.question.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.QuestionCategoryConverter; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "Question") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class Question { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false) + private String email; + @Convert(converter = QuestionCategoryConverter.class) + @Column(nullable = false) + private QuestionCategory category; + private String content; + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + private LocalDateTime deletedAt; + + @Builder + private Question(String email, QuestionCategory category, String content, LocalDateTime createdAt, LocalDateTime deletedAt) { + this.email = email; + this.category = category; + this.content = content; + this.createdAt = createdAt; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/QuestionCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/QuestionCategory.java new file mode 100644 index 000000000..6fabf68b1 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/QuestionCategory.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.domains.question.domain; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public enum QuestionCategory implements LegacyCommonType { + UTILIZATION("1", "이용 관련"), + BUG_REPORT("2", "오류 신고"), + SUGGESTION("3", "서비스 제안"), + ETC("4", "기타"); + + private final String code; + private final String title; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorCode.java new file mode 100644 index 000000000..6b20844a1 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorCode.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.domain.domains.question.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum QuestionErrorCode implements BaseErrorCode { + INTERNAL_MAIL_ERROR(StatusCode.INTERNAL_SERVER_ERROR, ReasonCode.UNEXPECTED_ERROR, "메일 발송에 실패했습니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorException.java new file mode 100644 index 000000000..3eb89c218 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorException.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.domains.question.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import lombok.Getter; + +public class QuestionErrorException extends GlobalErrorException { + private final QuestionErrorCode questionErrorCode; + + public QuestionErrorException(QuestionErrorCode questionErrorCode) { + super(questionErrorCode); + this.questionErrorCode = questionErrorCode; + } + + public CausedBy causedBy() { + return questionErrorCode.causedBy(); + } + + public String getExplainError() { + return questionErrorCode.getExplainError(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/repository/QuestionRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/repository/QuestionRepository.java new file mode 100644 index 000000000..d50f22cdd --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/repository/QuestionRepository.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.domains.question.repository; + +import kr.co.pennyway.domain.domains.question.domain.Question; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface QuestionRepository extends JpaRepository { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/service/QuestionService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/service/QuestionService.java new file mode 100644 index 000000000..a8e68ce4d --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/service/QuestionService.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.domains.question.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.question.domain.Question; +import kr.co.pennyway.domain.domains.question.repository.QuestionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +@DomainService +@RequiredArgsConstructor +public class QuestionService { + private final QuestionRepository questionRepository; + + @Transactional + public void createQuestion(Question question) { + questionRepository.save(question); + } + +} diff --git a/pennyway-infra/build.gradle b/pennyway-infra/build.gradle index 30c5a749a..d3213ec95 100644 --- a/pennyway-infra/build.gradle +++ b/pennyway-infra/build.gradle @@ -25,4 +25,7 @@ dependencies { /* aws */ implementation platform("software.amazon.awssdk:bom:2.25.26") implementation 'software.amazon.awssdk:sns:2.25.26' + + /* mail */ + implementation 'org.springframework.boot:spring-boot-starter-mail:3.2.3' } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/mail/GoogleMailSender.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/mail/GoogleMailSender.java new file mode 100644 index 000000000..db12c5bfd --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/mail/GoogleMailSender.java @@ -0,0 +1,49 @@ +package kr.co.pennyway.infra.client.google.mail; + +import jakarta.mail.internet.MimeMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class GoogleMailSender { + private final JavaMailSender javaMailSender; + private final String adminAddress; + + GoogleMailSender(JavaMailSender javaMailSender, @Value("${app.question-address}") String adminAddress) { + this.javaMailSender = javaMailSender; + this.adminAddress = adminAddress; + } + + public void sendMail(String email, String content, String category) { + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + + try { + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); + helper.setTo(adminAddress); + helper.setSubject(createSubject(email)); + helper.setText(createContent(email, content, category), true); + + javaMailSender.send(mimeMessage); + } catch (Exception e) { + log.error("MailSender exception:" + e.getMessage()); + + throw new RuntimeException("메일 전송에 실패했습니다."); + } + } + + private String createSubject(String email) { + return email + "님께서 문의사항을 남겨 주셨어요."; + } + + private String createContent(String email, String content, String category) { + String fromField = "

문의자 : " + email + "

"; + String categoryField = "

카테고리 : " + category + "


"; + String contentField = "문의 내용 : " + content; + + return fromField + categoryField + contentField; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/MailEvent.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/MailEvent.java new file mode 100644 index 000000000..fcf6a0382 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/MailEvent.java @@ -0,0 +1,11 @@ +package kr.co.pennyway.infra.common.event; + +public record MailEvent( + String email, + String content, + String category +) { + public static MailEvent of(String email, String content, String category) { + return new MailEvent(email, content, category); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/MailEventHandling.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/MailEventHandling.java new file mode 100644 index 000000000..3ada82e56 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/MailEventHandling.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.infra.common.event; + +import kr.co.pennyway.infra.client.google.mail.GoogleMailSender; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@AllArgsConstructor +public class MailEventHandling { + GoogleMailSender googleMailSender; + + /** + * 관리자의 메일로 문의사항을 발송합니다. + *
+ * {@link EventListener}를 통해 createQuestion 트랜잭션 발생시 이벤트를 받아서 메일을 전송합니다. + * + * @param event {@link MailEvent} + */ + @TransactionalEventListener + public void handleMailEvent(MailEvent event) { + log.info("handleMailEvent: {}", event); + googleMailSender.sendMail(event.email(), event.content(), event.category()); + } +} \ No newline at end of file diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MailConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MailConfig.java new file mode 100644 index 000000000..57d0a7dcd --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MailConfig.java @@ -0,0 +1,46 @@ +package kr.co.pennyway.infra.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import java.util.Properties; + +@Configuration +public class MailConfig { + @Value("${app.mail.host}") + private String host; + @Value("${app.mail.port}") + private int port; + @Value("${app.mail.username}") + private String username; + @Value("${app.mail.password}") + private String password; + + + @Bean + public JavaMailSender javaMailService() { + JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl(); + + javaMailSender.setHost(host); + javaMailSender.setPort(port); + javaMailSender.setUsername(username); + javaMailSender.setPassword(password); + javaMailSender.setJavaMailProperties(getMailProperties()); + + return javaMailSender; + } + + private Properties getMailProperties() { + Properties properties = new Properties(); + properties.setProperty("mail.transport.protocol", "smtp"); + properties.setProperty("mail.debug", "false"); + properties.setProperty("mail.smtp.auth", "true"); + properties.setProperty("mail.smtp.starttls.enable", "true"); + properties.setProperty("mail.smtp.timeout", "5000"); + + return properties; + } +} diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index 78e2b188a..cf0b4cc83 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -18,6 +18,14 @@ spring: region: static: ${AWS_SNS_REGION:republic-of-korea-1} +app: + question-address: ${ADMIN_ADDRESS:team.collabu@gmail.com} + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME:pennyway} + password: ${MAIL_PASSWORD:password} + pennyway: server: domain: From a981c44ead34d31a0a468880f1b8077e54a6619b Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sun, 21 Apr 2024 18:28:30 +0900 Subject: [PATCH 054/152] =?UTF-8?q?=F0=9F=94=A7=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#5?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * rename: user_general_sign_mapper -> service * refactor: is_sign_up_allowed() 메서드 general sign service로 이동 * rename: user_oauth_sign_mapper -> service * refactor: is_sign_up_allowed() 메서드 oauth sign service로 이동 * fix: user sign mapper 제거 * refactor: 일반 회원가입 서비스 조건문 메서드로 분리 * fix: 일반 회원가입 시 log 추가 * refactor: user 생성 코드를 dto로 이동 * fix: oauth 회원가입 시 log 추가 * fix: log 정보 수정 * refactor: oauth 매핑 메서드 분리 * rename: 도메인 phone verification -> phone code * rename: phone_verification_mapper -> service * refactor: sms controller에서 service가 아닌 usecase를 의존하도록 수정 * style: 회원가입 시 인증 코드 확인 -> 인증 코드 제거 -> 자원 접근 순으로 변경 * style: 소셜 회원가입 시 인증 코드 확인 -> 인증 코드 제거 -> 자원 접근 순으로 변경 * refactor: 소셜 회원가입 시 조건문 메서드 명시적으로 변경 * feat: 사용자 sync 목적 dto 클래스 정의 * rename: user sync dto 주석 추가 * feat: user sync dto 편의용 도우미 메서드 생성 * refactor: oauth_sign_service 내 pair -> dto로 변환 * refactor: general_sign_service 내 pair -> dto로 변환 * rename: user sync dto의 is sign up allowed 필드 주석 추가 * refactor: auth_use_case pair -> dto * refactor: oauth_use_case pair -> dto * refactor: oauth signup 유효성 검사 리팩토링 * test: test에서 pair -> user_sync_dto 응답으로 변경 * rename: user_oauth_sign_service is_sign_up_allowed() 로그 추가 * fix: pk로 user select 하도록 하여 query 횟수 절약 --- .../apis/auth/controller/SmsController.java | 6 +- .../pennyway/api/apis/auth/dto/SignUpReq.java | 9 ++ .../api/apis/auth/dto/UserSyncDto.java | 31 +++++ .../apis/auth/mapper/UserOauthSignMapper.java | 60 --------- .../api/apis/auth/mapper/UserSyncMapper.java | 72 ----------- .../PhoneVerificationService.java} | 26 ++-- .../UserGeneralSignService.java} | 57 +++++++-- .../auth/service/UserOauthSignService.java | 86 +++++++++++++ .../api/apis/auth/usecase/AuthUseCase.java | 45 ++++--- .../api/apis/auth/usecase/OauthUseCase.java | 66 ++++++---- .../api/apis/auth/usecase/SmsUseCase.java | 20 +++ .../api/common/query/VerificationType.java | 12 +- .../AuthControllerIntegrationTest.java | 30 ++--- .../OAuthControllerIntegrationTest.java | 32 ++--- .../mapper/UserGeneralSignMapperTest.java | 73 ----------- .../apis/auth/mapper/UserSyncMapperTest.java | 71 ----------- .../service/UserGeneralSignServiceTest.java | 120 ++++++++++++++++++ ...icationType.java => PhoneCodeKeyType.java} | 4 +- ...pository.java => PhoneCodeRepository.java} | 12 +- ...tionService.java => PhoneCodeService.java} | 28 ++-- .../domains/oauth/service/OauthService.java | 4 +- .../redis/phone/PhoneValidationDaoTest.java | 28 ++-- 22 files changed, 466 insertions(+), 426 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/UserSyncDto.java delete mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java delete mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java rename pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/{mapper/PhoneVerificationMapper.java => service/PhoneVerificationService.java} (75%) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/{mapper/UserGeneralSignMapper.java => service/UserGeneralSignService.java} (54%) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/SmsUseCase.java delete mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapperTest.java delete mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapperTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignServiceTest.java rename pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/{PhoneVerificationType.java => PhoneCodeKeyType.java} (83%) rename pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/{PhoneVerificationRepository.java => PhoneCodeRepository.java} (66%) rename pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/{PhoneVerificationService.java => PhoneCodeService.java} (59%) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/SmsController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/SmsController.java index ead329d5f..aac0b7d16 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/SmsController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/SmsController.java @@ -2,7 +2,7 @@ import kr.co.pennyway.api.apis.auth.api.SmsApi; import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; -import kr.co.pennyway.api.apis.auth.mapper.PhoneVerificationMapper; +import kr.co.pennyway.api.apis.auth.usecase.SmsUseCase; import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; import kr.co.pennyway.api.common.exception.PhoneVerificationException; import kr.co.pennyway.api.common.query.VerificationType; @@ -20,7 +20,7 @@ @RequiredArgsConstructor @RequestMapping("/v1/phone") public class SmsController implements SmsApi { - private final PhoneVerificationMapper phoneVerificationMapper; + private final SmsUseCase smsUseCase; @Override @PostMapping("") @@ -29,6 +29,6 @@ public ResponseEntity sendCode(@RequestParam(value = "type") VerificationType if (type.equals(VerificationType.OAUTH) && provider == null) { throw new PhoneVerificationException(PhoneVerificationErrorCode.PROVIDER_IS_REQUIRED); } - return ResponseEntity.ok(SuccessResponse.from("sms", phoneVerificationMapper.sendCode(request, type.toPhoneVerificationType(provider)))); + return ResponseEntity.ok(SuccessResponse.from("sms", smsUseCase.sendCode(request, type, provider))); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java index 545c30c6e..471a56b6c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java @@ -41,6 +41,15 @@ public String password() { } public record OauthInfo(String idToken, String name, String username, String phone, String code) { + public User toUser() { + return User.builder() + .username(username) + .name(name) + .phone(phone) + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .build(); + } } @Schema(name = "signUpReqGeneral", title = "일반 회원가입 요청 DTO") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/UserSyncDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/UserSyncDto.java new file mode 100644 index 000000000..3867f5b90 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/UserSyncDto.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.api.apis.auth.dto; + +/** + * 전화번호 검증 후, 시나리오 분기 정보를 위한 DTO + */ +public record UserSyncDto( + /* isSignUpAllowed가 false인 경우, 반드시 예외를 던지도록 처리해야 한다. */ + boolean isSignUpAllowed, + boolean isExistAccount, + Long userId, + String username +) { + /** + * @param isSignUpAllowed boolean : 회원가입 시나리오 가능 여부 (true: 회원가입 혹은 계정 연동 가능, false: 불가능) + * @param isExistAccount boolean : 이미 존재하는 계정 여부 + * @param username boolean : 사용자명 (isExistAccount이 true인 경우에만 존재) + */ + public static UserSyncDto of(boolean isSignUpAllowed, boolean isExistAccount, Long userId, String username) { + return new UserSyncDto(isSignUpAllowed, isExistAccount, userId, username); + } + + /** + * 이미 회원이 존재하는 경우 사용하는 편의용 메서드.
+ * 내부에서 {@link UserSyncDto#of(boolean, boolean, Long, String)}를 호출한다. + * + * @param username String : 사용자명 + */ + public static UserSyncDto abort(Long userId, String username) { + return UserSyncDto.of(false, true, userId, username); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java deleted file mode 100644 index 9ad3d4c99..000000000 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java +++ /dev/null @@ -1,60 +0,0 @@ -package kr.co.pennyway.api.apis.auth.mapper; - -import kr.co.pennyway.api.apis.auth.dto.SignUpReq; -import kr.co.pennyway.common.annotation.Mapper; -import kr.co.pennyway.domain.domains.oauth.domain.Oauth; -import kr.co.pennyway.domain.domains.oauth.service.OauthService; -import kr.co.pennyway.domain.domains.oauth.type.Provider; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; -import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; -import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; -import kr.co.pennyway.domain.domains.user.type.Role; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.tuple.Pair; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -@Mapper -@RequiredArgsConstructor -public class UserOauthSignMapper { - private final UserService userService; - private final OauthService oauthService; - - @Transactional(readOnly = true) - public User readUser(String oauthId, Provider provider) { - Optional oauth = oauthService.readOauthByOauthIdAndProvider(oauthId, provider); - - return oauth.map(Oauth::getUser).orElse(null); - } - - /** - * 기존 계정이 존재하면 Oauth 계정을 생성하여 연동하고, 존재하지 않으면 새로운 계정을 생성한다. - * - * @param request {@link SignUpReq.OauthInfo} - */ - @Transactional - public User saveUser(SignUpReq.OauthInfo request, Pair isSignUpUser, Provider provider, String oauthId) { - User user; - - if (isSignUpUser.getLeft().equals(Boolean.TRUE)) { - user = userService.readUserByUsername(isSignUpUser.getRight()) - .orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); - } else { - user = User.builder() - .username(request.username()) - .name(request.name()) - .phone(request.phone()) - .role(Role.USER) - .profileVisibility(ProfileVisibility.PUBLIC).build(); - userService.createUser(user); - } - - Oauth oauth = Oauth.of(provider, oauthId, user); - oauthService.createOauth(oauth); - - return user; - } -} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java deleted file mode 100644 index c90183720..000000000 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java +++ /dev/null @@ -1,72 +0,0 @@ -package kr.co.pennyway.api.apis.auth.mapper; - -import kr.co.pennyway.common.annotation.Mapper; -import kr.co.pennyway.domain.domains.oauth.service.OauthService; -import kr.co.pennyway.domain.domains.oauth.type.Provider; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.service.UserService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -/** - * 일반 회원가입, Oauth 회원가입 시나리오를 제어하여 유저 정보를 동기화하는 클래스 - * - * @author YANG JAESEO - */ -@Slf4j -@Mapper -@RequiredArgsConstructor -public class UserSyncMapper { - private final UserService userService; - private final OauthService oauthService; - - /** - * 일반 회원가입이 가능한 유저인지 확인 - * - * @return Pair : 이미 가입된 회원인지 여부 (TRUE: 가입되지 않은 회원, FALSE: 가입된 회원), 가입된 회원인 경우 회원 - * ID 반환. 단, 이미 일반 회원가입을 한 유저인 경우에는 null을 반환한다. - */ - @Transactional(readOnly = true) - public Pair isGeneralSignUpAllowed(String phone) { - Optional user = userService.readUserByPhone(phone); - - if (user.isEmpty()) { - log.info("회원가입 이력이 없는 사용자입니다. phone: {}", phone); - return Pair.of(Boolean.FALSE, null); - } - - if (user.get().getPassword() != null) { - log.warn("이미 회원가입된 사용자입니다. phone: {}", phone); - return null; - } - - return Pair.of(Boolean.TRUE, user.get().getUsername()); - } - - /** - * Oauth 회원가입 시나리오를 결정한다. - * - * @return Pair : 이미 가입된 회원인지 여부 (TRUE: 계정 연동, FALSE: 소셜 회원가입) - * 단, 이미 동일한 Provider로 가입된 회원이 있는 경우에는 해당 회원의 ID를 반환한다. - */ - @Transactional(readOnly = true) - public Pair isOauthSignUpAllowed(Provider provider, String phone) { - Optional user = userService.readUserByPhone(phone); - - if (user.isEmpty()) { - log.info("회원가입 이력이 없는 사용자입니다. phone: {}", phone); - return Pair.of(Boolean.FALSE, null); - } - - if (oauthService.isExistOauthAccount(user.get().getId(), provider)) { - log.info("이미 동일한 Provider로 가입된 사용자입니다. phone: {}, provider: {}", phone, provider); - return null; - } - - return Pair.of(Boolean.TRUE, user.get().getUsername()); - } -} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/PhoneVerificationMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/PhoneVerificationService.java similarity index 75% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/PhoneVerificationMapper.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/PhoneVerificationService.java index 1b26768e4..4a3098356 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/PhoneVerificationMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/PhoneVerificationService.java @@ -1,36 +1,36 @@ -package kr.co.pennyway.api.apis.auth.mapper; +package kr.co.pennyway.api.apis.auth.service; import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; import kr.co.pennyway.api.common.exception.PhoneVerificationException; -import kr.co.pennyway.common.annotation.Mapper; -import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; -import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; import kr.co.pennyway.infra.common.event.PushCodeEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.util.concurrent.ThreadLocalRandom; @Slf4j -@Mapper +@Service @RequiredArgsConstructor -public class PhoneVerificationMapper { - private final PhoneVerificationService phoneVerificationService; +public class PhoneVerificationService { + private final PhoneCodeService phoneCodeService; private final ApplicationEventPublisher eventPublisher; /** * 휴대폰 번호로 인증 코드를 발송하고 캐싱한다. (5분간 유효) * * @param request {@link PhoneVerificationDto.PushCodeReq} - * @param codeType {@link PhoneVerificationType} + * @param codeType {@link PhoneCodeKeyType} * @return {@link PhoneVerificationDto.PushCodeRes} */ - public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeReq request, PhoneVerificationType codeType) { + public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeReq request, PhoneCodeKeyType codeType) { String code = issueVerificationCode(); - LocalDateTime expiresAt = phoneVerificationService.create(request.phone(), code, codeType); + LocalDateTime expiresAt = phoneCodeService.create(request.phone(), code, codeType); eventPublisher.publishEvent(PushCodeEvent.of(request.phone(), code)); @@ -41,14 +41,14 @@ public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeRe * 휴대폰 번호로 인증 코드를 확인한다. * * @param request {@link PhoneVerificationDto.VerifyCodeReq} - * @param codeType {@link PhoneVerificationType} + * @param codeType {@link PhoneCodeKeyType} * @return Boolean : 인증 코드가 유효한지 여부 (TRUE: 유효, 실패하는 경우 예외가 발생하므로 FALSE가 반환되지 않음) * @throws PhoneVerificationException : 전화번호가 만료되었거나 유효하지 않은 경우(EXPIRED_OR_INVALID_PHONE), 인증 코드가 유효하지 않은 경우(IS_NOT_VALID_CODE) */ - public Boolean isValidCode(PhoneVerificationDto.VerifyCodeReq request, PhoneVerificationType codeType) { + public Boolean isValidCode(PhoneVerificationDto.VerifyCodeReq request, PhoneCodeKeyType codeType) { String expectedCode; try { - expectedCode = phoneVerificationService.readByPhone(request.phone(), codeType); + expectedCode = phoneCodeService.readByPhone(request.phone(), codeType); } catch (IllegalArgumentException e) { throw new PhoneVerificationException(PhoneVerificationErrorCode.EXPIRED_OR_INVALID_PHONE); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java similarity index 54% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapper.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java index 0ac3ea083..2f4049ce4 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java @@ -1,15 +1,15 @@ -package kr.co.pennyway.api.apis.auth.mapper; +package kr.co.pennyway.api.apis.auth.service; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; -import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.api.apis.auth.dto.UserSyncDto; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @@ -20,27 +20,52 @@ * @author YANG JAESEO */ @Slf4j -@Mapper +@Service @RequiredArgsConstructor -public class UserGeneralSignMapper { +public class UserGeneralSignService { private final UserService userService; private final PasswordEncoder bCryptPasswordEncoder; + /** + * 일반 회원가입이 가능한 유저인지 확인 + * + * @return {@link UserSyncDto} + */ + @Transactional(readOnly = true) + public UserSyncDto isSignUpAllowed(String phone) { + Optional user = userService.readUserByPhone(phone); + + if (!isExistUser(user)) { + log.info("회원가입 이력이 없는 사용자입니다. phone: {}", phone); + return UserSyncDto.of(true, false, null, null); + } + + if (isGeneralSignUpUser(user.get())) { + log.warn("이미 회원가입된 사용자입니다. user: {}", user.get()); + return UserSyncDto.abort(user.get().getId(), user.get().getUsername()); + } + + log.info("소셜 회원가입 사용자입니다. user: {}", user.get()); + return UserSyncDto.of(true, true, user.get().getId(), user.get().getUsername()); + } + /** * 일반 회원가입이라면 새롭게 유저를 생성하고, 기존 Oauth 유저라면 비밀번호를 업데이트한다. * * @param request {@link SignUpReq.Info} */ @Transactional - public User saveUserWithEncryptedPassword(SignUpReq.Info request, Pair isOauthUser) { + public User saveUserWithEncryptedPassword(SignUpReq.Info request, UserSyncDto userSync) { User user; - if (isOauthUser.getLeft().equals(Boolean.TRUE)) { - user = userService.readUserByUsername(isOauthUser.getRight()) + if (userSync.isExistAccount()) { + log.info("기존 Oauth 회원입니다. username: {}", userSync.username()); + user = userService.readUser(userSync.userId()) .orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); user.updatePassword(request.password(bCryptPasswordEncoder)); } else { + log.info("새로운 회원입니다. username: {}", request.username()); user = userService.createUser(request.toEntity(bCryptPasswordEncoder)); } @@ -56,16 +81,28 @@ public User saveUserWithEncryptedPassword(SignUpReq.Info request, Pair user = userService.readUserByUsername(username); - if (user.isEmpty()) { + if (!isExistUser(user)) { log.warn("해당 유저가 존재하지 않습니다. username: {}", username); throw new UserErrorException(UserErrorCode.INVALID_USERNAME_OR_PASSWORD); } - if (!bCryptPasswordEncoder.matches(password, user.get().getPassword())) { + if (!isValidPassword(password, user.get())) { log.warn("비밀번호가 일치하지 않습니다. username: {}", username); throw new UserErrorException(UserErrorCode.INVALID_USERNAME_OR_PASSWORD); } return user.get(); } + + private boolean isExistUser(Optional user) { + return user.isPresent(); + } + + private boolean isGeneralSignUpUser(User user) { + return user.getPassword() != null; + } + + private boolean isValidPassword(String password, User user) { + return bCryptPasswordEncoder.matches(password, user.getPassword()); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java new file mode 100644 index 000000000..b80eec441 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java @@ -0,0 +1,86 @@ +package kr.co.pennyway.api.apis.auth.service; + +import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.api.apis.auth.dto.UserSyncDto; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserOauthSignService { + private final UserService userService; + private final OauthService oauthService; + + @Transactional(readOnly = true) + public User readUser(String oauthId, Provider provider) { + Optional oauth = oauthService.readOauthByOauthIdAndProvider(oauthId, provider); + + return oauth.map(Oauth::getUser).orElse(null); + } + + /** + * Oauth 회원가입 시나리오를 결정한다. + * + * @return {@link UserSyncDto} : 이미 가입된 회원인지 여부를 담은 DTO. + * 단, 이미 동일한 Provider로 가입된 회원이 있는 경우에는 해당 회원의 ID를 반환한다. + */ + @Transactional(readOnly = true) + public UserSyncDto isSignUpAllowed(Provider provider, String phone) { + Optional user = userService.readUserByPhone(phone); + + if (user.isEmpty()) { + log.info("회원가입 이력이 없는 사용자입니다. phone: {}", phone); + return UserSyncDto.of(true, false, null, null); + } + + if (oauthService.isExistOauthAccount(user.get().getId(), provider)) { + log.info("이미 동일한 Provider로 가입된 사용자입니다. phone: {}, provider: {}", phone, provider); + return UserSyncDto.abort(user.get().getId(), user.get().getUsername()); + } + + log.info("소셜 회원가입 사용자입니다. user: {}", user.get()); + return UserSyncDto.of(true, true, user.get().getId(), user.get().getUsername()); + } + + /** + * 기존 계정이 존재하면 Oauth 계정을 생성하여 연동하고, 존재하지 않으면 새로운 계정을 생성한다. + * + * @param request {@link SignUpReq.OauthInfo} + */ + @Transactional + public User saveUser(SignUpReq.OauthInfo request, UserSyncDto userSync, Provider provider, String oauthId) { + User user; + + if (userSync.isExistAccount()) { + log.info("기존 계정에 연동합니다. username: {}", userSync.username()); + user = userService.readUser(userSync.userId()) + .orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + } else { + log.info("새로운 계정을 생성합니다. username: {}", request.username()); + user = request.toUser(); + userService.createUser(user); + } + + Oauth oauth = mappingOauthToUser(user, provider, oauthId); + log.info("연동된 Oauth 정보 : {}", oauth); + + return user; + } + + private Oauth mappingOauthToUser(User user, Provider provider, String oauthId) { + Oauth oauth = Oauth.of(provider, oauthId, user); + return oauthService.createOauth(oauth); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java index 614f2e275..102436380 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java @@ -3,14 +3,14 @@ import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.dto.SignInReq; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.api.apis.auth.dto.UserSyncDto; import kr.co.pennyway.api.apis.auth.helper.JwtAuthHelper; -import kr.co.pennyway.api.apis.auth.mapper.PhoneVerificationMapper; -import kr.co.pennyway.api.apis.auth.mapper.UserGeneralSignMapper; -import kr.co.pennyway.api.apis.auth.mapper.UserSyncMapper; +import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService; +import kr.co.pennyway.api.apis.auth.service.UserGeneralSignService; import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.common.annotation.UseCase; -import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; -import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; @@ -23,40 +23,39 @@ @UseCase @RequiredArgsConstructor public class AuthUseCase { - private final UserSyncMapper userSyncMapper; - private final UserGeneralSignMapper userGeneralSignMapper; + private final UserGeneralSignService userGeneralSignService; private final JwtAuthHelper jwtAuthHelper; - private final PhoneVerificationMapper phoneVerificationMapper; private final PhoneVerificationService phoneVerificationService; + private final PhoneCodeService phoneCodeService; public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeReq request) { - return phoneVerificationMapper.sendCode(request, PhoneVerificationType.SIGN_UP); + return phoneVerificationService.sendCode(request, PhoneCodeKeyType.SIGN_UP); } public PhoneVerificationDto.VerifyCodeRes verifyCode(PhoneVerificationDto.VerifyCodeReq request) { - Boolean isValidCode = phoneVerificationMapper.isValidCode(request, PhoneVerificationType.SIGN_UP); - Pair isOauthUser = checkOauthUserNotGeneralSignUp(request.phone()); + Boolean isValidCode = phoneVerificationService.isValidCode(request, PhoneCodeKeyType.SIGN_UP); + UserSyncDto userSync = checkOauthUserNotGeneralSignUp(request.phone()); - phoneVerificationService.extendTimeToLeave(request.phone(), PhoneVerificationType.SIGN_UP); + phoneCodeService.extendTimeToLeave(request.phone(), PhoneCodeKeyType.SIGN_UP); - return PhoneVerificationDto.VerifyCodeRes.valueOfGeneral(isValidCode, isOauthUser.getLeft(), isOauthUser.getRight()); + return PhoneVerificationDto.VerifyCodeRes.valueOfGeneral(isValidCode, userSync.isExistAccount(), userSync.username()); } @Transactional public Pair signUp(SignUpReq.Info request) { - phoneVerificationMapper.isValidCode(PhoneVerificationDto.VerifyCodeReq.from(request), PhoneVerificationType.SIGN_UP); - Pair isOauthUser = checkOauthUserNotGeneralSignUp(request.phone()); + phoneVerificationService.isValidCode(PhoneVerificationDto.VerifyCodeReq.from(request), PhoneCodeKeyType.SIGN_UP); + phoneCodeService.delete(request.phone(), PhoneCodeKeyType.SIGN_UP); - User user = userGeneralSignMapper.saveUserWithEncryptedPassword(request, isOauthUser); - phoneVerificationService.delete(request.phone(), PhoneVerificationType.SIGN_UP); + UserSyncDto userSync = checkOauthUserNotGeneralSignUp(request.phone()); + User user = userGeneralSignService.saveUserWithEncryptedPassword(request, userSync); return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); } @Transactional(readOnly = true) public Pair signIn(SignInReq.General request) { - User user = userGeneralSignMapper.readUserIfValid(request.username(), request.password()); + User user = userGeneralSignService.readUserIfValid(request.username(), request.password()); return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); } @@ -65,14 +64,14 @@ public Pair refresh(String refreshToken) { return jwtAuthHelper.refresh(refreshToken); } - private Pair checkOauthUserNotGeneralSignUp(String phone) { - Pair isGeneralSignUpAllowed = userSyncMapper.isGeneralSignUpAllowed(phone); + private UserSyncDto checkOauthUserNotGeneralSignUp(String phone) { + UserSyncDto userSync = userGeneralSignService.isSignUpAllowed(phone); - if (isGeneralSignUpAllowed == null) { - phoneVerificationService.delete(phone, PhoneVerificationType.SIGN_UP); + if (!userSync.isSignUpAllowed()) { + phoneCodeService.delete(phone, PhoneCodeKeyType.SIGN_UP); throw new UserErrorException(UserErrorCode.ALREADY_SIGNUP); } - return isGeneralSignUpAllowed; + return userSync; } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java index 29d982c53..366df2f75 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java @@ -3,15 +3,15 @@ import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.dto.SignInReq; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.api.apis.auth.dto.UserSyncDto; import kr.co.pennyway.api.apis.auth.helper.JwtAuthHelper; import kr.co.pennyway.api.apis.auth.helper.OauthOidcHelper; -import kr.co.pennyway.api.apis.auth.mapper.PhoneVerificationMapper; -import kr.co.pennyway.api.apis.auth.mapper.UserOauthSignMapper; -import kr.co.pennyway.api.apis.auth.mapper.UserSyncMapper; +import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService; +import kr.co.pennyway.api.apis.auth.service.UserOauthSignService; import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.common.annotation.UseCase; -import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; -import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; import kr.co.pennyway.domain.domains.oauth.exception.OauthException; import kr.co.pennyway.domain.domains.oauth.type.Provider; @@ -27,11 +27,10 @@ @RequiredArgsConstructor public class OauthUseCase { private final OauthOidcHelper oauthOidcHelper; - private final PhoneVerificationMapper phoneVerificationMapper; private final PhoneVerificationService phoneVerificationService; + private final PhoneCodeService phoneCodeService; private final JwtAuthHelper jwtAuthHelper; - private final UserOauthSignMapper userOauthSignMapper; - private final UserSyncMapper userSyncMapper; + private final UserOauthSignService userOauthSignService; public Pair signIn(Provider provider, SignInReq.Oauth request) { OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken()); @@ -39,38 +38,39 @@ public Pair signIn(Provider provider, SignInReq.Oauth request) { if (!request.oauthId().equals(payload.sub())) throw new OauthException(OauthErrorCode.NOT_MATCHED_OAUTH_ID); - User user = userOauthSignMapper.readUser(request.oauthId(), provider); + User user = userOauthSignService.readUser(request.oauthId(), provider); return (user != null) ? Pair.of(user.getId(), jwtAuthHelper.createToken(user)) : Pair.of(-1L, null); } public PhoneVerificationDto.PushCodeRes sendCode(Provider provider, PhoneVerificationDto.PushCodeReq request) { - return phoneVerificationMapper.sendCode(request, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + return phoneVerificationService.sendCode(request, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); } @Transactional(readOnly = true) public PhoneVerificationDto.VerifyCodeRes verifyCode(Provider provider, PhoneVerificationDto.VerifyCodeReq request) { - Boolean isValidCode = phoneVerificationMapper.isValidCode(request, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); - Pair isSignUpUser = checkSignUpUserNotOauthByProvider(provider, request.phone()); + Boolean isValidCode = phoneVerificationService.isValidCode(request, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + UserSyncDto userSync = checkSignUpUserNotOauthByProvider(provider, request.phone()); - phoneVerificationService.extendTimeToLeave(request.phone(), PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.extendTimeToLeave(request.phone(), PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - return PhoneVerificationDto.VerifyCodeRes.valueOfOauth(isValidCode, isSignUpUser.getLeft(), isSignUpUser.getRight()); + return PhoneVerificationDto.VerifyCodeRes.valueOfOauth(isValidCode, userSync.isExistAccount(), userSync.username()); } @Transactional public Pair signUp(Provider provider, SignUpReq.OauthInfo request) { - phoneVerificationMapper.isValidCode(PhoneVerificationDto.VerifyCodeReq.from(request), PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); - Pair isSignUpUser = checkSignUpUserNotOauthByProvider(provider, request.phone()); + phoneVerificationService.isValidCode(PhoneVerificationDto.VerifyCodeReq.from(request), PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.delete(request.phone(), PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - if (isSignUpUser.getLeft().equals(Boolean.FALSE) && request.username() == null) - throw new OauthException(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST); - if (isSignUpUser.getLeft().equals(Boolean.TRUE) && request.username() != null) + UserSyncDto userSync = checkSignUpUserNotOauthByProvider(provider, request.phone()); + + if (isValidRequestScenario(userSync, request)) { + log.warn("유효한 소셜 회원가입 요청 플로우가 아닙니다."); throw new OauthException(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST); + } OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken()); - User user = userOauthSignMapper.saveUser(request, isSignUpUser, provider, payload.sub()); - phoneVerificationService.delete(request.phone(), PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + User user = userOauthSignService.saveUser(request, userSync, provider, payload.sub()); return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); } @@ -78,14 +78,28 @@ public Pair signUp(Provider provider, SignUpReq.OauthInfo request) { /** * Oauth 회원가입 진행 도중, Provider로 가입한 사용자인지 지속적으로 검증하기 위한 메서드 */ - private Pair checkSignUpUserNotOauthByProvider(Provider provider, String phone) { - Pair isOauthSignUpAllowed = userSyncMapper.isOauthSignUpAllowed(provider, phone); + private UserSyncDto checkSignUpUserNotOauthByProvider(Provider provider, String phone) { + UserSyncDto userSync = userOauthSignService.isSignUpAllowed(provider, phone); - if (isOauthSignUpAllowed == null) { - phoneVerificationService.delete(phone, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + if (!userSync.isSignUpAllowed()) { + phoneCodeService.delete(phone, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); throw new OauthException(OauthErrorCode.ALREADY_SIGNUP_OAUTH); } - return isOauthSignUpAllowed; + return userSync; + } + + /** + * Oauth 회원가입 요청 시나리오가 유효한지 확인하는 메서드
+ * - 회원가입 이력이 없는 사용자는 Oauth 회원가입 요청만 가능하다.
+ * - 이미 회원가입된 사용자(같은 Provider 소셜 회원가입이 이력이 없어야 함)는 계정 연동 요청만 가능하다. + */ + private boolean isValidRequestScenario(UserSyncDto userSync, SignUpReq.OauthInfo request) { + return (!userSync.isExistAccount() && !isOauthSyncRequest(request)) || + (userSync.isExistAccount() && isOauthSyncRequest(request)); + } + + private boolean isOauthSyncRequest(SignUpReq.OauthInfo request) { + return request.username() != null; } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/SmsUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/SmsUseCase.java new file mode 100644 index 000000000..ac910c710 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/SmsUseCase.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.api.apis.auth.usecase; + +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService; +import kr.co.pennyway.api.common.query.VerificationType; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class SmsUseCase { + private final PhoneVerificationService phoneVerificationService; + + public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeReq request, VerificationType type, Provider provider) { + return phoneVerificationService.sendCode(request, type.toPhoneVerificationType(provider)); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java index 88a34e5af..c3de0bfbf 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java @@ -1,6 +1,6 @@ package kr.co.pennyway.api.common.query; -import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; import kr.co.pennyway.domain.domains.oauth.type.Provider; public enum VerificationType { @@ -15,12 +15,12 @@ public enum VerificationType { this.type = type; } - public PhoneVerificationType toPhoneVerificationType(Provider provider) { + public PhoneCodeKeyType toPhoneVerificationType(Provider provider) { return switch (this) { - case OAUTH -> PhoneVerificationType.getOauthSignUpTypeByProvider(provider); - case USERNAME -> PhoneVerificationType.FIND_USERNAME; - case PASSWORD -> PhoneVerificationType.FIND_PASSWORD; - default -> PhoneVerificationType.SIGN_UP; + case OAUTH -> PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider); + case USERNAME -> PhoneCodeKeyType.FIND_USERNAME; + case PASSWORD -> PhoneCodeKeyType.FIND_PASSWORD; + default -> PhoneCodeKeyType.SIGN_UP; }; } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java index 913b01e4a..a33039d08 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java @@ -6,8 +6,8 @@ import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; -import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; -import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.oauth.type.Provider; @@ -52,7 +52,7 @@ public class AuthControllerIntegrationTest extends ExternalApiDBTestConfig { @Autowired private ObjectMapper objectMapper; @SpyBean - private PhoneVerificationService phoneVerificationService; + private PhoneCodeService phoneCodeService; @SpyBean private UserService userService; @Autowired @@ -109,7 +109,7 @@ class GeneralSignUpPhoneVerifyTest { @DisplayName("일반 회원가입 이력이 있는 경우 400 BAD_REQUEST를 반환하고, 인증 코드 캐시 데이터가 제거된다.") void generalSignUpFailBecauseAlreadyGeneralSignUp() throws Exception { // given - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.SIGN_UP); given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.of(createGeneralSignedUser())); // when @@ -121,7 +121,7 @@ void generalSignUpFailBecauseAlreadyGeneralSignUp() throws Exception { .andExpect(jsonPath("$.code").value(UserErrorCode.ALREADY_SIGNUP.causedBy().getCode())) .andExpect(jsonPath("$.message").value(UserErrorCode.ALREADY_SIGNUP.getExplainError())) .andDo(print()); - assertThrows(IllegalArgumentException.class, () -> phoneVerificationService.readByPhone(expectedPhone, PhoneVerificationType.SIGN_UP)); + assertThrows(IllegalArgumentException.class, () -> phoneCodeService.readByPhone(expectedPhone, PhoneCodeKeyType.SIGN_UP)); } @Test @@ -129,7 +129,7 @@ void generalSignUpFailBecauseAlreadyGeneralSignUp() throws Exception { @DisplayName("인증 번호가 일치하지 않는 경우 401 UNAUTHORIZED를 반환한다.") void generalSignUpFailBecauseInvalidCode() throws Exception { // given - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.SIGN_UP); given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.empty()); String invalidCode = "111111"; @@ -167,7 +167,7 @@ void generalSignUpFailBecauseNotFound() throws Exception { @DisplayName("소셜 로그인 이력이 없는 경우, 200 OK를 반환하고 oauth 필드가 false이다.") void generalSignUpSuccess() throws Exception { // given - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.SIGN_UP); given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.empty()); // when @@ -186,7 +186,7 @@ void generalSignUpSuccess() throws Exception { @DisplayName("소셜 로그인 이력이 있는 경우, 200 OK를 반환하고 oauth 필드가 true고 username 필드가 존재한다.") void generalSignUpSuccessWithOauth() throws Exception { // given - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.SIGN_UP); given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.of(createOauthSignedUser())); // when @@ -203,7 +203,7 @@ void generalSignUpSuccessWithOauth() throws Exception { @AfterEach void tearDown() { - phoneVerificationService.delete(expectedPhone, PhoneVerificationType.SIGN_UP); + phoneCodeService.delete(expectedPhone, PhoneCodeKeyType.SIGN_UP); } private ResultActions performPhoneVerificationRequest(String expectedCode) throws Exception { @@ -225,7 +225,7 @@ class GeneralSignUpTest { @DisplayName("인증번호가 일치하지 않는 경우 401 UNAUTHORIZED를 반환한다.") void generalSignUpFailBecauseInvalidCode() throws Exception { // given - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.SIGN_UP); String invalidCode = "111111"; // when @@ -245,7 +245,7 @@ void generalSignUpFailBecauseInvalidCode() throws Exception { @DisplayName("인증번호가 일치하는 경우 200 OK를 반환하고, 회원가입이 완료된다.") void generalSignUpSuccess() throws Exception { // given - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.SIGN_UP); given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.empty()); // when @@ -271,7 +271,7 @@ private ResultActions performGeneralSignUpRequest(String code) throws Exception @AfterEach void tearDown() { - phoneVerificationService.delete(expectedPhone, PhoneVerificationType.SIGN_UP); + phoneCodeService.delete(expectedPhone, PhoneCodeKeyType.SIGN_UP); } } @@ -284,7 +284,7 @@ class SyncWithOauthSignUpTest { @DisplayName("인증번호가 일치하지 않는 경우 401 UNAUTHORIZED를 반환한다.") void syncWithOauthSignUpFailBecauseInvalidCode() throws Exception { // given - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.SIGN_UP); String invalidCode = "111111"; // when @@ -304,7 +304,7 @@ void syncWithOauthSignUpFailBecauseInvalidCode() throws Exception { @DisplayName("인증번호가 일치하는 경우 200 OK를 반환하고, 기존의 소셜 계정과 연동된 회원가입이 완료된다.") void syncWithOauthSignUpSuccess() throws Exception { // given - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.SIGN_UP); User user = createOauthSignedUser(); userService.createUser(user); oauthService.createOauth(createOauthAccount(user)); @@ -334,7 +334,7 @@ private ResultActions performSyncWithOauthSignUpRequest(String code) throws Exce @AfterEach void tearDown() { - phoneVerificationService.delete(expectedPhone, PhoneVerificationType.SIGN_UP); + phoneCodeService.delete(expectedPhone, PhoneCodeKeyType.SIGN_UP); } } } \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java index 09acb9e9c..05698c011 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java @@ -8,8 +8,8 @@ import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; -import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; -import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; import kr.co.pennyway.domain.domains.oauth.service.OauthService; @@ -69,7 +69,7 @@ public class OAuthControllerIntegrationTest extends ExternalApiDBTestConfig { @MockBean private OauthOidcHelper oauthOidcHelper; @SpyBean - private PhoneVerificationService phoneVerificationService; + private PhoneCodeService phoneCodeService; @Autowired private UserService userService; @Autowired @@ -269,7 +269,7 @@ void signUpWithGeneralSignedUser() throws Exception { User user = createGeneralSignedUser(); userService.createUser(user); - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); // when ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); @@ -296,7 +296,7 @@ void signUpWithDifferentProvider() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(Provider.KAKAO)); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(Provider.KAKAO)); // when ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); @@ -318,7 +318,7 @@ void signUpWithDifferentProvider() throws Exception { void signUpWithNoSignedUser() throws Exception { // given Provider provider = Provider.KAKAO; - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); // when ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); @@ -345,7 +345,7 @@ void signUpWithSameProvider() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); // when ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); @@ -364,7 +364,7 @@ void signUpWithSameProvider() throws Exception { @DisplayName("인증 코드를 요청한 provider와 다른 provider로 인증 코드를 입력하면 404 에러가 발생한다.") void signUpWithDifferentProviderCode() throws Exception { // given - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(Provider.KAKAO)); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(Provider.KAKAO)); // when ResultActions result = performOauthSignUpPhoneVerification(Provider.GOOGLE, expectedCode); @@ -385,7 +385,7 @@ void signUpWithDifferentProviderCode() throws Exception { void signUpWithInvalidCode() throws Exception { // given Provider provider = Provider.KAKAO; - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); // when ResultActions result = performOauthSignUpPhoneVerification(provider, "123457"); @@ -421,7 +421,7 @@ void signUpWithGeneralSignedUser() throws Exception { User user = createGeneralSignedUser(); userService.createUser(user); - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when @@ -451,7 +451,7 @@ void signUpWithDifferentProvider() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when @@ -476,7 +476,7 @@ void signUpWithDifferentProvider() throws Exception { void signUpWithNoSignedUser() throws Exception { // given Provider provider = Provider.KAKAO; - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when @@ -502,7 +502,7 @@ void signUpWithSameProvider() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when @@ -536,7 +536,7 @@ class OauthSignUpTest { void signUpWithNoSignedUser() throws Exception { // given Provider provider = Provider.KAKAO; - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when @@ -564,7 +564,7 @@ void signUpWithGeneralSignedUser() throws Exception { User user = createGeneralSignedUser(); userService.createUser(user); - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when @@ -590,7 +590,7 @@ void signUpWithOauthSignedUser() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); - phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapperTest.java deleted file mode 100644 index f59202b2a..000000000 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserGeneralSignMapperTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package kr.co.pennyway.api.apis.auth.mapper; - -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; -import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.crypto.password.PasswordEncoder; - -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.BDDMockito.given; - -@ExtendWith(MockitoExtension.class) -public class UserGeneralSignMapperTest { - private UserGeneralSignMapper userGeneralSignMapper; - @Mock - private UserService userService; - @Mock - private PasswordEncoder passwordEncoder; - - @BeforeEach - void setUp() { - userGeneralSignMapper = new UserGeneralSignMapper(userService, passwordEncoder); - } - - @DisplayName("로그인 시, 유저가 존재하고 비밀번호가 일치하면 User를 반환한다.") - @Test - void readUserIfValidReturnUser() { - // given - User user = User.builder().username("pennyway").password("password").build(); - given(userService.readUserByUsername("pennyway")).willReturn(Optional.of(user)); - given(passwordEncoder.matches("password", user.getPassword())).willReturn(true); - - // when - User result = userGeneralSignMapper.readUserIfValid("pennyway", "password"); - - // then - assertEquals(result, user); - } - - @DisplayName("로그인 시, username에 해당하는 유저가 존재하지 않으면 UserErrorException을 발생시킨다.") - @Test - void readUserIfNotFound() { - // given - given(userService.readUserByUsername("pennyway")).willThrow( - new UserErrorException(UserErrorCode.NOT_FOUND)); - - // when - then - UserErrorException exception = assertThrows(UserErrorException.class, () -> userGeneralSignMapper.readUserIfValid("pennyway", "password")); - System.out.println(exception.getExplainError()); - } - - @DisplayName("로그인 시, 비밀번호가 일치하지 않으면 UserErrorException을 발생시킨다.") - @Test - void readUserIfNotMatchedPassword() { - // given - User user = User.builder().username("pennyway").password("password").build(); - given(userService.readUserByUsername("pennyway")).willReturn(Optional.of(user)); - given(passwordEncoder.matches("password", user.getPassword())).willReturn(false); - - // when - then - UserErrorException exception = assertThrows(UserErrorException.class, () -> userGeneralSignMapper.readUserIfValid("pennyway", "password")); - System.out.println(exception.getExplainError()); - } -} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapperTest.java deleted file mode 100644 index 0d6ff35ad..000000000 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapperTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package kr.co.pennyway.api.apis.auth.mapper; - -import kr.co.pennyway.domain.domains.oauth.service.OauthService; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.service.UserService; -import org.apache.commons.lang3.tuple.Pair; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.BDDMockito.given; - -@ExtendWith(MockitoExtension.class) -public class UserSyncMapperTest { - private final String phone = "010-1234-5678"; - private UserSyncMapper userSyncMapper; - @Mock - private UserService userService; - @Mock - private OauthService oauthService; - - @BeforeEach - void setUp() { - userSyncMapper = new UserSyncMapper(userService, oauthService); - } - - @DisplayName("일반 회원가입 시, 회원 정보가 없으면 FALSE를 반환한다.") - @Test - void isSignedUserWhenGeneralReturnFalse() { - // given - given(userService.readUserByPhone(phone)).willReturn(Optional.empty()); - - // when - Boolean result = userSyncMapper.isGeneralSignUpAllowed(phone).getKey(); - - // then - assertEquals(result, Boolean.FALSE); - } - - @DisplayName("일반 회원가입 시, oauth 회원 정보가 있으면 TRUE를 반환한다.") - @Test - void isSignedUserWhenGeneralReturnTrue() { - // given - given(userService.readUserByPhone(phone)).willReturn(Optional.of(User.builder().username("pennyway").password(null).build())); - - // when - Pair result = userSyncMapper.isGeneralSignUpAllowed(phone); - - // then - assertEquals(result.getLeft(), Boolean.TRUE); - assertEquals(result.getRight(), "pennyway"); - } - - @DisplayName("일반 회원가입 시, 이미 일반회원 가입된 회원인 경우 null을 반환한다.") - @Test - void isSignedUserWhenGeneralThrowUserErrorException() { - // given - given(userService.readUserByPhone(phone)).willReturn( - Optional.of(User.builder().password("password").build())); - - // when - then - assertNull(userSyncMapper.isGeneralSignUpAllowed(phone)); - } -} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignServiceTest.java new file mode 100644 index 000000000..2c6dbffa7 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignServiceTest.java @@ -0,0 +1,120 @@ +package kr.co.pennyway.api.apis.auth.service; + +import kr.co.pennyway.api.apis.auth.dto.UserSyncDto; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +public class UserGeneralSignServiceTest { + private final String phone = "010-1234-5678"; + private UserGeneralSignService userGeneralSignService; + @Mock + private UserService userService; + @Mock + private PasswordEncoder passwordEncoder; + + @BeforeEach + void setUp() { + userGeneralSignService = new UserGeneralSignService(userService, passwordEncoder); + } + + @DisplayName("일반 회원가입 시, 회원 정보가 없으면 {회원 가입 가능, 기존 계정 없음} 응답을 반환한다.") + @Test + void isSignedUserWhenGeneralReturnFalse() { + // given + given(userService.readUserByPhone(phone)).willReturn(Optional.empty()); + + // when + UserSyncDto userSync = userGeneralSignService.isSignUpAllowed(phone); + + // then + assertTrue(userSync.isSignUpAllowed()); + assertFalse(userSync.isExistAccount()); + assertNull(userSync.username()); + } + + @DisplayName("일반 회원가입 시, oauth 회원 정보만 있으면 {회원 가입 가능, 기존 계정 있음, 기존 계정 아이디} 응답을 반환한다.") + @Test + void isSignedUserWhenGeneralReturnTrue() { + // given + given(userService.readUserByPhone(phone)).willReturn(Optional.of(User.builder().username("pennyway").password(null).build())); + + // when + UserSyncDto userSync = userGeneralSignService.isSignUpAllowed(phone); + + // then + assertTrue(userSync.isSignUpAllowed()); + assertTrue(userSync.isExistAccount()); + assertEquals("pennyway", userSync.username()); + } + + @DisplayName("일반 회원가입 시, 이미 일반회원 가입된 회원인 경우 계정 생성 불가 응답을 반환한다.") + @Test + void isSignedUserWhenGeneralThrowUserErrorException() { + // given + given(userService.readUserByPhone(phone)).willReturn( + Optional.of(User.builder().username("pennyway").password("password").build())); + + // when + UserSyncDto userSync = userGeneralSignService.isSignUpAllowed(phone); + + // then + assertFalse(userSync.isSignUpAllowed()); + assertTrue(userSync.isExistAccount()); + assertEquals("pennyway", userSync.username()); + } + + @DisplayName("로그인 시, 유저가 존재하고 비밀번호가 일치하면 User를 반환한다.") + @Test + void readUserIfValidReturnUser() { + // given + User user = User.builder().username("pennyway").password("password").build(); + given(userService.readUserByUsername("pennyway")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("password", user.getPassword())).willReturn(true); + + // when + User result = userGeneralSignService.readUserIfValid("pennyway", "password"); + + // then + assertEquals(result, user); + } + + @DisplayName("로그인 시, username에 해당하는 유저가 존재하지 않으면 UserErrorException을 발생시킨다.") + @Test + void readUserIfNotFound() { + // given + given(userService.readUserByUsername("pennyway")).willThrow( + new UserErrorException(UserErrorCode.NOT_FOUND)); + + // when - then + UserErrorException exception = assertThrows(UserErrorException.class, () -> userGeneralSignService.readUserIfValid("pennyway", "password")); + System.out.println(exception.getExplainError()); + } + + @DisplayName("로그인 시, 비밀번호가 일치하지 않으면 UserErrorException을 발생시킨다.") + @Test + void readUserIfNotMatchedPassword() { + // given + User user = User.builder().username("pennyway").password("password").build(); + given(userService.readUserByUsername("pennyway")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("password", user.getPassword())).willReturn(false); + + // when - then + UserErrorException exception = assertThrows(UserErrorException.class, () -> userGeneralSignService.readUserIfValid("pennyway", "password")); + System.out.println(exception.getExplainError()); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationType.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeKeyType.java similarity index 83% rename from pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationType.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeKeyType.java index 7c35d8a62..754b83ce9 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationType.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeKeyType.java @@ -6,7 +6,7 @@ @Getter @RequiredArgsConstructor -public enum PhoneVerificationType { +public enum PhoneCodeKeyType { SIGN_UP("signUp"), OAUTH_SIGN_UP_KAKAO("oauthSignUp:kakao"), OAUTH_SIGN_UP_GOOGLE("oauthSignUp:google"), @@ -16,7 +16,7 @@ public enum PhoneVerificationType { private final String prefix; - public static PhoneVerificationType getOauthSignUpTypeByProvider(Provider provider) { + public static PhoneCodeKeyType getOauthSignUpTypeByProvider(Provider provider) { return switch (provider) { case KAKAO -> OAUTH_SIGN_UP_KAKAO; case GOOGLE -> OAUTH_SIGN_UP_GOOGLE; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeRepository.java similarity index 66% rename from pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationRepository.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeRepository.java index 9afcb5643..68ee158f9 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeRepository.java @@ -9,28 +9,28 @@ import java.util.Objects; @Repository -public class PhoneVerificationRepository { +public class PhoneCodeRepository { private final RedisTemplate redisTemplate; - public PhoneVerificationRepository(@DomainRedisTemplate RedisTemplate redisTemplate) { + public PhoneCodeRepository(@DomainRedisTemplate RedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } - public LocalDateTime save(String phone, String code, PhoneVerificationType codeType) { + public LocalDateTime save(String phone, String code, PhoneCodeKeyType codeType) { LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(5); redisTemplate.opsForValue().set(codeType.getPrefix() + ":" + phone, code, Duration.between(LocalDateTime.now(), expiresAt)); return expiresAt; } - public String findCodeByPhone(String phone, PhoneVerificationType codeType) throws NullPointerException { + public String findCodeByPhone(String phone, PhoneCodeKeyType codeType) throws NullPointerException { return Objects.requireNonNull(redisTemplate.opsForValue().get(codeType.getPrefix() + ":" + phone)).toString(); } - public void extendTimeToLeave(String phone, PhoneVerificationType codeType) { + public void extendTimeToLeave(String phone, PhoneCodeKeyType codeType) { redisTemplate.expire(codeType.getPrefix() + ":" + phone, Duration.ofMinutes(5)); } - public void delete(String phone, PhoneVerificationType codeType) { + public void delete(String phone, PhoneCodeKeyType codeType) { redisTemplate.opsForValue().getAndDelete(codeType.getPrefix() + ":" + phone); } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeService.java similarity index 59% rename from pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationService.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeService.java index 3ac886b46..d5a26e93a 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneVerificationService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeService.java @@ -9,8 +9,8 @@ @Slf4j @DomainService @RequiredArgsConstructor -public class PhoneVerificationService { - private final PhoneVerificationRepository phoneVerificationRepository; +public class PhoneCodeService { + private final PhoneCodeRepository phoneCodeRepository; /** * 휴대폰 번호와 코드를 저장한다. (5분간 유효) @@ -19,24 +19,24 @@ public class PhoneVerificationService { * * @param phone String : 휴대폰 번호 * @param code String : 6자리 정수 코드 - * @param codeType {@link PhoneVerificationType} : 코드 타입 + * @param codeType {@link PhoneCodeKeyType} : 코드 타입 * @return LocalDateTime : 만료 시간 */ - public LocalDateTime create(String phone, String code, PhoneVerificationType codeType) { - return phoneVerificationRepository.save(phone, code, codeType); + public LocalDateTime create(String phone, String code, PhoneCodeKeyType codeType) { + return phoneCodeRepository.save(phone, code, codeType); } /** * 휴대폰 번호로 저장된 코드를 조회한다. * * @param phone String : 휴대폰 번호 - * @param codeType {@link PhoneVerificationType} : 코드 타입 + * @param codeType {@link PhoneCodeKeyType} : 코드 타입 * @return String : 6자리 정수 코드 * @throws IllegalArgumentException : 코드가 없을 경우 */ - public String readByPhone(String phone, PhoneVerificationType codeType) throws IllegalArgumentException { + public String readByPhone(String phone, PhoneCodeKeyType codeType) throws IllegalArgumentException { try { - return phoneVerificationRepository.findCodeByPhone(phone, codeType); + return phoneCodeRepository.findCodeByPhone(phone, codeType); } catch (NullPointerException e) { log.error("{}:{}에 해당하는 키가 존재하지 않습니다.", phone, codeType); throw new IllegalArgumentException(e); @@ -47,19 +47,19 @@ public String readByPhone(String phone, PhoneVerificationType codeType) throws I * 휴대폰 번호로 저장된 데이터의 ttl을 5분으로 연장(롤백)한다. * * @param phone String : 휴대폰 번호 - * @param codeType {@link PhoneVerificationType} : 코드 타입 + * @param codeType {@link PhoneCodeKeyType} : 코드 타입 */ - public void extendTimeToLeave(String phone, PhoneVerificationType codeType) { - phoneVerificationRepository.extendTimeToLeave(phone, codeType); + public void extendTimeToLeave(String phone, PhoneCodeKeyType codeType) { + phoneCodeRepository.extendTimeToLeave(phone, codeType); } /** * 휴대폰 번호로 저장된 코드를 삭제한다. * * @param phone String : 휴대폰 번호 - * @param codeType {@link PhoneVerificationType} : 코드 타입 + * @param codeType {@link PhoneCodeKeyType} : 코드 타입 */ - public void delete(String phone, PhoneVerificationType codeType) { - phoneVerificationRepository.delete(phone, codeType); + public void delete(String phone, PhoneCodeKeyType codeType) { + phoneCodeRepository.delete(phone, codeType); } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java index f340c371b..708fe6385 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java @@ -15,8 +15,8 @@ public class OauthService { private final OauthRepository oauthRepository; @Transactional - public void createOauth(Oauth oauth) { - oauthRepository.save(oauth); + public Oauth createOauth(Oauth oauth) { + return oauthRepository.save(oauth); } @Transactional(readOnly = true) diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/phone/PhoneValidationDaoTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/phone/PhoneValidationDaoTest.java index 1ff6c010b..9bb610d32 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/phone/PhoneValidationDaoTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/phone/PhoneValidationDaoTest.java @@ -14,35 +14,35 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @DisplayName("휴대폰 검증 Redis 서비스 테스트") -@SpringBootTest(classes = {PhoneVerificationRepository.class, RedisConfig.class}) +@SpringBootTest(classes = {PhoneCodeRepository.class, RedisConfig.class}) @ActiveProfiles("local") public class PhoneValidationDaoTest extends ContainerRedisTestConfig { @Autowired - private PhoneVerificationRepository phoneVerificationRepository; + private PhoneCodeRepository phoneCodeRepository; private String phone; private String code; - private PhoneVerificationType codeType; + private PhoneCodeKeyType codeType; @BeforeEach void setUp() { phone = "01012345678"; code = "123456"; - codeType = PhoneVerificationType.SIGN_UP; + codeType = PhoneCodeKeyType.SIGN_UP; } @AfterEach void tearDown() { - phoneVerificationRepository.delete(phone, codeType); + phoneCodeRepository.delete(phone, codeType); } @Test @DisplayName("Redis에 데이터를 저장하면 {'codeType:phone':code}로 데이터가 저장된다.") void codeSaveTest() { // given - phoneVerificationRepository.save(phone, code, codeType); + phoneCodeRepository.save(phone, code, codeType); // when - String savedCode = phoneVerificationRepository.findCodeByPhone(phone, codeType); + String savedCode = phoneCodeRepository.findCodeByPhone(phone, codeType); // then assertEquals(code, savedCode); @@ -53,31 +53,31 @@ void codeSaveTest() { @DisplayName("Redis에 'codeType:phone'에 해당하는 값이 없으면 NullPointerException이 발생한다.") void codeReadError() { // given - phoneVerificationRepository.delete(phone, codeType); + phoneCodeRepository.delete(phone, codeType); String wrongPhone = "01087654321"; // when - then - assertThrows(NullPointerException.class, () -> phoneVerificationRepository.findCodeByPhone(wrongPhone, codeType)); + assertThrows(NullPointerException.class, () -> phoneCodeRepository.findCodeByPhone(wrongPhone, codeType)); } @Test @DisplayName("Redis에 저장된 데이터를 삭제하면 해당 데이터가 삭제된다.") void codeRemoveTest() { // given - phoneVerificationRepository.save(phone, code, codeType); + phoneCodeRepository.save(phone, code, codeType); // when - phoneVerificationRepository.delete(phone, codeType); + phoneCodeRepository.delete(phone, codeType); // then - assertThrows(NullPointerException.class, () -> phoneVerificationRepository.findCodeByPhone(phone, codeType)); + assertThrows(NullPointerException.class, () -> phoneCodeRepository.findCodeByPhone(phone, codeType)); } @Test @DisplayName("저장되지 않은 데이터를 삭제해도 에러가 발생하지 않는다.") void codeRemoveError() { // when - thengi - assertThrows(NullPointerException.class, () -> phoneVerificationRepository.findCodeByPhone(phone, codeType)); - phoneVerificationRepository.delete(phone, codeType); + assertThrows(NullPointerException.class, () -> phoneCodeRepository.findCodeByPhone(phone, codeType)); + phoneCodeRepository.delete(phone, codeType); } } From 5e4cd9b62f566e19a51edb03f46c2712056c5c27 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 22 Apr 2024 14:56:40 +0900 Subject: [PATCH 055/152] =?UTF-8?q?=E2=9C=A8=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EB=B3=B8=EC=9D=B8=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: get_my_account api 작성 * feat: user_profile_dto 정의 * feat: get_my_account() use case 추가 * rename: account_api requirement_security 주석 제거 * fix: local time serializaer format 추가 * fix: profile_visibility @jsonvalue 메서드 수정 * fix: 소셜 계정인 경우 password_updated_at 필드 직렬화 제외 * docs: 사용자 계정 조회 성공 응답 swagger 문서화 * docs: localdatetime 응답 포맷 수정 * feat: 사용자 응답 fe 편의용 필드 is_oauth_account 추가 --- .../api/apis/users/api/UserAccountApi.java | 9 ++- .../controller/UserAccountController.java | 6 ++ .../api/apis/users/dto/UserProfileDto.java | 74 +++++++++++++++++++ .../users/usecase/UserAccountUseCase.java | 10 +++ .../domains/user/type/ProfileVisibility.java | 6 +- 5 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java index 3776f7cce..28380b432 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java @@ -5,12 +5,14 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.SchemaProperty; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.NotBlank; import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.api.apis.users.dto.UserProfileDto; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -19,7 +21,6 @@ import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "사용자 계정 관리 API", description = "사용자 본인의 계정 관리를 위한 Usecase를 제공합니다.") -@SecurityRequirement(name = "access-token") public interface UserAccountApi { @Operation(summary = "디바이스 등록", description = "사용자의 디바이스 정보를 등록(originToken == newToken)하거나 갱신(originToken != newToken)합니다.") @ApiResponses({ @@ -55,4 +56,8 @@ public interface UserAccountApi { """) })) ResponseEntity deleteDevice(@RequestParam("token") @Validated @NotBlank String token, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "사용자 계정 조회", description = "사용자 본인의 계정 정보를 조회합니다.") + @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "user", schema = @Schema(implementation = UserProfileDto.class)))) + ResponseEntity getMyAccount(@AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java index f0b49900f..94d5be364 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java @@ -33,4 +33,10 @@ public ResponseEntity deleteDevice(@RequestParam("token") @Validated @NotBlan userAccountUseCase.unregisterDevice(user.getUserId(), token); return ResponseEntity.ok(SuccessResponse.noContent()); } + + @GetMapping("") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getMyAccount(@AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from("user", userAccountUseCase.getMyAccount(user.getUserId()))); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java new file mode 100644 index 000000000..667415f2b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java @@ -0,0 +1,74 @@ +package kr.co.pennyway.api.apis.users.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Builder +@Schema(title = "사용자 프로필 정보 응답") +public record UserProfileDto( + @Schema(description = "사용자 ID", example = "1") + Long id, + @Schema(description = "사용자 아이디", example = "user1") + String username, + @Schema(description = "사용자 이름", example = "홍길동") + String name, + @Schema(description = "Oauth 계정 여부. 일반 회원가입 계정이 있으면 true, 없으면 false", example = "false") + boolean isOauthAccount, + @Schema(description = "비밀번호 변경 일시. isOauthAccount가 true면 존재하지 않는 필드", nullable = true, type = "string", example = "yyyy-MM-dd HH:mm:ss") + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime passwordUpdatedAt, + @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg") + String profileImageUrl, + @Schema(description = "전화번호", example = "010-1234-5678") + String phone, + @Schema(description = "프로필 공개 여부", example = "PUBLIC") + ProfileVisibility profileVisibility, + @Schema(description = "계정 잠금 여부", example = "false") + Boolean locked, + @Schema(description = "알림 설정 정보") + NotifySetting notifySetting, + @Schema(description = "계정 생성 일시", type = "string", example = "yyyy-MM-dd HH:mm:ss") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt +) { + public UserProfileDto { + Objects.requireNonNull(id); + Objects.requireNonNull(username); + Objects.requireNonNull(name); + Objects.requireNonNull(profileImageUrl); + Objects.requireNonNull(phone); + Objects.requireNonNull(profileVisibility); + Objects.requireNonNull(locked); + Objects.requireNonNull(notifySetting); + Objects.requireNonNull(createdAt); + } + + public static UserProfileDto from(User user) { + return UserProfileDto.builder() + .id(user.getId()) + .username(user.getUsername()) + .name(user.getName()) + .passwordUpdatedAt(user.getPasswordUpdatedAt()) + .profileImageUrl(Objects.toString(user.getProfileImageUrl(), "")) + .phone(user.getPhone()) + .profileVisibility(user.getProfileVisibility()) + .locked(user.getLocked()) + .notifySetting(user.getNotifySetting()) + .isOauthAccount(user.getPassword() == null) + .createdAt(user.getCreatedAt()) + .build(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java index 4415a6991..5754f5e6b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java @@ -1,6 +1,7 @@ package kr.co.pennyway.api.apis.users.usecase; import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.api.apis.users.dto.UserProfileDto; import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.domains.device.domain.Device; @@ -47,4 +48,13 @@ public void unregisterDevice(Long userId, String token) { deviceService.deleteDevice(device); } + + @Transactional(readOnly = true) + public UserProfileDto getMyAccount(Long userId) { + User user = userService.readUser(userId).orElseThrow( + () -> new UserErrorException(UserErrorCode.NOT_FOUND) + ); + + return UserProfileDto.from(user); + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java index 30649677e..3d4e505b9 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java @@ -18,11 +18,15 @@ public String getCode() { return code; } - @JsonValue public String getType() { return type; } + @JsonValue + public String createJson() { + return name(); + } + @Override public String toString() { return type; From 2e614adc260b711b0c23b706a45edc35d2010c73 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 24 Apr 2024 12:44:23 +0900 Subject: [PATCH 056/152] =?UTF-8?q?=E2=9C=A8=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=ED=99=95=EC=9D=B8=20API=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: user token 검증 api 설계 * style: user_auth_controller & usecase & test 모두 auth 패키지로 이전 * test: user_auth_usecase test 생성 * test: pre-condition 설정 * test: 토큰 유효성 검사 api 4가지 시나리오 작성 * fix: get_auth_state에서 authorization 헤더 매개변수 추가 및 예외 처리 * test: controller에서 넘겨주는 데이터에 맞게 given 수정 * fix: auth_header가 비었거나 bearer로 시작하지 않을 시, 예외 처리 -> 비로그인 유저 응답 * feat: is_sign_in 구현 * rename: is_signed_in() -> is_sign_in() * refactor: auth_header 추출 로직 controller -> usecase로 이전 * test: token 접두사 bearer 추가 * test: access token을 보낸 의도가 확실할 때는 검증 실패 시 예외처리 * fix: is_token_expired() -> get_jwt_claims() 예외 핸들링 로직 수정 * fix: /v1/auth url 인가 권한 permit_all로 수정 * docs: get_auth_state() swagger 주석 추가 * feat: sigin state record 정의 * fix: state dto 분리 * test: is_sign_in 반환 타입 dto로 수정 * feat: jwt_claims value를 얻기 위한 편의용 메서드 추가 * fix: is_sign_in() 반환 타입 dto로 변경 * test: given() 인자 내에 any()로 수정 * rename: get_claim_value() 주석 추가 * docs: 성공 응답 schema로 수정 * fix: 사용자 로그인 여부 인가 권한 is_authenticated()로 수정 * fix: auth_state_dto is_sign_in 필드 제거 * fix: controller authorization 헤더 필수값으로 수정 * fix: usecase 내 is_sign_in false 처리 로직 제거 * test: jwt_auth_helper mock -> 실제 객체 생성 * docs: auth state dto id 필드 설명 수정 * test: security filter에서 걸러지는 테스트 케이스 제거 * fix: is_sign_in log 레벨 debug -> info * style: web_sucurity 인증/인가 경로 상수 클래스 분리 * fix: /v1/auth 인가 권한 authenticated로 변경 * test: jwt_auth_helper 실제 인스턴스 주입 * fix: get_claims_value 로직 수정 --- .../apis/{users => auth}/api/UserAuthApi.java | 9 ++- .../controller/UserAuthController.java | 14 +++- .../api/apis/auth/dto/AuthStateDto.java | 18 +++++ .../api/apis/auth/helper/JwtAuthHelper.java | 33 +++++++- .../apis/auth/usecase/UserAuthUseCase.java | 32 ++++++++ .../apis/users/usecase/UserAuthUseCase.java | 17 ----- .../authentication/SecurityUserDetails.java | 2 +- .../filter/JwtAuthenticationFilter.java | 2 +- .../jwt/access/AccessTokenClaimKeys.java | 2 +- .../jwt/refresh/RefreshTokenClaimKeys.java | 2 +- .../api/config/security/SecurityConfig.java | 9 +-- .../api/config/security/WebSecurityUrls.java | 9 +++ .../UserAuthControllerIntegrationTest.java | 4 +- .../auth/usecase/UserAuthUseCaseUnitTest.java | 76 +++++++++++++++++++ 14 files changed, 193 insertions(+), 36 deletions(-) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/{users => auth}/api/UserAuthApi.java (81%) rename pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/{users => auth}/controller/UserAuthController.java (75%) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/AuthStateDto.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java delete mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAuthUseCase.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/WebSecurityUrls.java rename pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/{users => auth}/controller/UserAuthControllerIntegrationTest.java (99%) create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCaseUnitTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAuthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java similarity index 81% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAuthApi.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java index 11519c075..f670cef8a 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAuthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java @@ -1,14 +1,16 @@ -package kr.co.pennyway.api.apis.users.api; +package kr.co.pennyway.api.apis.auth.api; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.auth.dto.AuthStateDto; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -18,6 +20,11 @@ @Tag(name = "[사용자 인증 관리 API]", description = "사용자의 인증과 관련된 UseCase(로그아웃, 소셜 계정 연동/해지 등)를 제공하는 API") public interface UserAuthApi { + @Operation(summary = "로그인 상태 확인", description = "사용자의 로그인 상태를 확인한다. 단, 유효하지 않은 토큰은 에러 응답이 발생한다.") + @Parameter(name = "Authorization", in = ParameterIn.HEADER, hidden = true) + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", schema = @Schema(implementation = AuthStateDto.class))) + ResponseEntity getAuthState(@RequestHeader(value = "Authorization", required = false, defaultValue = "") String authHeader); + @Operation(summary = "로그아웃", description = """ 사용자의 로그아웃을 수행한다. Access Token과 Refresh Token을 받아서 Access Token을 만료시키고, Refresh Token을 삭제한다.
Refresh Token이 없는 경우에는 Access Token만 만료시킨다. (만료된 refesh token이면 access token 만료만 수행)
diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/UserAuthController.java similarity index 75% rename from pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAuthController.java rename to pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/UserAuthController.java index 3dd6910fc..32ed50c4b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAuthController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/UserAuthController.java @@ -1,7 +1,7 @@ -package kr.co.pennyway.api.apis.users.controller; +package kr.co.pennyway.api.apis.auth.controller; -import kr.co.pennyway.api.apis.users.api.UserAuthApi; -import kr.co.pennyway.api.apis.users.usecase.UserAuthUseCase; +import kr.co.pennyway.api.apis.auth.api.UserAuthApi; +import kr.co.pennyway.api.apis.auth.usecase.UserAuthUseCase; import kr.co.pennyway.api.common.response.SuccessResponse; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import kr.co.pennyway.api.common.util.CookieUtil; @@ -16,11 +16,17 @@ @Slf4j @RestController @RequiredArgsConstructor -@RequestMapping("/v1/users") +@RequestMapping("/v1") public class UserAuthController implements UserAuthApi { private final UserAuthUseCase userAuthUseCase; private final CookieUtil cookieUtil; + @GetMapping("/auth") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getAuthState(@RequestHeader(value = "Authorization") String authHeader) { + return ResponseEntity.ok(SuccessResponse.from("user", userAuthUseCase.isSignIn(authHeader))); + } + @GetMapping("/sign-out") @PreAuthorize("isAuthenticated()") public ResponseEntity signOut( diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/AuthStateDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/AuthStateDto.java new file mode 100644 index 000000000..850a1af8c --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/AuthStateDto.java @@ -0,0 +1,18 @@ +package kr.co.pennyway.api.apis.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Objects; + +public record AuthStateDto( + @Schema(description = "로그인한 사용자의 pk", example = "1") + Long id +) { + public AuthStateDto { + Objects.requireNonNull(id); + } + + public static AuthStateDto of(Long userId) { + return new AuthStateDto(userId); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java index e6f7c4384..6e054bd5e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java @@ -21,6 +21,7 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.Map; +import java.util.function.Function; @Slf4j @Helper @@ -42,6 +43,34 @@ public JwtAuthHelper( this.forbiddenTokenService = forbiddenTokenService; } + /** + * JwtClaims에서 key에 해당하는 값을 반환하는 메서드 + * + * @return key에 해당하는 값이 없거나, 타입이 일치하지 않을 경우 null을 반환한다. + */ + @SuppressWarnings("unchecked") + public T getClaimValue(JwtClaims claims, String key, Class type) { + Object value = claims.getClaims().get(key); + if (value != null && type.isAssignableFrom(value.getClass())) { + return (T) value; + } + return null; + } + + /** + * JwtClaims에서 valueConverter를 이용하여 key에 해당하는 값을 반환하는 메서드 + * + * @param valueConverter : String 타입의 값을 T 타입으로 변환하는 함수 + * @return key에 해당하는 값이 없을 경우 null을 반환한다. + */ + public T getClaimsValue(JwtClaims claims, String key, Function valueConverter) { + Object value = claims.getClaims().get(key); + if (value != null) { + return valueConverter.apply((String) value); + } + return null; + } + /** * 사용자 정보 기반으로 access token과 refresh token을 생성하는 메서드
* refresh token은 redis에 저장된다. @@ -106,7 +135,7 @@ public void removeAccessTokenAndRefreshToken(Long userId, String accessToken, St private void deleteRefreshToken(Long userId, JwtClaims jwtClaims, String refreshToken) { Long refreshTokenUserId = Long.parseLong((String) jwtClaims.getClaims().get(RefreshTokenClaimKeys.USER_ID.getValue())); - log.info("로그아웃 요청 refresh token userId : {}", refreshTokenUserId); + log.info("로그아웃 요청 refresh token id : {}", refreshTokenUserId); if (!userId.equals(refreshTokenUserId)) { throw new JwtErrorException(JwtErrorCode.WITHOUT_OWNERSHIP_REFRESH_TOKEN); @@ -115,7 +144,7 @@ private void deleteRefreshToken(Long userId, JwtClaims jwtClaims, String refresh try { refreshTokenService.delete(refreshTokenUserId, refreshToken); } catch (IllegalArgumentException e) { - log.warn("refresh token not found. userId : {}", userId); + log.warn("refresh token not found. id : {}", userId); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java new file mode 100644 index 000000000..8ca55bfee --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java @@ -0,0 +1,32 @@ +package kr.co.pennyway.api.apis.auth.usecase; + +import kr.co.pennyway.api.apis.auth.dto.AuthStateDto; +import kr.co.pennyway.api.apis.auth.helper.JwtAuthHelper; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaimKeys; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.infra.common.jwt.JwtClaims; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class UserAuthUseCase { + private final JwtAuthHelper jwtAuthHelper; + private final JwtProvider accessTokenProvider; + + public AuthStateDto isSignIn(String authHeader) { + String accessToken = accessTokenProvider.resolveToken(authHeader); + JwtClaims claims = accessTokenProvider.getJwtClaimsFromToken(accessToken); + Long userId = jwtAuthHelper.getClaimsValue(claims, AccessTokenClaimKeys.USER_ID.getValue(), Long::parseLong); + + log.info("auth_id {} 사용자는 로그인 중입니다.", userId); + + return AuthStateDto.of(userId); + } + + public void signOut(Long userId, String authHeader, String refreshToken) { + jwtAuthHelper.removeAccessTokenAndRefreshToken(userId, authHeader, refreshToken); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAuthUseCase.java deleted file mode 100644 index 392c6734e..000000000 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAuthUseCase.java +++ /dev/null @@ -1,17 +0,0 @@ -package kr.co.pennyway.api.apis.users.usecase; - -import kr.co.pennyway.api.apis.auth.helper.JwtAuthHelper; -import kr.co.pennyway.common.annotation.UseCase; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@UseCase -@RequiredArgsConstructor -public class UserAuthUseCase { - private final JwtAuthHelper jwtAuthHelper; - - public void signOut(Long userId, String authHeader, String refreshToken) { - jwtAuthHelper.removeAccessTokenAndRefreshToken(userId, authHeader, refreshToken); - } -} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java index f5dc6ac68..6ec85b6ee 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java @@ -88,7 +88,7 @@ public boolean isEnabled() { @Override public String toString() { return "SecurityUserDetails{" + - "userId=" + userId + + "id=" + userId + ", username='" + username + '\'' + ", authorities=" + authorities + ", accountNonLocked=" + accountNonLocked + diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java index ccff4d231..79a08b871 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java @@ -33,7 +33,7 @@ * {@code * @GetMapping("/user") * public ResponseEntity getUser(@AuthenticationPrincipal SecurityUser user) { - * Long userId = user.getId(); + * Long id = user.getId(); * ... * } * } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenClaimKeys.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenClaimKeys.java index 73c2c8def..5eb34a3cc 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenClaimKeys.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/access/AccessTokenClaimKeys.java @@ -1,7 +1,7 @@ package kr.co.pennyway.api.common.security.jwt.access; public enum AccessTokenClaimKeys { - USER_ID("userId"), + USER_ID("id"), ROLE("role"); private final String value; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java index b7a880732..46a9752ad 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/refresh/RefreshTokenClaimKeys.java @@ -1,7 +1,7 @@ package kr.co.pennyway.api.common.security.jwt.refresh; public enum RefreshTokenClaimKeys { - USER_ID("userId"), + USER_ID("id"), ROLE("role"); private final String value; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java index 7660c6ada..5fedfbadc 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java @@ -21,17 +21,13 @@ import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.web.cors.CorsConfigurationSource; +import static kr.co.pennyway.api.config.security.WebSecurityUrls.*; + @Configuration @EnableWebSecurity @ConditionalOnDefaultWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/v1/duplicate/**"}; - private static final String[] PUBLIC_ENDPOINTS = {"/v1/questions/**"}; - private static final String[] ANONYMOUS_ENDPOINTS = {"/v1/auth/**", "/v1/phone/**"}; - private static final String[] SWAGGER_ENDPOINTS = {"/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger",}; - - private final SecurityAdapterConfig securityAdapterConfig; private final CorsConfigurationSource corsConfigurationSource; private final AccessDeniedHandler accessDeniedHandler; @@ -82,6 +78,7 @@ private AbstractRequestMatcherRegistry userAuthUseCase.isSignIn("Bearer " + expiredToken)); + + // then + assertEquals(JwtErrorCode.EXPIRED_TOKEN, exception.getErrorCode()); + } + + @Test + @DisplayName("[2] 유효한 토큰이면 토큰의 사용자 아이디를 반환한다.") + public void isSignedInWithValidToken() { + // given + String token = accessTokenProvider.generateToken(jwtClaims); + + // when + AuthStateDto result = userAuthUseCase.isSignIn("Bearer " + token); + + // then + assertEquals(1L, result.id()); + } +} From c6d595a56252fb4dea5bd882b9195ae784358a04 Mon Sep 17 00:00:00 2001 From: Jinwoo Lee Date: Wed, 24 Apr 2024 14:56:37 +0900 Subject: [PATCH 057/152] =?UTF-8?q?=08=EC=9D=BC=EB=B0=98=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=EC=95=84=EC=9D=B4=EB=94=94=20=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: auth-check-controller request-mapping값 변경 * refactor: auth-check controller와 api 분리 * feat: 아이디 찾기 api 생성 * feat: 아이디 찾기 api 전체 허용 * feat: 아이디 찾기 api 구현 * fix: sysout 제거 * fix: 아이디 찾기 api 접근 권한 변경 * feat: 아이디 찾기 api 응답 객체 수정 및 dto 정의 * fix: auth-find-mapper 클래스 정의 및 존재하지 않는 번호 예외 처리 * feat: 존재하지 않는 아이디 error-code 정의 * feat: 계정 찾기 관련 커스텀 에러 정의 * fix: 일반 회원이 아닌 휴대폰 번호 조회 시 not-found 예외 처리 * test: 아이디 찾기 시 존재하지 않는 휴대폰 번호 예외 처리 케이스 작성 * test: 아이디 찾기 시 oauth 사용자 휴대폰 번호 예외 처리 케이스 작성 * test: 아이디 찾기 시 휴대폰 번호 조회 성공 케이스 작성 * test: 아이디 api 성공 테스트 케이스 작성 * test: 아이디 api 요청 실패 테스트 케이스 작성 * rename: auth-find-error-code의 존재하지 않는 휴대폰에 대한 상수 변경 * rename: 아이디 찾기 api 테스트명 수정 * docs: 아이디 찾기 api swagger 문서 구체화 * fix: 아이디 찾기 api의 휴대폰 번호 not-null 설정 * fix: 아이디 찾기 시 처리 예외를 user-error-exception으로 변경 * rename: auth-find-mapper에서 auth-find-service로 변경 * fix: 아이디 찾기 endpoint security 수정 * fix: 아이디 찾기 flow 수정 * fix: auth-check-controller의 테스트 단위 변경에 따른 어노테이션 수정 * fix: oauth entity 내 provider converter 정의 (#52) * fix: 아이디 찾기 api에서 phone param을 not-blank로 변경 * fix: 아이디 찾기 실패에 따른 예외 로그 레벨 및 서비스 패키지 변경 * fix: auth-find-service 어노테이션을 service로 수정 * fix: 아이디 찾기 시 인증 코드 검증 및 캐시 제거 로직 추가 * fix: 아이디 찾기 시 인증 코드 검증 및 캐시 제거 로직 추가 * docs: 아이디 찾기 로직 주석 및 api response 추가 * fix: 아이디 찾기 api query에 code 추가 * feat: phone-verification-dto의 verify-code-req에 of 메서드 추가 * refactor: 아이디 찾기 로직의 코드 검증 및 삭제 로직을 usecase로 이동 * test: 아이디 찾기 api의 query에 code 추가에 따른 테스트 코드 변경 * test: 아이디 찾기 api의 query에 code 추가에 따른 result-actions 객체 매개변수 추가 * refactor: 인증코드 관련 패키지 및 클래스명 변경에 따른 수정 * fix: required-args-constructor import문 추가 --------- Co-authored-by: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> --- .../api/apis/auth/api/AuthCheckApi.java | 55 ++++++ .../auth/controller/AuthCheckController.java | 39 ++-- .../api/apis/auth/dto/AuthFindDto.java | 25 +++ .../apis/auth/dto/PhoneVerificationDto.java | 171 +++++++++--------- .../apis/auth/service/AuthFindService.java | 42 +++++ .../apis/auth/usecase/AuthCheckUseCase.java | 29 ++- .../api/config/security/SecurityConfig.java | 108 +++++------ .../controller/AuthCheckControllerTest.java | 88 +++++++++ .../apis/auth/mapper/AuthFindMapperTest.java | 82 +++++++++ 9 files changed, 483 insertions(+), 156 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthCheckApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/AuthFindDto.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/AuthFindService.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/AuthFindMapperTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthCheckApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthCheckApi.java new file mode 100644 index 000000000..7fb3cc85e --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthCheckApi.java @@ -0,0 +1,55 @@ +package kr.co.pennyway.api.apis.auth.api; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestParam; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotBlank; + +@Tag(name = "[계정 검사 API]") +public interface AuthCheckApi { + @Operation(summary = "닉네임 중복 검사") + ResponseEntity checkUsername(@RequestParam @Validated String username); + + @Operation(summary = "일반 회원 아이디 찾기") + @Parameter(name = "phone", description = "휴대폰 번호", required = true, in = ParameterIn.QUERY, example = "010-1234-5678") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "일반 회원으로 등록된 휴대폰 번호일 경우", value = """ + { + "code": "2000", + "data": { + "user": { + "username": "pennyway" + } + } + } + """) + })), + @ApiResponse(responseCode = "404", description = "일반 회원으로 등록되지 않은 휴대폰 번호일 경우", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "일반 회원으로 등록되지 않은 휴대폰 번호일 경우", value = """ + { + "code": "4040", + "message": "일반 회원으로 등록되지 않은 휴대폰 번호입니다." + } + """) + })), + @ApiResponse(responseCode = "404", description = "인증번호 만료 또는 유효하지 않은 경우", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "인증번호 만료 또는 유효하지 않은 경우", value = """ + { + "code": "4042", + "message": "인증번호가 만료되었거나 유효하지 않습니다." + } + """) + })), + }) + ResponseEntity findUsername(@RequestParam @NotBlank String phone, @RequestParam @NotBlank String code); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java index 1e1d826e5..2636ef4a5 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java @@ -1,11 +1,5 @@ package kr.co.pennyway.api.apis.auth.controller; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import kr.co.pennyway.api.apis.auth.usecase.AuthCheckUseCase; -import kr.co.pennyway.api.common.response.SuccessResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; @@ -14,18 +8,31 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import jakarta.validation.constraints.NotBlank; +import kr.co.pennyway.api.apis.auth.api.AuthCheckApi; +import kr.co.pennyway.api.apis.auth.usecase.AuthCheckUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + @Slf4j -@Tag(name = "[계정 검사 API]") @RestController @RequiredArgsConstructor -@RequestMapping("/v1/duplicate") -public class AuthCheckController { - private final AuthCheckUseCase authCheckUseCase; +@RequestMapping("/v1") +public class AuthCheckController implements AuthCheckApi { + private final AuthCheckUseCase authCheckUseCase; + + @GetMapping("/duplicate/username") + @PreAuthorize("permitAll()") + public ResponseEntity checkUsername(@RequestParam @Validated String username) { + return ResponseEntity.ok( + SuccessResponse.from("isDuplicate", + authCheckUseCase.checkUsernameDuplicate(username))); + } - @Operation(summary = "닉네임 중복 검사") - @GetMapping("/username") - @PreAuthorize("permitAll()") - public ResponseEntity checkUsername(@RequestParam @Validated String username) { - return ResponseEntity.ok(SuccessResponse.from("isDuplicate", authCheckUseCase.checkUsernameDuplicate(username))); - } + @GetMapping("/find/username") + @PreAuthorize("isAnonymous()") + public ResponseEntity findUsername(@RequestParam @NotBlank String phone, @RequestParam @NotBlank String code) { + return ResponseEntity.ok(SuccessResponse.from("user", authCheckUseCase.findUsername(phone, code))); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/AuthFindDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/AuthFindDto.java new file mode 100644 index 000000000..020a3e10d --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/AuthFindDto.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.api.apis.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import kr.co.pennyway.domain.domains.user.domain.User; + +public class AuthFindDto { + @Schema(title = "사용자 이름 찾기 응답 DTO", description = "전화번호로 사용자 이름 찾기 응답을 위한 DTO") + public record FindUsernameRes( + @Schema(description = "사용자 이름") + String username + ) { + /** + * 사용자 이름 찾기 응답 객체 생성 + * + * @param username String : 사용자 이름 + */ + public static FindUsernameRes of(String username) { + return new FindUsernameRes(username); + } + + public static FindUsernameRes of(User user) { + return new FindUsernameRes(user.getUsername()); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java index 573da8a13..2086ad238 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java @@ -1,100 +1,105 @@ package kr.co.pennyway.api.apis.auth.dto; +import java.time.LocalDateTime; + import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; + import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; -import java.time.LocalDateTime; - public class PhoneVerificationDto { - @Schema(title = "인증번호 요청 DTO", description = "전화번호로 인증번호 송신 요청을 위한 DTO") - public record PushCodeReq( - @Schema(description = "전화번호", example = "010-2629-4624") - @NotBlank(message = "전화번호는 필수입니다.") - @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") - String phone - ) { - } + @Schema(title = "인증번호 요청 DTO", description = "전화번호로 인증번호 송신 요청을 위한 DTO") + public record PushCodeReq( + @Schema(description = "전화번호", example = "010-2629-4624") + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phone + ) { + } + + @Schema(title = "인증번호 발송 응답 DTO", description = "전화번호로 인증번호 송신 응답을 위한 DTO") + public record PushCodeRes( + @Schema(description = "수신자 번호") + String to, + @Schema(description = "발송 시간") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime sendAt, + @Schema(description = "만료 시간 (default: 3분)", example = "2021-08-01T00:00:00") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime expiresAt + ) { + /** + * 인증번호 발송 응답 객체 생성 + * + * @param to String : 수신자 번호 + * @param sendAt LocalDateTime : 발송 시간 + * @param expiresAt LocalDateTime : 만료 시간 (default: 5분) + */ + public static PushCodeRes of(String to, LocalDateTime sendAt, LocalDateTime expiresAt) { + return new PushCodeRes(to, sendAt, expiresAt); + } + } - @Schema(title = "인증번호 발송 응답 DTO", description = "전화번호로 인증번호 송신 응답을 위한 DTO") - public record PushCodeRes( - @Schema(description = "수신자 번호") - String to, - @Schema(description = "발송 시간") - @JsonSerialize(using = LocalDateTimeSerializer.class) - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - LocalDateTime sendAt, - @Schema(description = "만료 시간 (default: 3분)", example = "2021-08-01T00:00:00") - @JsonSerialize(using = LocalDateTimeSerializer.class) - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - LocalDateTime expiresAt - ) { - /** - * 인증번호 발송 응답 객체 생성 - * - * @param to String : 수신자 번호 - * @param sendAt LocalDateTime : 발송 시간 - * @param expiresAt LocalDateTime : 만료 시간 (default: 5분) - */ - public static PushCodeRes of(String to, LocalDateTime sendAt, LocalDateTime expiresAt) { - return new PushCodeRes(to, sendAt, expiresAt); - } - } + @Schema(title = "인증번호 검증 DTO", description = "전화번호로 인증번호 검증 요청을 위한 DTO") + public record VerifyCodeReq( + @Schema(description = "전화번호", example = "010-2629-4624") + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phone, + @Schema(description = "6자리 정수 인증번호", example = "123456") + @NotBlank(message = "인증번호는 필수입니다.") + @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") + String code + ) { + public static VerifyCodeReq from(SignUpReq.Info request) { + return new VerifyCodeReq(request.phone(), request.code()); + } - @Schema(title = "인증번호 검증 DTO", description = "전화번호로 인증번호 검증 요청을 위한 DTO") - public record VerifyCodeReq( - @Schema(description = "전화번호", example = "010-2629-4624") - @NotBlank(message = "전화번호는 필수입니다.") - @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") - String phone, - @Schema(description = "6자리 정수 인증번호", example = "123456") - @NotBlank(message = "인증번호는 필수입니다.") - @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") - String code - ) { - public static VerifyCodeReq from(SignUpReq.Info request) { - return new VerifyCodeReq(request.phone(), request.code()); - } + public static VerifyCodeReq from(SignUpReq.OauthInfo request) { + return new VerifyCodeReq(request.phone(), request.code()); + } - public static VerifyCodeReq from(SignUpReq.OauthInfo request) { - return new VerifyCodeReq(request.phone(), request.code()); - } - } + public static VerifyCodeReq of(String phone, String code) { + return new VerifyCodeReq(phone, code); + } + } - @Schema(title = "인증번호 검증 응답 DTO") - public record VerifyCodeRes( - @Schema(description = "코드 일치 여부 : 일치하지 않으면 예외이므로 성공하면 언제나 true", example = "true") - Boolean code, - @Schema(description = "oauth 사용자 여부. true면 sync, false면 회원가입으로 진행 (일반 회원가입 시 필수값)", example = "true") - @JsonInclude(JsonInclude.Include.NON_NULL) - Boolean oauth, - @Schema(description = "기존 계정 존재 여부. true면 sync, false면 회원가입 (oauth 회원가입 시 필수값)", example = "true") - @JsonInclude(JsonInclude.Include.NON_NULL) - Boolean existsUser, - @Schema(description = "기존 사용자 아이디", example = "pennyway") - @JsonInclude(JsonInclude.Include.NON_NULL) - String username - ) { - /** - * 일반 회원가입 시 인증 코드 응답 객체 생성 - * - * @param isOauthUser Boolean : oauth 사용자 여부. true면 sync, false면 회원가입으로 진행 - */ - public static VerifyCodeRes valueOfGeneral(Boolean isValidCode, Boolean isOauthUser, String username) { - return new VerifyCodeRes(isValidCode, isOauthUser, null, username); - } + @Schema(title = "인증번호 검증 응답 DTO") + public record VerifyCodeRes( + @Schema(description = "코드 일치 여부 : 일치하지 않으면 예외이므로 성공하면 언제나 true", example = "true") + Boolean code, + @Schema(description = "oauth 사용자 여부. true면 sync, false면 회원가입으로 진행 (일반 회원가입 시 필수값)", example = "true") + @JsonInclude(JsonInclude.Include.NON_NULL) + Boolean oauth, + @Schema(description = "기존 계정 존재 여부. true면 sync, false면 회원가입 (oauth 회원가입 시 필수값)", example = "true") + @JsonInclude(JsonInclude.Include.NON_NULL) + Boolean existsUser, + @Schema(description = "기존 사용자 아이디", example = "pennyway") + @JsonInclude(JsonInclude.Include.NON_NULL) + String username + ) { + /** + * 일반 회원가입 시 인증 코드 응답 객체 생성 + * + * @param isOauthUser Boolean : oauth 사용자 여부. true면 sync, false면 회원가입으로 진행 + */ + public static VerifyCodeRes valueOfGeneral(Boolean isValidCode, Boolean isOauthUser, String username) { + return new VerifyCodeRes(isValidCode, isOauthUser, null, username); + } - /** - * oauth 회원가입 시 인증 코드 응답 객체 생성 - * - * @param existsUser Boolean : 기존 계정 존재 여부. true면 sync, false면 회원가입으로 진행 - */ - public static VerifyCodeRes valueOfOauth(Boolean isValidCode, Boolean existsUser, String username) { - return new VerifyCodeRes(isValidCode, null, existsUser, username); - } - } + /** + * oauth 회원가입 시 인증 코드 응답 객체 생성 + * + * @param existsUser Boolean : 기존 계정 존재 여부. true면 sync, false면 회원가입으로 진행 + */ + public static VerifyCodeRes valueOfOauth(Boolean isValidCode, Boolean existsUser, String username) { + return new VerifyCodeRes(isValidCode, null, existsUser, username); + } + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/AuthFindService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/AuthFindService.java new file mode 100644 index 000000000..7a9b7f4a9 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/AuthFindService.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.api.apis.auth.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthFindService { + private final UserService userService; + private final PhoneVerificationService phoneVerificationService; + + /** + * 일반 회원 아이디 찾기 + * + * @param phone 전화번호 (e.g. 010-1234-5678) + * @return AuthFindDto.FindPasswordRes 비밀번호 찾기 응답 + */ + @Transactional(readOnly = true) + public AuthFindDto.FindUsernameRes findUsername(String phone) { + User user = userService.readUserByPhone(phone).orElseThrow(() -> { + log.info("User not found by phone: {}", phone); + return new UserErrorException(UserErrorCode.NOT_FOUND); + }); + + // 일반 회원 여부 검증 + if (user.getPassword() == null) { + log.info("User not found by phone: {}", phone); + throw new UserErrorException(UserErrorCode.NOT_FOUND); + } + + return AuthFindDto.FindUsernameRes.of(user); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java index 13ba7064d..6d8937673 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java @@ -1,19 +1,36 @@ package kr.co.pennyway.api.apis.auth.usecase; +import org.springframework.transaction.annotation.Transactional; + +import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.apis.auth.service.AuthFindService; +import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService; import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.transaction.annotation.Transactional; @Slf4j @UseCase @RequiredArgsConstructor public class AuthCheckUseCase { - private final UserService userService; + private final UserService userService; + private final AuthFindService authFindService; + private final PhoneVerificationService phoneVerificationService; + private final PhoneCodeService phoneCodeService; + + @Transactional(readOnly = true) + public boolean checkUsernameDuplicate(String username) { + return userService.isExistUsername(username); + } - @Transactional(readOnly = true) - public boolean checkUsernameDuplicate(String username) { - return userService.isExistUsername(username); - } + @Transactional(readOnly = true) + public AuthFindDto.FindUsernameRes findUsername(String phone, String code) { + phoneVerificationService.isValidCode(PhoneVerificationDto.VerifyCodeReq.of(phone, code), PhoneCodeKeyType.FIND_USERNAME); + phoneCodeService.delete(phone, PhoneCodeKeyType.FIND_USERNAME); + return authFindService.findUsername(phone); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java index 5fedfbadc..cd39d3aab 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java @@ -1,6 +1,7 @@ package kr.co.pennyway.api.config.security; -import lombok.RequiredArgsConstructor; +import static kr.co.pennyway.api.config.security.WebSecurityUrls.*; + import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity; import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; @@ -21,64 +22,69 @@ import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.web.cors.CorsConfigurationSource; -import static kr.co.pennyway.api.config.security.WebSecurityUrls.*; +import lombok.RequiredArgsConstructor; @Configuration @EnableWebSecurity @ConditionalOnDefaultWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private final SecurityAdapterConfig securityAdapterConfig; - private final CorsConfigurationSource corsConfigurationSource; - private final AccessDeniedHandler accessDeniedHandler; - private final AuthenticationEntryPoint authenticationEntryPoint; + private static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/v1/duplicate/**"}; + private static final String[] PUBLIC_ENDPOINTS = {"/v1/questions/**"}; + private static final String[] ANONYMOUS_ENDPOINTS = {"/v1/auth/**", "/v1/phone/**", "/v1/find/**"}; + private static final String[] SWAGGER_ENDPOINTS = {"/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger",}; + + private final SecurityAdapterConfig securityAdapterConfig; + private final CorsConfigurationSource corsConfigurationSource; + private final AccessDeniedHandler accessDeniedHandler; + private final AuthenticationEntryPoint authenticationEntryPoint; - @Bean - @Profile({"local", "dev", "test"}) - @Order(SecurityProperties.BASIC_AUTH_ORDER) - public SecurityFilterChain filterChainDev(HttpSecurity http) throws Exception { - return defaultSecurity(http) - .cors((cors) -> cors.configurationSource(corsConfigurationSource)) - .authorizeHttpRequests( - auth -> defaultAuthorizeHttpRequests(auth) - .requestMatchers(SWAGGER_ENDPOINTS).permitAll() - .anyRequest().authenticated() - ).build(); - } + @Bean + @Profile({"local", "dev", "test"}) + @Order(SecurityProperties.BASIC_AUTH_ORDER) + public SecurityFilterChain filterChainDev(HttpSecurity http) throws Exception { + return defaultSecurity(http) + .cors((cors) -> cors.configurationSource(corsConfigurationSource)) + .authorizeHttpRequests( + auth -> defaultAuthorizeHttpRequests(auth) + .requestMatchers(SWAGGER_ENDPOINTS).permitAll() + .anyRequest().authenticated() + ).build(); + } - @Bean - @Profile({"prod"}) - @Order(SecurityProperties.BASIC_AUTH_ORDER) - public SecurityFilterChain filterChainProd(HttpSecurity http) throws Exception { - return defaultSecurity(http) - .cors(AbstractHttpConfigurer::disable) - .authorizeHttpRequests( - auth -> defaultAuthorizeHttpRequests(auth).anyRequest().authenticated() - ).build(); - } + @Bean + @Profile({"prod"}) + @Order(SecurityProperties.BASIC_AUTH_ORDER) + public SecurityFilterChain filterChainProd(HttpSecurity http) throws Exception { + return defaultSecurity(http) + .cors(AbstractHttpConfigurer::disable) + .authorizeHttpRequests( + auth -> defaultAuthorizeHttpRequests(auth).anyRequest().authenticated() + ).build(); + } - private HttpSecurity defaultSecurity(HttpSecurity http) throws Exception { - return http.httpBasic(AbstractHttpConfigurer::disable) - .csrf(AbstractHttpConfigurer::disable) - .sessionManagement((sessionManagement) -> - sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .formLogin(AbstractHttpConfigurer::disable) - .logout(AbstractHttpConfigurer::disable) - .with(securityAdapterConfig, Customizer.withDefaults()) - .exceptionHandling( - exception -> exception - .accessDeniedHandler(accessDeniedHandler) - .authenticationEntryPoint(authenticationEntryPoint) - ); - } + private HttpSecurity defaultSecurity(HttpSecurity http) throws Exception { + return http.httpBasic(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement((sessionManagement) -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .with(securityAdapterConfig, Customizer.withDefaults()) + .exceptionHandling( + exception -> exception + .accessDeniedHandler(accessDeniedHandler) + .authenticationEntryPoint(authenticationEntryPoint) + ); + } - private AbstractRequestMatcherRegistry.AuthorizedUrl> defaultAuthorizeHttpRequests( - AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth) { - return auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() - .requestMatchers(HttpMethod.OPTIONS, "*").permitAll() - .requestMatchers(HttpMethod.GET, READ_ONLY_PUBLIC_ENDPOINTS).permitAll() - .requestMatchers(PUBLIC_ENDPOINTS).permitAll() - .requestMatchers(AUTHENTICATED_ENDPOINTS).authenticated() // FIXME: 2024-04-23 /v1/auth가 anonymous로 설정되어 있어서 authenticated로 덮어씀. - .requestMatchers(ANONYMOUS_ENDPOINTS).anonymous(); - } + private AbstractRequestMatcherRegistry.AuthorizedUrl> defaultAuthorizeHttpRequests( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth) { + return auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .requestMatchers(HttpMethod.OPTIONS, "*").permitAll() + .requestMatchers(HttpMethod.GET, READ_ONLY_PUBLIC_ENDPOINTS).permitAll() + .requestMatchers(PUBLIC_ENDPOINTS).permitAll() + .requestMatchers(AUTHENTICATED_ENDPOINTS).authenticated() // FIXME: 2024-04-23 /v1/auth가 anonymous로 설정되어 있어서 authenticated로 덮어씀. + .requestMatchers(ANONYMOUS_ENDPOINTS).anonymous(); + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java new file mode 100644 index 000000000..69e175dd8 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java @@ -0,0 +1,88 @@ +package kr.co.pennyway.api.apis.auth.controller; + +import static org.mockito.BDDMockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; +import kr.co.pennyway.api.apis.auth.usecase.AuthCheckUseCase; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; + +@WebMvcTest(controllers = {AuthCheckController.class}) +@ActiveProfiles("local") +class AuthCheckControllerTest { + private final String inputPhone = "010-1234-5678"; + private final String expectedUsername = "pennyway"; + private final String code = "123456"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private AuthCheckUseCase authCheckUseCase; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(post("/**").with(csrf())) + .build(); + } + + @Test + @DisplayName("일반 회원의 휴대폰 번호로 아이디를 찾을 때 200 응답을 반환한다.") + void findUsername() throws Exception { + // given + given(authCheckUseCase.findUsername(inputPhone, code)).willReturn(new AuthFindDto.FindUsernameRes(expectedUsername)); + + // when + ResultActions resultActions = findUsernameRequest(inputPhone, code); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.username").value(expectedUsername)); + } + + @Test + @DisplayName("일반 회원이 아닌 휴대폰 번호로 아이디를 찾을 때 404 응답을 반환한다.") + void findUsernameIfUserNotFound() throws Exception { + // given + String phone = "010-1111-1111"; + given(authCheckUseCase.findUsername(phone, code)).willThrow(new UserErrorException(UserErrorCode.NOT_FOUND)); + + // when + ResultActions resultActions = findUsernameRequest(phone, code); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(UserErrorCode.NOT_FOUND.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(UserErrorCode.NOT_FOUND.getExplainError())); + } + + private ResultActions findUsernameRequest(String phone, String code) throws Exception { + return mockMvc.perform(get("/v1/find/username") + .param("phone", phone) + .param("code", code)); + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/AuthFindMapperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/AuthFindMapperTest.java new file mode 100644 index 000000000..a96f5ef03 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/AuthFindMapperTest.java @@ -0,0 +1,82 @@ +package kr.co.pennyway.api.apis.auth.mapper; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; +import kr.co.pennyway.api.apis.auth.service.AuthFindService; +import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; + +@ExtendWith(MockitoExtension.class) +class AuthFindMapperTest { + private AuthFindService authFindService; + @Mock + private UserService userService; + + @Mock + private PhoneVerificationService phoneVerificationService; + + @BeforeEach + void setUp() { + authFindService = new AuthFindService(userService, phoneVerificationService); + } + + @DisplayName("휴대폰 번호로 유저를 찾을 수 없을 때 AuthFinderException을 발생시킨다.") + @Test + void findUsernameIfUserNotFound() { + // given + String phone = "010-1234-5678"; + given(userService.readUserByPhone(phone)).willReturn(Optional.empty()); + + // when - then + UserErrorException exception = assertThrows(UserErrorException.class, () -> authFindService.findUsername(phone)); + System.out.println(exception.getExplainError()); + } + + @DisplayName("휴대폰 번호로 유저를 찾았으나 OAuth 유저일 때 AuthFinderException을 발생시킨다.") + @Test + void findUsernameIfUserIsOAuth() { + // given + String phone = "010-2629-4624"; + User user = User.builder() + .username("pennyway") + .password(null) + .build(); + given(userService.readUserByPhone(phone)).willReturn(Optional.of(user)); + + // when - then + UserErrorException exception = assertThrows(UserErrorException.class, () -> authFindService.findUsername(phone)); + System.out.println(exception.getExplainError()); + } + + @DisplayName("휴대폰 번호를 통해 유저를 찾아 User를 반환한다.") + @Test + void findUsernameIfUserFound() { + // given + String phone = "010-2629-4624"; + String username = "pennyway"; + User user = User.builder() + .username("pennyway") + .password("password") + .build(); + given(userService.readUserByPhone(phone)).willReturn(Optional.of(user)); + + // when + AuthFindDto.FindUsernameRes result = authFindService.findUsername(phone); + + // then + assertEquals(result, new AuthFindDto.FindUsernameRes(username)); + } +} \ No newline at end of file From 1fe2abf7d74492509cef494fa79c8276cd2ac2aa Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 24 Apr 2024 15:24:16 +0900 Subject: [PATCH 058/152] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EC=A0=84=EC=86=A1=20API=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=EC=97=90=20=EB=94=B0=EB=A5=B8=20Deprecated=20API=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 로그인 상태 확인 api swagger 문서 수정 * fix: auth_controller send code api 제거 * fix: oauth_controller send code api 제거 --- .../pennyway/api/apis/auth/api/AuthApi.java | 18 ---------------- .../pennyway/api/apis/auth/api/OauthApi.java | 21 ------------------- .../api/apis/auth/api/UserAuthApi.java | 5 +++-- .../apis/auth/controller/AuthController.java | 6 ------ .../apis/auth/controller/OauthController.java | 7 ------- 5 files changed, 3 insertions(+), 54 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java index f95d885fe..d2615abaf 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java @@ -19,24 +19,6 @@ @Tag(name = "[인증 API]") public interface AuthApi { - @Deprecated - @Operation(summary = "[1] 일반 회원가입 인증번호 전송", description = "deprecated된 API입니다. [인증코드 SMS 요청]의 /v1/phone API를 사용해주세요.", deprecated = true) - @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "발신 성공", value = """ - { - "code": "2000", - "data": { - "sms": { - "to": "010-1234-5678", - "sendAt": "2024-04-04 00:31:57", - "expiresAt": "2024-04-04 00:36:57" - } - } - } - """) - })) - ResponseEntity sendCode(@RequestBody @Validated PhoneVerificationDto.PushCodeReq request); - @Operation(summary = "[2] 일반 회원가입 인증번호 검증", description = "인증번호를 검증합니다. 미인증 사용자만 가능합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java index 12162c0b3..89aba2641 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java @@ -59,27 +59,6 @@ public interface OauthApi { }) ResponseEntity signIn(@RequestParam Provider provider, @RequestBody @Validated SignInReq.Oauth request); - @Deprecated - @Operation(summary = "[2] 인증번호 발송", description = "deprecated된 API입니다. [인증코드 SMS 요청]의 /v1/phone API를 사용해주세요.", deprecated = true) - @Parameter(name = "provider", description = "소셜 제공자", examples = { - @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") - }, required = true, in = ParameterIn.QUERY) - @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "발신 성공", value = """ - { - "code": "2000", - "data": { - "sms": { - "to": "010-1234-5678", - "sendAt": "2024-04-04 00:31:57", - "expiresAt": "2024-04-04 00:36:57" - } - } - } - """) - })) - ResponseEntity sendCode(@RequestParam Provider provider, @RequestBody @Validated PhoneVerificationDto.PushCodeReq request); - @Operation(summary = "[3] 전화번호 인증", description = "전화번호 인증 후 이미 계정이 존재하면 연동, 없으면 회원가입") @Parameter(name = "provider", description = "소셜 제공자", examples = { @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java index f670cef8a..d28634759 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.SchemaProperty; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; @@ -20,9 +21,9 @@ @Tag(name = "[사용자 인증 관리 API]", description = "사용자의 인증과 관련된 UseCase(로그아웃, 소셜 계정 연동/해지 등)를 제공하는 API") public interface UserAuthApi { - @Operation(summary = "로그인 상태 확인", description = "사용자의 로그인 상태를 확인한다. 단, 유효하지 않은 토큰은 에러 응답이 발생한다.") + @Operation(summary = "로그인 상태 확인", description = "사용자의 로그인 상태를 확인하고 토큰에 등록된 사용자 pk값을 확인한다. 만약, 토큰이 만료되었거나 유효하지 않은 경우에는 401 에러를 반환한다.") @Parameter(name = "Authorization", in = ParameterIn.HEADER, hidden = true) - @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", schema = @Schema(implementation = AuthStateDto.class))) + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "user", schema = @Schema(implementation = AuthStateDto.class)))) ResponseEntity getAuthState(@RequestHeader(value = "Authorization", required = false, defaultValue = "") String authHeader); @Operation(summary = "로그아웃", description = """ diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java index c26544ab2..67e8bd105 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java @@ -30,12 +30,6 @@ public class AuthController implements AuthApi { private final AuthUseCase authUseCase; private final CookieUtil cookieUtil; - @PostMapping("/phone") - @PreAuthorize("isAnonymous()") - public ResponseEntity sendCode(@RequestBody @Validated PhoneVerificationDto.PushCodeReq request) { - return ResponseEntity.ok(SuccessResponse.from("sms", authUseCase.sendCode(request))); - } - @PostMapping("/phone/verification") @PreAuthorize("isAnonymous()") public ResponseEntity verifyCode(@RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request) { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/OauthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/OauthController.java index 404ebe937..dcc8c484e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/OauthController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/OauthController.java @@ -42,13 +42,6 @@ public ResponseEntity signIn(@RequestParam Provider provider, @RequestBody @V return createAuthenticatedResponse(userInfo); } - @Override - @PostMapping("/phone") - @PreAuthorize("isAnonymous()") - public ResponseEntity sendCode(@RequestParam Provider provider, @RequestBody @Validated PhoneVerificationDto.PushCodeReq request) { - return ResponseEntity.ok(SuccessResponse.from("sms", oauthUseCase.sendCode(provider, request))); - } - @Override @PostMapping("/phone/verification") @PreAuthorize("isAnonymous()") From f00003431aff467e67a4864b730888f695553e83 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 24 Apr 2024 21:47:42 +0900 Subject: [PATCH 059/152] =?UTF-8?q?=E2=9C=A8=20OIDC=20=EC=A0=95=EC=B1=85?= =?UTF-8?q?=EC=97=90=EC=84=9C=20id=20token=EC=97=90=20nonce=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 로그인 상태 확인 api swagger 문서 수정 * fix: auth_controller send code api 제거 * fix: oauth_controller send code api 제거 * fix: oauth_oidc_provider nonce 검증 구문 추가 * fix: oauth 로그인/회원가입 요청 시 nonce 필드 추가 * fix: service 로직 내 nonce 인자 전달 * fix: sign_up_req oauth_info dto에 nonce 필드 추가 * test: oauth controller 테스트 nonce 수정 * fix: oauth_use_case 영속화 실패 -> @transactional 추가 * fix: send_code api 통합 pr에서 제거하지 않은 use case 내 send code 메서드 제거 --- .../pennyway/api/apis/auth/dto/SignInReq.java | 5 ++- .../pennyway/api/apis/auth/dto/SignUpReq.java | 12 +++-- .../api/apis/auth/helper/OauthOidcHelper.java | 5 ++- .../api/apis/auth/usecase/AuthUseCase.java | 4 -- .../api/apis/auth/usecase/OauthUseCase.java | 9 ++-- .../OAuthControllerIntegrationTest.java | 44 +++++++++---------- .../common/oidc/OauthOidcProviderImpl.java | 4 +- 7 files changed, 43 insertions(+), 40 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java index 9c56621f0..788aa335e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignInReq.java @@ -22,7 +22,10 @@ public record Oauth( String oauthId, @Schema(description = "OIDC 토큰") @NotBlank(message = "OIDC 토큰은 필수 입력값입니다.") - String idToken + String idToken, + @Schema(description = "OIDC nonce") + @NotBlank(message = "OIDC nonce는 필수 입력값입니다.") + String nonce ) { } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java index 471a56b6c..323cd4069 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java @@ -40,7 +40,7 @@ public String password() { } } - public record OauthInfo(String idToken, String name, String username, String phone, String code) { + public record OauthInfo(String idToken, String nonce, String name, String username, String phone, String code) { public User toUser() { return User.builder() .username(username) @@ -105,6 +105,9 @@ public record Oauth( @Schema(description = "OIDC 토큰") @NotBlank(message = "OIDC 토큰은 필수 입력값입니다.") String idToken, + @Schema(description = "OIDC nonce") + @NotBlank(message = "OIDC nonce는 필수 입력값입니다.") + String nonce, @Schema(description = "이름", example = "페니웨이") @NotBlank(message = "이름을 입력해주세요") @Pattern(regexp = "^[가-힣a-z]{2,8}$", message = "2~8자의 한글, 영문 소문자만 사용 가능합니다.") @@ -123,7 +126,7 @@ public record Oauth( String code ) { public OauthInfo toOauthInfo() { - return new OauthInfo(idToken, name, username, phone, code); + return new OauthInfo(idToken, nonce, name, username, phone, code); } } @@ -132,6 +135,9 @@ public record SyncWithAuth( @Schema(description = "OIDC 토큰") @NotBlank(message = "OIDC 토큰은 필수 입력값입니다.") String idToken, + @Schema(description = "OIDC nonce") + @NotBlank(message = "OIDC nonce는 필수 입력값입니다.") + String nonce, @Schema(description = "전화번호", example = "010-1234-5678") @NotBlank(message = "전화번호를 입력해주세요") @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") @@ -142,7 +148,7 @@ public record SyncWithAuth( String code ) { public OauthInfo toOauthInfo() { - return new OauthInfo(idToken, null, null, phone, code); + return new OauthInfo(idToken, nonce, null, null, phone, code); } } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java index eeccae84e..c097cc463 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java @@ -41,14 +41,15 @@ public OauthOidcHelper( * * @param provider : {@link Provider} * @param idToken : idToken + * @param nonce : 인증 서버 로그인 요청 시 전달한 임의의 문자열 * @return OIDCDecodePayload : ID Token의 payload */ - public OidcDecodePayload getPayload(Provider provider, String idToken) { + public OidcDecodePayload getPayload(Provider provider, String idToken, String nonce) { OauthOidcClient client = oauthOidcClients.get(provider).keySet().iterator().next(); OauthOidcClientProperties properties = oauthOidcClients.get(provider).values().iterator().next(); OidcPublicKeyResponse response = client.getOidcPublicKey(); - return getPayloadFromIdToken(idToken, properties.getIssuer(), properties.getSecret(), null, response); + return getPayloadFromIdToken(idToken, properties.getIssuer(), properties.getSecret(), nonce, response); } /** diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java index 102436380..6dc094a9e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthUseCase.java @@ -29,10 +29,6 @@ public class AuthUseCase { private final PhoneVerificationService phoneVerificationService; private final PhoneCodeService phoneCodeService; - public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeReq request) { - return phoneVerificationService.sendCode(request, PhoneCodeKeyType.SIGN_UP); - } - public PhoneVerificationDto.VerifyCodeRes verifyCode(PhoneVerificationDto.VerifyCodeReq request) { Boolean isValidCode = phoneVerificationService.isValidCode(request, PhoneCodeKeyType.SIGN_UP); UserSyncDto userSync = checkOauthUserNotGeneralSignUp(request.phone()); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java index 366df2f75..f6704e813 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java @@ -32,8 +32,9 @@ public class OauthUseCase { private final JwtAuthHelper jwtAuthHelper; private final UserOauthSignService userOauthSignService; + @Transactional(readOnly = true) public Pair signIn(Provider provider, SignInReq.Oauth request) { - OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken()); + OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken(), request.nonce()); log.debug("payload : {}", payload); if (!request.oauthId().equals(payload.sub())) @@ -43,10 +44,6 @@ public Pair signIn(Provider provider, SignInReq.Oauth request) { return (user != null) ? Pair.of(user.getId(), jwtAuthHelper.createToken(user)) : Pair.of(-1L, null); } - public PhoneVerificationDto.PushCodeRes sendCode(Provider provider, PhoneVerificationDto.PushCodeReq request) { - return phoneVerificationService.sendCode(request, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - } - @Transactional(readOnly = true) public PhoneVerificationDto.VerifyCodeRes verifyCode(Provider provider, PhoneVerificationDto.VerifyCodeReq request) { Boolean isValidCode = phoneVerificationService.isValidCode(request, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); @@ -69,7 +66,7 @@ public Pair signUp(Provider provider, SignUpReq.OauthInfo request) { throw new OauthException(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST); } - OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken()); + OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken(), request.nonce()); User user = userOauthSignService.saveUser(request, userSync, provider, payload.sub()); return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java index 05698c011..9043ce5f6 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java @@ -57,9 +57,9 @@ @TestPropertySource(properties = "oauth2.client.provider.kakao.jwks-uri=http://localhost:${wiremock.server.port}") public class OAuthControllerIntegrationTest extends ExternalApiDBTestConfig { private final String expectedUsername = "jayang"; - private final String expectedOauthId = "testOauthId"; private final String expectedIdToken = "testIdToken"; + private final String expectedNonce = "testNonce"; private final String expectedPhone = "010-1234-5678"; private final String expectedCode = "123456"; @Autowired @@ -140,12 +140,12 @@ void signInWithOauth() throws Exception { Provider provider = Provider.KAKAO; User user = createOauthSignedUser(); - given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); userService.createUser(user); oauthService.createOauth(createOauthAccount(user, provider)); // when - ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken, expectedNonce); // then result @@ -165,12 +165,12 @@ void signInWithDifferentProvider() throws Exception { Provider provider = Provider.KAKAO; User user = createOauthSignedUser(); - given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); userService.createUser(user); oauthService.createOauth(createOauthAccount(user, Provider.GOOGLE)); // when - ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken, expectedNonce); // then result @@ -188,11 +188,11 @@ void signInWithGeneralSignedUser() throws Exception { Provider provider = Provider.KAKAO; User user = createGeneralSignedUser(); - given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); userService.createUser(user); // when - ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken, expectedNonce); // then result @@ -209,10 +209,10 @@ void signInWithNoSignedUser() throws Exception { // given Provider provider = Provider.KAKAO; - given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when - ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken, expectedNonce); // then result @@ -230,12 +230,12 @@ void signInWithNotMatchedOauthId() throws Exception { Provider provider = Provider.KAKAO; User user = createOauthSignedUser(); - given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", "differentOauthId", "email")); + given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", "differentOauthId", "email")); userService.createUser(user); oauthService.createOauth(createOauthAccount(user, provider)); // when - ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken, expectedNonce); // then result @@ -245,8 +245,8 @@ void signInWithNotMatchedOauthId() throws Exception { .andDo(print()); } - private ResultActions performOauthSignIn(Provider provider, String oauthId, String idToken) throws Exception { - SignInReq.Oauth request = new SignInReq.Oauth(oauthId, idToken); + private ResultActions performOauthSignIn(Provider provider, String oauthId, String idToken, String nonce) throws Exception { + SignInReq.Oauth request = new SignInReq.Oauth(oauthId, idToken, expectedNonce); return mockMvc.perform(post("/v1/auth/oauth/sign-in") .param("provider", provider.name()) @@ -422,7 +422,7 @@ void signUpWithGeneralSignedUser() throws Exception { userService.createUser(user); phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); @@ -452,7 +452,7 @@ void signUpWithDifferentProvider() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); @@ -477,7 +477,7 @@ void signUpWithNoSignedUser() throws Exception { // given Provider provider = Provider.KAKAO; phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); @@ -503,7 +503,7 @@ void signUpWithSameProvider() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); @@ -517,7 +517,7 @@ void signUpWithSameProvider() throws Exception { } private ResultActions performOauthSignUpAccountLinking(Provider provider, String code) throws Exception { - SignUpReq.SyncWithAuth request = new SignUpReq.SyncWithAuth(expectedIdToken, expectedPhone, code); + SignUpReq.SyncWithAuth request = new SignUpReq.SyncWithAuth(expectedIdToken, expectedNonce, expectedPhone, code); return mockMvc.perform(post("/v1/auth/oauth/link-auth") .param("provider", provider.name()) .contentType("application/json") @@ -537,7 +537,7 @@ void signUpWithNoSignedUser() throws Exception { // given Provider provider = Provider.KAKAO; phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when ResultActions result = performOauthSignUp(provider, expectedCode); @@ -565,7 +565,7 @@ void signUpWithGeneralSignedUser() throws Exception { userService.createUser(user); phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when ResultActions result = performOauthSignUp(provider, expectedCode); @@ -591,7 +591,7 @@ void signUpWithOauthSignedUser() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when ResultActions result = performOauthSignUp(provider, expectedCode); @@ -605,7 +605,7 @@ void signUpWithOauthSignedUser() throws Exception { } private ResultActions performOauthSignUp(Provider provider, String code) throws Exception { - SignUpReq.Oauth request = new SignUpReq.Oauth(expectedIdToken, "jayang", expectedUsername, expectedPhone, code); + SignUpReq.Oauth request = new SignUpReq.Oauth(expectedIdToken, expectedNonce, "jayang", expectedUsername, expectedPhone, code); return mockMvc.perform(post("/v1/auth/oauth/sign-up") .param("provider", provider.name()) .contentType("application/json") diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java index 8ab41e347..69cc6da03 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java @@ -52,6 +52,7 @@ public OidcDecodePayload getOIDCTokenBody(String token, String modulus, String e * ID Token의 header와 body를 Base64 방식으로 디코딩하는 메서드
* payload의 iss, aud, exp, nonce를 검증하고, 실패시 예외 처리 */ + @SuppressWarnings("unchecked") private Map> getUnsignedTokenClaims(String token, String iss, String aud, String nonce) { try { Base64.Decoder decoder = Base64.getUrlDecoder(); @@ -60,13 +61,12 @@ private Map> getUnsignedTokenClaims(String token, St String headerJson = new String(decoder.decode(unsignedToken.split("\\.")[0])); String payloadJson = new String(decoder.decode(unsignedToken.split("\\.")[1])); - @SuppressWarnings("unchecked") Map header = objectMapper.readValue(headerJson, Map.class); - @SuppressWarnings("unchecked") Map payload = objectMapper.readValue(payloadJson, Map.class); Assert.isTrue(payload.get("aud").equals(aud), "aud is not matched. expected : " + aud + ", actual : " + payload.get("aud")); Assert.isTrue(payload.get("iss").equals(iss), "iss is not matched. expected : " + iss + ", actual : " + payload.get("iss")); + Assert.isTrue(payload.get("nonce").equals(nonce), "nonce is not matched. expected : " + nonce + ", actual : " + payload.get("nonce")); return Map.of("header", header, "payload", payload); } catch (IllegalArgumentException e) { From b267155c826ba9c4f0f48f6c7918fd47ae8be8bb Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 26 Apr 2024 11:26:14 +0900 Subject: [PATCH 060/152] =?UTF-8?q?=E2=9C=A8=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=EB=94=94/=EC=9D=B4=EB=A6=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20API=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 사용자 계정 api 내부 클래스로 분리 * test: 사용자 이름 수정 controller unit pre-condition 작성 * test: 이름 수정 요청 controller unit test case 작성 * fix: 일반 회원가입 계정이 아닌 예외 상수 추가 : 4004 * feat: 이름 변경 요청 dto 정의 * feat: put_name() controller 메서드 추가 * feat: update_name() usecase 추가 및 void 타입에 맞게 테스트 코드 given 수정 * test: 422 예상 에러 코드 수정 * test: 사용자 계정 usecase 기존 test 내부 클래스로 분리 * test: 일반 회원가입 유저 pre-condition 제거 * test: 이름 수정 usecase test case 작성 * feat: 사용자 이름 수정 로직 구현 * feat: user 도메인 이름 수정 메서드 추가 * test: user_account_use_case_test 순서 지정 * fix: 디바이스 비활성화 em.create_query() -> 메서드 호출 (기존 방식 에러 발생) * test: 사용자 닉네임 수정 controller unit test 작성 * feat: 사용자 아이디 변경 요청 dto 작성 * feat: 사용자 아이디 변경 요청 api 작성 * feat: 사용자 아이디 변경 요청 usecase 작성 * feat: 사용자 아이디 변경 service 로직 구현 * test: nickname -> username * test: 테스트 코드에서 entitymanager 주입 제거 * refactor: user account use case 사용자 조회 메서드 분리 * fix: 이름 및 아이디 수정 요청 메서드 put -> patch * test: put 요청 patch로 변경 --- .../api/apis/users/api/UserAccountApi.java | 7 + .../controller/UserAccountController.java | 20 + .../apis/users/dto/UserProfileUpdateDto.java | 25 + .../service/UserProfileUpdateService.java | 22 + .../users/usecase/UserAccountUseCase.java | 35 +- .../UserAccountControllerUnitTest.java | 216 ++++++++- .../users/usecase/UserAccountUseCaseTest.java | 434 ++++++++++-------- .../domain/domains/device/domain/Device.java | 4 + .../domain/domains/user/domain/User.java | 8 + 9 files changed, 545 insertions(+), 226 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java index 28380b432..7c568b2b6 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java @@ -13,6 +13,7 @@ import jakarta.validation.constraints.NotBlank; import kr.co.pennyway.api.apis.users.dto.DeviceDto; import kr.co.pennyway.api.apis.users.dto.UserProfileDto; +import kr.co.pennyway.api.apis.users.dto.UserProfileUpdateDto; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -60,4 +61,10 @@ public interface UserAccountApi { @Operation(summary = "사용자 계정 조회", description = "사용자 본인의 계정 정보를 조회합니다.") @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "user", schema = @Schema(implementation = UserProfileDto.class)))) ResponseEntity getMyAccount(@AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "사용자 이름 수정") + ResponseEntity putName(@RequestBody @Validated UserProfileUpdateDto.NameReq request, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "사용자 아이디 수정") + ResponseEntity putUsername(@RequestBody @Validated UserProfileUpdateDto.UsernameReq request, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java index 94d5be364..2293d56f9 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java @@ -3,6 +3,7 @@ import jakarta.validation.constraints.NotBlank; import kr.co.pennyway.api.apis.users.api.UserAccountApi; import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.api.apis.users.dto.UserProfileUpdateDto; import kr.co.pennyway.api.apis.users.usecase.UserAccountUseCase; import kr.co.pennyway.api.common.response.SuccessResponse; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; @@ -21,12 +22,14 @@ public class UserAccountController implements UserAccountApi { private final UserAccountUseCase userAccountUseCase; + @Override @PutMapping("/devices") @PreAuthorize("isAuthenticated()") public ResponseEntity putDevice(@RequestBody @Validated DeviceDto.RegisterReq request, @AuthenticationPrincipal SecurityUserDetails user) { return ResponseEntity.ok(SuccessResponse.from("device", userAccountUseCase.registerDevice(user.getUserId(), request))); } + @Override @DeleteMapping("/devices") @PreAuthorize("isAuthenticated()") public ResponseEntity deleteDevice(@RequestParam("token") @Validated @NotBlank String token, @AuthenticationPrincipal SecurityUserDetails user) { @@ -34,9 +37,26 @@ public ResponseEntity deleteDevice(@RequestParam("token") @Validated @NotBlan return ResponseEntity.ok(SuccessResponse.noContent()); } + @Override @GetMapping("") @PreAuthorize("isAuthenticated()") public ResponseEntity getMyAccount(@AuthenticationPrincipal SecurityUserDetails user) { return ResponseEntity.ok(SuccessResponse.from("user", userAccountUseCase.getMyAccount(user.getUserId()))); } + + @Override + @PatchMapping("/name") + @PreAuthorize("isAuthenticated()") + public ResponseEntity putName(UserProfileUpdateDto.NameReq request, SecurityUserDetails user) { + userAccountUseCase.updateName(user.getUserId(), request.name()); + return ResponseEntity.ok(SuccessResponse.noContent()); + } + + @Override + @PatchMapping("/username") + @PreAuthorize("isAuthenticated()") + public ResponseEntity putUsername(UserProfileUpdateDto.UsernameReq request, SecurityUserDetails user) { + userAccountUseCase.updateUsername(user.getUserId(), request.username()); + return ResponseEntity.ok(SuccessResponse.noContent()); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java new file mode 100644 index 000000000..381f456b0 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.api.apis.users.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public class UserProfileUpdateDto { + @Schema(title = "이름 변경 요청 DTO") + public record NameReq( + @Schema(description = "이름", example = "페니웨이") + @NotBlank(message = "이름을 입력해주세요") + @Pattern(regexp = "^[가-힣a-z]{2,8}$", message = "2~8자의 한글, 영문 소문자만 사용 가능합니다.") + String name + ) { + } + + @Schema(title = "아이디 변경 요청 DTO") + public record UsernameReq( + @Schema(description = "아이디", example = "pennyway") + @NotBlank(message = "아이디를 입력해주세요") + @Pattern(regexp = "^[a-z-_.]{5,20}$", message = "5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.") + String username + ) { + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java new file mode 100644 index 000000000..e4d87ea3f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.api.apis.users.service; + +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserProfileUpdateService { + @Transactional + public void updateName(User user, String newName) { + user.updateName(newName); + } + + @Transactional + public void updateUsername(User user, String newUsername) { + user.updateUsername(newUsername); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java index 5754f5e6b..6097b5638 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java @@ -3,6 +3,7 @@ import kr.co.pennyway.api.apis.users.dto.DeviceDto; import kr.co.pennyway.api.apis.users.dto.UserProfileDto; import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; +import kr.co.pennyway.api.apis.users.service.UserProfileUpdateService; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.domains.device.domain.Device; import kr.co.pennyway.domain.domains.device.exception.DeviceErrorCode; @@ -23,13 +24,13 @@ public class UserAccountUseCase { private final UserService userService; private final DeviceService deviceService; + private final UserProfileUpdateService userProfileUpdateService; + private final DeviceRegisterService deviceRegisterService; @Transactional public DeviceDto.RegisterRes registerDevice(Long userId, DeviceDto.RegisterReq request) { - User user = userService.readUser(userId).orElseThrow( - () -> new UserErrorException(UserErrorCode.NOT_FOUND) - ); + User user = readUserOrThrow(userId); Device device = deviceRegisterService.createOrUpdateDevice(user, request); @@ -38,9 +39,7 @@ public DeviceDto.RegisterRes registerDevice(Long userId, DeviceDto.RegisterReq r @Transactional public void unregisterDevice(Long userId, String token) { - User user = userService.readUser(userId).orElseThrow( - () -> new UserErrorException(UserErrorCode.NOT_FOUND) - ); + User user = readUserOrThrow(userId); Device device = deviceService.readDeviceByUserIdAndToken(user.getId(), token).orElseThrow( () -> new DeviceErrorException(DeviceErrorCode.NOT_FOUND_DEVICE) @@ -51,10 +50,28 @@ public void unregisterDevice(Long userId, String token) { @Transactional(readOnly = true) public UserProfileDto getMyAccount(Long userId) { - User user = userService.readUser(userId).orElseThrow( - () -> new UserErrorException(UserErrorCode.NOT_FOUND) - ); + User user = readUserOrThrow(userId); return UserProfileDto.from(user); } + + @Transactional + public void updateName(Long userId, String newName) { + User user = readUserOrThrow(userId); + + userProfileUpdateService.updateName(user, newName); + } + + @Transactional + public void updateUsername(Long userId, String newUsername) { + User user = readUserOrThrow(userId); + + userProfileUpdateService.updateUsername(user, newUsername); + } + + private User readUserOrThrow(Long userId) { + return userService.readUser(userId).orElseThrow( + () -> new UserErrorException(UserErrorCode.NOT_FOUND) + ); + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java index 59ff8d083..7f0d74b4b 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java @@ -2,11 +2,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.api.apis.users.dto.UserProfileUpdateDto; import kr.co.pennyway.api.apis.users.usecase.UserAccountUseCase; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import kr.co.pennyway.common.exception.StatusCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; @@ -16,16 +18,18 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; +import static kr.co.pennyway.common.exception.ReasonCode.REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(controllers = {UserAccountController.class}) @ActiveProfiles("local") +@TestClassOrder(ClassOrderer.OrderAnnotation.class) public class UserAccountControllerUnitTest { @Autowired private MockMvc mockMvc; @@ -42,28 +46,190 @@ void setUp(WebApplicationContext webApplicationContext) { .webAppContextSetup(webApplicationContext) .defaultRequest(put("/**").with(csrf())) .defaultRequest(delete("/**").with(csrf())) + .defaultRequest(patch("/**").with(csrf())) .build(); } - @DisplayName("[1] 디바이스가 정상적으로 저장되었을 때, 디바이스 pk와 등록된 토큰을 반환한다.") - @Test - @WithSecurityMockUser - void putDeviceSuccess() throws Exception { - // given - DeviceDto.RegisterReq request = new DeviceDto.RegisterReq("newToken", "newToken", "modelA", "Windows"); - DeviceDto.RegisterRes expectedResponse = new DeviceDto.RegisterRes(2L, "newToken"); - given(userAccountUseCase.registerDevice(1L, request)).willReturn(expectedResponse); - - // when - ResultActions result = mockMvc.perform(put("/v2/users/me/devices") - .contentType("application/json") - .content(objectMapper.writeValueAsString(request))); - - // then - result.andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("2000")) - .andExpect(jsonPath("$.data.device.id").value(expectedResponse.id())) - .andExpect(jsonPath("$.data.device.token").value(expectedResponse.token())) - .andDo(print()); + @Nested + @Order(1) + @DisplayName("[1] 디바이스 요청 테스트") + class DeviceRequestTest { + @DisplayName("디바이스가 정상적으로 저장되었을 때, 디바이스 pk와 등록된 토큰을 반환한다.") + @Test + @WithSecurityMockUser + void putDevice() throws Exception { + // given + DeviceDto.RegisterReq request = new DeviceDto.RegisterReq("newToken", "newToken", "modelA", "Windows"); + DeviceDto.RegisterRes expectedResponse = new DeviceDto.RegisterRes(2L, "newToken"); + given(userAccountUseCase.registerDevice(1L, request)).willReturn(expectedResponse); + + // when + ResultActions result = mockMvc.perform(put("/v2/users/me/devices") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andExpect(jsonPath("$.data.device.id").value(expectedResponse.id())) + .andExpect(jsonPath("$.data.device.token").value(expectedResponse.token())) + .andDo(print()); + } + } + + @Nested + @Order(2) + @DisplayName("[2] 사용자 이름 수정 테스트") + class UpdateNameTest { + @DisplayName("사용자 이름 수정 요청 시, 유효성 검사에 실패하면 422 에러를 반환한다.") + @Test + @WithSecurityMockUser + void updateNameValidationFail() throws Exception { + // given + String newNameWithBlank = " "; + String newNameWithOverLength = "안녕하세요장페르센입니다"; + String newNameWithSpecialCharacter = "hello!"; + String expectedErrorCode = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY.getCode()); + + // when + ResultActions result1 = performUpdateNameRequest(newNameWithBlank); + ResultActions result2 = performUpdateNameRequest(newNameWithOverLength); + ResultActions result3 = performUpdateNameRequest(newNameWithSpecialCharacter); + + // then + result1.andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value(expectedErrorCode)) + .andDo(print()); + result2.andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value(expectedErrorCode)) + .andDo(print()); + result3.andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value(expectedErrorCode)) + .andDo(print()); + } + + @DisplayName("사용자 이름 수정 요청 시, 삭제된 사용자인 경우 404 에러를 반환한다.") + @Test + @WithSecurityMockUser + void updateNameDeletedUser() throws Exception { + // given + String newName = "양재서"; + willThrow(new UserErrorException(UserErrorCode.NOT_FOUND)).given(userAccountUseCase).updateName(1L, newName); + + // when + ResultActions result = performUpdateNameRequest(newName); + + // then + result.andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(UserErrorCode.NOT_FOUND.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(UserErrorCode.NOT_FOUND.getExplainError())) + .andDo(print()); + } + + @DisplayName("사용자 이름 수정 요청 시, 사용자 이름이 정상적으로 수정되면 200 코드를 반환한다.") + @Test + @WithSecurityMockUser + void updateNameSuccess() throws Exception { + // given + String newName = "양재서"; + + // when + ResultActions result = performUpdateNameRequest(newName); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andDo(print()); + } + + private ResultActions performUpdateNameRequest(String newName) throws Exception { + UserProfileUpdateDto.NameReq request = new UserProfileUpdateDto.NameReq(newName); + return mockMvc.perform(patch("/v2/users/me/name") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + } + } + + @Nested + @Order(3) + @DisplayName("[3] 사용자 닉네임 수정 테스트") + class UpdateNicknameTest { + @DisplayName("사용자 닉네임 수정 요청 시, 유효성 검사에 실패하면 422 에러를 반환한다.") + @Test + @WithSecurityMockUser + void updateNicknameValidationFail() throws Exception { + // given + String newNicknameWithBlank = " "; + String newNicknameWithOverLength = "한글이름"; + String newNicknameWithSpecialCharacter = "hello!"; + String newNicknameWithWhiteSpace = "jay ang"; + String newNicknameWithOverLengthAndWhiteSpace = "myNameisJayangHello"; + String expectedErrorCode = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY.getCode()); + + // when + ResultActions result1 = performUpdateNicknameRequest(newNicknameWithBlank); + ResultActions result2 = performUpdateNicknameRequest(newNicknameWithOverLength); + ResultActions result3 = performUpdateNicknameRequest(newNicknameWithSpecialCharacter); + ResultActions result4 = performUpdateNicknameRequest(newNicknameWithWhiteSpace); + ResultActions result5 = performUpdateNicknameRequest(newNicknameWithOverLengthAndWhiteSpace); + + // then + result1.andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value(expectedErrorCode)) + .andDo(print()); + result2.andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value(expectedErrorCode)) + .andDo(print()); + result3.andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value(expectedErrorCode)) + .andDo(print()); + result4.andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value(expectedErrorCode)) + .andDo(print()); + result5.andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value(expectedErrorCode)) + .andDo(print()); + } + + @DisplayName("사용자 닉네임 수정 요청 시, 삭제된 사용자인 경우 404 에러를 반환한다.") + @Test + @WithSecurityMockUser + void updateNicknameDeletedUser() throws Exception { + // given + String newNickname = "jayang._."; + willThrow(new UserErrorException(UserErrorCode.NOT_FOUND)).given(userAccountUseCase).updateUsername(1L, newNickname); + + // when + ResultActions result = performUpdateNicknameRequest(newNickname); + + // then + result.andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(UserErrorCode.NOT_FOUND.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(UserErrorCode.NOT_FOUND.getExplainError())) + .andDo(print()); + } + + @DisplayName("사용자 닉네임 수정 요청 시, 사용자 닉네임이 정상적으로 수정되면 200 코드를 반환한다.") + @Test + @WithSecurityMockUser + void updateNicknameSuccess() throws Exception { + // given + String newNickname = "jayang._."; + + // when + ResultActions result = performUpdateNicknameRequest(newNickname); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andDo(print()); + } + + private ResultActions performUpdateNicknameRequest(String newNickname) throws Exception { + UserProfileUpdateDto.UsernameReq request = new UserProfileUpdateDto.UsernameReq(newNickname); + return mockMvc.perform(patch("/v2/users/me/username") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + } } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java index 902ae07f8..74b17adee 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java @@ -1,22 +1,23 @@ package kr.co.pennyway.api.apis.users.usecase; -import jakarta.persistence.EntityManager; import kr.co.pennyway.api.apis.users.dto.DeviceDto; import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; +import kr.co.pennyway.api.apis.users.service.UserProfileUpdateService; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.fixture.DeviceFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; import kr.co.pennyway.domain.config.JpaConfig; import kr.co.pennyway.domain.domains.device.domain.Device; import kr.co.pennyway.domain.domains.device.exception.DeviceErrorCode; import kr.co.pennyway.domain.domains.device.exception.DeviceErrorException; import kr.co.pennyway.domain.domains.device.service.DeviceService; import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; import kr.co.pennyway.domain.domains.user.service.UserService; import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; import kr.co.pennyway.domain.domains.user.type.Role; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.annotation.Autowired; @@ -33,9 +34,10 @@ @ExtendWith(MockitoExtension.class) @DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") -@ContextConfiguration(classes = {JpaConfig.class, UserAccountUseCase.class, DeviceRegisterService.class, UserService.class, DeviceService.class}) +@ContextConfiguration(classes = {JpaConfig.class, UserAccountUseCase.class, DeviceRegisterService.class, UserService.class, DeviceService.class, UserProfileUpdateService.class}) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ActiveProfiles("test") +@TestClassOrder(ClassOrderer.OrderAnnotation.class) class UserAccountUseCaseTest extends ExternalApiDBTestConfig { @Autowired private UserService userService; @@ -46,196 +48,244 @@ class UserAccountUseCaseTest extends ExternalApiDBTestConfig { @Autowired private UserAccountUseCase userAccountUseCase; - @Autowired - private EntityManager em; - - private User requestUser; - - @BeforeEach - void setUp() { - User user = User.builder().role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).build(); - requestUser = userService.createUser(user); - } - - @Test - @Transactional - @DisplayName("[1] originToken과 newToken이 같은 경우, 신규 디바이스를 등록한다.") - void registerNewDevice() { - // given - DeviceDto.RegisterReq request = DeviceFixture.INIT.toRegisterReq(); - - // when - DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - System.out.println("device = " + device); - }, - () -> fail("신규 디바이스가 등록되어 있어야 한다.") - ); + @Order(1) + @Nested + @DisplayName("[1] 디바이스 등록 테스트") + class DeviceRegisterTest { + private User requestUser; + + @BeforeEach + void setUp() { + User user = User.builder().role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).build(); + requestUser = userService.createUser(user); + } + + @Test + @Transactional + @DisplayName("[1] originToken과 newToken이 같은 경우, 신규 디바이스를 등록한다.") + void registerNewDevice() { + // given + DeviceDto.RegisterReq request = DeviceFixture.INIT.toRegisterReq(); + + // when + DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + System.out.println("device = " + device); + }, + () -> fail("신규 디바이스가 등록되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[1-1] 저장 요청에서 originToken에 대한 디바이스가 이미 존재하는 경우, 디바이스 정보 변경 사항만 업데이트하고 기존 디바이스 정보를 반환한다.") + void registerNewDeviceWhenDeviceIsAlreadyExists() { + // given + Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(originDevice); + DeviceDto.RegisterReq request = DeviceFixture.ONLY_MODEL_AND_OS_CHANGED.toRegisterReq(); + + // when + DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertEquals("디바이스 ID가 일치해야 한다.", originDevice.getId(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); + System.out.println("device = " + device); + }, + () -> fail("신규 디바이스가 등록되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[2] originToken과 일치하는 활성화 디바이스 토큰이 존재한다면, 디바이스 토큰을 갱신한다.") + void updateActivateDeviceToken() { + // given + Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(originDevice); + + System.out.println("originDevice = " + originDevice); + DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); + + // when + DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); + System.out.println("device = " + device); + }, + () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[2-1] 기존에 등록된 비활성화 디바이스 토큰이 있고 디바이스 정보가 일치한다면, 디바이스 토큰을 갱신하고 활성화로 변경한다.") + void updateDeactivateDeviceToken() { + // given + Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + originDevice.deactivate(); + deviceService.createDevice(originDevice); + + System.out.println("originDevice = " + originDevice); + DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); + + // when + DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); + System.out.println("device = " + device); + }, + () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[2-2] 사용자가 유효한 토큰을 가지고 있지만 모델명이나 OS가 다른 경우, 디바이스 정보를 업데이트한다.") + void notMatchDevice() { + // given + Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(originDevice); + System.out.println("originDevice = " + originDevice); + DeviceDto.RegisterReq request = DeviceFixture.ALL_CHANGED.toRegisterReq(); + + // when + userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", request.newToken(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); + System.out.println("device = " + device); + }, + () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[3] 토큰 수정 요청에서 oldToken에 대한 디바이스가 존재하지 않는 경우, NOT_FOUND 에러를 반환한다.") + void registerNewDeviceWhenOldDeviceTokenIsNotExists() { + // given + DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); + + // when - then + DeviceErrorException ex = assertThrows(DeviceErrorException.class, () -> userAccountUseCase.registerDevice(requestUser.getId(), request)); + assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode()); + } } - @Test - @Transactional - @DisplayName("[1-1] 저장 요청에서 originToken에 대한 디바이스가 이미 존재하는 경우, 디바이스 정보 변경 사항만 업데이트하고 기존 디바이스 정보를 반환한다.") - void registerNewDeviceWhenDeviceIsAlreadyExists() { - // given - Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(originDevice); - DeviceDto.RegisterReq request = DeviceFixture.ONLY_MODEL_AND_OS_CHANGED.toRegisterReq(); - - // when - DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertEquals("디바이스 ID가 일치해야 한다.", originDevice.getId(), device.getId()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); - System.out.println("device = " + device); - }, - () -> fail("신규 디바이스가 등록되어 있어야 한다.") - ); - } - - @Test - @Transactional - @DisplayName("[2] originToken과 일치하는 활성화 디바이스 토큰이 존재한다면, 디바이스 토큰을 갱신한다.") - void updateActivateDeviceToken() { - // given - Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(originDevice); - - System.out.println("originDevice = " + originDevice); - DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); - - // when - DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); - System.out.println("device = " + device); - }, - () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") - ); - } - - @Test - @Transactional - @DisplayName("[2-1] 기존에 등록된 비활성화 디바이스 토큰이 있고 디바이스 정보가 일치한다면, 디바이스 토큰을 갱신하고 활성화로 변경한다.") - void updateDeactivateDeviceToken() { - // given - Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(originDevice); - em.createQuery("UPDATE Device d SET d.activated = false WHERE d.id = :id AND d.token = :token") - .setParameter("id", originDevice.getId()) - .setParameter("token", originDevice.getToken()) - .executeUpdate(); // 비활성화 처리 - - System.out.println("originDevice = " + originDevice); - DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); - - // when - DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); - System.out.println("device = " + device); - }, - () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") - ); - } - - - @Test - @Transactional - @DisplayName("[2-2] 사용자가 유효한 토큰을 가지고 있지만 모델명이나 OS가 다른 경우, 디바이스 정보를 업데이트한다.") - void notMatchDevice() { - // given - Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(originDevice); - System.out.println("originDevice = " + originDevice); - DeviceDto.RegisterReq request = DeviceFixture.ALL_CHANGED.toRegisterReq(); - - // when - userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", request.newToken(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); - System.out.println("device = " + device); - }, - () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") - ); - } - - @Test - @Transactional - @DisplayName("[3] 토큰 수정 요청에서 oldToken에 대한 디바이스가 존재하지 않는 경우, NOT_FOUND 에러를 반환한다.") - void registerNewDeviceWhenOldDeviceTokenIsNotExists() { - // given - DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); - - // when - then - DeviceErrorException ex = assertThrows(DeviceErrorException.class, () -> userAccountUseCase.registerDevice(requestUser.getId(), request)); - assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("[4] 사용자 ID와 origin token에 매칭되는 활성 디바이스가 존재하는 경우 디바이스를 삭제한다.") - void unregisterDevice() { - // given - Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(device); - - // when - userAccountUseCase.unregisterDevice(requestUser.getId(), device.getToken()); - - // then - Optional deletedDevice = deviceService.readDeviceByUserIdAndToken(requestUser.getId(), device.getToken()); - assertNull("디바이스가 삭제되어 있어야 한다.", deletedDevice.orElse(null)); + @Order(2) + @Nested + @DisplayName("[2] 디바이스 삭제 테스트") + class DeviceUnregisterTest { + private User requestUser; + + @BeforeEach + void setUp() { + User user = User.builder().role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).build(); + requestUser = userService.createUser(user); + } + + @Test + @Transactional + @DisplayName("사용자 ID와 origin token에 매칭되는 활성 디바이스가 존재하는 경우 디바이스를 삭제한다.") + void unregisterDevice() { + // given + Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(device); + + // when + userAccountUseCase.unregisterDevice(requestUser.getId(), device.getToken()); + + // then + Optional deletedDevice = deviceService.readDeviceByUserIdAndToken(requestUser.getId(), device.getToken()); + assertNull("디바이스가 삭제되어 있어야 한다.", deletedDevice.orElse(null)); + } + + @Test + @Transactional + @DisplayName("사용자 ID와 token에 매칭되는 디바이스가 존재하지 않는 경우 NOT_FOUND_DEVICE 에러를 반환한다.") + void unregisterDeviceWhenDeviceIsNotExists() { + // given + Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(device); + + // when - then + DeviceErrorException ex = assertThrows(DeviceErrorException.class, () -> userAccountUseCase.unregisterDevice(requestUser.getId(), "notExistsToken")); + assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode()); + } } - @Test - @Transactional - @DisplayName("[5] 사용자 ID와 token에 매칭되는 디바이스가 존재하지 않는 경우 NOT_FOUND_DEVICE 에러를 반환한다.") - void unregisterDeviceWhenDeviceIsNotExists() { - // given - Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(device); - - // when - then - DeviceErrorException ex = assertThrows(DeviceErrorException.class, () -> userAccountUseCase.unregisterDevice(requestUser.getId(), "notExistsToken")); - assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode()); + @Order(3) + @Nested + @DisplayName("[3] 사용자 이름 수정 테스트") + class UpdateNameTest { + @Test + @Transactional + @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") + void updateNameWhenUserIsDeleted() { + // given + String newName = "양재서"; + User originUser = UserFixture.GENERAL_USER.toUser(); + userService.createUser(originUser); + userService.deleteUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.updateName(originUser.getId(), newName)); + assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("사용자의 이름이 성공적으로 변경된다.") + void updateName() { + // given + User originUser = UserFixture.GENERAL_USER.toUser(); + userService.createUser(originUser); + String newName = "양재서"; + + // when + userAccountUseCase.updateName(originUser.getId(), newName); + + // then + User updatedUser = userService.readUser(originUser.getId()).orElseThrow(); + assertEquals("사용자 이름이 변경되어 있어야 한다.", newName, updatedUser.getName()); + } } } \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java index 7772d3a4b..556b0558b 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java @@ -47,6 +47,10 @@ public void activate() { this.activated = Boolean.TRUE; } + public void deactivate() { + this.activated = Boolean.FALSE; + } + public void updateToken(String token) { this.token = token; } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java index 1b8e77b98..632a95c3c 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java @@ -75,6 +75,14 @@ public void updatePassword(String password) { this.passwordUpdatedAt = LocalDateTime.now(); } + public void updateName(String name) { + this.name = name; + } + + public void updateUsername(String username) { + this.username = username; + } + @Override public String toString() { return "User{" + From f262134f519a617d6f87340dc81644fa21f6c4af Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 26 Apr 2024 12:38:20 +0900 Subject: [PATCH 061/152] =?UTF-8?q?=E2=9C=A8=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EC=84=A4=EC=A0=95=20API=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: notify setting update 메서드 추가 * feat: notification 활성화 api 추가 * feat: notification 비활성화 api 추가 * feat: notification 활성화/비활성화 usecase 추가 * refactor: user account use case 사용자 조회 메서드 분리 * rename: notify -> flag * feat: 사용자 알림 설정 service 로직 구현 * feat: 사용자 알림 설정 응답 dto 정의 * feat: use case 응답에 dto 반영 * feat: notify type converter 정의 * feat: web_config에 notify_type_converter 등록 * fix: invalid_notify_type 400 -> 422 변경 * docs: notify api 응답 swagger 문서 작성 * test: user_account_use_case_test 빈 등록 * fix: 알림 설정 api 메서드 변경 put -> patch --- .../api/apis/users/api/UserAccountApi.java | 83 ++++++++++++++++++- .../controller/UserAccountController.java | 17 +++- .../apis/users/dto/UserProfileUpdateDto.java | 23 +++++ .../service/UserProfileUpdateService.java | 6 ++ .../users/usecase/UserAccountUseCase.java | 21 ++++- .../common/converter/NotifyTypeConverter.java | 17 ++++ .../kr/co/pennyway/api/config/WebConfig.java | 2 + .../domains/user/domain/NotifySetting.java | 12 +++ .../domains/user/exception/UserErrorCode.java | 5 +- 9 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/NotifyTypeConverter.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java index 7c568b2b6..086ad83a5 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java @@ -15,6 +15,7 @@ import kr.co.pennyway.api.apis.users.dto.UserProfileDto; import kr.co.pennyway.api.apis.users.dto.UserProfileUpdateDto; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -61,10 +62,90 @@ public interface UserAccountApi { @Operation(summary = "사용자 계정 조회", description = "사용자 본인의 계정 정보를 조회합니다.") @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "user", schema = @Schema(implementation = UserProfileDto.class)))) ResponseEntity getMyAccount(@AuthenticationPrincipal SecurityUserDetails user); - + @Operation(summary = "사용자 이름 수정") ResponseEntity putName(@RequestBody @Validated UserProfileUpdateDto.NameReq request, @AuthenticationPrincipal SecurityUserDetails user); @Operation(summary = "사용자 아이디 수정") ResponseEntity putUsername(@RequestBody @Validated UserProfileUpdateDto.UsernameReq request, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "사용자 알림 활성화") + @Parameter(name = "type", description = "알림 타입", examples = { + @ExampleObject(name = "가계부", value = "account_book"), @ExampleObject(name = "피드", value = "feed"), @ExampleObject(name = "채팅", value = "chat") + }, required = true, in = ParameterIn.QUERY) + @ApiResponses({ + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "가계부 알림 활성화", value = """ + { + "code": "2000", + "data": { + "notifySetting": { + "accountBookNotify": true + } + } + } + """), + @ExampleObject(name = "피드 알림 활성화", value = """ + { + "code": "2000", + "data": { + "notifySetting": { + "feedNotify": true + } + } + } + """), + @ExampleObject(name = "채팅 알림 활성화", value = """ + { + "code": "2000", + "data": { + "notifySetting": { + "chatNotify": true + } + } + } + """) + })) + }) + ResponseEntity patchNotifySetting(@RequestParam NotifySetting.NotifyType type, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "사용자 알림 비활성화") + @Parameter(name = "type", description = "알림 타입", examples = { + @ExampleObject(name = "가계부", value = "account_book"), @ExampleObject(name = "피드", value = "feed"), @ExampleObject(name = "채팅", value = "chat") + }, required = true, in = ParameterIn.QUERY) + @ApiResponses({ + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "가계부 알림 비활성화", value = """ + { + "code": "2000", + "data": { + "notifySetting": { + "accountBookNotify": false + } + } + } + """), + @ExampleObject(name = "피드 알림 비활성화", value = """ + { + "code": "2000", + "data": { + "notifySetting": { + "feedNotify": false + } + } + } + """), + @ExampleObject(name = "채팅 알림 비활성화", value = """ + { + "code": "2000", + "data": { + "notifySetting": { + "chatNotify": false + } + } + } + """) + })) + }) + ResponseEntity deleteNotifySetting(@RequestParam NotifySetting.NotifyType type, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java index 2293d56f9..68afa1c6e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java @@ -7,6 +7,7 @@ import kr.co.pennyway.api.apis.users.usecase.UserAccountUseCase; import kr.co.pennyway.api.common.response.SuccessResponse; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -43,7 +44,7 @@ public ResponseEntity deleteDevice(@RequestParam("token") @Validated @NotBlan public ResponseEntity getMyAccount(@AuthenticationPrincipal SecurityUserDetails user) { return ResponseEntity.ok(SuccessResponse.from("user", userAccountUseCase.getMyAccount(user.getUserId()))); } - + @Override @PatchMapping("/name") @PreAuthorize("isAuthenticated()") @@ -59,4 +60,18 @@ public ResponseEntity putUsername(UserProfileUpdateDto.UsernameReq request, S userAccountUseCase.updateUsername(user.getUserId(), request.username()); return ResponseEntity.ok(SuccessResponse.noContent()); } + + @Override + @PatchMapping("/notifications") + @PreAuthorize("isAuthenticated()") + public ResponseEntity patchNotifySetting(@RequestParam NotifySetting.NotifyType type, @AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from("notifySetting", userAccountUseCase.activateNotification(user.getUserId(), type))); + } + + @Override + @DeleteMapping("/notifications") + @PreAuthorize("isAuthenticated()") + public ResponseEntity deleteNotifySetting(@RequestParam NotifySetting.NotifyType type, @AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from("notifySetting", userAccountUseCase.deactivateNotification(user.getUserId(), type))); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java index 381f456b0..d70838a74 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java @@ -1,6 +1,8 @@ package kr.co.pennyway.api.apis.users.dto; +import com.fasterxml.jackson.annotation.JsonInclude; import io.swagger.v3.oas.annotations.media.Schema; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -22,4 +24,25 @@ public record UsernameReq( String username ) { } + + @Schema(title = "사용자 알림 설정 응답 DTO") + public record NotifySettingUpdateReq( + @Schema(description = "계좌 알림 설정", example = "true", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonInclude(JsonInclude.Include.NON_NULL) + Boolean accountBookNotify, + @Schema(description = "피드 알림 설정", example = "true", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonInclude(JsonInclude.Include.NON_NULL) + Boolean feedNotify, + @Schema(description = "채팅 알림 설정", example = "true", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonInclude(JsonInclude.Include.NON_NULL) + Boolean chatNotify + ) { + public static NotifySettingUpdateReq of(NotifySetting.NotifyType type, Boolean flag) { + return switch (type) { + case ACCOUNT_BOOK -> new NotifySettingUpdateReq(flag, null, null); + case FEED -> new NotifySettingUpdateReq(null, flag, null); + case CHAT -> new NotifySettingUpdateReq(null, null, flag); + }; + } + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java index e4d87ea3f..12135330f 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java @@ -1,5 +1,6 @@ package kr.co.pennyway.api.apis.users.service; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -19,4 +20,9 @@ public void updateName(User user, String newName) { public void updateUsername(User user, String newUsername) { user.updateUsername(newUsername); } + + @Transactional + public void updateNotifySetting(User user, NotifySetting.NotifyType type, Boolean flag) { + user.getNotifySetting().updateNotifySetting(type, flag); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java index 6097b5638..b26a50dd7 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java @@ -2,6 +2,7 @@ import kr.co.pennyway.api.apis.users.dto.DeviceDto; import kr.co.pennyway.api.apis.users.dto.UserProfileDto; +import kr.co.pennyway.api.apis.users.dto.UserProfileUpdateDto; import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; import kr.co.pennyway.api.apis.users.service.UserProfileUpdateService; import kr.co.pennyway.common.annotation.UseCase; @@ -9,6 +10,7 @@ import kr.co.pennyway.domain.domains.device.exception.DeviceErrorCode; import kr.co.pennyway.domain.domains.device.exception.DeviceErrorException; import kr.co.pennyway.domain.domains.device.service.DeviceService; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; @@ -25,7 +27,6 @@ public class UserAccountUseCase { private final DeviceService deviceService; private final UserProfileUpdateService userProfileUpdateService; - private final DeviceRegisterService deviceRegisterService; @Transactional @@ -54,7 +55,7 @@ public UserProfileDto getMyAccount(Long userId) { return UserProfileDto.from(user); } - + @Transactional public void updateName(Long userId, String newName) { User user = readUserOrThrow(userId); @@ -69,6 +70,22 @@ public void updateUsername(Long userId, String newUsername) { userProfileUpdateService.updateUsername(user, newUsername); } + @Transactional + public UserProfileUpdateDto.NotifySettingUpdateReq activateNotification(Long userId, NotifySetting.NotifyType type) { + User user = readUserOrThrow(userId); + + userProfileUpdateService.updateNotifySetting(user, type, Boolean.TRUE); + return UserProfileUpdateDto.NotifySettingUpdateReq.of(type, Boolean.TRUE); + } + + @Transactional + public UserProfileUpdateDto.NotifySettingUpdateReq deactivateNotification(Long userId, NotifySetting.NotifyType type) { + User user = readUserOrThrow(userId); + + userProfileUpdateService.updateNotifySetting(user, type, Boolean.FALSE); + return UserProfileUpdateDto.NotifySettingUpdateReq.of(type, Boolean.FALSE); + } + private User readUserOrThrow(Long userId) { return userService.readUser(userId).orElseThrow( () -> new UserErrorException(UserErrorCode.NOT_FOUND) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/NotifyTypeConverter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/NotifyTypeConverter.java new file mode 100644 index 000000000..d3295a1fc --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/NotifyTypeConverter.java @@ -0,0 +1,17 @@ +package kr.co.pennyway.api.common.converter; + +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import org.springframework.core.convert.converter.Converter; + +public class NotifyTypeConverter implements Converter { + @Override + public NotifySetting.NotifyType convert(String type) { + try { + return NotifySetting.NotifyType.valueOf(type.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new UserErrorException(UserErrorCode.INVALID_NOTIFY_TYPE); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java index 0f8c97480..2572264f5 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java @@ -1,5 +1,6 @@ package kr.co.pennyway.api.config; +import kr.co.pennyway.api.common.converter.NotifyTypeConverter; import kr.co.pennyway.api.common.converter.ProviderConverter; import kr.co.pennyway.api.common.converter.VerificationTypeConverter; import org.springframework.context.annotation.Configuration; @@ -13,5 +14,6 @@ public void addFormatters(FormatterRegistry registrar) { registrar.addConverter(new ProviderConverter()); registrar.addConverter(new VerificationTypeConverter()); + registrar.addConverter(new NotifyTypeConverter()); } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java index edd688346..87a0f89ba 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java @@ -32,4 +32,16 @@ public static NotifySetting of(Boolean accountBookNotify, Boolean feedNotify, Bo .chatNotify(chatNotify) .build(); } + + public void updateNotifySetting(NotifyType notifyType, Boolean flag) { + switch (notifyType) { + case ACCOUNT_BOOK -> this.accountBookNotify = flag; + case FEED -> this.feedNotify = flag; + case CHAT -> this.chatNotify = flag; + } + } + + public enum NotifyType { + ACCOUNT_BOOK, FEED, CHAT + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java index 4d8bc86c9..ae624f0e7 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java @@ -19,7 +19,10 @@ public enum UserErrorCode implements BaseErrorCode { ALREADY_WITHDRAWAL(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "이미 탈퇴한 유저입니다."), /* 404 NOT_FOUND */ - NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "유저를 찾을 수 없습니다."); + NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "유저를 찾을 수 없습니다."), + + /* 422 UNPROCESSABLE_ENTITY */ + INVALID_NOTIFY_TYPE(StatusCode.UNPROCESSABLE_CONTENT, ReasonCode.TYPE_MISMATCH_ERROR_IN_REQUEST_BODY, "유효하지 않은 알림 타입입니다."); private final StatusCode statusCode; private final ReasonCode reasonCode; From bb3477bc29292864a566433e86a4b884a372feea Mon Sep 17 00:00:00 2001 From: Jinwoo Lee Date: Fri, 26 Apr 2024 17:09:21 +0900 Subject: [PATCH 062/152] =?UTF-8?q?=F0=9F=90=9B=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EB=94=94=20=EC=B0=BE=EA=B8=B0=20API=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=8B=9C=20=ED=9C=B4=EB=8C=80=ED=8F=B0=20=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20=EC=BD=94=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=ED=95=98=EC=A7=80=20=EC=95=8A=EC=9D=84=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20INTERNAL=5FSERVER=5FERROR=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 아이디 찾기 api 요청 시 휴대폰 번호 및 코드를 입력하지 않은 케이스 추가 * fix: 아이디 찾기 api의 query-string 유효성 검증을 위한 인자 수정 * fix: 아이디 찾기 api 인자값 변경에 따른 usecase 및 service 메서드 인자 수정 * fix: of 메서드 삭제 --- .../api/apis/auth/api/AuthCheckApi.java | 4 +- .../auth/controller/AuthCheckController.java | 6 +- .../apis/auth/dto/PhoneVerificationDto.java | 4 - .../service/PhoneVerificationService.java | 94 ++++++++++--------- .../apis/auth/usecase/AuthCheckUseCase.java | 8 +- .../controller/AuthCheckControllerTest.java | 22 ++++- 6 files changed, 77 insertions(+), 61 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthCheckApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthCheckApi.java index 7fb3cc85e..ee35263a8 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthCheckApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthCheckApi.java @@ -12,7 +12,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.constraints.NotBlank; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; @Tag(name = "[계정 검사 API]") public interface AuthCheckApi { @@ -51,5 +51,5 @@ public interface AuthCheckApi { """) })), }) - ResponseEntity findUsername(@RequestParam @NotBlank String phone, @RequestParam @NotBlank String code); + ResponseEntity findUsername(@Validated PhoneVerificationDto.VerifyCodeReq request); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java index 2636ef4a5..78ff54175 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java @@ -8,8 +8,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import jakarta.validation.constraints.NotBlank; import kr.co.pennyway.api.apis.auth.api.AuthCheckApi; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.usecase.AuthCheckUseCase; import kr.co.pennyway.api.common.response.SuccessResponse; import lombok.RequiredArgsConstructor; @@ -32,7 +32,7 @@ public ResponseEntity checkUsername(@RequestParam @Validated String username) @GetMapping("/find/username") @PreAuthorize("isAnonymous()") - public ResponseEntity findUsername(@RequestParam @NotBlank String phone, @RequestParam @NotBlank String code) { - return ResponseEntity.ok(SuccessResponse.from("user", authCheckUseCase.findUsername(phone, code))); + public ResponseEntity findUsername(@Validated PhoneVerificationDto.VerifyCodeReq request) { + return ResponseEntity.ok(SuccessResponse.from("user", authCheckUseCase.findUsername(request))); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java index 2086ad238..3d51df645 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java @@ -64,10 +64,6 @@ public static VerifyCodeReq from(SignUpReq.Info request) { public static VerifyCodeReq from(SignUpReq.OauthInfo request) { return new VerifyCodeReq(request.phone(), request.code()); } - - public static VerifyCodeReq of(String phone, String code) { - return new VerifyCodeReq(phone, code); - } } @Schema(title = "인증번호 검증 응답 DTO") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/PhoneVerificationService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/PhoneVerificationService.java index 4a3098356..52172b461 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/PhoneVerificationService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/PhoneVerificationService.java @@ -1,5 +1,11 @@ package kr.co.pennyway.api.apis.auth.service; +import java.time.LocalDateTime; +import java.util.concurrent.ThreadLocalRandom; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; import kr.co.pennyway.api.common.exception.PhoneVerificationException; @@ -8,62 +14,58 @@ import kr.co.pennyway.infra.common.event.PushCodeEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.concurrent.ThreadLocalRandom; @Slf4j @Service @RequiredArgsConstructor public class PhoneVerificationService { - private final PhoneCodeService phoneCodeService; - private final ApplicationEventPublisher eventPublisher; + private final PhoneCodeService phoneCodeService; + private final ApplicationEventPublisher eventPublisher; + + /** + * 휴대폰 번호로 인증 코드를 발송하고 캐싱한다. (5분간 유효) + * + * @param request {@link PhoneVerificationDto.PushCodeReq} + * @param codeType {@link PhoneCodeKeyType} + * @return {@link PhoneVerificationDto.PushCodeRes} + */ + public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeReq request, PhoneCodeKeyType codeType) { + String code = issueVerificationCode(); + LocalDateTime expiresAt = phoneCodeService.create(request.phone(), code, codeType); - /** - * 휴대폰 번호로 인증 코드를 발송하고 캐싱한다. (5분간 유효) - * - * @param request {@link PhoneVerificationDto.PushCodeReq} - * @param codeType {@link PhoneCodeKeyType} - * @return {@link PhoneVerificationDto.PushCodeRes} - */ - public PhoneVerificationDto.PushCodeRes sendCode(PhoneVerificationDto.PushCodeReq request, PhoneCodeKeyType codeType) { - String code = issueVerificationCode(); - LocalDateTime expiresAt = phoneCodeService.create(request.phone(), code, codeType); + eventPublisher.publishEvent(PushCodeEvent.of(request.phone(), code)); - eventPublisher.publishEvent(PushCodeEvent.of(request.phone(), code)); + return PhoneVerificationDto.PushCodeRes.of(request.phone(), LocalDateTime.now(), expiresAt); + } - return PhoneVerificationDto.PushCodeRes.of(request.phone(), LocalDateTime.now(), expiresAt); - } + /** + * 휴대폰 번호로 인증 코드를 확인한다. + * + * @param request {@link PhoneVerificationDto.VerifyCodeReq} + * @param codeType {@link PhoneCodeKeyType} + * @return Boolean : 인증 코드가 유효한지 여부 (TRUE: 유효, 실패하는 경우 예외가 발생하므로 FALSE가 반환되지 않음) + * @throws PhoneVerificationException : 전화번호가 만료되었거나 유효하지 않은 경우(EXPIRED_OR_INVALID_PHONE), 인증 코드가 유효하지 않은 경우(IS_NOT_VALID_CODE) + */ + public Boolean isValidCode(PhoneVerificationDto.VerifyCodeReq request, PhoneCodeKeyType codeType) throws IllegalArgumentException { + String expectedCode; + try { + expectedCode = phoneCodeService.readByPhone(request.phone(), codeType); + } catch (IllegalArgumentException e) { + throw new PhoneVerificationException(PhoneVerificationErrorCode.EXPIRED_OR_INVALID_PHONE); + } - /** - * 휴대폰 번호로 인증 코드를 확인한다. - * - * @param request {@link PhoneVerificationDto.VerifyCodeReq} - * @param codeType {@link PhoneCodeKeyType} - * @return Boolean : 인증 코드가 유효한지 여부 (TRUE: 유효, 실패하는 경우 예외가 발생하므로 FALSE가 반환되지 않음) - * @throws PhoneVerificationException : 전화번호가 만료되었거나 유효하지 않은 경우(EXPIRED_OR_INVALID_PHONE), 인증 코드가 유효하지 않은 경우(IS_NOT_VALID_CODE) - */ - public Boolean isValidCode(PhoneVerificationDto.VerifyCodeReq request, PhoneCodeKeyType codeType) { - String expectedCode; - try { - expectedCode = phoneCodeService.readByPhone(request.phone(), codeType); - } catch (IllegalArgumentException e) { - throw new PhoneVerificationException(PhoneVerificationErrorCode.EXPIRED_OR_INVALID_PHONE); - } + if (!expectedCode.equals(request.code())) + throw new PhoneVerificationException(PhoneVerificationErrorCode.IS_NOT_VALID_CODE); - if (!expectedCode.equals(request.code())) - throw new PhoneVerificationException(PhoneVerificationErrorCode.IS_NOT_VALID_CODE); - return Boolean.TRUE; - } + return Boolean.TRUE; + } - private String issueVerificationCode() { - StringBuilder sb = new StringBuilder(); + private String issueVerificationCode() { + StringBuilder sb = new StringBuilder(); - for (int i = 0; i < 6; i++) { - sb.append(ThreadLocalRandom.current().nextInt(0, 10)); - } - return sb.toString(); - } + for (int i = 0; i < 6; i++) { + sb.append(ThreadLocalRandom.current().nextInt(0, 10)); + } + return sb.toString(); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java index 6d8937673..6a67fd24e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java @@ -28,9 +28,9 @@ public boolean checkUsernameDuplicate(String username) { } @Transactional(readOnly = true) - public AuthFindDto.FindUsernameRes findUsername(String phone, String code) { - phoneVerificationService.isValidCode(PhoneVerificationDto.VerifyCodeReq.of(phone, code), PhoneCodeKeyType.FIND_USERNAME); - phoneCodeService.delete(phone, PhoneCodeKeyType.FIND_USERNAME); - return authFindService.findUsername(phone); + public AuthFindDto.FindUsernameRes findUsername(PhoneVerificationDto.VerifyCodeReq request) { + phoneVerificationService.isValidCode(request, PhoneCodeKeyType.FIND_USERNAME); + phoneCodeService.delete(request.phone(), PhoneCodeKeyType.FIND_USERNAME); + return authFindService.findUsername(request.phone()); } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java index 69e175dd8..6a37e0ffb 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java @@ -1,5 +1,6 @@ package kr.co.pennyway.api.apis.auth.controller; +import static kr.co.pennyway.common.exception.ReasonCode.*; import static org.mockito.BDDMockito.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; @@ -20,7 +21,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.usecase.AuthCheckUseCase; +import kr.co.pennyway.common.exception.StatusCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; @@ -52,7 +55,8 @@ void setUp(WebApplicationContext webApplicationContext) { @DisplayName("일반 회원의 휴대폰 번호로 아이디를 찾을 때 200 응답을 반환한다.") void findUsername() throws Exception { // given - given(authCheckUseCase.findUsername(inputPhone, code)).willReturn(new AuthFindDto.FindUsernameRes(expectedUsername)); + given(authCheckUseCase.findUsername(new PhoneVerificationDto.VerifyCodeReq(inputPhone, code))).willReturn( + new AuthFindDto.FindUsernameRes(expectedUsername)); // when ResultActions resultActions = findUsernameRequest(inputPhone, code); @@ -68,7 +72,7 @@ void findUsername() throws Exception { void findUsernameIfUserNotFound() throws Exception { // given String phone = "010-1111-1111"; - given(authCheckUseCase.findUsername(phone, code)).willThrow(new UserErrorException(UserErrorCode.NOT_FOUND)); + given(authCheckUseCase.findUsername(new PhoneVerificationDto.VerifyCodeReq(phone, code))).willThrow(new UserErrorException(UserErrorCode.NOT_FOUND)); // when ResultActions resultActions = findUsernameRequest(phone, code); @@ -80,6 +84,20 @@ void findUsernameIfUserNotFound() throws Exception { .andExpect(jsonPath("$.message").value(UserErrorCode.NOT_FOUND.getExplainError())); } + @Test + @DisplayName("휴대폰 번호와 코드를 입력하지 않았을 때 422 응답을 반환한다.") + void findUsernameIfInputIsEmpty() throws Exception { + // when + ResultActions resultActions = findUsernameRequest("", ""); + + // then + resultActions + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("$.code").value( + String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY.getCode()))) + .andExpect(jsonPath("$.message").value(StatusCode.UNPROCESSABLE_CONTENT.name())); + } + private ResultActions findUsernameRequest(String phone, String code) throws Exception { return mockMvc.perform(get("/v1/find/username") .param("phone", phone) From 4ebcd9aac5e514721d6a30a0e1ebb8ecb0f08e09 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sat, 27 Apr 2024 09:05:40 +0900 Subject: [PATCH 063/152] =?UTF-8?q?=E2=9C=A8=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EA=B2=80=EC=A6=9D/=EB=B3=80=EA=B2=BD=20API=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 현재 비밀번호 검증 api 구현 * feat: 일반 회원가입 없는 유저 예외 코드 추가 * feat: 비밀번호 검증 요청 dto 정의 * fix: 미회원가입 계정 오류 400 -> 403 * feat: 비밀번호 검증 controller 정의 * test: use case 임시 구현 및 테스트 통과 확인 * test: 비밀번호 검증 use case 테스트 케이스 작성 * feat: 패스워드 암호화 helper 클래스 정의 * feat: use case에 패스워드 helper class 의존성 주입 * test: 패스워드 헬퍼 클래스 의존성 주입 * feat: mock -> mockbean 수정 * fix: password_encoder final 한정자 추가 * test: 사용자 비밀번호 수정 controller unit test 작성 * feat: reason coe 400번대 5번 비트 client_error 추가 * feat: 동일한 비밀번호 변경 요청 에러 코드 추가 * feat: 비밀번호 변경 요청 dto 정의 * feat: 비밀번호 수정 controller 구현 * feat: 임시 비밀번호 변경 usecase 작성 && controller unit test 통과 확인 * feat: 비밀번호 수정 usecase 및 service 구현 * feat: password encoder helper 클래스의 문자열 인코딩 검증 메서드 제거 * test: 비밀번호 변경 usecase unit test 작성 * feat: use case 예외 로그 추가 * test: 동일 비밀번호 요청 시 given 절 추가 * test: given 사용자 생성 @before_each 통합 * fix: 기존과 동일한 비밀번호 검증 조건식 수정 * test: 정상 유저 비밀번호 변경 요청 given(is_same_password) 조건 분리 --- .../api/apis/users/api/UserAccountApi.java | 50 +++- .../controller/UserAccountController.java | 18 +- .../apis/users/dto/UserProfileUpdateDto.java | 25 +- .../users/helper/PasswordEncoderHelper.java | 36 +++ .../service/UserProfileUpdateService.java | 17 +- .../users/usecase/UserAccountUseCase.java | 42 +++- .../UserAccountControllerUnitTest.java | 230 ++++++++++++++++++ .../users/usecase/UserAccountUseCaseTest.java | 142 +++++++++++ .../api/config/fixture/UserFixture.java | 3 +- .../pennyway/common/exception/ReasonCode.java | 1 + .../domain/domains/user/domain/User.java | 4 + .../domains/user/exception/UserErrorCode.java | 4 +- 12 files changed, 563 insertions(+), 9 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/helper/PasswordEncoderHelper.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java index 086ad83a5..b242f8b42 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java @@ -62,13 +62,61 @@ public interface UserAccountApi { @Operation(summary = "사용자 계정 조회", description = "사용자 본인의 계정 정보를 조회합니다.") @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "user", schema = @Schema(implementation = UserProfileDto.class)))) ResponseEntity getMyAccount(@AuthenticationPrincipal SecurityUserDetails user); - + @Operation(summary = "사용자 이름 수정") ResponseEntity putName(@RequestBody @Validated UserProfileUpdateDto.NameReq request, @AuthenticationPrincipal SecurityUserDetails user); @Operation(summary = "사용자 아이디 수정") ResponseEntity putUsername(@RequestBody @Validated UserProfileUpdateDto.UsernameReq request, @AuthenticationPrincipal SecurityUserDetails user); + @Operation(summary = "사용자 비밀번호 검증") + @ApiResponses({ + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "비밀번호 불일치", value = """ + { + "code": "4004", + "message": "비밀번호가 일치하지 않습니다." + } + """) + })), + @ApiResponse(responseCode = "403", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "일반 회원가입 이력이 없는 경우", value = """ + { + "code": "4030", + "message": "일반 회원가입 계정이 아닙니다." + } + """) + })) + }) + ResponseEntity postPasswordVerification(@RequestBody @Validated UserProfileUpdateDto.PasswordVerificationReq request, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "사용자 비밀번호 변경") + @ApiResponses({ + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "기존 비밀번호 불일치", value = """ + { + "code": "4004", + "message": "비밀번호가 일치하지 않습니다." + } + """), + @ExampleObject(name = "변경 비밀번호가 기존 비밀번호와 동일한 경우", value = """ + { + "code": "4005", + "message": "현재 비밀번호와 동일한 비밀번호로 변경할 수 없습니다." + } + """) + })), + @ApiResponse(responseCode = "403", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "일반 회원가입 이력이 없는 경우", value = """ + { + "code": "4030", + "message": "일반 회원가입 계정이 아닙니다." + } + """) + })) + }) + ResponseEntity patchPassword(@RequestBody @Validated UserProfileUpdateDto.PasswordReq request, @AuthenticationPrincipal SecurityUserDetails user); + @Operation(summary = "사용자 알림 활성화") @Parameter(name = "type", description = "알림 타입", examples = { @ExampleObject(name = "가계부", value = "account_book"), @ExampleObject(name = "피드", value = "feed"), @ExampleObject(name = "채팅", value = "chat") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java index 68afa1c6e..0ffbaee73 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java @@ -44,7 +44,7 @@ public ResponseEntity deleteDevice(@RequestParam("token") @Validated @NotBlan public ResponseEntity getMyAccount(@AuthenticationPrincipal SecurityUserDetails user) { return ResponseEntity.ok(SuccessResponse.from("user", userAccountUseCase.getMyAccount(user.getUserId()))); } - + @Override @PatchMapping("/name") @PreAuthorize("isAuthenticated()") @@ -61,6 +61,22 @@ public ResponseEntity putUsername(UserProfileUpdateDto.UsernameReq request, S return ResponseEntity.ok(SuccessResponse.noContent()); } + @Override + @PostMapping("/password/verification") + @PreAuthorize("isAuthenticated()") + public ResponseEntity postPasswordVerification(UserProfileUpdateDto.PasswordVerificationReq request, SecurityUserDetails user) { + userAccountUseCase.verifyPassword(user.getUserId(), request.password()); + return ResponseEntity.ok(SuccessResponse.noContent()); + } + + @Override + @PatchMapping("/password") + @PreAuthorize("isAuthenticated()") + public ResponseEntity patchPassword(UserProfileUpdateDto.PasswordReq request, SecurityUserDetails user) { + userAccountUseCase.updatePassword(user.getUserId(), request.oldPassword(), request.newPassword()); + return ResponseEntity.ok(SuccessResponse.noContent()); + } + @Override @PatchMapping("/notifications") @PreAuthorize("isAuthenticated()") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java index d70838a74..141116cfb 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java @@ -2,9 +2,10 @@ import com.fasterxml.jackson.annotation.JsonInclude; import io.swagger.v3.oas.annotations.media.Schema; -import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; +import kr.co.pennyway.api.common.validator.Password; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; public class UserProfileUpdateDto { @Schema(title = "이름 변경 요청 DTO") @@ -24,7 +25,27 @@ public record UsernameReq( String username ) { } - + + @Schema(title = "현재 비밀번호 검증 요청 DTO") + public record PasswordVerificationReq( + @Schema(description = "현재 비밀번호", example = "password") + @NotBlank(message = "비밀번호를 입력해주세요") + String password + ) { + } + + @Schema(title = "비밀번호 변경 요청 DTO") + public record PasswordReq( + @Schema(description = "현재 비밀번호. 공백 문자만 포함되어 있으면 안 됨", example = "password") + @NotBlank(message = "현재 비밀번호를 입력해주세요") + String oldPassword, + @Schema(description = "새 비밀번호. 8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)", example = "newPassword") + @NotBlank(message = "새 비밀번호를 입력해주세요") + @Password(message = "8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)") + String newPassword + ) { + } + @Schema(title = "사용자 알림 설정 응답 DTO") public record NotifySettingUpdateReq( @Schema(description = "계좌 알림 설정", example = "true", requiredMode = Schema.RequiredMode.NOT_REQUIRED) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/helper/PasswordEncoderHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/helper/PasswordEncoderHelper.java new file mode 100644 index 000000000..221c76032 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/helper/PasswordEncoderHelper.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.api.apis.users.helper; + +import kr.co.pennyway.common.annotation.Helper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * 비밀번호 암호화 도우미 클래스 + * + * @author YANG JAESEO + */ +@Helper +@RequiredArgsConstructor +public class PasswordEncoderHelper { + private final PasswordEncoder passwordEncoder; + + /** + * 비밀번호 암호화 메서드 + * + * @return password를 암호화한 문자열을 반환한다. + */ + public String encodePassword(String password) { + return passwordEncoder.encode(password); + } + + /** + * 비밀번호 일치 여부 확인 메서드 + * + * @param actual PasswordEncoder를 통해 암호화된 비밀번호 + * @param expected 비교할 비밀번호 + * @return 비밀번호가 일치하면 true, 일치하지 않으면 false를 반환한다. + */ + public boolean isSamePassword(String actual, String expected) { + return passwordEncoder.matches(actual, expected); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java index 12135330f..03126ba93 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java @@ -1,7 +1,10 @@ package kr.co.pennyway.api.apis.users.service; +import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -11,6 +14,8 @@ @Service @RequiredArgsConstructor public class UserProfileUpdateService { + private final PasswordEncoderHelper passwordEncoderHelper; + @Transactional public void updateName(User user, String newName) { user.updateName(newName); @@ -20,7 +25,17 @@ public void updateName(User user, String newName) { public void updateUsername(User user, String newUsername) { user.updateUsername(newUsername); } - + + @Transactional + public void updatePassword(User user, String oldPassword, String newPassword) { + if (passwordEncoderHelper.isSamePassword(user.getPassword(), newPassword)) { + log.info("기존과 동일한 비밀번호로는 변경할 수 없습니다."); + throw new UserErrorException(UserErrorCode.PASSWORD_NOT_CHANGED); + } + + user.updatePassword(passwordEncoderHelper.encodePassword(newPassword)); + } + @Transactional public void updateNotifySetting(User user, NotifySetting.NotifyType type, Boolean flag) { user.getNotifySetting().updateNotifySetting(type, flag); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java index b26a50dd7..149c1d45d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java @@ -3,6 +3,7 @@ import kr.co.pennyway.api.apis.users.dto.DeviceDto; import kr.co.pennyway.api.apis.users.dto.UserProfileDto; import kr.co.pennyway.api.apis.users.dto.UserProfileUpdateDto; +import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; import kr.co.pennyway.api.apis.users.service.UserProfileUpdateService; import kr.co.pennyway.common.annotation.UseCase; @@ -29,6 +30,8 @@ public class UserAccountUseCase { private final UserProfileUpdateService userProfileUpdateService; private final DeviceRegisterService deviceRegisterService; + private final PasswordEncoderHelper passwordEncoderHelper; + @Transactional public DeviceDto.RegisterRes registerDevice(Long userId, DeviceDto.RegisterReq request) { User user = readUserOrThrow(userId); @@ -55,7 +58,7 @@ public UserProfileDto getMyAccount(Long userId) { return UserProfileDto.from(user); } - + @Transactional public void updateName(Long userId, String newName) { User user = readUserOrThrow(userId); @@ -70,6 +73,24 @@ public void updateUsername(Long userId, String newUsername) { userProfileUpdateService.updateUsername(user, newUsername); } + @Transactional(readOnly = true) + public void verifyPassword(Long userId, String expectedPassword) { + User user = readUserOrThrow(userId); + + validateGeneralSignedUpUser(user); + validatePasswordMatch(expectedPassword, user.getPassword()); + } + + @Transactional + public void updatePassword(Long userId, String oldPassword, String newPassword) { + User user = readUserOrThrow(userId); + + validateGeneralSignedUpUser(user); + validatePasswordMatch(oldPassword, user.getPassword()); + + userProfileUpdateService.updatePassword(user, oldPassword, newPassword); + } + @Transactional public UserProfileUpdateDto.NotifySettingUpdateReq activateNotification(Long userId, NotifySetting.NotifyType type) { User user = readUserOrThrow(userId); @@ -88,7 +109,24 @@ public UserProfileUpdateDto.NotifySettingUpdateReq deactivateNotification(Long u private User readUserOrThrow(Long userId) { return userService.readUser(userId).orElseThrow( - () -> new UserErrorException(UserErrorCode.NOT_FOUND) + () -> { + log.info("사용자를 찾을 수 없습니다."); + return new UserErrorException(UserErrorCode.NOT_FOUND); + } ); } + + private void validateGeneralSignedUpUser(User user) { + if (!user.isGeneralSignedUpUser()) { + log.info("일반 회원가입 이력이 없습니다."); + throw new UserErrorException(UserErrorCode.DO_NOT_GENERAL_SIGNED_UP); + } + } + + private void validatePasswordMatch(String password, String storedPassword) { + if (!passwordEncoderHelper.isSamePassword(password, storedPassword)) { + log.info("기존 비밀번호와 일치하지 않습니다."); + throw new UserErrorException(UserErrorCode.NOT_MATCHED_PASSWORD); + } + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java index 7f0d74b4b..ce253d51d 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java @@ -18,6 +18,8 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; +import java.util.List; + import static kr.co.pennyway.common.exception.ReasonCode.REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; @@ -232,4 +234,232 @@ private ResultActions performUpdateNicknameRequest(String newNickname) throws Ex .content(objectMapper.writeValueAsString(request))); } } + + @Nested + @Order(4) + @DisplayName("[4] 사용자 비밀번호 검증 테스트") + class VerifyNicknameTest { + @DisplayName("사용자 현재 비밀번호 검증 시, 빈 문자열이면 422 에러를 반환한다.") + @Test + @WithSecurityMockUser + void verifyPasswordValidationFail() throws Exception { + // given + String currentPasswordWithBlank = " "; + String expectedErrorCode = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY.getCode()); + + // when + ResultActions result = performVerifyCurrentPasswordRequest(currentPasswordWithBlank); + + // then + result.andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value(expectedErrorCode)) + .andDo(print()); + } + + @DisplayName("사용자 현재 비밀번호 검증 시, 삭제된 사용자인 경우 404 에러를 반환한다.") + @Test + @WithSecurityMockUser + void verifyCurrentPasswordDeletedUser() throws Exception { + // given + String currentPassword = "currentPassword"; + willThrow(new UserErrorException(UserErrorCode.NOT_FOUND)).given(userAccountUseCase).verifyPassword(1L, currentPassword); + + // when + ResultActions result = performVerifyCurrentPasswordRequest(currentPassword); + + // then + result.andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(UserErrorCode.NOT_FOUND.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(UserErrorCode.NOT_FOUND.getExplainError())) + .andDo(print()); + } + + @DisplayName("사용자 현재 비밀번호 검증 시, 일반 회원가입 이력이 없는 경우 403 에러를 반환한다.") + @Test + @WithSecurityMockUser + void verifyCurrentPasswordSocialUser() throws Exception { + // given + String currentPassword = "currentPassword"; + willThrow(new UserErrorException(UserErrorCode.DO_NOT_GENERAL_SIGNED_UP)).given(userAccountUseCase).verifyPassword(1L, currentPassword); + + // when + ResultActions result = performVerifyCurrentPasswordRequest(currentPassword); + + // then + result.andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(UserErrorCode.DO_NOT_GENERAL_SIGNED_UP.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(UserErrorCode.DO_NOT_GENERAL_SIGNED_UP.getExplainError())) + .andDo(print()); + } + + @DisplayName("사용자 현재 비밀번호 검증 시, 비밀번호가 일치하지 않으면 400 에러를 반환한다.") + @Test + @WithSecurityMockUser + void verifyCurrentPasswordFail() throws Exception { + // given + String currentPassword = "currentPassword"; + willThrow(new UserErrorException(UserErrorCode.NOT_MATCHED_PASSWORD)).given(userAccountUseCase).verifyPassword(1L, currentPassword); + + // when + ResultActions result = performVerifyCurrentPasswordRequest(currentPassword); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(UserErrorCode.NOT_MATCHED_PASSWORD.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(UserErrorCode.NOT_MATCHED_PASSWORD.getExplainError())) + .andDo(print()); + } + + @DisplayName("사용자 현재 비밀번호 검증 시, 비밀번호가 일치하면 200 코드를 반환한다.") + @Test + @WithSecurityMockUser + void verifyCurrentPasswordSuccess() throws Exception { + // given + String currentPassword = "currentPassword"; + + // when + ResultActions result = performVerifyCurrentPasswordRequest(currentPassword); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andDo(print()); + } + + private ResultActions performVerifyCurrentPasswordRequest(String currentPassword) throws Exception { + UserProfileUpdateDto.PasswordVerificationReq request = new UserProfileUpdateDto.PasswordVerificationReq(currentPassword); + return mockMvc.perform(post("/v2/users/me/password/verification") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + } + + } + + @Nested + @Order(5) + @DisplayName("[5] 사용자 비밀번호 수정 테스트") + class UpdatePasswordTest { + String oldPassword = "oldPassword1"; + String newPassword = "newPassword1"; + + @DisplayName("비밀번호가 8~16자의 영문 대/소문, 숫자, 특수문자(이모티콘, 공백 사용 불가, 적어도 하나 이상의 소문자 알파벳과 숫자 포함)가 아니면 422 에러를 반환한다.") + @Test + @WithSecurityMockUser + void updatePasswordValidationFail() throws Exception { + // given + String oldPassword = "oldPassword"; + String newPasswordWithBlank = " "; + String newPasswordWithUnderLength = "short"; + String newPasswordWithOverLength = "passwordpasswordpasswordpassword"; + String newPasswordWithOnlyAlphabet = "passwordpassword"; + String newPasswordWithOnlyNumber = "1234567890"; + String newPasswordWithOnlySpecialCharacter = "!@#$%^&*()"; + String newPasswordWithOnlyUpperCase = "PASSWORDPASSWORD"; + String newPasswordWithOnlyLowerCase = "passwordpassword"; + String newPasswordWithOnlyEmoji = "😊😊😊😊😊😊😊😊"; + String newPasswordWithOnlyWhiteSpace = "password password"; + String newPasswordWithOnlySpecialCharacterAndWhiteSpace = "!@#$%^&*() "; + String newPasswordWithOnlySpecialCharacterAndEmoji = "!@#$%^&*()😊"; + String newPasswordWithOnlySpecialCharacterAndEmojiAndWhiteSpace = "!@#$%^&*() 😊"; + List newPasswords = List.of(newPasswordWithBlank, newPasswordWithUnderLength, newPasswordWithOverLength, newPasswordWithOnlyAlphabet, newPasswordWithOnlyNumber, newPasswordWithOnlySpecialCharacter, newPasswordWithOnlyUpperCase, newPasswordWithOnlyLowerCase, newPasswordWithOnlyEmoji, newPasswordWithOnlyWhiteSpace, newPasswordWithOnlySpecialCharacterAndWhiteSpace, newPasswordWithOnlySpecialCharacterAndEmoji, newPasswordWithOnlySpecialCharacterAndEmojiAndWhiteSpace); + + String expectedErrorCode = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY.getCode()); + + // when - then + for (String newPassword : newPasswords) { + ResultActions result = performUpdatePasswordRequest(oldPassword, newPassword); + result.andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value(expectedErrorCode)) + .andDo(print()); + } + } + + @DisplayName("비밀번호가 현재 비밀번호와 동일하면 400 에러를 반환한다.") + @Test + @WithSecurityMockUser + void updatePasswordSamePassword() throws Exception { + // given + willThrow(new UserErrorException(UserErrorCode.PASSWORD_NOT_CHANGED)).given(userAccountUseCase).updatePassword(1L, oldPassword, newPassword); + + // when + ResultActions result = performUpdatePasswordRequest(oldPassword, newPassword); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(UserErrorCode.PASSWORD_NOT_CHANGED.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(UserErrorCode.PASSWORD_NOT_CHANGED.getExplainError())) + .andDo(print()); + } + + @DisplayName("기존 비밀번호가 일치하지 않으면 400 에러를 반환한다.") + @Test + @WithSecurityMockUser + void updatePasswordFail() throws Exception { + // given + willThrow(new UserErrorException(UserErrorCode.NOT_MATCHED_PASSWORD)).given(userAccountUseCase).updatePassword(1L, oldPassword, newPassword); + + // when + ResultActions result = performUpdatePasswordRequest(oldPassword, newPassword); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(UserErrorCode.NOT_MATCHED_PASSWORD.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(UserErrorCode.NOT_MATCHED_PASSWORD.getExplainError())) + .andDo(print()); + } + + @DisplayName("사용자가 삭제된 사용자인 경우 404 에러를 반환한다.") + @Test + @WithSecurityMockUser + void updatePasswordDeletedUser() throws Exception { + // given + willThrow(new UserErrorException(UserErrorCode.NOT_FOUND)).given(userAccountUseCase).updatePassword(1L, oldPassword, newPassword); + + // when + ResultActions result = performUpdatePasswordRequest(oldPassword, newPassword); + + // then + result.andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(UserErrorCode.NOT_FOUND.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(UserErrorCode.NOT_FOUND.getExplainError())) + .andDo(print()); + } + + @DisplayName("사용자가 일반 회원가입 이력이 없는 경우 403 에러를 반환한다.") + @Test + @WithSecurityMockUser + void updatePasswordSocialUser() throws Exception { + // given + willThrow(new UserErrorException(UserErrorCode.DO_NOT_GENERAL_SIGNED_UP)).given(userAccountUseCase).updatePassword(1L, oldPassword, newPassword); + + // when + ResultActions result = performUpdatePasswordRequest(oldPassword, newPassword); + + // then + result.andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(UserErrorCode.DO_NOT_GENERAL_SIGNED_UP.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(UserErrorCode.DO_NOT_GENERAL_SIGNED_UP.getExplainError())) + .andDo(print()); + } + + @DisplayName("비밀번호가 정상적으로 수정되면 200 코드를 반환한다.") + @Test + @WithSecurityMockUser + void updatePasswordSuccess() throws Exception { + // when + ResultActions result = performUpdatePasswordRequest(oldPassword, newPassword); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andDo(print()); + } + + private ResultActions performUpdatePasswordRequest(String oldPassword, String newPassword) throws Exception { + UserProfileUpdateDto.PasswordReq request = new UserProfileUpdateDto.PasswordReq(oldPassword, newPassword); + return mockMvc.perform(patch("/v2/users/me/password") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + } + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java index 74b17adee..c08eb088f 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java @@ -1,6 +1,7 @@ package kr.co.pennyway.api.apis.users.usecase; import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; import kr.co.pennyway.api.apis.users.service.UserProfileUpdateService; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; @@ -23,13 +24,17 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; import static org.springframework.test.util.AssertionErrors.*; @ExtendWith(MockitoExtension.class) @@ -48,6 +53,9 @@ class UserAccountUseCaseTest extends ExternalApiDBTestConfig { @Autowired private UserAccountUseCase userAccountUseCase; + @MockBean + private PasswordEncoderHelper passwordEncoderHelper; + @Order(1) @Nested @DisplayName("[1] 디바이스 등록 테스트") @@ -288,4 +296,138 @@ void updateName() { assertEquals("사용자 이름이 변경되어 있어야 한다.", newName, updatedUser.getName()); } } + + @Order(4) + @Nested + @DisplayName("[4] 사용자 비밀번호 검증 테스트") + class VerificationPasswordTest { + private User originUser; + + @BeforeEach + void setUp() { + originUser = UserFixture.GENERAL_USER.toUser(); + userService.createUser(originUser); + } + + @Test + @Transactional + @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") + void verifyPasswordWhenUserIsDeleted() { + // given + userService.deleteUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.verifyPassword(originUser.getId(), originUser.getPassword())); + assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("사용자가 일반 회원가입 이력이 없는 소셜 계정인 경우, DO_NOT_GENERAL_SIGNED_UP 에러를 반환한다.") + void verifyPasswordWhenUserIsNotGeneralSignedUp() { + // given + User originUser = UserFixture.OAUTH_USER.toUser(); + userService.createUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.verifyPassword(originUser.getId(), originUser.getPassword())); + assertEquals("일반 회원가입 이력이 없는 경우 Do Not General Signed Up을 반환한다.", UserErrorCode.DO_NOT_GENERAL_SIGNED_UP, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("비밀번호가 다른 경우 NOT_MATCHED_PASSWORD 에러를 반환한다.") + void verifyPasswordWhenPasswordIsNotMatched() { + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.verifyPassword(originUser.getId(), "notMatchedPassword")); + assertEquals("비밀번호가 다른 경우 Not Matched Password를 반환한다.", UserErrorCode.NOT_MATCHED_PASSWORD, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("비밀번호가 일치하는 경우 정상적으로 처리된다.") + void verifyPassword() { + // given + given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(true); + + // when - then + assertDoesNotThrow(() -> userAccountUseCase.verifyPassword(originUser.getId(), originUser.getPassword())); + } + } + + @Order(5) + @Nested + @DisplayName("[5] 사용자 비밀번호 변경 테스트") + class UpdatePasswordTest { + private User originUser; + + @BeforeEach + void setUp() { + originUser = UserFixture.GENERAL_USER.toUser(); + userService.createUser(originUser); + } + + @Test + @Transactional + @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") + void updatePasswordWhenUserIsDeleted() { + // given + userService.deleteUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), "newPassword")); + assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("oldPassword와 newPassword가 일치하는 경우와 현재 비밀번호와 동일한 비밀번호로 변경을 시도하는 경우, CLIENT_ERROR 에러를 반환한다.") + void updatePasswordWhenSamePassword() { + // given + given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(true); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), originUser.getPassword())); + assertEquals("현재 비밀번호와 동일한 비밀번호로 변경할 수 없는 경우 Client Error를 반환한다.", UserErrorCode.PASSWORD_NOT_CHANGED, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("비밀번호가 다른 경우 NOT_MATCHED_PASSWORD 에러를 반환한다.") + void updatePasswordWhenPasswordIsNotMatched() { + // given + given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(false); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.updatePassword(originUser.getId(), "notMatchedPassword", "newPassword")); + assertEquals("비밀번호가 다른 경우 Not Matched Password를 반환한다.", UserErrorCode.NOT_MATCHED_PASSWORD, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("사용자가 일반 회원가입 이력이 없는 소셜 계정인 경우, DO_NOT_GENERAL_SIGNED_UP 에러를 반환한다.") + void updatePasswordWhenUserIsNotGeneralSignedUp() { + // given + User originUser = UserFixture.OAUTH_USER.toUser(); + userService.createUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), "newPassword")); + assertEquals("일반 회원가입 이력이 없는 경우 Do Not General Signed Up을 반환한다.", UserErrorCode.DO_NOT_GENERAL_SIGNED_UP, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("정상적인 요청인 경우 비밀번호가 정상적으로 변경된다.") + void updatePassword() { + // given + given(passwordEncoderHelper.isSamePassword(originUser.getPassword(), originUser.getPassword())).willReturn(true); + given(passwordEncoderHelper.isSamePassword(originUser.getPassword(), "newPassword")).willReturn(false); + given(passwordEncoderHelper.encodePassword(any())).willReturn("encodedPassword"); + + // when - then + assertDoesNotThrow(() -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), "newPassword")); + assertEquals("비밀번호가 정상적으로 변경되어 있어야 한다.", "encodedPassword", userService.readUser(originUser.getId()).orElseThrow().getPassword()); + } + } } \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java index 8ac68a26f..c96e3851e 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java @@ -9,7 +9,8 @@ import java.util.List; public enum UserFixture { - GENERAL_USER(1L, "jayang", "dkssudgktpdy1", "Yang", "abc@abc.com", Role.USER, ProfileVisibility.PUBLIC, false), + GENERAL_USER(1L, "jayang", "dkssudgktpdy1", "Yang", "010-1111-1111", Role.USER, ProfileVisibility.PUBLIC, false), + OAUTH_USER(2L, "only._.o", null, "Only", "0101-2222-2222", Role.USER, ProfileVisibility.PUBLIC, false), ; private final Long id; diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java index bfd6180f6..9dc1f5671 100644 --- a/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/exception/ReasonCode.java @@ -15,6 +15,7 @@ public enum ReasonCode { MALFORMED_PARAMETER(2), MALFORMED_REQUEST_BODY(3), INVALID_REQUEST(4), + CLIENT_ERROR(5), /* 401_UNAUTHORIZED */ MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS(0), diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java index 632a95c3c..a838d84f1 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java @@ -83,6 +83,10 @@ public void updateUsername(String username) { this.username = username; } + public boolean isGeneralSignedUpUser() { + return password != null; + } + @Override public String toString() { return "User{" + diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java index ae624f0e7..c420ca919 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java @@ -10,13 +10,15 @@ public enum UserErrorCode implements BaseErrorCode { /* 400 BAD_REQUEST */ ALREADY_SIGNUP(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "이미 회원가입한 유저입니다."), + NOT_MATCHED_PASSWORD(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "비밀번호가 일치하지 않습니다."), + PASSWORD_NOT_CHANGED(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "현재 비밀번호와 동일한 비밀번호로 변경할 수 없습니다."), /* 401 UNAUTHORIZED */ - NOT_MATCHED_PASSWORD(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "비밀번호가 일치하지 않습니다."), INVALID_USERNAME_OR_PASSWORD(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "유효하지 않은 아이디 또는 비밀번호입니다."), /* 403 FORBIDDEN */ ALREADY_WITHDRAWAL(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "이미 탈퇴한 유저입니다."), + DO_NOT_GENERAL_SIGNED_UP(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "일반 회원가입 계정이 아닙니다."), /* 404 NOT_FOUND */ NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "유저를 찾을 수 없습니다."), From 9ac81490022b44abab4b43344b457aba04ad82b6 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sat, 27 Apr 2024 12:46:36 +0900 Subject: [PATCH 064/152] =?UTF-8?q?=E2=9C=A8=20External=20Api=20Controller?= =?UTF-8?q?=20=EB=A1=9C=EA=B9=85=EC=9D=84=20=EC=9C=84=ED=95=9C=20AOP=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: external-api-log-aspect 구현 * fix: authorization && set-cookie && cookie 헤더는 debug 모드로 로깅 * fix: request argument가 null인 경우 제외 * feat: request header log 어노테이션 작성 * fix: cookie, authorization 문자열 의존 제거 * fix: request header log 어노테이션 인증 헤더 제거 * fix: controller의 request header log default value 제거 --- .../apis/auth/controller/AuthController.java | 2 + .../api/common/aop/ExternalApiLogAspect.java | 94 +++++++++++++++++++ .../api/common/aop/RequestHeaderLog.java | 14 +++ 3 files changed, 110 insertions(+) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/aop/ExternalApiLogAspect.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/aop/RequestHeaderLog.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java index 67e8bd105..315b11b92 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthController.java @@ -6,6 +6,7 @@ import kr.co.pennyway.api.apis.auth.dto.SignInReq; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; import kr.co.pennyway.api.apis.auth.usecase.AuthUseCase; +import kr.co.pennyway.api.common.aop.RequestHeaderLog; import kr.co.pennyway.api.common.response.SuccessResponse; import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.api.common.util.CookieUtil; @@ -55,6 +56,7 @@ public ResponseEntity signIn(@RequestBody @Validated SignInReq.General reques } @GetMapping("/refresh") + @RequestHeaderLog @PreAuthorize("isAnonymous()") public ResponseEntity refresh(@CookieValue("refreshToken") @Valid String refreshToken) { return createAuthenticatedResponse(authUseCase.refresh(refreshToken)); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/aop/ExternalApiLogAspect.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/aop/ExternalApiLogAspect.java new file mode 100644 index 000000000..46b1661dc --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/aop/ExternalApiLogAspect.java @@ -0,0 +1,94 @@ +package kr.co.pennyway.api.common.aop; + +import kr.co.pennyway.infra.common.jwt.AuthConstants; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.*; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.Map; + +/** + * Exteranl Api의 Request, Response 로그를 남기기 위한 Aspect + * + * @author YANG JAESEO + */ +@Slf4j +@Aspect +@Component +public class ExternalApiLogAspect { + /** + * kr.co.pennyway.api.apis 패키지 하위의 모든 Controller 클래스의 모든 메서드를 대상으로 한다.
+ * 단, 클래스명의 접미사가 Controller로 끝나는 클래스만 대상으로 한다. + */ + @Pointcut("execution(* kr.co.pennyway.api.apis.*.controller.*Controller.*(..))") + private void cut() { + } + + @Before("cut()") + public void beforeRequest(JoinPoint joinPoint) { + Method method = getMethod(joinPoint); + + log.info("================================= Request ================================="); + log.info("요청 메서드 이름 : {}", method.getName()); + log.debug("요청 메서드 경로 : {}", method.getDeclaringClass() + "." + method.getName()); + + Object[] args = joinPoint.getArgs(); + + if (args.length == 0) { + log.info("None Request Parameter"); + } + + for (Object arg : args) { + if (arg == null) continue; + + if (method.isAnnotationPresent(RequestHeaderLog.class) && method.getAnnotation(RequestHeaderLog.class).hasCookie()) { + log.debug("요청 헤더 : {} ⇾ 값 : {}", HttpHeaders.COOKIE, arg); + continue; + } + + if (arg instanceof String param && param.startsWith(AuthConstants.TOKEN_TYPE.getValue())) { + log.debug("요청 헤더 : {} ⇾ 값 : {}", HttpHeaders.AUTHORIZATION, param); + } else { + log.info("요청 파라미터 타입 : {} ⇾ 값 : {}", arg.getClass().getSimpleName(), arg); + } + } + log.info("==========================================================================="); + } + + @AfterReturning(pointcut = "cut()", returning = "returnObject") + public void afterResponse(JoinPoint joinPoint, Object returnObject) { + ResponseEntity responseEntity = (ResponseEntity) returnObject; + HttpHeaders headers = responseEntity.getHeaders(); + + log.info("================================= Response ================================="); + log.info("응답 상태 : {}", responseEntity.getStatusCode()); + + for (Map.Entry entry : headers.toSingleValueMap().entrySet()) { + if (entry.getKey().equals(HttpHeaders.SET_COOKIE) || entry.getKey().equals(HttpHeaders.AUTHORIZATION)) { + log.debug("응답 헤더 : {} ⇾ 값 : {}", entry.getKey(), entry.getValue()); + } else { + log.info("응답 헤더 : {} ⇾ 값 : {}", entry.getKey(), entry.getValue()); + } + } + + log.info("응답 내용 : {}", responseEntity.getBody()); + log.info("============================================================================"); + } + + @AfterThrowing(pointcut = "cut()", throwing = "exception") + public void afterThrowing(JoinPoint joinPoint, Throwable exception) { + log.error("================================= Exception ================================"); + log.error("예외 종류 : {} ⇾ 메시지 : {}", exception.getClass().getSimpleName(), exception.getMessage()); + log.error("============================================================================"); + } + + private Method getMethod(JoinPoint joinPoint) { + MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + return methodSignature.getMethod(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/aop/RequestHeaderLog.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/aop/RequestHeaderLog.java new file mode 100644 index 000000000..669e01c17 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/aop/RequestHeaderLog.java @@ -0,0 +1,14 @@ +package kr.co.pennyway.api.common.aop; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RequestHeaderLog { + /** + * 요청 헤더에 쿠키가 포함되어 있는 지 여부
+ * {@link org.springframework.web.bind.annotation.CookieValue}를 사용하는 컨트롤러에서는 반드시 해당 어노테이션을 명시해야 한다. + */ + boolean hasCookie() default true; +} From 12f1f077e315ceae0b5c5a3e73b2e10878ea3965 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 30 Apr 2024 10:19:15 +0900 Subject: [PATCH 065/152] =?UTF-8?q?=F0=9F=90=9B=20RTR=20=EC=98=A4=EB=8F=99?= =?UTF-8?q?=EC=9E=91=20=EA=B2=80=EC=A6=9D=20(=EC=95=84=EB=AC=B4=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=97=86=EC=97=88=EB=8B=A4..)=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: redis 단위 테스트 편의용 어노테이션 작성 * test: refresh token service 단위 테스트 작성 * test: refresh token service 테스트에 불필요한 의존성 제거 * fix: jwt_auth_helper claims 추출 메서드 사용하도록 변경 && 로그 갱신 * test: jwt_auth_helper refresh 테스트 * test: refresh token service 단위 테스트 탈취 시나리오 통과 확인 * test: jwt_auth_helper 단위 테스트 탈취 시나리오 통과 확인 * refactor: refresh token 생성이 통과되면 access token 생성하도록 수정 * test: 탈취 시나리오 테스트 given 수정 * docs: refresh api 스웨거 에러 응답 개선 --- .../pennyway/api/apis/auth/api/AuthApi.java | 47 +++++--- .../api/apis/auth/helper/JwtAuthHelper.java | 15 ++- .../apis/auth/helper/JwtAuthHelperTest.java | 111 ++++++++++++++++++ .../pennyway/domain/config/RedisUnitTest.java | 16 +++ .../refresh/RefreshTokenServiceUnitTest.java | 91 ++++++++++++++ 5 files changed, 260 insertions(+), 20 deletions(-) create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisUnitTest.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceUnitTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java index d2615abaf..e70109dda 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java @@ -144,22 +144,41 @@ public interface AuthApi { ResponseEntity signIn(@RequestBody @Validated SignInReq.General request); @Operation(summary = "[5] 토큰 갱신", description = "리프레시 토큰을 이용해 액세스 토큰과 리프레시 토큰을 갱신합니다.") - @ApiResponse(responseCode = "200", description = "로그인 성공", - headers = { - @Header(name = "Set-Cookie", description = "리프레시 토큰", schema = @Schema(type = "string"), required = true), - @Header(name = "Authorization", description = "액세스 토큰", schema = @Schema(type = "string", format = "jwt"), required = true) - }, - content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "성공", value = """ - { - "code": "2000", - "data": { - "user": { - "id": 1 + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그인 성공", + headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰", schema = @Schema(type = "string"), required = true), + @Header(name = "Authorization", description = "액세스 토큰", schema = @Schema(type = "string", format = "jwt"), required = true) + }, + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "성공", value = """ + { + "code": "2000", + "data": { + "user": { + "id": 1 + } + } } - } + """) + })), + @ApiResponse(responseCode = "401", description = "리프레시 토큰 만료", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "실패 - 리프레시 토큰 만료", value = """ + { + "code": "4011", + "message": "사용기간이 만료된 토큰입니다" } """) - })) + })), + @ApiResponse(responseCode = "403", description = "탈취된 토큰", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "실패 - 탈취된 토큰", value = """ + { + "code": "4030", + "message": "탈취당한 토큰입니다. 다시 로그인 해주세요." + } + """) + })), + + }) ResponseEntity refresh(@CookieValue("refreshToken") @Valid String refreshToken); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java index 6e054bd5e..dd35ff14f 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java @@ -20,7 +20,6 @@ import java.time.Duration; import java.time.LocalDateTime; -import java.util.Map; import java.util.function.Function; @Slf4j @@ -49,7 +48,7 @@ public JwtAuthHelper( * @return key에 해당하는 값이 없거나, 타입이 일치하지 않을 경우 null을 반환한다. */ @SuppressWarnings("unchecked") - public T getClaimValue(JwtClaims claims, String key, Class type) { + public T getClaimsValue(JwtClaims claims, String key, Class type) { Object value = claims.getClaims().get(key); if (value != null && type.isAssignableFrom(value.getClass())) { return (T) value; @@ -87,21 +86,25 @@ public Jwts createToken(User user) { } public Pair refresh(String refreshToken) { - Map claims = refreshTokenProvider.getJwtClaimsFromToken(refreshToken).getClaims(); + JwtClaims claims = refreshTokenProvider.getJwtClaimsFromToken(refreshToken); - Long userId = Long.parseLong((String) claims.get(RefreshTokenClaimKeys.USER_ID.getValue())); - String role = (String) claims.get(RefreshTokenClaimKeys.ROLE.getValue()); + Long userId = getClaimsValue(claims, RefreshTokenClaimKeys.USER_ID.getValue(), Long::parseLong); + String role = getClaimsValue(claims, RefreshTokenClaimKeys.ROLE.getValue(), String.class); + log.debug("refresh token userId : {}, role : {}", userId, role); - String newAccessToken = accessTokenProvider.generateToken(AccessTokenClaim.of(userId, role)); RefreshToken newRefreshToken; try { newRefreshToken = refreshTokenService.refresh(userId, refreshToken, refreshTokenProvider.generateToken(RefreshTokenClaim.of(userId, role))); + log.debug("new refresh token : {}", newRefreshToken.getToken()); } catch (IllegalArgumentException e) { throw new JwtErrorException(JwtErrorCode.EXPIRED_TOKEN); } catch (IllegalStateException e) { throw new JwtErrorException(JwtErrorCode.TAKEN_AWAY_TOKEN); } + String newAccessToken = accessTokenProvider.generateToken(AccessTokenClaim.of(userId, role)); + log.debug("new access token : {}", newAccessToken); + return Pair.of(userId, Jwts.of(newAccessToken, newRefreshToken.getToken())); } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java new file mode 100644 index 000000000..7231ce298 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java @@ -0,0 +1,111 @@ +package kr.co.pennyway.api.apis.auth.helper; + +import kr.co.pennyway.api.common.security.jwt.Jwts; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenProvider; +import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaim; +import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenProvider; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; +import kr.co.pennyway.domain.common.redis.refresh.RefreshToken; +import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenRepository; +import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; +import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenServiceImpl; +import kr.co.pennyway.domain.config.RedisConfig; +import kr.co.pennyway.domain.config.RedisUnitTest; +import kr.co.pennyway.domain.domains.user.type.Role; +import kr.co.pennyway.infra.common.exception.JwtErrorCode; +import kr.co.pennyway.infra.common.exception.JwtErrorException; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertFalse; + +@Slf4j +@ExtendWith(MockitoExtension.class) +@RedisUnitTest +@DataRedisTest(properties = "spring.config.location=classpath:application-domain.yml") +@ContextConfiguration(classes = {RedisConfig.class, JwtAuthHelper.class}) +@ActiveProfiles("test") +public class JwtAuthHelperTest extends ExternalApiDBTestConfig { + @Autowired + private JwtAuthHelper jwtAuthHelper; + + private RefreshTokenService refreshTokenService; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @MockBean + private AccessTokenProvider accessTokenProvider; + + @MockBean + private RefreshTokenProvider refreshTokenProvider; + + @MockBean + private ForbiddenTokenService forbiddenTokenService; + + @BeforeEach + void setUp() { + this.refreshTokenService = new RefreshTokenServiceImpl(refreshTokenRepository); + } + + @Test + @DisplayName("사용자 아이디에 해당하는 리프레시 토큰이 존재할 시, 리프레시 토큰 갱신에 성공한다.") + public void RefreshTokenRefreshSuccess() { + // given + RefreshToken refreshToken = RefreshToken.builder() + .userId(1L) + .token("refreshToken") + .ttl(1000L) + .build(); + refreshTokenRepository.save(refreshToken); + given(refreshTokenProvider.getJwtClaimsFromToken(refreshToken.getToken())).willReturn(RefreshTokenClaim.of(refreshToken.getUserId(), Role.USER.getType())); + given(accessTokenProvider.generateToken(any())).willReturn("newAccessToken"); + given(refreshTokenProvider.generateToken(any())).willReturn("newRefreshToken"); + + // when + Pair jwts = jwtAuthHelper.refresh(refreshToken.getToken()); + + // then + assertEquals("사용자 아이디가 일치하지 않습니다.", refreshToken.getUserId(), jwts.getLeft()); + assertEquals("갱신된 액세스 토큰이 일치하지 않습니다.", "newAccessToken", jwts.getRight().accessToken()); + assertEquals("리프레시 토큰이 갱신되지 않았습니다.", "newRefreshToken", jwts.getRight().refreshToken()); + log.info("갱신된 리프레시 토큰 정보 : {}", refreshTokenRepository.findById(refreshToken.getUserId()).orElse(null)); + } + + @Test + @DisplayName("사용자 아이디에 해당하는 다른 리프레시 토큰이 저장되어 있을 시, 탈취되었다고 판단하고 토큰을 제거한 후 JwtErrorException을 발생시킨다.") + public void RefreshTokenRefreshFail() { + // given + RefreshToken refreshToken = RefreshToken.builder() + .userId(1L) + .token("refreshToken") + .ttl(1000L) + .build(); + refreshTokenRepository.save(refreshToken); + + given(refreshTokenProvider.getJwtClaimsFromToken("anotherRefreshToken")).willReturn(RefreshTokenClaim.of(refreshToken.getUserId(), Role.USER.toString())); + given(refreshTokenProvider.generateToken(any())).willReturn("newRefreshToken"); + + // when + JwtErrorException jwtErrorException = assertThrows(JwtErrorException.class, () -> jwtAuthHelper.refresh("anotherRefreshToken")); + + // then + assertEquals("탈취 시나리오 예외가 발생하지 않았습니다.", JwtErrorCode.TAKEN_AWAY_TOKEN, jwtErrorException.getErrorCode()); + assertFalse("리프레시 토큰이 삭제되지 않았습니다.", refreshTokenRepository.existsById(refreshToken.getUserId())); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisUnitTest.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisUnitTest.java new file mode 100644 index 000000000..2a5f06660 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisUnitTest.java @@ -0,0 +1,16 @@ +package kr.co.pennyway.domain.config; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@ComponentScan(basePackages = "kr.co.pennyway.domain.common.redis") +@EnableAutoConfiguration +@EnableRedisRepositories(basePackages = "kr.co.pennyway.domain.common.redis") +@Documented +public @interface RedisUnitTest { +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceUnitTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceUnitTest.java new file mode 100644 index 000000000..06e1f0a23 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceUnitTest.java @@ -0,0 +1,91 @@ +package kr.co.pennyway.domain.common.redis.refresh; + +import kr.co.pennyway.domain.config.ContainerRedisTestConfig; +import kr.co.pennyway.domain.config.RedisConfig; +import kr.co.pennyway.domain.config.RedisUnitTest; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertFalse; + +@Slf4j +@RedisUnitTest +@DataRedisTest(properties = "spring.config.location=classpath:application-domain.yml") +@ContextConfiguration(classes = {RedisConfig.class}) +@ActiveProfiles("test") +public class RefreshTokenServiceUnitTest extends ContainerRedisTestConfig { + @Autowired + private RefreshTokenRepository refreshTokenRepository; + private RefreshTokenService refreshTokenService; + + @BeforeEach + void setUp() { + this.refreshTokenService = new RefreshTokenServiceImpl(refreshTokenRepository); + } + + @Test + @DisplayName("리프레시 토큰 저장 테스트") + void saveTest() { + // given + RefreshToken refreshToken = RefreshToken.builder() + .userId(1L) + .token("refreshToken") + .ttl(1000L) + .build(); + + // when + refreshTokenService.save(refreshToken); + + // then + RefreshToken savedRefreshToken = refreshTokenRepository.findById(1L).orElse(null); + assertEquals("저장된 리프레시 토큰이 일치하지 않습니다.", refreshToken, savedRefreshToken); + log.info("저장된 리프레시 토큰 정보 : {}", savedRefreshToken); + } + + @Test + @DisplayName("리프레시 토큰 갱신 테스트") + void refreshTest() { + // given + RefreshToken refreshToken = RefreshToken.builder() + .userId(1L) + .token("refreshToken") + .ttl(1000L) + .build(); + refreshTokenService.save(refreshToken); + + // when + refreshTokenService.refresh(1L, "refreshToken", "newRefreshToken"); + + // then + RefreshToken savedRefreshToken = refreshTokenRepository.findById(1L).orElse(null); + assertEquals("갱신된 리프레시 토큰이 일치하지 않습니다.", "newRefreshToken", savedRefreshToken.getToken()); + log.info("갱신된 리프레시 토큰 정보 : {}", savedRefreshToken); + } + + @Test + @DisplayName("요청한 리프레시 토큰과 저장된 리프레시 토큰이 다를 경우 토큰이 탈취되었다고 판단하여 값 삭제") + void validateTokenTest() { + // given + RefreshToken refreshToken = RefreshToken.builder() + .userId(1L) + .token("refreshToken") + .ttl(1000L) + .build(); + refreshTokenService.save(refreshToken); + + // when + IllegalStateException exception = assertThrows(IllegalStateException.class, () -> refreshTokenService.refresh(1L, "anotherRefreshToken", "newRefreshToken")); + + // then + assertEquals("리프레시 토큰이 탈취되었을 때 예외가 발생해야 합니다.", "refresh token mismatched", exception.getMessage()); + assertFalse("리프레시 토큰이 탈취되었을 때 저장된 리프레시 토큰이 삭제되어야 합니다.", refreshTokenRepository.existsById(1L)); + } +} From fc8c11fc91ad10964a82fc78f89e36345f7a3a84 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 30 Apr 2024 19:50:19 +0900 Subject: [PATCH 066/152] =?UTF-8?q?=E2=9C=A8=20=EC=9D=B8=EC=A6=9D=EB=90=9C?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=EC=9D=98=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EC=97=B0=EB=8F=99=20API=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 소셜 계정 연동 api 정의 * fix: request param 추가 dto 정의 -> sign_in_req.oauth dto 활용 * docs: 소셜 계정 연동 api 상세 설명 스웨거 추가 * feat: 소셜 계정 연동 usecase 작성 * feat: user_oauth_sign_server 조건부 user 조회 메서드 추가 * fix: user_oauth_sign_server 조건부 user 조회 메서드 -> user_sync_dto 반환 메서드로 수정 * fix: 소셜 계정 연동 usecase 불필요 의존성 제거 * test: user_auth_user_case 단위 테스트 추가 의존성 주입 * fix: already_signup_oauth error code 400 -> 409 * docs: 409 에러 응답 swagger 반영 * fix: already_signup error code 400 -> 409 * docs: 일반 회원가입 전화번호 인증 409 에러 응답 swagger 반영 * test: 인증된 사용자의 소셜 계정 연동 api 통합 테스트 * docs: 소셜 계정 연동 api 스웨거 409 예외 추가 * test: 기존 already_sign_up 400 에러 예상 응답 409로 수정 * style: user_oauth_sign_service is_link_allowed() 메서드 순서 변경 * fix: like-oauth api post -> put 요청으로 수정 * fix: oauth entity @sql_restriction 어노테이션 제거 * feat: user_id와 provider로 oauth 조회 메서드 추가 * fix: user_sync_dto 연동하기 위한 oauth 정보를 추가로 받는 내부 record 추가 * fix: 사용자 연동 dto user entity -> 직접 필드를 파라미터 주입하도록 수정 * fix: 회원가입 이력 가능 user_sync_dto 생성용 편의 메서드 추가 * feat: oauth entity soft deleted 여부 확인 메서드 추가 * feat: oauth entity soft delete 취소 메서드 추가 * fix: oauth sync dto 팩터리 메서드 내 oauth null 처리 로직 추가 * feat: user_sync_dto 기존 oauth 정보 존재여부 판단용 메서드 추가 * feat: oauth_service id로 oauth 조회 메서드 추가 * feat: oauth_error_code not_found 추가 * fix: soft delete된 oauth 존재 시나리오 반영하여 oauth sign service 수정 * refactor: oauth sign service 클래스 save_user 분기 처리 중첩 if문 제거 * fix: general_sign_service user_sync_dto 인자 추가 * feat: oauth domain service delete 메서드 추가 * feat: soft delete된 oauth 등록 테스트 케이스 추가 * feat: soft delete된 oauth 재활성화 시, oauth_id 필드 given 절 수정 * fix: oauth entity revert_delete 상태 조건문 수정 * fix: oauth revert 시, 사용자가 전송한 oauth_id로 업데이트 --- .../pennyway/api/apis/auth/api/AuthApi.java | 16 +-- .../pennyway/api/apis/auth/api/OauthApi.java | 18 +-- .../api/apis/auth/api/UserAuthApi.java | 19 +++ .../auth/controller/UserAuthController.java | 13 +++ .../api/apis/auth/dto/UserSyncDto.java | 56 +++++++-- .../auth/service/UserGeneralSignService.java | 4 +- .../auth/service/UserOauthSignService.java | 52 +++++++-- .../apis/auth/usecase/UserAuthUseCase.java | 24 ++++ .../AuthControllerIntegrationTest.java | 4 +- .../OAuthControllerIntegrationTest.java | 8 +- .../UserAuthControllerIntegrationTest.java | 108 ++++++++++++++++++ .../auth/usecase/UserAuthUseCaseUnitTest.java | 10 +- .../domain/domains/oauth/domain/Oauth.java | 14 ++- .../oauth/exception/OauthErrorCode.java | 7 +- .../oauth/repository/OauthRepository.java | 2 + .../domains/oauth/service/OauthService.java | 15 +++ .../domains/user/exception/UserErrorCode.java | 4 +- 17 files changed, 326 insertions(+), 48 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java index e70109dda..e624dad7b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java @@ -46,14 +46,6 @@ public interface AuthApi { } """) })), - @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "일반 회원가입 계정이 이미 존재함", value = """ - { - "code": "4004", - "message": "이미 회원가입한 유저입니다." - } - """) - })), @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = { @ExampleObject(name = "검증 실패", value = """ { @@ -69,6 +61,14 @@ public interface AuthApi { "message": "만료되었거나 등록되지 않은 휴대폰 정보입니다." } """) + })), + @ApiResponse(responseCode = "409", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "일반 회원가입 계정이 이미 존재함", value = """ + { + "code": "4091", + "message": "이미 회원가입한 유저입니다." + } + """) })) }) ResponseEntity verifyCode(@RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java index 89aba2641..2f00d28e4 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java @@ -89,14 +89,6 @@ public interface OauthApi { } """) })), - @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "해당 provider로 로그인한 이력이 이미 존재함", value = """ - { - "code": "4004", - "message": "이미 해당 제공자로 가입된 사용자입니다." - } - """) - })), @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = { @ExampleObject(name = "인증코드 불일치", value = """ { @@ -112,7 +104,15 @@ public interface OauthApi { "message": "만료되었거나 등록되지 않은 휴대폰 정보입니다." } """), - })) + })), + @ApiResponse(responseCode = "409", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "해당 provider로 로그인한 이력이 이미 존재함", value = """ + { + "code": "4091", + "message": "이미 해당 제공자로 가입된 사용자입니다." + } + """) + })), }) ResponseEntity verifyCode(@RequestParam Provider provider, @RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java index d28634759..99550f0cb 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java @@ -12,12 +12,17 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.co.pennyway.api.apis.auth.dto.AuthStateDto; +import kr.co.pennyway.api.apis.auth.dto.SignInReq; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.domain.domains.oauth.type.Provider; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "[사용자 인증 관리 API]", description = "사용자의 인증과 관련된 UseCase(로그아웃, 소셜 계정 연동/해지 등)를 제공하는 API") public interface UserAuthApi { @@ -57,4 +62,18 @@ ResponseEntity signOut( @CookieValue(value = "refreshToken", required = false) String refreshToken, @AuthenticationPrincipal SecurityUserDetails user ); + + @Operation(summary = "소셜 계정 연동", description = "인증된 사용자의 소셜 계정을 연동한다. 이미 연동된 계정이 있는 경우에는 409 에러를 반환한다. 미인증 사용자는 해당 API를 사용할 수 없다.") + @Parameter(name = "provider", description = "소셜 제공자", examples = { + @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") + }, required = true, in = ParameterIn.QUERY) + @ApiResponse(responseCode = "409", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "해당 provider로 로그인한 이력이 이미 존재함", value = """ + { + "code": "4091", + "message": "이미 해당 제공자로 가입된 사용자입니다." + } + """) + })) + ResponseEntity linkOauth(@RequestParam Provider provider, @RequestBody @Validated SignInReq.Oauth request, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/UserAuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/UserAuthController.java index 32ed50c4b..6a4ba1cfb 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/UserAuthController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/UserAuthController.java @@ -1,16 +1,19 @@ package kr.co.pennyway.api.apis.auth.controller; import kr.co.pennyway.api.apis.auth.api.UserAuthApi; +import kr.co.pennyway.api.apis.auth.dto.SignInReq; import kr.co.pennyway.api.apis.auth.usecase.UserAuthUseCase; import kr.co.pennyway.api.common.response.SuccessResponse; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import kr.co.pennyway.api.common.util.CookieUtil; +import kr.co.pennyway.domain.domains.oauth.type.Provider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @Slf4j @@ -21,12 +24,14 @@ public class UserAuthController implements UserAuthApi { private final UserAuthUseCase userAuthUseCase; private final CookieUtil cookieUtil; + @Override @GetMapping("/auth") @PreAuthorize("isAuthenticated()") public ResponseEntity getAuthState(@RequestHeader(value = "Authorization") String authHeader) { return ResponseEntity.ok(SuccessResponse.from("user", userAuthUseCase.isSignIn(authHeader))); } + @Override @GetMapping("/sign-out") @PreAuthorize("isAuthenticated()") public ResponseEntity signOut( @@ -40,4 +45,12 @@ public ResponseEntity signOut( .header(HttpHeaders.SET_COOKIE, cookieUtil.deleteCookie("refreshToken").toString()) .body(SuccessResponse.noContent()); } + + @Override + @PutMapping("/link-oauth") + @PreAuthorize("isAuthenticated()") + public ResponseEntity linkOauth(@RequestParam Provider provider, @RequestBody @Validated SignInReq.Oauth request, @AuthenticationPrincipal SecurityUserDetails user) { + userAuthUseCase.linkOauth(provider, request, user.getUserId()); + return ResponseEntity.ok(SuccessResponse.noContent()); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/UserSyncDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/UserSyncDto.java index 3867f5b90..a6d0a345b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/UserSyncDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/UserSyncDto.java @@ -1,5 +1,10 @@ package kr.co.pennyway.api.apis.auth.dto; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.type.Provider; + +import java.time.LocalDateTime; + /** * 전화번호 검증 후, 시나리오 분기 정보를 위한 DTO */ @@ -8,24 +13,59 @@ public record UserSyncDto( boolean isSignUpAllowed, boolean isExistAccount, Long userId, - String username + String username, + /* 계정과 연동하기 위한 oauth 정보. 없다면 null */ + OauthSync oauthSync ) { /** * @param isSignUpAllowed boolean : 회원가입 시나리오 가능 여부 (true: 회원가입 혹은 계정 연동 가능, false: 불가능) * @param isExistAccount boolean : 이미 존재하는 계정 여부 - * @param username boolean : 사용자명 (isExistAccount이 true인 경우에만 존재) + * @param userId Long : 사용자 ID. 없다면 null + * @param username String : 사용자 이름. 없다면 null + * @param oauthSync {@link OauthSync} : 연동할 Oauth 정보. 없다면 null */ - public static UserSyncDto of(boolean isSignUpAllowed, boolean isExistAccount, Long userId, String username) { - return new UserSyncDto(isSignUpAllowed, isExistAccount, userId, username); + public static UserSyncDto of(boolean isSignUpAllowed, boolean isExistAccount, Long userId, String username, OauthSync oauthSync) { + return new UserSyncDto(isSignUpAllowed, isExistAccount, userId, username, oauthSync); } /** * 이미 회원이 존재하는 경우 사용하는 편의용 메서드.
- * 내부에서 {@link UserSyncDto#of(boolean, boolean, Long, String)}를 호출한다. - * - * @param username String : 사용자명 + * 내부에서 {@link UserSyncDto#of(boolean, boolean, Long, String, OauthSync)}를 호출한다. */ public static UserSyncDto abort(Long userId, String username) { - return UserSyncDto.of(false, true, userId, username); + return UserSyncDto.of(false, true, userId, username, null); + } + + /** + * 회원 가입 이력이 없는 경우 사용하는 편의용 메서드.
+ * 내부에서 {@link UserSyncDto#of(boolean, boolean, Long, String, OauthSync)}를 호출한다. + */ + public static UserSyncDto signUpAllowed() { + return UserSyncDto.of(true, false, null, null, null); + } + + /** + * 기존의 soft delete된 Oauth 정보가 있는지 확인한다. + */ + public boolean isExistOauthAccount() { + return oauthSync != null; + } + + public record OauthSync( + Long id, + String oauthId, + Provider provider, + LocalDateTime deletedAt + ) { + /** + * Oauth 정보를 OauthSync로 변환한다.
+ * Oauth 정보가 없는 경우 null을 반환한다. + */ + public static OauthSync from(Oauth oauth) { + if (oauth == null) { + return null; + } + return new OauthSync(oauth.getId(), oauth.getOauthId(), oauth.getProvider(), oauth.getDeletedAt()); + } } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java index 2f4049ce4..dac852447 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java @@ -38,7 +38,7 @@ public UserSyncDto isSignUpAllowed(String phone) { if (!isExistUser(user)) { log.info("회원가입 이력이 없는 사용자입니다. phone: {}", phone); - return UserSyncDto.of(true, false, null, null); + return UserSyncDto.signUpAllowed(); } if (isGeneralSignUpUser(user.get())) { @@ -47,7 +47,7 @@ public UserSyncDto isSignUpAllowed(String phone) { } log.info("소셜 회원가입 사용자입니다. user: {}", user.get()); - return UserSyncDto.of(true, true, user.get().getId(), user.get().getUsername()); + return UserSyncDto.of(true, true, user.get().getId(), user.get().getUsername(), null); } /** diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java index b80eec441..5f84b3f88 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java @@ -3,6 +3,8 @@ import kr.co.pennyway.api.apis.auth.dto.SignUpReq; import kr.co.pennyway.api.apis.auth.dto.UserSyncDto; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; +import kr.co.pennyway.domain.domains.oauth.exception.OauthException; import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.oauth.type.Provider; import kr.co.pennyway.domain.domains.user.domain.User; @@ -27,6 +29,9 @@ public class UserOauthSignService { public User readUser(String oauthId, Provider provider) { Optional oauth = oauthService.readOauthByOauthIdAndProvider(oauthId, provider); + if (oauth.isPresent() && oauth.get().isDeleted()) + return null; + return oauth.map(Oauth::getUser).orElse(null); } @@ -42,16 +47,37 @@ public UserSyncDto isSignUpAllowed(Provider provider, String phone) { if (user.isEmpty()) { log.info("회원가입 이력이 없는 사용자입니다. phone: {}", phone); - return UserSyncDto.of(true, false, null, null); + return UserSyncDto.signUpAllowed(); } - if (oauthService.isExistOauthAccount(user.get().getId(), provider)) { + Optional oauth = oauthService.readOauthByUserIdAndProvider(user.get().getId(), provider); + + if (oauth.isPresent() && !oauth.get().isDeleted()) { log.info("이미 동일한 Provider로 가입된 사용자입니다. phone: {}, provider: {}", phone, provider); return UserSyncDto.abort(user.get().getId(), user.get().getUsername()); } log.info("소셜 회원가입 사용자입니다. user: {}", user.get()); - return UserSyncDto.of(true, true, user.get().getId(), user.get().getUsername()); + return UserSyncDto.of(true, true, user.get().getId(), user.get().getUsername(), UserSyncDto.OauthSync.from(oauth.orElse(null))); + } + + /** + * 인증된 사용자에게 provider로 연동할 수 있는지 여부를 반환한다. + * + * @return {@link UserSyncDto} + */ + @Transactional(readOnly = true) + public UserSyncDto isLinkAllowed(Long userId, Provider provider) { + Optional oauth = oauthService.readOauthByUserIdAndProvider(userId, provider); + + if (oauth.isPresent() && !oauth.get().isDeleted()) { + log.info("이미 동일한 Provider로 가입된 사용자입니다. userId: {}, provider: {}", userId, provider); + throw new OauthException(OauthErrorCode.ALREADY_SIGNUP_OAUTH); + } + + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + + return UserSyncDto.of(true, true, user.getId(), user.getUsername(), UserSyncDto.OauthSync.from(oauth.orElse(null))); } /** @@ -65,22 +91,30 @@ public User saveUser(SignUpReq.OauthInfo request, UserSyncDto userSync, Provider if (userSync.isExistAccount()) { log.info("기존 계정에 연동합니다. username: {}", userSync.username()); - user = userService.readUser(userSync.userId()) - .orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + user = userService.readUser(userSync.userId()).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); } else { log.info("새로운 계정을 생성합니다. username: {}", request.username()); user = request.toUser(); userService.createUser(user); } - Oauth oauth = mappingOauthToUser(user, provider, oauthId); + Oauth oauth = readOrCreateOauth(userSync, provider, oauthId, user); + oauthService.createOauth(oauth); log.info("연동된 Oauth 정보 : {}", oauth); return user; } - private Oauth mappingOauthToUser(User user, Provider provider, String oauthId) { - Oauth oauth = Oauth.of(provider, oauthId, user); - return oauthService.createOauth(oauth); + private Oauth readOrCreateOauth(UserSyncDto userSync, Provider provider, String oauthId, User user) { + if (userSync.isExistOauthAccount()) { + Oauth oauth = oauthService.readOauth(userSync.oauthSync().id()).orElseThrow(() -> new OauthException(OauthErrorCode.NOT_FOUND_OAUTH)); + oauth.revertDelete(oauthId); + log.info("기존 Oauth 계정을 복구합니다. oauth: {}", oauth); + + return oauth; + } + + log.info("새로운 Oauth 계정을 생성합니다. oauthId: {}", oauthId); + return Oauth.of(provider, oauthId, user); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java index 8ca55bfee..f4f78cae6 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java @@ -1,19 +1,32 @@ package kr.co.pennyway.api.apis.auth.usecase; import kr.co.pennyway.api.apis.auth.dto.AuthStateDto; +import kr.co.pennyway.api.apis.auth.dto.SignInReq; +import kr.co.pennyway.api.apis.auth.dto.UserSyncDto; import kr.co.pennyway.api.apis.auth.helper.JwtAuthHelper; +import kr.co.pennyway.api.apis.auth.helper.OauthOidcHelper; +import kr.co.pennyway.api.apis.auth.service.UserOauthSignService; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaimKeys; import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; +import kr.co.pennyway.domain.domains.oauth.exception.OauthException; +import kr.co.pennyway.domain.domains.oauth.type.Provider; import kr.co.pennyway.infra.common.jwt.JwtClaims; import kr.co.pennyway.infra.common.jwt.JwtProvider; +import kr.co.pennyway.infra.common.oidc.OidcDecodePayload; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; @Slf4j @UseCase @RequiredArgsConstructor public class UserAuthUseCase { + private final UserOauthSignService userOauthSignService; + private final JwtAuthHelper jwtAuthHelper; + private final OauthOidcHelper oauthOidcHelper; + private final JwtProvider accessTokenProvider; public AuthStateDto isSignIn(String authHeader) { @@ -29,4 +42,15 @@ public AuthStateDto isSignIn(String authHeader) { public void signOut(Long userId, String authHeader, String refreshToken) { jwtAuthHelper.removeAccessTokenAndRefreshToken(userId, authHeader, refreshToken); } + + @Transactional + public void linkOauth(Provider provider, SignInReq.Oauth request, Long userId) { + OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken(), request.nonce()); + + if (!request.oauthId().equals(payload.sub())) + throw new OauthException(OauthErrorCode.NOT_MATCHED_OAUTH_ID); + + UserSyncDto userSync = userOauthSignService.isLinkAllowed(userId, provider); + userOauthSignService.saveUser(null, userSync, provider, request.oauthId()); + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java index a33039d08..c0f9de416 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java @@ -106,7 +106,7 @@ private Oauth createOauthAccount(User user) { class GeneralSignUpPhoneVerifyTest { @Test @WithAnonymousUser - @DisplayName("일반 회원가입 이력이 있는 경우 400 BAD_REQUEST를 반환하고, 인증 코드 캐시 데이터가 제거된다.") + @DisplayName("일반 회원가입 이력이 있는 경우 409 Conflict를 반환하고, 인증 코드 캐시 데이터가 제거된다.") void generalSignUpFailBecauseAlreadyGeneralSignUp() throws Exception { // given phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.SIGN_UP); @@ -117,7 +117,7 @@ void generalSignUpFailBecauseAlreadyGeneralSignUp() throws Exception { // then resultActions - .andExpect(status().isBadRequest()) + .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value(UserErrorCode.ALREADY_SIGNUP.causedBy().getCode())) .andExpect(jsonPath("$.message").value(UserErrorCode.ALREADY_SIGNUP.getExplainError())) .andDo(print()); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java index 9043ce5f6..32819fe6f 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java @@ -336,7 +336,7 @@ void signUpWithNoSignedUser() throws Exception { @Test @WithAnonymousUser @Transactional - @DisplayName("같은 provider로 OAuth 회원가입 이력이 있으면 400 에러가 발생한다.") + @DisplayName("같은 provider로 OAuth 회원가입 이력이 있으면 409 Conflict 에러가 발생한다.") void signUpWithSameProvider() throws Exception { // given Provider provider = Provider.KAKAO; @@ -352,7 +352,7 @@ void signUpWithSameProvider() throws Exception { // then result - .andExpect(status().isBadRequest()) + .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value(OauthErrorCode.ALREADY_SIGNUP_OAUTH.causedBy().getCode())) .andExpect(jsonPath("$.message").value(OauthErrorCode.ALREADY_SIGNUP_OAUTH.getExplainError())) .andDo(print()); @@ -493,7 +493,7 @@ void signUpWithNoSignedUser() throws Exception { @Test @WithAnonymousUser @Transactional - @DisplayName("같은 provider로 OAuth 회원가입 이력이 있으면 400 에러가 발생한다.") + @DisplayName("같은 provider로 OAuth 회원가입 이력이 있으면 409 Conflict 에러가 발생한다.") void signUpWithSameProvider() throws Exception { // given Provider provider = Provider.KAKAO; @@ -510,7 +510,7 @@ void signUpWithSameProvider() throws Exception { // then result - .andExpect(status().isBadRequest()) + .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value(OauthErrorCode.ALREADY_SIGNUP_OAUTH.causedBy().getCode())) .andExpect(jsonPath("$.message").value(OauthErrorCode.ALREADY_SIGNUP_OAUTH.getExplainError())) .andDo(print()); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java index ebbecde63..af49b4039 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java @@ -1,36 +1,52 @@ package kr.co.pennyway.api.apis.auth.controller; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.Cookie; +import kr.co.pennyway.api.apis.auth.dto.SignInReq; +import kr.co.pennyway.api.apis.auth.helper.OauthOidcHelper; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenProvider; import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaim; import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenProvider; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; import kr.co.pennyway.domain.common.redis.refresh.RefreshToken; import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.oauth.type.Provider; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; import kr.co.pennyway.domain.domains.user.type.Role; import kr.co.pennyway.infra.common.exception.JwtErrorCode; +import kr.co.pennyway.infra.common.oidc.OidcDecodePayload; +import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.transaction.annotation.Transactional; import java.time.ZoneId; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +@Slf4j @ExternalApiIntegrationTest @AutoConfigureMockMvc @TestClassOrder(ClassOrderer.OrderAnnotation.class) @@ -38,6 +54,9 @@ public class UserAuthControllerIntegrationTest extends ExternalApiDBTestConfig { @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired private AccessTokenProvider accessTokenProvider; @@ -50,7 +69,11 @@ public class UserAuthControllerIntegrationTest extends ExternalApiDBTestConfig { private ForbiddenTokenService forbiddenTokenService; @Autowired private UserService userService; + @Autowired + private OauthService oauthService; + @MockBean + private OauthOidcHelper oauthOidcHelper; @Nested @Order(1) @@ -209,4 +232,89 @@ private MockHttpServletRequestBuilder performSignOut() { .accept(MediaType.APPLICATION_JSON); } } + + @Nested + @Order(2) + @DisplayName("소셜 계정 연동") + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class LinkOauth { + @Order(1) + @Test + @DisplayName("provider로 로그인한 이력이 없다면, 사용자는 계정 연동에 성공한다.") + @WithSecurityMockUser(userId = "8") + @Transactional + void linkOauthWithNoHistory() throws Exception { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + Provider expectedProvider = Provider.KAKAO; + given(oauthOidcHelper.getPayload(expectedProvider, "idToken", "nonce")).willReturn(new OidcDecodePayload("iss", "aud", "oauthId", "email")); + + // when + ResultActions result = performLinkOauth(expectedProvider, "oauthId"); + + // then + result.andExpect(status().isOk()).andDo(print()); + assertTrue(oauthService.isExistOauthAccount(user.getId(), expectedProvider)); + } + + @Order(2) + @Test + @DisplayName("provider로 로그인한 이력이 있다면, 사용자는 계정 연동에 실패하고 409 에러를 반환한다.") + @WithSecurityMockUser(userId = "9") + @Transactional + void linkOauthWithHistory() throws Exception { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + Provider expectedProvider = Provider.KAKAO; + oauthService.createOauth(Oauth.of(expectedProvider, "oauthId", user)); + given(oauthOidcHelper.getPayload(expectedProvider, "idToken", "nonce")).willReturn(new OidcDecodePayload("iss", "aud", "oauthId", "email")); + + // when + ResultActions result = performLinkOauth(expectedProvider, "oauthId"); + + // then + result.andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.ALREADY_SIGNUP_OAUTH.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.ALREADY_SIGNUP_OAUTH.getExplainError())) + .andDo(print()); + } + + @Order(3) + @Test + @DisplayName("해당 provider가 soft delete된 이력이 존재한다면, deleted_at을 null로 업데이트하고 최신 oauth_id를 반영하여 계정 연동에 성공한다.") + @WithSecurityMockUser(userId = "10") + @Transactional + void linkOauthWithDeletedHistory() throws Exception { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + Provider expectedProvider = Provider.KAKAO; + Oauth oauth = Oauth.of(expectedProvider, "oauthId", user); + oauthService.createOauth(oauth); + oauthService.deleteOauth(oauth); + given(oauthOidcHelper.getPayload(expectedProvider, "idToken", "nonce")).willReturn(new OidcDecodePayload("iss", "aud", "newOauthId", "email")); + + // when + ResultActions result = performLinkOauth(expectedProvider, "newOauthId"); + + // then + result.andExpect(status().isOk()).andDo(print()); + Oauth savedOauth = oauthService.readOauth(oauth.getId()).orElse(null); + assertNotNull(savedOauth); + assertEquals("newOauthId", savedOauth.getOauthId()); + assertNull(savedOauth.getDeletedAt()); + log.info("연동된 Oauth 정보 : {}", savedOauth); + } + + private ResultActions performLinkOauth(Provider provider, String oauthId) throws Exception { + SignInReq.Oauth request = new SignInReq.Oauth(oauthId, "idToken", "nonce"); + return mockMvc.perform(put("/v1/link-oauth") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .queryParam("provider", provider.name()) + .content(objectMapper.writeValueAsString(request))); + } + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCaseUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCaseUnitTest.java index 4cf2c34b3..f9a7048d2 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCaseUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCaseUnitTest.java @@ -2,6 +2,8 @@ import kr.co.pennyway.api.apis.auth.dto.AuthStateDto; import kr.co.pennyway.api.apis.auth.helper.JwtAuthHelper; +import kr.co.pennyway.api.apis.auth.helper.OauthOidcHelper; +import kr.co.pennyway.api.apis.auth.service.UserOauthSignService; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenProvider; import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; @@ -32,6 +34,10 @@ public class UserAuthUseCaseUnitTest { private UserAuthUseCase userAuthUseCase; private JwtAuthHelper jwtAuthHelper; + @Mock + private UserOauthSignService userOauthSignService; + @Mock + private OauthOidcHelper oauthOidcHelper; @Mock private JwtProvider refreshTokenProvider; @Mock @@ -43,7 +49,7 @@ public class UserAuthUseCaseUnitTest { public void setUp() { accessTokenProvider = new AccessTokenProvider(secretStr, Duration.ofMinutes(5)); jwtAuthHelper = new JwtAuthHelper(accessTokenProvider, refreshTokenProvider, refreshTokenService, forbiddenTokenService); - userAuthUseCase = new UserAuthUseCase(jwtAuthHelper, accessTokenProvider); + userAuthUseCase = new UserAuthUseCase(userOauthSignService, jwtAuthHelper, oauthOidcHelper, accessTokenProvider); } @Test @@ -51,7 +57,7 @@ public void setUp() { public void isSignedInWithExpiredToken() { // given accessTokenProvider = new AccessTokenProvider(secretStr, Duration.ofMillis(0)); - userAuthUseCase = new UserAuthUseCase(jwtAuthHelper, accessTokenProvider); + userAuthUseCase = new UserAuthUseCase(userOauthSignService, jwtAuthHelper, oauthOidcHelper, accessTokenProvider); String expiredToken = accessTokenProvider.generateToken(jwtClaims); // when diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java index 0207097a5..8bc836c57 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java @@ -11,7 +11,6 @@ import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.SQLRestriction; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -23,7 +22,6 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @EntityListeners(AuditingEntityListener.class) @DynamicInsert -@SQLRestriction("deleted_at IS NULL") @SQLDelete(sql = "UPDATE oauth SET deleted_at = NOW() WHERE id = ?") public class Oauth { @Id @@ -62,6 +60,18 @@ public static Oauth of(Provider provider, String oauthId, User user) { .build(); } + public boolean isDeleted() { + return deletedAt != null; + } + + public void revertDelete(String oauthId) { + if (deletedAt == null) { + throw new IllegalStateException("삭제되지 않은 oauth 정보 갱신 요청입니다. oauthId: " + oauthId); + } + this.oauthId = oauthId; + this.deletedAt = null; + } + @Override public String toString() { return "Oauth{" + diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java index b372d38ed..de32dfdd5 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java @@ -11,12 +11,17 @@ @RequiredArgsConstructor public enum OauthErrorCode implements BaseErrorCode { /* 400 Bad Request */ - ALREADY_SIGNUP_OAUTH(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "이미 해당 제공자로 가입된 사용자입니다."), INVALID_OAUTH_SYNC_REQUEST(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "Oauth 동기화 요청이 잘못되었습니다."), /* 401 Unauthorized */ NOT_MATCHED_OAUTH_ID(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "OAuth ID가 일치하지 않습니다."), + /* 404 Not Found */ + NOT_FOUND_OAUTH(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "해당 제공자로 가입된 이력을 찾을 수 없습니다."), + + /* 409 Conflict */ + ALREADY_SIGNUP_OAUTH(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 해당 제공자로 가입된 사용자입니다."), + /* 422 Unprocessable Entity */ INVALID_PROVIDER(StatusCode.UNPROCESSABLE_CONTENT, ReasonCode.TYPE_MISMATCH_ERROR_IN_REQUEST_BODY, "유효하지 않은 제공자입니다."); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java index 09c519a55..8de6fbb51 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java @@ -9,5 +9,7 @@ public interface OauthRepository extends JpaRepository { Optional findByOauthIdAndProvider(String oauthId, Provider provider); + Optional findByUser_IdAndProvider(Long userId, Provider provider); + boolean existsByUser_IdAndProvider(Long userId, Provider provider); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java index 708fe6385..11e5a8c89 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java @@ -19,13 +19,28 @@ public Oauth createOauth(Oauth oauth) { return oauthRepository.save(oauth); } + @Transactional + public Optional readOauth(Long id) { + return oauthRepository.findById(id); + } + @Transactional(readOnly = true) public Optional readOauthByOauthIdAndProvider(String oauthId, Provider provider) { return oauthRepository.findByOauthIdAndProvider(oauthId, provider); } + @Transactional(readOnly = true) + public Optional readOauthByUserIdAndProvider(Long userId, Provider provider) { + return oauthRepository.findByUser_IdAndProvider(userId, provider); + } + @Transactional(readOnly = true) public boolean isExistOauthAccount(Long userId, Provider provider) { return oauthRepository.existsByUser_IdAndProvider(userId, provider); } + + @Transactional + public void deleteOauth(Oauth oauth) { + oauthRepository.delete(oauth); + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java index c420ca919..17dbe5092 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java @@ -9,7 +9,6 @@ @RequiredArgsConstructor public enum UserErrorCode implements BaseErrorCode { /* 400 BAD_REQUEST */ - ALREADY_SIGNUP(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "이미 회원가입한 유저입니다."), NOT_MATCHED_PASSWORD(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "비밀번호가 일치하지 않습니다."), PASSWORD_NOT_CHANGED(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "현재 비밀번호와 동일한 비밀번호로 변경할 수 없습니다."), @@ -20,6 +19,9 @@ public enum UserErrorCode implements BaseErrorCode { ALREADY_WITHDRAWAL(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "이미 탈퇴한 유저입니다."), DO_NOT_GENERAL_SIGNED_UP(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "일반 회원가입 계정이 아닙니다."), + /* 409 Conflict */ + ALREADY_SIGNUP(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 회원가입한 유저입니다."), + /* 404 NOT_FOUND */ NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "유저를 찾을 수 없습니다."), From 9ffaf2dcf3ff724e497cb47b915a23fae3a51eea Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 1 May 2024 10:42:55 +0900 Subject: [PATCH 067/152] =?UTF-8?q?=F0=9F=90=9B=20OAuth=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8/=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=8B=9C=20Soft=20Delete=20=EC=A0=95=EC=B1=85=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=EA=B2=80=EC=82=AC=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 같은 provider로 Oauth 로그인 이력이 soft delete 되었으면 성공 응답을 반환한다 * test: 소셜 회원가입 시 soft delete된 oauth 업데이트 여부 테스트 추가 * fix: 소셜 회원가입 dto 필드로 oauth_id 추가 * fix: oauth_oidc_helper oauth_id 메개변수 추가 * fix: get_payload() 사용하던 usecase에서 oauth_id 인자 전달 * test: get_payload() 사용하던 test에서 oauth_id 인자 전달 * test: user auth controller 통합 테스트에서 soft delete 테스트 시 expected_oauth_id 수정 --- .../pennyway/api/apis/auth/dto/SignUpReq.java | 13 +- .../api/apis/auth/helper/OauthOidcHelper.java | 14 +- .../api/apis/auth/usecase/OauthUseCase.java | 6 +- .../apis/auth/usecase/UserAuthUseCase.java | 9 +- .../OAuthControllerIntegrationTest.java | 125 ++++++++++++------ .../UserAuthControllerIntegrationTest.java | 6 +- .../infra/common/oidc/OauthOidcProvider.java | 2 +- .../common/oidc/OauthOidcProviderImpl.java | 9 +- 8 files changed, 112 insertions(+), 72 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java index 323cd4069..3bf20ba3c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java @@ -40,7 +40,8 @@ public String password() { } } - public record OauthInfo(String idToken, String nonce, String name, String username, String phone, String code) { + public record OauthInfo(String oauthId, String idToken, String nonce, String name, String username, String phone, + String code) { public User toUser() { return User.builder() .username(username) @@ -102,6 +103,9 @@ public Info toInfo() { @Schema(title = "소셜 회원가입 요청 DTO") public record Oauth( + @Schema(description = "OAuth id") + @NotBlank(message = "OAuth id는 필수 입력값입니다.") + String oauthId, @Schema(description = "OIDC 토큰") @NotBlank(message = "OIDC 토큰은 필수 입력값입니다.") String idToken, @@ -126,12 +130,15 @@ public record Oauth( String code ) { public OauthInfo toOauthInfo() { - return new OauthInfo(idToken, nonce, name, username, phone, code); + return new OauthInfo(oauthId, idToken, nonce, name, username, phone, code); } } @Schema(title = "소셜 회원가입(기존 계정 존재) 요청 DTO") public record SyncWithAuth( + @Schema(description = "OAuth id") + @NotBlank(message = "OAuth id는 필수 입력값입니다.") + String oauthId, @Schema(description = "OIDC 토큰") @NotBlank(message = "OIDC 토큰은 필수 입력값입니다.") String idToken, @@ -148,7 +155,7 @@ public record SyncWithAuth( String code ) { public OauthInfo toOauthInfo() { - return new OauthInfo(idToken, nonce, null, null, phone, code); + return new OauthInfo(oauthId, idToken, nonce, null, null, phone, code); } } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java index c097cc463..97a96b0ca 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/OauthOidcHelper.java @@ -40,16 +40,17 @@ public OauthOidcHelper( * Provider에 따라 Client와 Properties를 선택하고 Odic public key 정보를 가져와서 ID Token의 payload를 추출하는 메서드 * * @param provider : {@link Provider} + * @param oauthId : Provider에서 발급한 사용자 식별자 * @param idToken : idToken * @param nonce : 인증 서버 로그인 요청 시 전달한 임의의 문자열 * @return OIDCDecodePayload : ID Token의 payload */ - public OidcDecodePayload getPayload(Provider provider, String idToken, String nonce) { + public OidcDecodePayload getPayload(Provider provider, String oauthId, String idToken, String nonce) { OauthOidcClient client = oauthOidcClients.get(provider).keySet().iterator().next(); OauthOidcClientProperties properties = oauthOidcClients.get(provider).values().iterator().next(); OidcPublicKeyResponse response = client.getOidcPublicKey(); - return getPayloadFromIdToken(idToken, properties.getIssuer(), properties.getSecret(), nonce, response); + return getPayloadFromIdToken(idToken, properties.getIssuer(), oauthId, properties.getSecret(), nonce, response); } /** @@ -58,13 +59,14 @@ public OidcDecodePayload getPayload(Provider provider, String idToken, String no * * @param idToken : idToken * @param iss : ID Token을 발급한 provider의 URL + * @param sub : ID Token의 subject (사용자 식별자) * @param aud : ID Token이 발급된 앱의 앱 키 * @param nonce : 인증 서버 로그인 요청 시 전달한 임의의 문자열 (Optional, 현재는 사용하지 않음) * @param response : 공개키 목록 * @return OIDCDecodePayload : ID Token의 payload */ - private OidcDecodePayload getPayloadFromIdToken(String idToken, String iss, String aud, String nonce, OidcPublicKeyResponse response) { - String kid = getKidFromUnsignedIdToken(idToken, iss, aud, nonce); + private OidcDecodePayload getPayloadFromIdToken(String idToken, String iss, String sub, String aud, String nonce, OidcPublicKeyResponse response) { + String kid = getKidFromUnsignedIdToken(idToken, iss, sub, aud, nonce); OidcPublicKey key = response.getKeys().stream() .filter(k -> k.kid().equals(kid)) @@ -73,7 +75,7 @@ private OidcDecodePayload getPayloadFromIdToken(String idToken, String iss, Stri return oauthOidcProvider.getOIDCTokenBody(idToken, key.n(), key.e()); } - private String getKidFromUnsignedIdToken(String token, String iss, String aud, String nonce) { - return oauthOidcProvider.getKidFromUnsignedTokenHeader(token, iss, aud, nonce); + private String getKidFromUnsignedIdToken(String token, String iss, String sub, String aud, String nonce) { + return oauthOidcProvider.getKidFromUnsignedTokenHeader(token, iss, sub, aud, nonce); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java index f6704e813..d71b9939e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java @@ -34,11 +34,9 @@ public class OauthUseCase { @Transactional(readOnly = true) public Pair signIn(Provider provider, SignInReq.Oauth request) { - OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken(), request.nonce()); + OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.oauthId(), request.idToken(), request.nonce()); log.debug("payload : {}", payload); - if (!request.oauthId().equals(payload.sub())) - throw new OauthException(OauthErrorCode.NOT_MATCHED_OAUTH_ID); User user = userOauthSignService.readUser(request.oauthId(), provider); return (user != null) ? Pair.of(user.getId(), jwtAuthHelper.createToken(user)) : Pair.of(-1L, null); @@ -66,7 +64,7 @@ public Pair signUp(Provider provider, SignUpReq.OauthInfo request) { throw new OauthException(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST); } - OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken(), request.nonce()); + OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.oauthId(), request.idToken(), request.nonce()); User user = userOauthSignService.saveUser(request, userSync, provider, payload.sub()); return Pair.of(user.getId(), jwtAuthHelper.createToken(user)); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java index f4f78cae6..eaabd96f4 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java @@ -8,8 +8,6 @@ import kr.co.pennyway.api.apis.auth.service.UserOauthSignService; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaimKeys; import kr.co.pennyway.common.annotation.UseCase; -import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; -import kr.co.pennyway.domain.domains.oauth.exception.OauthException; import kr.co.pennyway.domain.domains.oauth.type.Provider; import kr.co.pennyway.infra.common.jwt.JwtClaims; import kr.co.pennyway.infra.common.jwt.JwtProvider; @@ -45,12 +43,9 @@ public void signOut(Long userId, String authHeader, String refreshToken) { @Transactional public void linkOauth(Provider provider, SignInReq.Oauth request, Long userId) { - OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken(), request.nonce()); - - if (!request.oauthId().equals(payload.sub())) - throw new OauthException(OauthErrorCode.NOT_MATCHED_OAUTH_ID); + OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.oauthId(), request.idToken(), request.nonce()); UserSyncDto userSync = userOauthSignService.isLinkAllowed(userId, provider); - userOauthSignService.saveUser(null, userSync, provider, request.oauthId()); + userOauthSignService.saveUser(null, userSync, provider, payload.sub()); } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java index 32819fe6f..4553fe20b 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java @@ -41,8 +41,7 @@ import java.nio.file.Path; import static com.github.tomakehurst.wiremock.client.WireMock.*; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -140,7 +139,7 @@ void signInWithOauth() throws Exception { Provider provider = Provider.KAKAO; User user = createOauthSignedUser(); - given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); userService.createUser(user); oauthService.createOauth(createOauthAccount(user, provider)); @@ -165,7 +164,7 @@ void signInWithDifferentProvider() throws Exception { Provider provider = Provider.KAKAO; User user = createOauthSignedUser(); - given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); userService.createUser(user); oauthService.createOauth(createOauthAccount(user, Provider.GOOGLE)); @@ -188,7 +187,7 @@ void signInWithGeneralSignedUser() throws Exception { Provider provider = Provider.KAKAO; User user = createGeneralSignedUser(); - given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); userService.createUser(user); // when @@ -209,7 +208,7 @@ void signInWithNoSignedUser() throws Exception { // given Provider provider = Provider.KAKAO; - given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken, expectedNonce); @@ -221,30 +220,6 @@ void signInWithNoSignedUser() throws Exception { .andDo(print()); } - @Test - @WithAnonymousUser - @Transactional - @DisplayName("OAuth id와 payload의 sub가 다른 경우에는 NOT_MATCHED_OAUTH_ID 에러가 발생한다.") - void signInWithNotMatchedOauthId() throws Exception { - // given - Provider provider = Provider.KAKAO; - User user = createOauthSignedUser(); - - given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", "differentOauthId", "email")); - userService.createUser(user); - oauthService.createOauth(createOauthAccount(user, provider)); - - // when - ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken, expectedNonce); - - // then - result - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.code").value(OauthErrorCode.NOT_MATCHED_OAUTH_ID.causedBy().getCode())) - .andExpect(jsonPath("$.message").value(OauthErrorCode.NOT_MATCHED_OAUTH_ID.getExplainError())) - .andDo(print()); - } - private ResultActions performOauthSignIn(Provider provider, String oauthId, String idToken, String nonce) throws Exception { SignInReq.Oauth request = new SignInReq.Oauth(oauthId, idToken, expectedNonce); @@ -398,6 +373,34 @@ void signUpWithInvalidCode() throws Exception { .andDo(print()); } + @Test + @WithAnonymousUser + @Transactional + @DisplayName("같은 provider로 Oauth 로그인 이력이 soft delete 되었으면 성공 응답을 반환한다.") + void signUpWithDeletedOauth() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createGeneralSignedUser(); + Oauth oauth = createOauthAccount(user, provider); + + userService.createUser(user); + oauthService.createOauth(oauth); + oauthService.deleteOauth(oauth); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + + // when + ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andExpect(jsonPath("$.data.sms.code").value(true)) + .andExpect(jsonPath("$.data.sms.existsUser").value(true)) + .andExpect(jsonPath("$.data.sms.username").value(user.getUsername())) + .andDo(print()); + } + private ResultActions performOauthSignUpPhoneVerification(Provider provider, String code) throws Exception { PhoneVerificationDto.VerifyCodeReq request = new PhoneVerificationDto.VerifyCodeReq(expectedPhone, code); return mockMvc.perform(post("/v1/auth/oauth/phone/verification") @@ -422,10 +425,10 @@ void signUpWithGeneralSignedUser() throws Exception { userService.createUser(user); phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when - ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); + ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode, expectedOauthId); // then result @@ -452,10 +455,10 @@ void signUpWithDifferentProvider() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when - ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); + ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode, expectedOauthId); // then result @@ -477,10 +480,10 @@ void signUpWithNoSignedUser() throws Exception { // given Provider provider = Provider.KAKAO; phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when - ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); + ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode, expectedOauthId); // then result @@ -503,10 +506,10 @@ void signUpWithSameProvider() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when - ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); + ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode, expectedOauthId); // then result @@ -516,8 +519,42 @@ void signUpWithSameProvider() throws Exception { .andDo(print()); } - private ResultActions performOauthSignUpAccountLinking(Provider provider, String code) throws Exception { - SignUpReq.SyncWithAuth request = new SignUpReq.SyncWithAuth(expectedIdToken, expectedNonce, expectedPhone, code); + @Test + @WithAnonymousUser + @Transactional + @DisplayName("같은 provider로 Oauth 로그인 이력이 soft delete 되었으면, Oauth 정보가 복구되고 새로운 oauth_id를 반영한다.") + void signUpWithDeletedOauth() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createGeneralSignedUser(); + Oauth oauth = createOauthAccount(user, provider); + + userService.createUser(user); + oauthService.createOauth(oauth); + oauthService.deleteOauth(oauth); + phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, "newOauthId", expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", "newOauthId", "email")); + + // when + ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode, "newOauthId"); + + // then + result + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().exists("Authorization")) + .andExpect(jsonPath("$.data.user.id").value(user.getId())) + .andDo(print()); + Oauth savedOauth = oauthService.readOauthByOauthIdAndProvider("newOauthId", provider).get(); + assertEquals(user.getId(), savedOauth.getUser().getId()); + assertEquals(oauth.getId(), savedOauth.getId()); + assertEquals("newOauthId", savedOauth.getOauthId()); + assertFalse(savedOauth.isDeleted()); + System.out.println("oauth : " + savedOauth); + } + + private ResultActions performOauthSignUpAccountLinking(Provider provider, String code, String oauthId) throws Exception { + SignUpReq.SyncWithAuth request = new SignUpReq.SyncWithAuth(oauthId, expectedIdToken, expectedNonce, expectedPhone, code); return mockMvc.perform(post("/v1/auth/oauth/link-auth") .param("provider", provider.name()) .contentType("application/json") @@ -537,7 +574,7 @@ void signUpWithNoSignedUser() throws Exception { // given Provider provider = Provider.KAKAO; phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when ResultActions result = performOauthSignUp(provider, expectedCode); @@ -565,7 +602,7 @@ void signUpWithGeneralSignedUser() throws Exception { userService.createUser(user); phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when ResultActions result = performOauthSignUp(provider, expectedCode); @@ -591,7 +628,7 @@ void signUpWithOauthSignedUser() throws Exception { userService.createUser(user); oauthService.createOauth(oauth); phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider)); - given(oauthOidcHelper.getPayload(provider, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + given(oauthOidcHelper.getPayload(provider, expectedOauthId, expectedIdToken, expectedNonce)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); // when ResultActions result = performOauthSignUp(provider, expectedCode); @@ -605,7 +642,7 @@ void signUpWithOauthSignedUser() throws Exception { } private ResultActions performOauthSignUp(Provider provider, String code) throws Exception { - SignUpReq.Oauth request = new SignUpReq.Oauth(expectedIdToken, expectedNonce, "jayang", expectedUsername, expectedPhone, code); + SignUpReq.Oauth request = new SignUpReq.Oauth(expectedOauthId, expectedIdToken, expectedNonce, "jayang", expectedUsername, expectedPhone, code); return mockMvc.perform(post("/v1/auth/oauth/sign-up") .param("provider", provider.name()) .contentType("application/json") diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java index af49b4039..89cb42486 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java @@ -248,7 +248,7 @@ void linkOauthWithNoHistory() throws Exception { User user = UserFixture.GENERAL_USER.toUser(); userService.createUser(user); Provider expectedProvider = Provider.KAKAO; - given(oauthOidcHelper.getPayload(expectedProvider, "idToken", "nonce")).willReturn(new OidcDecodePayload("iss", "aud", "oauthId", "email")); + given(oauthOidcHelper.getPayload(expectedProvider, "oauthId", "idToken", "nonce")).willReturn(new OidcDecodePayload("iss", "aud", "oauthId", "email")); // when ResultActions result = performLinkOauth(expectedProvider, "oauthId"); @@ -269,7 +269,7 @@ void linkOauthWithHistory() throws Exception { userService.createUser(user); Provider expectedProvider = Provider.KAKAO; oauthService.createOauth(Oauth.of(expectedProvider, "oauthId", user)); - given(oauthOidcHelper.getPayload(expectedProvider, "idToken", "nonce")).willReturn(new OidcDecodePayload("iss", "aud", "oauthId", "email")); + given(oauthOidcHelper.getPayload(expectedProvider, "oauthId", "idToken", "nonce")).willReturn(new OidcDecodePayload("iss", "aud", "oauthId", "email")); // when ResultActions result = performLinkOauth(expectedProvider, "oauthId"); @@ -294,7 +294,7 @@ void linkOauthWithDeletedHistory() throws Exception { Oauth oauth = Oauth.of(expectedProvider, "oauthId", user); oauthService.createOauth(oauth); oauthService.deleteOauth(oauth); - given(oauthOidcHelper.getPayload(expectedProvider, "idToken", "nonce")).willReturn(new OidcDecodePayload("iss", "aud", "newOauthId", "email")); + given(oauthOidcHelper.getPayload(expectedProvider, "newOauthId", "idToken", "nonce")).willReturn(new OidcDecodePayload("iss", "aud", "newOauthId", "email")); // when ResultActions result = performLinkOauth(expectedProvider, "newOauthId"); diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProvider.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProvider.java index 57bd3938f..4754f0bbe 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProvider.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProvider.java @@ -10,7 +10,7 @@ public interface OauthOidcProvider { * @param nonce : 인증 서버 로그인 요청 시 전달한 임의의 문자열 * @return kid : ID Token의 서명에 사용된 공개키의 ID */ - String getKidFromUnsignedTokenHeader(String token, String iss, String aud, String nonce); + String getKidFromUnsignedTokenHeader(String token, String iss, String sub, String aud, String nonce); /** * ID Token의 payload를 추출하는 메서드 diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java index 69cc6da03..8da487a55 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/oidc/OauthOidcProviderImpl.java @@ -32,8 +32,8 @@ public class OauthOidcProviderImpl implements OauthOidcProvider { private final ObjectMapper objectMapper; @Override - public String getKidFromUnsignedTokenHeader(String token, String iss, String aud, String nonce) { - return getUnsignedTokenClaims(token, iss, aud, nonce).get("header").get(KID); + public String getKidFromUnsignedTokenHeader(String token, String iss, String sub, String aud, String nonce) { + return getUnsignedTokenClaims(token, iss, sub, aud, nonce).get("header").get(KID); } @Override @@ -53,7 +53,7 @@ public OidcDecodePayload getOIDCTokenBody(String token, String modulus, String e * payload의 iss, aud, exp, nonce를 검증하고, 실패시 예외 처리 */ @SuppressWarnings("unchecked") - private Map> getUnsignedTokenClaims(String token, String iss, String aud, String nonce) { + private Map> getUnsignedTokenClaims(String token, String iss, String sub, String aud, String nonce) { try { Base64.Decoder decoder = Base64.getUrlDecoder(); @@ -64,8 +64,9 @@ private Map> getUnsignedTokenClaims(String token, St Map header = objectMapper.readValue(headerJson, Map.class); Map payload = objectMapper.readValue(payloadJson, Map.class); - Assert.isTrue(payload.get("aud").equals(aud), "aud is not matched. expected : " + aud + ", actual : " + payload.get("aud")); Assert.isTrue(payload.get("iss").equals(iss), "iss is not matched. expected : " + iss + ", actual : " + payload.get("iss")); + Assert.isTrue(payload.get("sub").equals(sub), "sub is not matched. expected : " + sub + ", actual : " + payload.get("sub")); + Assert.isTrue(payload.get("aud").equals(aud), "aud is not matched. expected : " + aud + ", actual : " + payload.get("aud")); Assert.isTrue(payload.get("nonce").equals(nonce), "nonce is not matched. expected : " + nonce + ", actual : " + payload.get("nonce")); return Map.of("header", header, "payload", payload); From 731515a20ae3d342bc854ab2f9cfe40f5b0900ca Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 1 May 2024 20:03:15 +0900 Subject: [PATCH 068/152] =?UTF-8?q?=E2=9C=A8=20=EC=9D=B8=EC=A6=9D=EB=90=9C?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=EC=9D=98=20OAuth=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=ED=95=B4=EC=A7=80=20API=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 소셜 로그인 연동 해제 api 정의 * feat: user oauth sign service & usecase 로직 구현 * feat: oauth_error_code cannot_unlink_oauth 에러 코드 추가 * feat: user가 연동한 oauth 모든 정보 조회 목적 domain service 메서드 추가 * test: oauth 연동 해제 테스트 케이스 작성 * test: 인가 권한을 위해 @with_security_mock_user 어노테이션으로 수정 * refactor: service의 unlink() -> read_oauth_for_unlink()로 수정 * docs: 소셜 계정 연동 해제 409 에러 응답 문서 추가 * test: 테스트 케이스 위치 이관 --- .../api/apis/auth/api/UserAuthApi.java | 24 ++++ .../auth/controller/UserAuthController.java | 8 ++ .../auth/service/UserOauthSignService.java | 37 ++++- .../apis/auth/usecase/UserAuthUseCase.java | 9 ++ .../OAuthControllerIntegrationTest.java | 10 +- .../UserAuthControllerIntegrationTest.java | 126 ++++++++++++++++++ .../auth/usecase/UserAuthUseCaseUnitTest.java | 7 +- .../oauth/exception/OauthErrorCode.java | 1 + .../oauth/repository/OauthRepository.java | 3 + .../domains/oauth/service/OauthService.java | 6 + 10 files changed, 219 insertions(+), 12 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java index 99550f0cb..d266e8f5b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java @@ -76,4 +76,28 @@ ResponseEntity signOut( """) })) ResponseEntity linkOauth(@RequestParam Provider provider, @RequestBody @Validated SignInReq.Oauth request, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "소셜 계정 연동 해제", description = "인증된 사용자의 소셜 계정 연동을 해제한다. 연동되지 않은 계정을 해제하려고 하는 경우에는 404 에러를 반환한다. 미인증 사용자는 해당 API를 사용할 수 없다.") + @Parameter(name = "provider", description = "소셜 제공자", examples = { + @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") + }, required = true, in = ParameterIn.QUERY) + @ApiResponses({ + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "해당 provider로 로그인한 이력이 없음", value = """ + { + "code": "4040", + "message": "해당 제공자로 가입된 이력을 찾을 수 없습니다." + } + """) + })), + @ApiResponse(responseCode = "409", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "연결 해제 요청 실패", value = """ + { + "code": "4090", + "message": "해당 제공자로만 가입된 사용자는 연동을 해제할 수 없습니다." + } + """, description = "일반 회원 가입 이력이 없고, 연동된 소셜 계정이 해지를 요청하는 제공자 하나 뿐인 경우 -> 계정 삭제 API를 호출해야 한다.") + })) + }) + ResponseEntity unlinkOauth(@RequestParam Provider provider, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/UserAuthController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/UserAuthController.java index 6a4ba1cfb..d8f7ec29b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/UserAuthController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/UserAuthController.java @@ -53,4 +53,12 @@ public ResponseEntity linkOauth(@RequestParam Provider provider, @RequestBody userAuthUseCase.linkOauth(provider, request, user.getUserId()); return ResponseEntity.ok(SuccessResponse.noContent()); } + + @Override + @DeleteMapping("/link-oauth") + @PreAuthorize("isAuthenticated()") + public ResponseEntity unlinkOauth(@RequestParam Provider provider, @AuthenticationPrincipal SecurityUserDetails user) { + userAuthUseCase.unlinkOauth(provider, user.getUserId()); + return ResponseEntity.ok(SuccessResponse.noContent()); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java index 5f84b3f88..a69e12acb 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java @@ -17,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Optional; +import java.util.Set; @Slf4j @Service @@ -27,12 +28,10 @@ public class UserOauthSignService { @Transactional(readOnly = true) public User readUser(String oauthId, Provider provider) { - Optional oauth = oauthService.readOauthByOauthIdAndProvider(oauthId, provider); - - if (oauth.isPresent() && oauth.get().isDeleted()) - return null; - - return oauth.map(Oauth::getUser).orElse(null); + return oauthService.readOauthByOauthIdAndProvider(oauthId, provider) + .filter(o -> !o.isDeleted()) + .map(Oauth::getUser) + .orElse(null); } /** @@ -80,6 +79,32 @@ public UserSyncDto isLinkAllowed(Long userId, Provider provider) { return UserSyncDto.of(true, true, user.getId(), user.getUsername(), UserSyncDto.OauthSync.from(oauth.orElse(null))); } + /** + * 연동을 해지할 수 있는 Oauth 계정을 조회한다. + * + * @throws OauthException :
+ * {@link OauthErrorCode#NOT_FOUND_OAUTH} : Provider에 해당하는 Oauth가 없는 경우
+ * {@link OauthErrorCode#CANNOT_UNLINK_OAUTH} : Oauth가 1개이고 일반 회원 이력이 없어 연동 해지가 불가능한 경우
+ */ + @Transactional(readOnly = true) + public Oauth readOauthForUnlink(Long userId, Provider provider) { + Set oauths = oauthService.readOauthsByUserId(userId); + + Oauth oauth = oauths.stream() + .filter(o -> o.getProvider().equals(provider) && !o.isDeleted()) + .findFirst() + .orElseThrow(() -> new OauthException(OauthErrorCode.NOT_FOUND_OAUTH)); + long oauthCount = oauths.stream().filter(o -> !o.isDeleted()).count(); + + User user = oauth.getUser(); + + if (oauthCount == 1 && !user.isGeneralSignedUpUser()) { + throw new OauthException(OauthErrorCode.CANNOT_UNLINK_OAUTH); + } + + return oauth; + } + /** * 기존 계정이 존재하면 Oauth 계정을 생성하여 연동하고, 존재하지 않으면 새로운 계정을 생성한다. * diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java index eaabd96f4..e076c52a9 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java @@ -8,6 +8,8 @@ import kr.co.pennyway.api.apis.auth.service.UserOauthSignService; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaimKeys; import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.oauth.type.Provider; import kr.co.pennyway.infra.common.jwt.JwtClaims; import kr.co.pennyway.infra.common.jwt.JwtProvider; @@ -21,6 +23,7 @@ @RequiredArgsConstructor public class UserAuthUseCase { private final UserOauthSignService userOauthSignService; + private final OauthService oauthService; private final JwtAuthHelper jwtAuthHelper; private final OauthOidcHelper oauthOidcHelper; @@ -48,4 +51,10 @@ public void linkOauth(Provider provider, SignInReq.Oauth request, Long userId) { UserSyncDto userSync = userOauthSignService.isLinkAllowed(userId, provider); userOauthSignService.saveUser(null, userSync, provider, payload.sub()); } + + @Transactional + public void unlinkOauth(Provider provider, Long userId) { + Oauth oauth = userOauthSignService.readOauthForUnlink(userId, provider); + oauthService.deleteOauth(oauth); + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java index 4553fe20b..f4805e232 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java @@ -19,6 +19,7 @@ import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; import kr.co.pennyway.domain.domains.user.type.Role; import kr.co.pennyway.infra.common.oidc.OidcDecodePayload; +import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -49,6 +50,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +@Slf4j @ExternalApiIntegrationTest @AutoConfigureMockMvc @TestClassOrder(ClassOrderer.OrderAnnotation.class) @@ -439,7 +441,7 @@ void signUpWithGeneralSignedUser() throws Exception { .andDo(print()); Oauth savedOauth = oauthService.readOauthByOauthIdAndProvider(expectedOauthId, provider).get(); assertEquals(savedOauth.getUser().getId(), user.getId()); - System.out.println("oauth : " + savedOauth); + log.debug("oauth : {}", savedOauth); } @Test @@ -469,7 +471,7 @@ void signUpWithDifferentProvider() throws Exception { .andDo(print()); Oauth savedOauth = oauthService.readOauthByOauthIdAndProvider(expectedOauthId, provider).get(); assertEquals(savedOauth.getUser().getId(), user.getId()); - System.out.println("oauth : " + savedOauth); + log.debug("oauth : {}", savedOauth); } @Test @@ -550,7 +552,7 @@ void signUpWithDeletedOauth() throws Exception { assertEquals(oauth.getId(), savedOauth.getId()); assertEquals("newOauthId", savedOauth.getOauthId()); assertFalse(savedOauth.isDeleted()); - System.out.println("oauth : " + savedOauth); + log.debug("oauth : {}", savedOauth); } private ResultActions performOauthSignUpAccountLinking(Provider provider, String code, String oauthId) throws Exception { @@ -588,7 +590,7 @@ void signUpWithNoSignedUser() throws Exception { .andDo(print()); Oauth savedOauth = oauthService.readOauthByOauthIdAndProvider(expectedOauthId, provider).get(); assertNotNull(savedOauth.getUser().getId()); - System.out.println("oauth : " + savedOauth); + log.debug("oauth : {}", savedOauth); } @Test diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java index 89cb42486..028da08c3 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java @@ -35,6 +35,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.transaction.annotation.Transactional; import java.time.ZoneId; @@ -317,4 +318,129 @@ private ResultActions performLinkOauth(Provider provider, String oauthId) throws .content(objectMapper.writeValueAsString(request))); } } + + @Nested + @Order(5) + @DisplayName("소셜 연동 해제") + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class OauthUnlinkTest { + @Test + @Order(1) + @WithSecurityMockUser(userId = "11") + @Transactional + @DisplayName("제공자로 연동한 이력이 존재하지 않으면 404 에러가 발생한다.") + void unlinkWithNoOauth() throws Exception { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + + // when + ResultActions result = performOauthUnlink(Provider.KAKAO); + + // then + result + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.NOT_FOUND_OAUTH.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.NOT_FOUND_OAUTH.getExplainError())) + .andDo(print()); + } + + @Test + @Order(2) + @WithSecurityMockUser(userId = "12") + @Transactional + @DisplayName("제공자로 연동한 이력이 soft delete 되어 있으면 404 에러가 발생한다.") + void unlinkWithSoftDeletedOauth() throws Exception { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + + Oauth oauth = mappingOauthWithUser(user, Provider.KAKAO); + oauthService.deleteOauth(oauth); + + // when + ResultActions result = performOauthUnlink(Provider.KAKAO); + + // then + result + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.NOT_FOUND_OAUTH.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.NOT_FOUND_OAUTH.getExplainError())) + .andDo(print()); + } + + @Test + @Order(3) + @WithSecurityMockUser(userId = "13") + @Transactional + @DisplayName("연동된 Oauth가 1개이고 일반 회원 이력이 없는 경우에는 409 에러가 발생한다.") + void unlinkWithOnlyOauthSignedUser() throws Exception { + // given + User user = UserFixture.OAUTH_USER.toUser(); + userService.createUser(user); + + mappingOauthWithUser(user, Provider.KAKAO); + + // when + ResultActions result = performOauthUnlink(Provider.KAKAO); + + // then + result + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.CANNOT_UNLINK_OAUTH.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.CANNOT_UNLINK_OAUTH.getExplainError())) + .andDo(print()); + } + + @Test + @Order(4) + @WithSecurityMockUser(userId = "14") + @Transactional + @DisplayName("연동된 Oauth가 1개이고 일반 회원 이력이 있는 경우에는 연동 해제에 성공한다.") + void unlinkWithGeneralSignedUser() throws Exception { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + + Oauth oauth = mappingOauthWithUser(user, Provider.KAKAO); + + // when + ResultActions result = performOauthUnlink(Provider.KAKAO); + + // then + result.andExpect(status().isOk()).andDo(print()); + assertTrue(oauthService.readOauthByOauthIdAndProvider(oauth.getOauthId(), Provider.KAKAO).get().isDeleted()); + } + + @Test + @Order(5) + @WithSecurityMockUser(userId = "15") + @Transactional + @DisplayName("연동된 Oauth가 2개 이상이고 일반 회원 이력이 없는 경우에는 연동 해제에 성공한다.") + void unlinkWithMultipleOauthSignedUser() throws Exception { + // given + User user = UserFixture.OAUTH_USER.toUser(); + userService.createUser(user); + + Oauth kakao = mappingOauthWithUser(user, Provider.KAKAO); + Oauth google = mappingOauthWithUser(user, Provider.GOOGLE); + + // when + ResultActions result = performOauthUnlink(Provider.KAKAO); + + // then + result.andExpect(status().isOk()).andDo(print()); + assertTrue(oauthService.readOauthByOauthIdAndProvider(kakao.getOauthId(), Provider.KAKAO).get().isDeleted()); + } + + private ResultActions performOauthUnlink(Provider provider) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders.delete("/v1/link-oauth") + .param("provider", provider.name())); + } + + private Oauth mappingOauthWithUser(User user, Provider provider) { + Oauth oauth = Oauth.of(provider, "oauthId", user); + return oauthService.createOauth(oauth); + } + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCaseUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCaseUnitTest.java index f9a7048d2..bfd9f4b20 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCaseUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCaseUnitTest.java @@ -8,6 +8,7 @@ import kr.co.pennyway.api.common.security.jwt.access.AccessTokenProvider; import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.infra.common.exception.JwtErrorCode; import kr.co.pennyway.infra.common.exception.JwtErrorException; import kr.co.pennyway.infra.common.jwt.JwtClaims; @@ -37,6 +38,8 @@ public class UserAuthUseCaseUnitTest { @Mock private UserOauthSignService userOauthSignService; @Mock + private OauthService oauthService; + @Mock private OauthOidcHelper oauthOidcHelper; @Mock private JwtProvider refreshTokenProvider; @@ -49,7 +52,7 @@ public class UserAuthUseCaseUnitTest { public void setUp() { accessTokenProvider = new AccessTokenProvider(secretStr, Duration.ofMinutes(5)); jwtAuthHelper = new JwtAuthHelper(accessTokenProvider, refreshTokenProvider, refreshTokenService, forbiddenTokenService); - userAuthUseCase = new UserAuthUseCase(userOauthSignService, jwtAuthHelper, oauthOidcHelper, accessTokenProvider); + userAuthUseCase = new UserAuthUseCase(userOauthSignService, oauthService, jwtAuthHelper, oauthOidcHelper, accessTokenProvider); } @Test @@ -57,7 +60,7 @@ public void setUp() { public void isSignedInWithExpiredToken() { // given accessTokenProvider = new AccessTokenProvider(secretStr, Duration.ofMillis(0)); - userAuthUseCase = new UserAuthUseCase(userOauthSignService, jwtAuthHelper, oauthOidcHelper, accessTokenProvider); + userAuthUseCase = new UserAuthUseCase(userOauthSignService, oauthService, jwtAuthHelper, oauthOidcHelper, accessTokenProvider); String expiredToken = accessTokenProvider.generateToken(jwtClaims); // when diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java index de32dfdd5..1963f8d3e 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java @@ -20,6 +20,7 @@ public enum OauthErrorCode implements BaseErrorCode { NOT_FOUND_OAUTH(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "해당 제공자로 가입된 이력을 찾을 수 없습니다."), /* 409 Conflict */ + CANNOT_UNLINK_OAUTH(StatusCode.CONFLICT, ReasonCode.REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE, "해당 제공자로만 가입된 사용자는 연동을 해제할 수 없습니다."), ALREADY_SIGNUP_OAUTH(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 해당 제공자로 가입된 사용자입니다."), /* 422 Unprocessable Entity */ diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java index 8de6fbb51..5d05ea2fd 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java @@ -5,11 +5,14 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import java.util.Set; public interface OauthRepository extends JpaRepository { Optional findByOauthIdAndProvider(String oauthId, Provider provider); Optional findByUser_IdAndProvider(Long userId, Provider provider); + Set findAllByUser_Id(Long userId); + boolean existsByUser_IdAndProvider(Long userId, Provider provider); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java index 11e5a8c89..ae9142127 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java @@ -8,6 +8,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Optional; +import java.util.Set; @DomainService @RequiredArgsConstructor @@ -34,6 +35,11 @@ public Optional readOauthByUserIdAndProvider(Long userId, Provider provid return oauthRepository.findByUser_IdAndProvider(userId, provider); } + @Transactional(readOnly = true) + public Set readOauthsByUserId(Long userId) { + return oauthRepository.findAllByUser_Id(userId); + } + @Transactional(readOnly = true) public boolean isExistOauthAccount(Long userId, Provider provider) { return oauthRepository.existsByUser_IdAndProvider(userId, provider); From e65fe63bbc64ad5d8dc64f12f23a7a2bc758e511 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 2 May 2024 12:41:18 +0900 Subject: [PATCH 069/152] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C?= =?UTF-8?q?,=20=EC=86=8C=EC=85=9C=20=EA=B3=84=EC=A0=95=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 유저 조회 usecase에서 delete되지 않은 oauth 계정 정보 조회 추가 * feat: oauth account 정보를 담을 dto 정의 * feat: dto binding을 위한 user_profile_mapper 생성 * test: user_account_use_case_test oauth_service mock bean 주입 * fix: user_profile_dto oauth_account 필드 nullable 제거 * fix: is_oauth_user 필드 -> is_general_sign_up 필드로 수정 --- .../api/apis/users/dto/OauthAccountDto.java | 17 ++++++++++++ .../api/apis/users/dto/UserProfileDto.java | 14 ++++++---- .../apis/users/mapper/UserProfileMapper.java | 27 +++++++++++++++++++ .../users/usecase/UserAccountUseCase.java | 10 ++++++- .../users/usecase/UserAccountUseCaseTest.java | 4 +++ 5 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/OauthAccountDto.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/UserProfileMapper.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/OauthAccountDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/OauthAccountDto.java new file mode 100644 index 000000000..80f9b6a1f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/OauthAccountDto.java @@ -0,0 +1,17 @@ +package kr.co.pennyway.api.apis.users.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(title = "Oauth 계정 정보 응답") +public record OauthAccountDto( + @Schema(description = "카카오 계정 연동 여부", example = "true") + boolean kakao, + @Schema(description = "구글 계정 연동 여부", example = "false") + boolean google, + @Schema(description = "애플 계정 연동 여부", example = "false") + boolean apple +) { + public static OauthAccountDto of(boolean kakao, boolean google, boolean apple) { + return new OauthAccountDto(kakao, google, apple); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java index 667415f2b..22ff3b3a7 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java @@ -22,8 +22,8 @@ public record UserProfileDto( String username, @Schema(description = "사용자 이름", example = "홍길동") String name, - @Schema(description = "Oauth 계정 여부. 일반 회원가입 계정이 있으면 true, 없으면 false", example = "false") - boolean isOauthAccount, + @Schema(description = "일반 회원가입 이력. 일반 회원가입 계정이 있으면 true, 없으면 false", example = "false") + boolean isGeneralSignUp, @Schema(description = "비밀번호 변경 일시. isOauthAccount가 true면 존재하지 않는 필드", nullable = true, type = "string", example = "yyyy-MM-dd HH:mm:ss") @JsonInclude(JsonInclude.Include.NON_NULL) @JsonSerialize(using = LocalDateTimeSerializer.class) @@ -42,7 +42,9 @@ public record UserProfileDto( @Schema(description = "계정 생성 일시", type = "string", example = "yyyy-MM-dd HH:mm:ss") @JsonSerialize(using = LocalDateTimeSerializer.class) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - LocalDateTime createdAt + LocalDateTime createdAt, + @Schema(description = "Oauth 계정 정보") + OauthAccountDto oauthAccount ) { public UserProfileDto { Objects.requireNonNull(id); @@ -54,9 +56,10 @@ public record UserProfileDto( Objects.requireNonNull(locked); Objects.requireNonNull(notifySetting); Objects.requireNonNull(createdAt); + Objects.requireNonNull(oauthAccount); } - public static UserProfileDto from(User user) { + public static UserProfileDto from(User user, OauthAccountDto oauthAccount) { return UserProfileDto.builder() .id(user.getId()) .username(user.getUsername()) @@ -67,8 +70,9 @@ public static UserProfileDto from(User user) { .profileVisibility(user.getProfileVisibility()) .locked(user.getLocked()) .notifySetting(user.getNotifySetting()) - .isOauthAccount(user.getPassword() == null) + .isGeneralSignUp(user.isGeneralSignedUpUser()) .createdAt(user.getCreatedAt()) + .oauthAccount(oauthAccount) .build(); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/UserProfileMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/UserProfileMapper.java new file mode 100644 index 000000000..885875675 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/UserProfileMapper.java @@ -0,0 +1,27 @@ +package kr.co.pennyway.api.apis.users.mapper; + +import kr.co.pennyway.api.apis.users.dto.OauthAccountDto; +import kr.co.pennyway.api.apis.users.dto.UserProfileDto; +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.user.domain.User; + +import java.util.Set; + +@Mapper +public class UserProfileMapper { + public static UserProfileDto toUserProfileDto(User user, Set oauths) { + boolean kakao, google, apple; + kakao = google = apple = false; + + for (Oauth oauth : oauths) { + switch (oauth.getProvider()) { + case KAKAO -> kakao = true; + case GOOGLE -> google = true; + case APPLE -> apple = true; + } + } + + return UserProfileDto.from(user, OauthAccountDto.of(kakao, google, apple)); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java index 149c1d45d..9d9a661d1 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java @@ -4,6 +4,7 @@ import kr.co.pennyway.api.apis.users.dto.UserProfileDto; import kr.co.pennyway.api.apis.users.dto.UserProfileUpdateDto; import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; +import kr.co.pennyway.api.apis.users.mapper.UserProfileMapper; import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; import kr.co.pennyway.api.apis.users.service.UserProfileUpdateService; import kr.co.pennyway.common.annotation.UseCase; @@ -11,6 +12,8 @@ import kr.co.pennyway.domain.domains.device.exception.DeviceErrorCode; import kr.co.pennyway.domain.domains.device.exception.DeviceErrorException; import kr.co.pennyway.domain.domains.device.service.DeviceService; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; @@ -20,11 +23,15 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; +import java.util.Set; +import java.util.stream.Collectors; + @Slf4j @UseCase @RequiredArgsConstructor public class UserAccountUseCase { private final UserService userService; + private final OauthService oauthService; private final DeviceService deviceService; private final UserProfileUpdateService userProfileUpdateService; @@ -55,8 +62,9 @@ public void unregisterDevice(Long userId, String token) { @Transactional(readOnly = true) public UserProfileDto getMyAccount(Long userId) { User user = readUserOrThrow(userId); + Set oauths = oauthService.readOauthsByUserId(userId).stream().filter(oauth -> !oauth.isDeleted()).collect(Collectors.toUnmodifiableSet()); - return UserProfileDto.from(user); + return UserProfileMapper.toUserProfileDto(user, oauths); } @Transactional diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java index c08eb088f..d213411dd 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java @@ -12,6 +12,7 @@ import kr.co.pennyway.domain.domains.device.exception.DeviceErrorCode; import kr.co.pennyway.domain.domains.device.exception.DeviceErrorException; import kr.co.pennyway.domain.domains.device.service.DeviceService; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; @@ -56,6 +57,9 @@ class UserAccountUseCaseTest extends ExternalApiDBTestConfig { @MockBean private PasswordEncoderHelper passwordEncoderHelper; + @MockBean + private OauthService oauthService; + @Order(1) @Nested @DisplayName("[1] 디바이스 등록 테스트") From e371b86f2371ca9e06411401d523b47f110909d3 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sat, 4 May 2024 17:23:26 +0900 Subject: [PATCH 070/152] =?UTF-8?q?=E2=9C=A8=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EA=B3=84=EC=A0=95=20=EC=82=AD=EC=A0=9C=20API=20=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 계정 삭제 controller 단위 테스트 * feat: 사용자 계정 삭제 controller 구현 * feat: 유저 삭제 use case -> 컴파일 에러 해결 목적 임시 구현 * test: oauth_service mock bean -> autowired * feat: oauth repository 벌크 연산 삭제 메서드 추가 * feat: oauth service 사용자 아이디로 연관 관계의 oauth 정보 제거 메서드 추가 * feat: 사용자 삭제 서비스 추가 * test: user delete service 빈 추가 * feat: user domain 벌크 연산 삭제 메서드 추가 * fix: user 영속화 제거 * fix: delete 시, 영속화 된 user -> user_id 기반으로 제거 * fix: 컨트롤러 @delete_mapping 추가 * docs: 계정 삭제 api 주의 사항 추가 --- .../api/apis/users/api/UserAccountApi.java | 3 + .../controller/UserAccountController.java | 8 +++ .../apis/users/service/UserDeleteService.java | 29 ++++++++ .../users/usecase/UserAccountUseCase.java | 11 +++ .../UserAccountControllerUnitTest.java | 40 +++++++++++ .../users/usecase/UserAccountUseCaseTest.java | 68 +++++++++++++++++-- .../oauth/repository/OauthRepository.java | 8 +++ .../domains/oauth/service/OauthService.java | 5 ++ .../user/repository/UserRepository.java | 8 +++ .../domains/user/service/UserService.java | 5 ++ 10 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java index b242f8b42..9ed41b47d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java @@ -196,4 +196,7 @@ public interface UserAccountApi { })) }) ResponseEntity deleteNotifySetting(@RequestParam NotifySetting.NotifyType type, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "사용자 계정 삭제", description = "사용자 본인의 계정을 삭제합니다. 채팅방 방장이면 삭제가 안 되는 시나리오는 고려하지 않고 있습니다.") + ResponseEntity deleteAccount(@AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java index 0ffbaee73..c2729be69 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java @@ -90,4 +90,12 @@ public ResponseEntity patchNotifySetting(@RequestParam NotifySetting.NotifyTy public ResponseEntity deleteNotifySetting(@RequestParam NotifySetting.NotifyType type, @AuthenticationPrincipal SecurityUserDetails user) { return ResponseEntity.ok(SuccessResponse.from("notifySetting", userAccountUseCase.deactivateNotification(user.getUserId(), type))); } + + @Override + @DeleteMapping("") + @PreAuthorize("isAuthenticated()") + public ResponseEntity deleteAccount(@AuthenticationPrincipal SecurityUserDetails user) { + userAccountUseCase.deleteAccount(user.getUserId()); + return ResponseEntity.ok(SuccessResponse.noContent()); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java new file mode 100644 index 000000000..1af59641f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java @@ -0,0 +1,29 @@ +package kr.co.pennyway.api.apis.users.service; + +import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 사용자 삭제만을 담당하는 클래스
+ * 추후 연관 관계의 데이터가 늘어나면 Template Method Pattern을 적용하여 단위 테스트를 수행할 수 있도록 한다. + * + * @author YANG JAESEO + * @since 2024.05.03 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserDeleteService { + private final UserService userService; + private final OauthService oauthService; + + @Transactional + public void deleteUser(Long userId) { + oauthService.deleteOauthsByUserId(userId); + userService.deleteUser(userId); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java index 9d9a661d1..78c158267 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java @@ -6,6 +6,7 @@ import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; import kr.co.pennyway.api.apis.users.mapper.UserProfileMapper; import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; +import kr.co.pennyway.api.apis.users.service.UserDeleteService; import kr.co.pennyway.api.apis.users.service.UserProfileUpdateService; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.domains.device.domain.Device; @@ -35,6 +36,7 @@ public class UserAccountUseCase { private final DeviceService deviceService; private final UserProfileUpdateService userProfileUpdateService; + private final UserDeleteService userDeleteService; private final DeviceRegisterService deviceRegisterService; private final PasswordEncoderHelper passwordEncoderHelper; @@ -115,6 +117,15 @@ public UserProfileUpdateDto.NotifySettingUpdateReq deactivateNotification(Long u return UserProfileUpdateDto.NotifySettingUpdateReq.of(type, Boolean.FALSE); } + @Transactional + public void deleteAccount(Long userId) { + if (!userService.isExistUser(userId)) throw new UserErrorException(UserErrorCode.NOT_FOUND); + + // TODO: [2024-05-03] 하나라도 채팅방의 방장으로 참여하는 경우 삭제 불가능 처리 + + userDeleteService.deleteUser(userId); + } + private User readUserOrThrow(Long userId) { return userService.readUser(userId).orElseThrow( () -> { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java index ce253d51d..d75956cd3 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java @@ -462,4 +462,44 @@ private ResultActions performUpdatePasswordRequest(String oldPassword, String ne .content(objectMapper.writeValueAsString(request))); } } + + @Nested + @Order(6) + @DisplayName("[6] 사용자 계정 삭제 테스트") + class DeleteAccountTest { + @DisplayName("사용자 계정 삭제 요청 시, 삭제된 사용자인 경우 404 에러를 반환한다.") + @Test + @WithSecurityMockUser + void deleteAccountDeletedUser() throws Exception { + // given + willThrow(new UserErrorException(UserErrorCode.NOT_FOUND)).given(userAccountUseCase).deleteAccount(1L); + + // when + ResultActions result = performDeleteAccountRequest(); + + // then + result.andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(UserErrorCode.NOT_FOUND.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(UserErrorCode.NOT_FOUND.getExplainError())) + .andDo(print()); + } + + @DisplayName("사용자 계정 삭제 요청 시, 사용자 계정이 정상적으로 삭제되면 200 코드를 반환한다.") + @Test + @WithSecurityMockUser + void deleteAccountSuccess() throws Exception { + // when + ResultActions result = performDeleteAccountRequest(); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andDo(print()); + } + + private ResultActions performDeleteAccountRequest() throws Exception { + return mockMvc.perform(delete("/v2/users/me") + .contentType("application/json")); + } + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java index d213411dd..45adcb8c4 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java @@ -3,6 +3,7 @@ import kr.co.pennyway.api.apis.users.dto.DeviceDto; import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; +import kr.co.pennyway.api.apis.users.service.UserDeleteService; import kr.co.pennyway.api.apis.users.service.UserProfileUpdateService; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.fixture.DeviceFixture; @@ -12,7 +13,9 @@ import kr.co.pennyway.domain.domains.device.exception.DeviceErrorCode; import kr.co.pennyway.domain.domains.device.exception.DeviceErrorException; import kr.co.pennyway.domain.domains.device.service.DeviceService; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.oauth.type.Provider; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; @@ -40,7 +43,9 @@ @ExtendWith(MockitoExtension.class) @DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") -@ContextConfiguration(classes = {JpaConfig.class, UserAccountUseCase.class, DeviceRegisterService.class, UserService.class, DeviceService.class, UserProfileUpdateService.class}) +@ContextConfiguration(classes = { + JpaConfig.class, UserAccountUseCase.class, DeviceRegisterService.class, UserProfileUpdateService.class, UserDeleteService.class, + UserService.class, DeviceService.class, OauthService.class}) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ActiveProfiles("test") @TestClassOrder(ClassOrderer.OrderAnnotation.class) @@ -51,15 +56,15 @@ class UserAccountUseCaseTest extends ExternalApiDBTestConfig { @Autowired private DeviceService deviceService; + @Autowired + private OauthService oauthService; + @Autowired private UserAccountUseCase userAccountUseCase; @MockBean private PasswordEncoderHelper passwordEncoderHelper; - @MockBean - private OauthService oauthService; - @Order(1) @Nested @DisplayName("[1] 디바이스 등록 테스트") @@ -434,4 +439,59 @@ void updatePassword() { assertEquals("비밀번호가 정상적으로 변경되어 있어야 한다.", "encodedPassword", userService.readUser(originUser.getId()).orElseThrow().getPassword()); } } + + @Order(6) + @Nested + @DisplayName("[6] 사용자 계정 삭제") + class DeleteAccountTest { + @Test + @Transactional + @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") + void deleteAccountWhenUserIsDeleted() { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + userService.deleteUser(user); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.deleteAccount(user.getId())); + assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("일반 회원가입 이력만 있는 사용자의 경우, 정상적으로 계정이 삭제된다.") + void deleteAccount() { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + + // when - then + assertDoesNotThrow(() -> userAccountUseCase.deleteAccount(user.getId())); + assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); + } + + @Test + @Transactional + @DisplayName("삭제 시, 연동된 모든 소셜 계정은 soft delete 처리되어야 한다.") + void deleteAccountWithSocialAccounts() { + // given + User user = UserFixture.OAUTH_USER.toUser(); + userService.createUser(user); + + Oauth kakao = createOauth(Provider.KAKAO, "kakaoId", user); + Oauth google = createOauth(Provider.GOOGLE, "googleId", user); + + // when - then + assertDoesNotThrow(() -> userAccountUseCase.deleteAccount(user.getId())); + assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); + assertTrue("카카오 계정이 삭제되어 있어야 한다.", oauthService.readOauth(kakao.getId()).get().isDeleted()); + assertTrue("구글 계정이 삭제되어 있어야 한다.", oauthService.readOauth(google.getId()).get().isDeleted()); + } + + private Oauth createOauth(Provider provider, String providerId, User user) { + Oauth oauth = Oauth.of(provider, providerId, user); + return oauthService.createOauth(oauth); + } + } } \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java index 5d05ea2fd..928cf1a93 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java @@ -3,6 +3,9 @@ import kr.co.pennyway.domain.domains.oauth.domain.Oauth; import kr.co.pennyway.domain.domains.oauth.type.Provider; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; import java.util.Optional; import java.util.Set; @@ -15,4 +18,9 @@ public interface OauthRepository extends JpaRepository { Set findAllByUser_Id(Long userId); boolean existsByUser_IdAndProvider(Long userId, Provider provider); + + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE Oauth o SET o.deletedAt = NOW() WHERE o.user.id = :userId AND o.deletedAt IS NULL") + void deleteAllByUser_IdAndDeletedAtNullInQuery(Long userId); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java index ae9142127..b895294bb 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java @@ -49,4 +49,9 @@ public boolean isExistOauthAccount(Long userId, Provider provider) { public void deleteOauth(Oauth oauth) { oauthRepository.delete(oauth); } + + @Transactional + public void deleteOauthsByUserId(Long userId) { + oauthRepository.deleteAllByUser_IdAndDeletedAtNullInQuery(userId); + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java index a16ccdb8e..a63efe5d2 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java @@ -2,6 +2,9 @@ import kr.co.pennyway.domain.domains.user.domain.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @@ -11,4 +14,9 @@ public interface UserRepository extends JpaRepository { Optional findByUsername(String username); boolean existsByUsername(String username); + + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE User u SET u.deletedAt = NOW() WHERE u.id = :userId") + void deleteByIdInQuery(Long userId); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java index 8051472f9..054518861 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java @@ -47,4 +47,9 @@ public boolean isExistUsername(String username) { public void deleteUser(User user) { userRepository.delete(user); } + + @Transactional + public void deleteUser(Long userId) { + userRepository.deleteByIdInQuery(userId); + } } From f6c23e2bcbb8df2a76e091877a4a81695090a867 Mon Sep 17 00:00:00 2001 From: DinoDeveloper <79460319+asn6878@users.noreply.github.com> Date: Mon, 6 May 2024 18:46:41 +0900 Subject: [PATCH 071/152] =?UTF-8?q?=E2=9C=A8=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=B0=BE=EA=B8=B0=20API=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 비밀번호 변경 경로 설계 * feat: 비밀번호 내부로직 작성 * fix: @operation 오탈자 수정 * fix: dto 구조 변경 * docs: service 주석 작성 * docs: authcheckapi 작성 * fix: 변경된 멤버변수 반영 및 테스트파일명 변경 * fix: 동일 비밀번호 입력시 요청실패 수정 * feat: 비밀번호 인증 api 분리 * feat: 테스트코드 작성 * docs: swagger 예외응답 작성 * fix: 테스트코드 userfixture 사용 및 log.debug사용 * fix: preauthorize 및 @validated 누락 반영 * feat: 번호 인증 후 code 캐시 ttl 연장 * docs: swagger 404 에러코드 수정 * fix: service 로직 메서드 분리 * fix: authfindservicetest에 변경된 메서드명 반영 --- .../api/apis/auth/api/AuthCheckApi.java | 117 +++++++++----- .../auth/controller/AuthCheckController.java | 60 ++++--- .../api/apis/auth/dto/AuthFindDto.java | 20 +++ .../apis/auth/service/AuthFindService.java | 94 +++++++---- .../apis/auth/usecase/AuthCheckUseCase.java | 45 ++++-- .../apis/auth/mapper/AuthFindMapperTest.java | 82 ---------- .../auth/service/AuthFindServiceTest.java | 147 ++++++++++++++++++ 7 files changed, 376 insertions(+), 189 deletions(-) delete mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/AuthFindMapperTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/AuthFindServiceTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthCheckApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthCheckApi.java index ee35263a8..87e5e3f3f 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthCheckApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthCheckApi.java @@ -1,9 +1,5 @@ package kr.co.pennyway.api.apis.auth.api; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.RequestParam; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -12,44 +8,87 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "[계정 검사 API]") public interface AuthCheckApi { - @Operation(summary = "닉네임 중복 검사") - ResponseEntity checkUsername(@RequestParam @Validated String username); + @Operation(summary = "닉네임 중복 검사") + ResponseEntity checkUsername(@RequestParam @Validated String username); + + @Operation(summary = "일반 회원 아이디 찾기") + @Parameter(name = "phone", description = "휴대폰 번호", required = true, in = ParameterIn.QUERY, example = "010-1234-5678") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "일반 회원으로 등록된 휴대폰 번호일 경우", value = """ + { + "code": "2000", + "data": { + "user": { + "username": "pennyway" + } + } + } + """) + })), + @ApiResponse(responseCode = "404", description = "일반 회원으로 등록되지 않은 휴대폰 번호일 경우", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "일반 회원으로 등록되지 않은 휴대폰 번호일 경우", value = """ + { + "code": "4040", + "message": "일반 회원으로 등록되지 않은 휴대폰 번호입니다." + } + """), + @ExampleObject(name = "인증번호 만료 또는 유효하지 않은 경우", value = """ + { + "code": "4042", + "message": "인증번호가 만료되었거나 유효하지 않습니다." + } + """) + })) + }) + ResponseEntity findUsername(@Validated PhoneVerificationDto.VerifyCodeReq request); + + @Operation(summary = "일반 회원 비밀번호 찾기에 사용되는 인증코드 인증") + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "존재하지 않는 회원일 경우", value = """ + { + "code": "4040", + "message": "유저를 찾을 수 없습니다." + } + """), + @ExampleObject(name = "일반 회원가입 이력이 없는 경우", value = """ + { + "code": "4040", + "message": "일반 회원가입 계정이 아닙니다." + } + """), + @ExampleObject(name = "인증번호 만료 또는 유효하지 않은 경우", value = """ + { + "code": "4042", + "message": "인증번호가 만료되었거나 유효하지 않습니다." + } + """) + })) + ResponseEntity verifyCodeForPassword(@RequestBody PhoneVerificationDto.VerifyCodeReq request); - @Operation(summary = "일반 회원 아이디 찾기") - @Parameter(name = "phone", description = "휴대폰 번호", required = true, in = ParameterIn.QUERY, example = "010-1234-5678") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "성공", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "일반 회원으로 등록된 휴대폰 번호일 경우", value = """ - { - "code": "2000", - "data": { - "user": { - "username": "pennyway" - } - } - } - """) - })), - @ApiResponse(responseCode = "404", description = "일반 회원으로 등록되지 않은 휴대폰 번호일 경우", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "일반 회원으로 등록되지 않은 휴대폰 번호일 경우", value = """ - { - "code": "4040", - "message": "일반 회원으로 등록되지 않은 휴대폰 번호입니다." - } - """) - })), - @ApiResponse(responseCode = "404", description = "인증번호 만료 또는 유효하지 않은 경우", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "인증번호 만료 또는 유효하지 않은 경우", value = """ - { - "code": "4042", - "message": "인증번호가 만료되었거나 유효하지 않습니다." - } - """) - })), - }) - ResponseEntity findUsername(@Validated PhoneVerificationDto.VerifyCodeReq request); + @Operation(summary = "일반 회원 비밀번호 찾기에 사용되는 비밀번호 변경") + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "존재하지 않는 회원일 경우", value = """ + { + "code": "4040", + "message": "유저를 찾을 수 없습니다." + } + """), + @ExampleObject(name = "인증번호 만료 또는 유효하지 않은 경우", value = """ + { + "code": "4042", + "message": "인증번호가 만료되었거나 유효하지 않습니다." + } + """) + })) + public ResponseEntity findPassword(@Validated AuthFindDto.UpdatePasswordReq request); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java index 78ff54175..27d347a0a 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckController.java @@ -1,38 +1,52 @@ package kr.co.pennyway.api.apis.auth.controller; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - import kr.co.pennyway.api.apis.auth.api.AuthCheckApi; +import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.usecase.AuthCheckUseCase; import kr.co.pennyway.api.common.response.SuccessResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; @Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/v1") public class AuthCheckController implements AuthCheckApi { - private final AuthCheckUseCase authCheckUseCase; - - @GetMapping("/duplicate/username") - @PreAuthorize("permitAll()") - public ResponseEntity checkUsername(@RequestParam @Validated String username) { - return ResponseEntity.ok( - SuccessResponse.from("isDuplicate", - authCheckUseCase.checkUsernameDuplicate(username))); - } - - @GetMapping("/find/username") - @PreAuthorize("isAnonymous()") - public ResponseEntity findUsername(@Validated PhoneVerificationDto.VerifyCodeReq request) { - return ResponseEntity.ok(SuccessResponse.from("user", authCheckUseCase.findUsername(request))); - } + private final AuthCheckUseCase authCheckUseCase; + + @GetMapping("/duplicate/username") + @PreAuthorize("permitAll()") + public ResponseEntity checkUsername(@RequestParam @Validated String username) { + return ResponseEntity.ok( + SuccessResponse.from("isDuplicate", + authCheckUseCase.checkUsernameDuplicate(username))); + } + + @GetMapping("/find/username") + @PreAuthorize("isAnonymous()") + public ResponseEntity findUsername(@Validated PhoneVerificationDto.VerifyCodeReq request) { + return ResponseEntity.ok(SuccessResponse.from("user", authCheckUseCase.findUsername(request))); + } + + @PostMapping("/find/password/verification") + @PreAuthorize("isAnonymous()") + public ResponseEntity verifyCodeForPassword(@RequestBody @Validated PhoneVerificationDto.VerifyCodeReq request) { + authCheckUseCase.verifyCode(request); + + return ResponseEntity.ok(SuccessResponse.noContent()); + } + + @PatchMapping("/find/password") + @PreAuthorize("isAnonymous()") + public ResponseEntity findPassword(@RequestBody @Validated AuthFindDto.UpdatePasswordReq request) { + PhoneVerificationDto.VerifyCodeReq codeReq = new PhoneVerificationDto.VerifyCodeReq(request.phone(), request.code()); + + authCheckUseCase.findPassword(codeReq, request.newPassword()); + return ResponseEntity.ok(SuccessResponse.noContent()); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/AuthFindDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/AuthFindDto.java index 020a3e10d..67ed5f2c4 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/AuthFindDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/AuthFindDto.java @@ -1,6 +1,9 @@ package kr.co.pennyway.api.apis.auth.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import kr.co.pennyway.api.common.validator.Password; import kr.co.pennyway.domain.domains.user.domain.User; public class AuthFindDto { @@ -22,4 +25,21 @@ public static FindUsernameRes of(User user) { return new FindUsernameRes(user.getUsername()); } } + + @Schema(title = "비밀번호 변경 요청 DTO", description = "전화번호로 사용자 비밀번호 변경을 위한 DTO") + public record UpdatePasswordReq( + @Schema(description = "전화번호", example = "010-2629-4624") + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phone, + @Schema(description = "6자리 정수 인증번호", example = "123456") + @NotBlank(message = "인증번호는 필수입니다.") + @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") + String code, + @Schema(description = "새 비밀번호. 8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)", example = "newPassword") + @NotBlank(message = "새 비밀번호를 입력해주세요") + @Password(message = "8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)") + String newPassword + ) { + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/AuthFindService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/AuthFindService.java index 7a9b7f4a9..e50c720f8 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/AuthFindService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/AuthFindService.java @@ -1,42 +1,80 @@ package kr.co.pennyway.api.apis.auth.service; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; +import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @RequiredArgsConstructor public class AuthFindService { - private final UserService userService; - private final PhoneVerificationService phoneVerificationService; - - /** - * 일반 회원 아이디 찾기 - * - * @param phone 전화번호 (e.g. 010-1234-5678) - * @return AuthFindDto.FindPasswordRes 비밀번호 찾기 응답 - */ - @Transactional(readOnly = true) - public AuthFindDto.FindUsernameRes findUsername(String phone) { - User user = userService.readUserByPhone(phone).orElseThrow(() -> { - log.info("User not found by phone: {}", phone); - return new UserErrorException(UserErrorCode.NOT_FOUND); - }); - - // 일반 회원 여부 검증 - if (user.getPassword() == null) { - log.info("User not found by phone: {}", phone); - throw new UserErrorException(UserErrorCode.NOT_FOUND); - } - - return AuthFindDto.FindUsernameRes.of(user); - } -} + private final UserService userService; + private final PasswordEncoderHelper passwordEncoderHelper; + + /** + * 일반 회원 아이디 찾기 + * + * @param phone 전화번호 (e.g. 010-1234-5678) + * @return AuthFindDto.FindPasswordRes 비밀번호 찾기 응답 + */ + @Transactional(readOnly = true) + public AuthFindDto.FindUsernameRes findUsername(String phone) { + User user = readGeneralSignUpUser(phone); + + return AuthFindDto.FindUsernameRes.of(user); + } + + /** + * 일반 회원 비밀번호 찾기에 사용되는 코드 인증 + * 전화 번호를 이용해 사용자 조회후 사용자가 Oauth 사용자인지, General사용자인지 확인한다. + * + * @param phone 전화번호 (e.g. 010-1234-5678) + */ + @Transactional(readOnly = true) + public void existsGeneralSignUpUser(String phone) { + readGeneralSignUpUser(phone); + } + + /** + * 일반 회원 비밀번호 찾기 & 변경하기 + * + * @param phone 전화번호 (e.g. 010-1234-5678) + * @param newPassword 새롭게 변경할 비밀번호 (e.g. qwer1234) + */ + @Transactional + public void updatePassword(String phone, String newPassword) { + User user = readUserOrThrow(phone); + + user.updatePassword(passwordEncoderHelper.encodePassword(newPassword)); + } + + private User readGeneralSignUpUser(String phone) { + User user = readUserOrThrow(phone); + validateGeneralSignedUpUser(user); + + return user; + } + + private User readUserOrThrow(String phone) { + return userService.readUserByPhone(phone).orElseThrow( + () -> { + log.info("해당 번호의 사용자를 찾을 수 없습니다: {}", phone); + throw new UserErrorException(UserErrorCode.NOT_FOUND); + } + ); + } + + private void validateGeneralSignedUpUser(User user) { + if (!user.isGeneralSignedUpUser()) { + log.info("일반 회원가입 이력이 없습니다."); + throw new UserErrorException(UserErrorCode.DO_NOT_GENERAL_SIGNED_UP); + } + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java index 6a67fd24e..a0ca8c741 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/AuthCheckUseCase.java @@ -1,7 +1,5 @@ package kr.co.pennyway.api.apis.auth.usecase; -import org.springframework.transaction.annotation.Transactional; - import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.service.AuthFindService; @@ -12,25 +10,38 @@ import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; @Slf4j @UseCase @RequiredArgsConstructor public class AuthCheckUseCase { - private final UserService userService; - private final AuthFindService authFindService; - private final PhoneVerificationService phoneVerificationService; - private final PhoneCodeService phoneCodeService; + private final UserService userService; + private final AuthFindService authFindService; + private final PhoneVerificationService phoneVerificationService; + private final PhoneCodeService phoneCodeService; + + @Transactional(readOnly = true) + public boolean checkUsernameDuplicate(String username) { + return userService.isExistUsername(username); + } + + @Transactional(readOnly = true) + public AuthFindDto.FindUsernameRes findUsername(PhoneVerificationDto.VerifyCodeReq request) { + phoneVerificationService.isValidCode(request, PhoneCodeKeyType.FIND_USERNAME); + phoneCodeService.delete(request.phone(), PhoneCodeKeyType.FIND_USERNAME); + return authFindService.findUsername(request.phone()); + } - @Transactional(readOnly = true) - public boolean checkUsernameDuplicate(String username) { - return userService.isExistUsername(username); - } + public void verifyCode(PhoneVerificationDto.VerifyCodeReq request) { + phoneVerificationService.isValidCode(request, PhoneCodeKeyType.FIND_PASSWORD); + authFindService.existsGeneralSignUpUser(request.phone()); + phoneCodeService.extendTimeToLeave(request.phone(), PhoneCodeKeyType.FIND_PASSWORD); + } - @Transactional(readOnly = true) - public AuthFindDto.FindUsernameRes findUsername(PhoneVerificationDto.VerifyCodeReq request) { - phoneVerificationService.isValidCode(request, PhoneCodeKeyType.FIND_USERNAME); - phoneCodeService.delete(request.phone(), PhoneCodeKeyType.FIND_USERNAME); - return authFindService.findUsername(request.phone()); - } -} + public void findPassword(PhoneVerificationDto.VerifyCodeReq request, String passwordReq) { + phoneVerificationService.isValidCode(request, PhoneCodeKeyType.FIND_PASSWORD); + phoneCodeService.delete(request.phone(), PhoneCodeKeyType.FIND_PASSWORD); + authFindService.updatePassword(request.phone(), passwordReq); + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/AuthFindMapperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/AuthFindMapperTest.java deleted file mode 100644 index a96f5ef03..000000000 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/mapper/AuthFindMapperTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package kr.co.pennyway.api.apis.auth.mapper; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.BDDMockito.*; - -import java.util.Optional; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; -import kr.co.pennyway.api.apis.auth.service.AuthFindService; -import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; - -@ExtendWith(MockitoExtension.class) -class AuthFindMapperTest { - private AuthFindService authFindService; - @Mock - private UserService userService; - - @Mock - private PhoneVerificationService phoneVerificationService; - - @BeforeEach - void setUp() { - authFindService = new AuthFindService(userService, phoneVerificationService); - } - - @DisplayName("휴대폰 번호로 유저를 찾을 수 없을 때 AuthFinderException을 발생시킨다.") - @Test - void findUsernameIfUserNotFound() { - // given - String phone = "010-1234-5678"; - given(userService.readUserByPhone(phone)).willReturn(Optional.empty()); - - // when - then - UserErrorException exception = assertThrows(UserErrorException.class, () -> authFindService.findUsername(phone)); - System.out.println(exception.getExplainError()); - } - - @DisplayName("휴대폰 번호로 유저를 찾았으나 OAuth 유저일 때 AuthFinderException을 발생시킨다.") - @Test - void findUsernameIfUserIsOAuth() { - // given - String phone = "010-2629-4624"; - User user = User.builder() - .username("pennyway") - .password(null) - .build(); - given(userService.readUserByPhone(phone)).willReturn(Optional.of(user)); - - // when - then - UserErrorException exception = assertThrows(UserErrorException.class, () -> authFindService.findUsername(phone)); - System.out.println(exception.getExplainError()); - } - - @DisplayName("휴대폰 번호를 통해 유저를 찾아 User를 반환한다.") - @Test - void findUsernameIfUserFound() { - // given - String phone = "010-2629-4624"; - String username = "pennyway"; - User user = User.builder() - .username("pennyway") - .password("password") - .build(); - given(userService.readUserByPhone(phone)).willReturn(Optional.of(user)); - - // when - AuthFindDto.FindUsernameRes result = authFindService.findUsername(phone); - - // then - assertEquals(result, new AuthFindDto.FindUsernameRes(username)); - } -} \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/AuthFindServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/AuthFindServiceTest.java new file mode 100644 index 000000000..41a5cdf4b --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/AuthFindServiceTest.java @@ -0,0 +1,147 @@ +package kr.co.pennyway.api.apis.auth.service; + +import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; +import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; + +@Slf4j +@ExtendWith(MockitoExtension.class) +class AuthFindServiceTest { + private AuthFindService authFindService; + @Mock + private UserService userService; + + @Mock + private PasswordEncoderHelper passwordEncoderHelper; + + @BeforeEach + void setUp() { + authFindService = new AuthFindService(userService, passwordEncoderHelper); + } + + @DisplayName("휴대폰 번호로 유저를 찾을 수 없을 때 AuthFinderException을 발생시킨다.") + @Test + void findUsernameIfUserNotFound() { + // given + String phone = "010-1234-5678"; + given(userService.readUserByPhone(phone)).willReturn(Optional.empty()); + + // when - then + UserErrorException exception = assertThrows(UserErrorException.class, () -> authFindService.findUsername(phone)); + log.debug(exception.getExplainError()); + } + + @DisplayName("휴대폰 번호로 유저를 찾았으나 OAuth 유저일 때 AuthFinderException을 발생시킨다.") + @Test + void findUsernameIfUserIsOAuth() { + // given + String phone = "010-1234-5678"; + User user = UserFixture.OAUTH_USER.toUser(); + given(userService.readUserByPhone(phone)).willReturn(Optional.of(user)); + + // when - then + UserErrorException exception = assertThrows(UserErrorException.class, () -> authFindService.findUsername(phone)); + log.debug(exception.getExplainError()); + } + + @DisplayName("휴대폰 번호를 통해 유저를 찾아 User를 반환한다.") + @Test + void findUsernameIfUserFound() { + // given + String phone = "010-1234-5678"; + String username = "jayang"; + User user = UserFixture.GENERAL_USER.toUser(); + given(userService.readUserByPhone(phone)).willReturn(Optional.of(user)); + + // when + AuthFindDto.FindUsernameRes result = authFindService.findUsername(phone); + + // then + assertEquals(result, new AuthFindDto.FindUsernameRes(username)); + } + + + // BestPractice + + // 없는 유저 폰번호 쐈을때 + @DisplayName("존재하지 않는 사용자의 번호로 비밀번호 찾기 인증요청이 올 경우 UserErrorException을 발생시킨다.") + @Test + void findPasswordVerificationIfUserNotFound() { + // given + String phone = "010-1234-5678"; + given(userService.readUserByPhone(phone)).willReturn(Optional.empty()); + + // when - then + assertThrows(UserErrorException.class, () -> authFindService.existsGeneralSignUpUser(phone)); + } + + // Oauth 유저로 인증 쐈을때 + @DisplayName("Oauth 유저의 번호로 비밀번호 찾기 인증요청이 올 경우 UserErrorException을 발생시킨다.") + @Test + void findPasswordVerificationIfUserOauth() { + // given + String phone = "010-1234-5678"; + User user = UserFixture.OAUTH_USER.toUser(); + given(userService.readUserByPhone(phone)).willReturn(Optional.of(user)); + + // when - then + assertThrows(UserErrorException.class, () -> authFindService.existsGeneralSignUpUser(phone)); + } + + @DisplayName("정상적인 비밀번호 찾기 인증요청일 경우 SuccessResponse.noContent()를 반환한다.") + @Test + void findPasswordVerification() { + // given + String phone = "010-1234-5678"; + User user = UserFixture.GENERAL_USER.toUser(); + given(userService.readUserByPhone(phone)).willReturn(Optional.of(user)); + + // when + authFindService.existsGeneralSignUpUser(phone); + } + + @DisplayName("존재하지 않는 사용자의 번호로 비밀번호 변경 요청이 올 경우 UserErrorException을 발생시킨다.") + @Test + void updatePasswordIfUserNotFound() { + // given + String phone = "010-1234-5678"; + String newPassword = "newPassword123"; + given(userService.readUserByPhone(phone)).willReturn(Optional.empty()); + + // when - then + assertThrows(UserErrorException.class, () -> authFindService.updatePassword(phone, newPassword)); + } + + @DisplayName("정상적인 요청일 경우 비밀번호를 변경한다.") + @Test + void updatePassword() { + // given + String phone = "010-1234-5678"; + String newPassword = "newPassword123"; + User user = UserFixture.GENERAL_USER.toUser(); + given(userService.readUserByPhone(phone)).willReturn(Optional.of(user)); + given(passwordEncoderHelper.encodePassword(newPassword)).willReturn("encodedNewPassword"); + + // when + authFindService.updatePassword(phone, newPassword); + + // then + assertEquals("encodedNewPassword", user.getPassword()); + } +} \ No newline at end of file From 6b24d8590c6739d11f0f1b0902efd0cf8e3e1591 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 7 May 2024 15:31:08 +0900 Subject: [PATCH 072/152] =?UTF-8?q?=E2=9C=A8=20=EC=A7=80=EC=B6=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=98=81=EC=97=AD=20Domain=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 소비 아이콘 타입 정의 * feat: 지출 아이콘 컨버터 정의 * fix: ledger -> spending 패키지명 수정 * feat: 지출 entity 정의 * feat: 지출 카테고리 entity 정의 * feat: 지출 entity와 지출 카테고리 entity 연관관계 매핑 * rename: spending_category -> spending_custom_category 수정 * feat: 지출 및 지출 카테고리 repository 인터페이스 생성 * feat: 지출 domain service 생성 * feat: 지출 커스텀 카테고리 domain service 생성 * feat: 지출 목표 금액 entity, repository, domain service 정의 * rename: spending icon -> spending category * rename: package 이름 amount -> target --- .../converter/SpendingIconConverter.java | 13 +++++ .../domains/spending/domain/Spending.java | 53 +++++++++++++++++++ .../domain/SpendingCustomCategory.java | 43 +++++++++++++++ .../SpendingCustomCategoryRepository.java | 7 +++ .../repository/SpendingRepository.java | 7 +++ .../SpendingCustomCategoryService.java | 20 +++++++ .../spending/service/SpendingService.java | 20 +++++++ .../spending/type/SpendingCategory.java | 25 +++++++++ .../domains/target/domain/TargetAmount.java | 38 +++++++++++++ .../repository/TargetAmountRepository.java | 7 +++ .../target/service/TargetAmountService.java | 20 +++++++ 11 files changed, 253 insertions(+) create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingIconConverter.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingIconConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingIconConverter.java new file mode 100644 index 000000000..67460ac39 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingIconConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; + +@Converter +public class SpendingIconConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "지출 아이콘"; + + public SpendingIconConverter() { + super(SpendingCategory.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java new file mode 100644 index 000000000..cf1c1757a --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java @@ -0,0 +1,53 @@ +package kr.co.pennyway.domain.domains.spending.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.SpendingIconConverter; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "spending") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE spending SET deleted_at = NOW() WHERE id = ?") +public class Spending extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Integer amount; + @Convert(converter = SpendingIconConverter.class) + private SpendingCategory category; + private LocalDateTime spendAt; + private String accountName; + private String memo; + private LocalDateTime deletedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + /* category가 OTHER일 경우 spendingCustomCategory를 참조 */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "spending_custom_category_id") + private SpendingCustomCategory spendingCustomCategory; + + @Builder + private Spending(Integer amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo, User user, SpendingCustomCategory spendingCustomCategory) { + this.amount = amount; + this.category = category; + this.spendAt = spendAt; + this.accountName = accountName; + this.memo = memo; + this.user = user; + this.spendingCustomCategory = spendingCustomCategory; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java new file mode 100644 index 000000000..b79fbdf1e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java @@ -0,0 +1,43 @@ +package kr.co.pennyway.domain.domains.spending.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "spending_category") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE spending_category SET deleted_at = NOW() WHERE id = ?") +public class SpendingCustomCategory extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + private SpendingCategory icon; + private LocalDateTime deletedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + private SpendingCustomCategory(String name, SpendingCategory icon, User user) { + this.name = name; + this.icon = icon; + this.user = user; + } + + public static SpendingCustomCategory of(String name, SpendingCategory icon, User user) { + if (icon.equals(SpendingCategory.OTHER)) + throw new IllegalArgumentException("OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."); + return new SpendingCustomCategory(name, icon, user); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java new file mode 100644 index 000000000..2e36eaa61 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.domains.spending.repository; + +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SpendingCustomCategoryRepository extends JpaRepository { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java new file mode 100644 index 000000000..27178dce3 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.domains.spending.repository; + +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SpendingRepository extends JpaRepository { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java new file mode 100644 index 000000000..5ac8aff8e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.domains.spending.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.repository.SpendingCustomCategoryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class SpendingCustomCategoryService { + private final SpendingCustomCategoryRepository spendingCustomCategoryRepository; + + @Transactional + public SpendingCustomCategory save(SpendingCustomCategory spendingCustomCategory) { + return spendingCustomCategoryRepository.save(spendingCustomCategory); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java new file mode 100644 index 000000000..fa7df42fb --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.domains.spending.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.repository.SpendingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class SpendingService { + private final SpendingRepository spendingRepository; + + @Transactional + public Spending save(Spending spending) { + return spendingRepository.save(spending); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java new file mode 100644 index 000000000..5a847d339 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.domain.domains.spending.type; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SpendingCategory implements LegacyCommonType { + OTHER("0", "사용자 정의"), + FOOD("1", "식비"), + TRANSPORTATION("2", "교통비"), + BEAUTY_OR_FASHION("3", "뷰티/패션"), + CONVENIENCE_STORE("4", "편의점/마트"), + EDUCATION("5", "교육"), + LIVING("6", "생활"), + HEALTH("7", "건강"), + HOBBY("8", "취미/여가"), + TRAVEL("9", "여행/숙박"), + ALCOHOL_OR_ENTERTAINMENT("10", "술/유흥"), + MEMBERSHIP_OR_FAMILY_EVENT("11", "회비/경조사"); + + private final String code; + private final String type; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java new file mode 100644 index 000000000..8bb7acd8e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java @@ -0,0 +1,38 @@ +package kr.co.pennyway.domain.domains.target.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "target_amount") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE target_amount SET deleted_at = NOW() WHERE id = ?") +public class TargetAmount extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Integer amount; + private LocalDateTime deletedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + private TargetAmount(Integer amount, User user) { + this.amount = amount; + this.user = user; + } + + public static TargetAmount of(Integer amount, User user) { + return new TargetAmount(amount, user); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java new file mode 100644 index 000000000..293648728 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.domains.target.repository; + +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TargetAmountRepository extends JpaRepository { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java new file mode 100644 index 000000000..b97d444ed --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.domains.target.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.target.repository.TargetAmountRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class TargetAmountService { + private final TargetAmountRepository targetAmountRepository; + + @Transactional + public TargetAmount save(TargetAmount targetAmount) { + return targetAmountRepository.save(targetAmount); + } +} From bee64c4eeee2c6897150a48b600950d1b89f6bcc Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 7 May 2024 15:33:23 +0900 Subject: [PATCH 073/152] =?UTF-8?q?=F0=9F=90=9B=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EA=B0=9C=EC=84=A0=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 사용자 삭제 시, 디바이스 정보는 CASCADE로 삭제되어야 한다 * test: 테스트 display name 설명 수정 --- .../users/usecase/UserAccountUseCaseTest.java | 21 +++++++++++++++++-- .../domain/domains/device/domain/Device.java | 5 +++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java index 45adcb8c4..ff6cd83d4 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java @@ -446,7 +446,7 @@ void updatePassword() { class DeleteAccountTest { @Test @Transactional - @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") + @DisplayName("사용자가 삭제된 유저를 조회하려는 경우 NOT_FOUND 에러를 반환한다.") void deleteAccountWhenUserIsDeleted() { // given User user = UserFixture.GENERAL_USER.toUser(); @@ -473,7 +473,7 @@ void deleteAccount() { @Test @Transactional - @DisplayName("삭제 시, 연동된 모든 소셜 계정은 soft delete 처리되어야 한다.") + @DisplayName("사용자 계정 삭제 시, 연동된 모든 소셜 계정은 soft delete 처리되어야 한다.") void deleteAccountWithSocialAccounts() { // given User user = UserFixture.OAUTH_USER.toUser(); @@ -489,6 +489,23 @@ void deleteAccountWithSocialAccounts() { assertTrue("구글 계정이 삭제되어 있어야 한다.", oauthService.readOauth(google.getId()).get().isDeleted()); } + @Test + @Transactional + @DisplayName("사용자 삭제 시, 디바이스 정보는 CASCADE로 삭제되어야 한다.") + void deleteAccountWithDevices() { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + + Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(user); + deviceService.createDevice(device); + + // when - then + assertDoesNotThrow(() -> userAccountUseCase.deleteAccount(user.getId())); + assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); + assertTrue("디바이스가 삭제되어 있어야 한다.", deviceService.readDeviceByUserIdAndToken(user.getId(), device.getToken()).isEmpty()); + } + private Oauth createOauth(Provider provider, String providerId, User user) { Oauth oauth = Oauth.of(provider, providerId, user); return oauthService.createOauth(oauth); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java index 556b0558b..e1045f56c 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java @@ -23,7 +23,7 @@ public class Device extends DateAuditable { @ColumnDefault("true") private Boolean activated; - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) @JoinColumn(name = "user_id") private User user; @@ -66,6 +66,7 @@ public String toString() { "id=" + id + ", token='" + token + '\'' + ", model='" + model + '\'' + - ", os='" + os + '}'; + ", os='" + os + '\'' + + ", activated=" + activated + '}'; } } From bb760663be81e7e07ce70cfa499ab922b6bc7c54 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 8 May 2024 13:08:25 +0900 Subject: [PATCH 074/152] =?UTF-8?q?=E2=9C=A8=20QueryDsl=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20Repository=20=EB=B0=8F=20Util=20=EC=A0=95=EC=9D=98?= =?UTF-8?q?=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: query dsl extended repository 인터페이스 선언 * feat: jpa query를 전달하기 위한 함수형 인터페이스 추가 * docs: @see 추가 * feat: query dsl extended repository 인터페이스 구현체 정의 * feat: extended repository를 적용하기 위한 factory 정의 * chore: extended bean repository factory config 등록 * feat: query dsl util 추가 * refactor: repository 내부 메서드를 query dsl util에서 수행 * feat: slice 유틸 추가 * docs: util 클래스 범위 주석 추가 * rename: extended repository -> query dsl repository * feat: extended repository 통합 인터페이스 선언 * rename: query dsl repository factory -> extended repository factory * fix: user repository 상속 jpa repository -> extended repository * test: extended repository 사용법 공유를 위한 테스트 케이스 * docs: query_dsl_search_repository sort 사용법 수정 * fix: query dsl util 내 cast_to_query_dsl 중복 정의 제거 * rename: query_dsl_util null handling 메서드 설명 수정 * rename: query_dsl_search_repository_impl 클래스 레벨 주석 제거 && query_dsl_util 주석 수정 --- .../common/repository/ExtendedRepository.java | 10 + .../repository/ExtendedRepositoryFactory.java | 53 ++++ .../repository/QueryDslSearchRepository.java | 177 ++++++++++++ .../QueryDslSearchRepositoryImpl.java | 125 +++++++++ .../common/repository/QueryHandler.java | 13 + .../domain/common/util/QueryDslUtil.java | 94 +++++++ .../domain/common/util/SliceUtil.java | 35 +++ .../co/pennyway/domain/config/JpaConfig.java | 3 +- .../user/repository/UserRepository.java | 4 +- .../UserExtendedRepositoryTest.java | 255 ++++++++++++++++++ 10 files changed, 766 insertions(+), 3 deletions(-) create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepositoryFactory.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/QueryDslUtil.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/SliceUtil.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepository.java new file mode 100644 index 000000000..3198523a9 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepository.java @@ -0,0 +1,10 @@ +package kr.co.pennyway.domain.common.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.NoRepositoryBean; + +import java.io.Serializable; + +@NoRepositoryBean +public interface ExtendedRepository extends JpaRepository, QueryDslSearchRepository { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepositoryFactory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepositoryFactory.java new file mode 100644 index 000000000..dd6574cb8 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepositoryFactory.java @@ -0,0 +1,53 @@ +package kr.co.pennyway.domain.common.repository; + +import jakarta.persistence.EntityManager; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryComposition; +import org.springframework.data.repository.core.support.RepositoryFactorySupport; +import org.springframework.lang.NonNull; + +public class ExtendedRepositoryFactory, E, ID> extends JpaRepositoryFactoryBean { + /** + * Creates a new {@link JpaRepositoryFactoryBean} for the given repository interface. + * + * @param repositoryInterface must not be {@literal null}. + */ + public ExtendedRepositoryFactory(Class repositoryInterface) { + super(repositoryInterface); + } + + @Override + @NonNull + protected RepositoryFactorySupport createRepositoryFactory(@NonNull EntityManager em) { + return new InnerRepositoryFactory(em); + } + + private static class InnerRepositoryFactory extends JpaRepositoryFactory { + private final EntityManager em; + + public InnerRepositoryFactory(EntityManager em) { + super(em); + this.em = em; + } + + @Override + @NonNull + protected RepositoryComposition.RepositoryFragments getRepositoryFragments(@NonNull RepositoryMetadata metadata) { + RepositoryComposition.RepositoryFragments fragments = super.getRepositoryFragments(metadata); + + if (QueryDslSearchRepository.class.isAssignableFrom(metadata.getRepositoryInterface())) { + var implExtendedJpa = super.instantiateClass( + QueryDslSearchRepositoryImpl.class, + this.getEntityInformation(metadata.getDomainType()), + this.em + ); + fragments = fragments.append(RepositoryComposition.RepositoryFragments.just(implExtendedJpa)); + } + + return fragments; + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java new file mode 100644 index 000000000..a76da5e4d --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java @@ -0,0 +1,177 @@ +package kr.co.pennyway.domain.common.repository; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Predicate; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.List; +import java.util.Map; + +/** + * QueryDsl을 이용한 검색 조건을 처리하는 기본적인 메서드를 선언한 인터페이스 + * + * @author YANG JAESEO + */ +public interface QueryDslSearchRepository { + + /** + * 검색 조건에 해당하는 도메인 리스트를 조회하는 메서드 + * + * @param predicate : 검색 조건 + * @param queryHandler : 검색 조건에 추가적으로 적용할 조건 + * @param sort : 정렬 조건 + * + * // @formatter:off + *
+     * {@code
+     * @Component
+     * class SearchService {
+     *      private final QEntity entity = QEntity.entity;
+     *      private final QEntityChild entityChild = QEntityChild.entityChild;
+     *
+     *      private Entity select() {
+     *          Predicate predicate = new BooleanBuilder();
+     *          predicate.and(entity.id.eq(1L));
+     *
+     *          QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+     *          Sort sort = Sort.by(Sort.Order.desc("entity.id"));
+     *
+     *          return searchRepository.findList(predicate, queryHandler, sort);
+     *      }
+     * }
+     * }
+     * 
+ * // @formatter:on + * + * @see Predicate + * @see QueryHandler + * @see org.springframework.data.domain.PageRequest + */ + List findList(Predicate predicate, QueryHandler queryHandler, Sort sort); + + /** + * 검색 조건에 해당하는 도메인 페이지를 조회하는 메서드 + * + * @param predicate : 검색 조건 + * @param queryHandler : 검색 조건에 추가적으로 적용할 조건 + * @param pageable : 페이지 정보 + * + * // @formatter:off + *
+     * {@code
+     * @Component
+     * class SearchService {
+     *      private final QEntity entity = QEntity.entity;
+     *      private final QEntityChild entityChild = QEntityChild.entityChild;
+     *
+     *      private Entity select() {
+     *          Predicate predicate = new BooleanBuilder();
+     *          predicate.and(entity.id.eq(1L));
+     *
+     *          QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+     *          Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Order.desc("entity.id")));
+     *
+     *          return searchRepository.findList(predicate, queryHandler, pageable);
+     *      }
+     * }
+     * }
+     * 
+ * // @formatter:on + * + * @see Predicate + * @see QueryHandler + * @see org.springframework.data.domain.PageRequest + */ + Page findPage(Predicate predicate, QueryHandler queryHandler, Pageable pageable); + + /** + * 검색 조건에 해당하는 DTO 리스트를 조회하는 메서드 + * + * @param predicate : 검색 조건 + * @param type : 조회할 도메인(혹은 DTO) 타입 + * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드 + * @param queryHandler : 검색 조건에 추가적으로 적용할 조건 + * @param sort : 정렬 조건 + * + * // @formatter:off + *
+     * {@code
+     * @Component
+     * class SearchService {
+     *      private final QEntity entity = QEntity.entity;
+     *      private final QEntityChild entityChild = QEntityChild.entityChild;
+     *
+     *      private EntityDto select() {
+     *          Predicate predicate = new BooleanBuilder();
+     *          predicate.and(entity.id.eq(1L));
+     *
+     *          QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+     *          Sort sort = Sort.by(Sort.Order.desc("entity.id"));
+     *
+     *          return searchRepository.findList(predicate, EntityDto.class, this.buildBindings(), queryHandler, sort);
+     *      }
+     *
+     *      private Map> buildBindings() {
+     *          Map> bindings = new HashMap<>();
+     *
+     *          bindings.put("id", entity.id);
+     *          bindings.put("name", entity.name);
+     *
+     *          return bindings;
+     *      }
+     * }
+     * }
+     * 
+ * // @formatter:on + * + * @see Predicate + * @see QueryHandler + * @see org.springframework.data.domain.PageRequest + */ +

List

selectList(Predicate predicate, Class

type, Map> bindings, QueryHandler queryHandler, Sort sort); + + /** + * 검색 조건에 해당하는 DTO 페이지를 조회하는 메서드 + * + * @param predicate : 검색 조건 + * @param type : 조회할 도메인(혹은 DTO) 타입 + * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드 + * @param queryHandler : 검색 조건에 추가적으로 적용할 조건 + * @param pageable : 페이지 정보 + * + * // @formatter:off + *

+     * {@code
+     * @Component
+     * class SearchService {
+     *      private final QEntity entity = QEntity.entity;
+     *      private final QEntityChild entityChild = QEntityChild.entityChild;
+     *
+     *      private EntityDto select() {
+     *          Predicate predicate = new BooleanBuilder();
+     *          predicate.and(entity.id.eq(1L));
+     *          QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+     *          Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Order.desc("entity.id")));
+     *
+     *          return searchRepository.findPage(predicate, EntityDto.class, this.buildBindings(), queryHandler, pageable);
+     *      }
+     *
+     *      private Map> buildBindings() {
+     *          Map> bindings = new HashMap<>();
+     *          bindings.put("id", entity.id);
+     *          bindings.put("name", entity.name);
+     *          return bindings;
+     *      }
+     *  }
+     *  }
+     *  
+ * // @formatter:on + * + * @see Predicate + * @see QueryHandler + * @see org.springframework.data.domain.PageRequest + */ +

Page

selectPage(Predicate predicate, Class

type, Map> bindings, QueryHandler queryHandler, Pageable pageable); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java new file mode 100644 index 000000000..a63690482 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java @@ -0,0 +1,125 @@ +package kr.co.pennyway.domain.common.repository; + +import com.querydsl.core.types.*; +import com.querydsl.core.types.dsl.EntityPathBase; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import kr.co.pennyway.domain.common.util.QueryDslUtil; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.support.JpaEntityInformation; +import org.springframework.data.querydsl.QSort; +import org.springframework.data.querydsl.SimpleEntityPathResolver; +import org.springframework.util.Assert; + +import java.util.List; +import java.util.Map; + +public class QueryDslSearchRepositoryImpl implements QueryDslSearchRepository { + private final EntityManager em; + private final JPAQueryFactory queryFactory; + private final EntityPath path; + + public QueryDslSearchRepositoryImpl(JpaEntityInformation entityInformation, EntityManager entityManager) { + this.em = entityManager; + this.queryFactory = new JPAQueryFactory(entityManager); + this.path = SimpleEntityPathResolver.INSTANCE.createPath(entityInformation.getJavaType()); + } + + public QueryDslSearchRepositoryImpl(Class type, EntityManager entityManager) { + this.em = entityManager; + this.queryFactory = new JPAQueryFactory(entityManager); + this.path = new EntityPathBase<>(type, "entity"); + } + + @Override + public List findList(Predicate predicate, QueryHandler queryHandler, Sort sort) { + return this.buildWithoutSelect(predicate, null, queryHandler, sort).select(path).fetch(); + } + + @Override + public Page findPage(Predicate predicate, QueryHandler queryHandler, Pageable pageable) { + Assert.notNull(pageable, "pageable must not be null!"); + + JPAQuery query = this.buildWithoutSelect(predicate, null, queryHandler, pageable.getSort()).select(path); + + int totalSize = query.fetch().size(); + query = query.offset(pageable.getOffset()).limit(pageable.getPageSize()); + + return new PageImpl<>(query.select(path).fetch(), pageable, totalSize); + } + + @Override + public

List

selectList(Predicate predicate, Class

type, Map> bindings, QueryHandler queryHandler, Sort sort) { + return this.buildWithoutSelect(predicate, bindings, queryHandler, sort).select(Projections.bean(type, bindings)).fetch(); + } + + @Override + public

Page

selectPage(Predicate predicate, Class

type, Map> bindings, QueryHandler queryHandler, Pageable pageable) { + Assert.notNull(pageable, "pageable must not be null!"); + + JPAQuery query = this.buildWithoutSelect(predicate, bindings, queryHandler, pageable.getSort()).select(path); + + int totalSize = query.fetch().size(); + query = query.offset(pageable.getOffset()).limit(pageable.getPageSize()); + + return new PageImpl<>(query.select(Projections.bean(type, bindings)).fetch(), pageable, totalSize); + } + + /** + * 파라미터를 기반으로 Querydsl의 JPAQuery를 생성하는 메서드 + */ + private JPAQuery buildWithoutSelect(Predicate predicate, Map> bindings, QueryHandler queryHandler, Sort sort) { + JPAQuery query = queryFactory.from(path); + + applyPredicate(predicate, query); + applyQueryHandler(queryHandler, query); + applySort(query, sort, bindings); + + return query; + } + + /** + * Querydsl의 JPAQuery에 Predicate를 적용하는 메서드
+ * Predicate가 null이 아닐 경우에만 적용 + */ + private void applyPredicate(Predicate predicate, JPAQuery query) { + if (predicate != null) query.where(predicate); + } + + /** + * Querydsl의 JPAQuery에 QueryHandler를 적용하는 메서드
+ * QueryHandler가 null이 아닐 경우에만 적용 + */ + private void applyQueryHandler(QueryHandler queryHandler, JPAQuery query) { + if (queryHandler != null) queryHandler.apply(query); + } + + /** + * Querydsl의 JPAQuery에 Sort를 적용하는 메서드
+ * Sort가 null이 아닐 경우에만 적용
+ * Sort가 QSort일 경우에는 OrderSpecifier를 적용하고, 그 외의 경우에는 OrderSpecifier를 생성하여 적용 + */ + private void applySort(JPAQuery query, Sort sort, Map> bindings) { + if (sort != null) { + if (sort instanceof QSort qSort) { + query.orderBy(qSort.getOrderSpecifiers().toArray(new OrderSpecifier[0])); + } else { + applySortOrders(query, sort, bindings); + } + } + } + + private void applySortOrders(JPAQuery query, Sort sort, Map> bindings) { + for (Sort.Order order : sort) { + OrderSpecifier.NullHandling queryDslNullHandling = QueryDslUtil.getQueryDslNullHandling(order); + + OrderSpecifier os = QueryDslUtil.getOrderSpecifier(order, bindings, queryDslNullHandling); + + query.orderBy(os); + } + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java new file mode 100644 index 000000000..b0ae575be --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.repository; + +import com.querydsl.jpa.impl.JPAQuery; + +/** + * QueryDsl을 이용한 검색 조건을 처리하는 기본적인 메서드를 선언한 인터페이스 + * + * @author YANG JAESEO + */ +@FunctionalInterface +public interface QueryHandler { + JPAQuery apply(JPAQuery query); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/QueryDslUtil.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/QueryDslUtil.java new file mode 100644 index 000000000..5cc6b18f4 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/QueryDslUtil.java @@ -0,0 +1,94 @@ +package kr.co.pennyway.domain.common.util; + +import com.querydsl.core.types.*; +import com.querydsl.core.types.dsl.Expressions; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * QueryDsl의 편의 기능을 제공하는 유틸리티 클래스 + * + * @author YANG JAESEO + * @version 1.0 + */ +@Slf4j +public class QueryDslUtil { + private static final Function castToQueryDsl = nullHandling -> switch (nullHandling) { + case NATIVE -> OrderSpecifier.NullHandling.Default; + case NULLS_FIRST -> OrderSpecifier.NullHandling.NullsFirst; + case NULLS_LAST -> OrderSpecifier.NullHandling.NullsLast; + }; + + /** + * Pageable의 sort를 QueryDsl의 OrderSpecifier로 변환하는 메서드 + * + * @param sort : {@link Sort} + */ + public static List> getOrderSpecifier(Sort sort) { + List> orders = new ArrayList<>(); + + for (Sort.Order order : sort) { + OrderSpecifier.NullHandling nullHandling = castToQueryDsl.apply(order.getNullHandling()); + orders.add(getOrderSpecifier(order, nullHandling)); + } + + return orders; + } + + /** + * Sort.Order의 정보를 이용하여 OrderSpecifier.NullHandling을 반환하는 메서드 + * + * @param order : {@link Sort.Order} + * @return {@link OrderSpecifier.NullHandling} + */ + public static OrderSpecifier.NullHandling getQueryDslNullHandling(Sort.Order order) { + return castToQueryDsl.apply(order.getNullHandling()); + } + + /** + * OrderSpecifier를 생성할 때, Sort.Order의 정보를 이용하여 OrderSpecifier.NullHandling을 적용하는 메서드 + * + * @param order : {@link Sort.Order} + * @param nullHandling : {@link OrderSpecifier.NullHandling} + * @return {@link OrderSpecifier} + */ + public static OrderSpecifier getOrderSpecifier(Sort.Order order, OrderSpecifier.NullHandling nullHandling) { + Order orderBy = order.isAscending() ? Order.ASC : Order.DESC; + + return createOrderSpecifier(orderBy, Expressions.stringPath(order.getProperty()), nullHandling); + } + + /** + * Expression이 Operation이고 Operator가 ALIAS일 경우, OrderSpecifier를 생성할 때, Expression을 StringPath로 변환하여 생성한다.
+ * 그 외의 경우에는 OrderSpecifier를 생성한다. + * + * @param order : {@link Sort.Order} + * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드 정보. {@code binding}은 Map> 형태로 전달된다. + * @param queryDslNullHandling : {@link OrderSpecifier.NullHandling} + * @return {@link OrderSpecifier} + */ + public static OrderSpecifier getOrderSpecifier(Sort.Order order, Map> bindings, OrderSpecifier.NullHandling queryDslNullHandling) { + Order orderBy = order.isAscending() ? Order.ASC : Order.DESC; + + if (bindings != null && bindings.containsKey(order.getProperty())) { + Expression expression = bindings.get(order.getProperty()); + return createOrderSpecifier(orderBy, expression, queryDslNullHandling); + } else { + return createOrderSpecifier(orderBy, Expressions.stringPath(order.getProperty()), queryDslNullHandling); + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static OrderSpecifier createOrderSpecifier(Order orderBy, Expression expression, OrderSpecifier.NullHandling queryDslNullHandling) { + if (expression instanceof Operation && ((Operation) expression).getOperator() == Ops.ALIAS) { + return new OrderSpecifier<>(orderBy, Expressions.stringPath(((Operation) expression).getArg(1).toString()), queryDslNullHandling); + } else { + return new OrderSpecifier(orderBy, expression, queryDslNullHandling); + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/SliceUtil.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/SliceUtil.java new file mode 100644 index 000000000..7c5d3916e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/SliceUtil.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.domain.common.util; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +import java.util.List; + +/** + * Slice를 생성하는 메서드를 제공하는 유틸리티 클래스 + * + * @author YANG JAESEO + * @version 1.0 + */ +public class SliceUtil { + /** + * List로 받은 contents를 Slice로 변환한다. + * + * @param contents : 변환할 List + * @param pageable : Pageable + * @return Slice : 변환된 Slice. 단, contents.size()가 pageable.getPageSize()보다 작을 경우 hasNext는 true이며, Slice의 size는 contents.size() - 1이다. + */ + public static Slice toSlice(List contents, Pageable pageable) { + boolean hasNext = isContentSizeGreaterThanPageSize(contents, pageable); + return new SliceImpl<>(hasNext ? subListLastContent(contents, pageable) : contents, pageable, hasNext); + } + + private static boolean isContentSizeGreaterThanPageSize(List content, Pageable pageable) { + return pageable.isPaged() && content.size() > pageable.getPageSize(); + } + + private static List subListLastContent(List content, Pageable pageable) { + return content.subList(0, pageable.getPageSize()); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java index 671227146..7e03c853a 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java @@ -1,6 +1,7 @@ package kr.co.pennyway.domain.config; import kr.co.pennyway.domain.DomainPackageLocation; +import kr.co.pennyway.domain.common.repository.ExtendedRepositoryFactory; import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @@ -9,6 +10,6 @@ @Configuration @EnableJpaAuditing @EntityScan(basePackageClasses = DomainPackageLocation.class) -@EnableJpaRepositories(basePackageClasses = DomainPackageLocation.class) +@EnableJpaRepositories(basePackageClasses = DomainPackageLocation.class, repositoryFactoryBeanClass = ExtendedRepositoryFactory.class) public class JpaConfig { } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java index a63efe5d2..6554c6167 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java @@ -1,14 +1,14 @@ package kr.co.pennyway.domain.domains.user.repository; +import kr.co.pennyway.domain.common.repository.ExtendedRepository; import kr.co.pennyway.domain.domains.user.domain.User; -import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; -public interface UserRepository extends JpaRepository { +public interface UserRepository extends ExtendedRepository { Optional findByPhone(String phone); Optional findByUsername(String username); diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java new file mode 100644 index 000000000..fdfd79a81 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java @@ -0,0 +1,255 @@ +package kr.co.pennyway.domain.domains.user.repository; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Predicate; +import kr.co.pennyway.domain.common.repository.QueryHandler; +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.domain.QOauth; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +import static java.time.LocalDateTime.now; +import static org.springframework.test.util.AssertionErrors.*; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create", "logging.level.org.springframework.jdbc=debug"}) +@ContextConfiguration(classes = JpaConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +public class UserExtendedRepositoryTest extends ContainerMySqlTestConfig { + private static final String USER_TABLE = "user"; + private static final String OAUTH_TABLE = "oauth"; + + private final QUser qUser = QUser.user; + private final QOauth qOauth = QOauth.oauth; + + @Autowired + private UserRepository userRepository; + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + @BeforeEach + public void setUp() { + List users = getRandomUsers(); + bulkInsertUser(users); + + users = userRepository.findAll(); + + List oauths = getRandomOauths(users); + bulkInsertOauth(oauths); + } + + @Test + @DisplayName(""" + Entity findList 테스트: 이름이 양재서고, 일반 회원가입 이력이 존재하면서, lock이 걸려있지 않은 사용자 정보를 조회한다. + 이때, 결과는 id 내림차순으로 정렬한다. + """) + @Transactional + public void findList() { + // given + Predicate predicate = qUser.name.eq("양재서") + .and(qUser.password.isNotNull()) + .and(qUser.locked.isFalse()); + + QueryHandler queryHandler = null; // queryHandler는 사용하지 않으므로 null로 설정 + + Sort sort = Sort.by(Sort.Order.desc("id")); + + // when + List users = userRepository.findList(predicate, queryHandler, sort); + + // then + Long maxValue = 100000L; + for (User user : users) { + log.info("user: {}", user); + + assertTrue("id는 내림차순 정렬되어야 한다.", user.getId() <= maxValue); + assertTrue("일반 회원가입 이력이 존재해야 한다.", user.isGeneralSignedUpUser()); + assertFalse("lock이 걸려있지 않아야 한다.", user.getLocked()); + + maxValue = user.getId(); + } + } + + @Test + @DisplayName(""" + Entity findPage 테스트: 이름이 양재서고, Kakao로 가입한 Oauth 정보를 조회한다. + 단, 결과는 처음 5개만 조회하며, id 내림차순으로 정렬한다. + """) + @Transactional + public void findPage() { + // given + Predicate predicate = qUser.name.eq("양재서") + .and(qOauth.provider.eq(Provider.KAKAO)); + + QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id)); + Sort sort = Sort.by(Sort.Order.desc("user.id")); + + int pageNumber = 0, pageSize = 5; + Pageable pageable = PageRequest.of(pageNumber, pageSize, sort); + + // when + Page users = userRepository.findPage(predicate, queryHandler, pageable); + + // then + assertEquals("users의 크기는 5여야 한다.", 5, users.getSize()); + Long maxValue = 100000L; + for (User user : users.getContent()) { + log.debug("user: {}", user); + assertTrue("id는 내림차순 정렬되어야 한다.", user.getId() <= maxValue); + assertEquals("이름이 양재서여야 한다.", "양재서", user.getName()); + maxValue = user.getId(); + } + } + + @Test + @DisplayName(""" + Dto selectList 테스트: 사용자 이름이 양재서인 사용자의 username, name, phone 그리고 연동된 Oauth 정보를 조회한다. + """) + @Transactional + public void selectList() { + // given + Predicate predicate = qUser.name.eq("양재서"); + + QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id)); + Sort sort = null; + + Map> bindings = new HashMap<>(); + + bindings.put("userId", qUser.id); + bindings.put("username", qUser.username); + bindings.put("name", qUser.name); + bindings.put("phone", qUser.phone); + bindings.put("oauthId", qOauth.id); + bindings.put("provider", qOauth.provider); + + // when + List userAndOauthInfos = userRepository.selectList(predicate, UserAndOauthInfo.class, bindings, queryHandler, sort); + + // then + userAndOauthInfos.forEach(userAndOauthInfo -> { + log.debug("userAndOauthInfo: {}", userAndOauthInfo); + assertEquals("이름이 양재서인 사용자만 조회되어야 한다.", "양재서", userAndOauthInfo.getName()); + assertEquals("provider는 KAKAO여야 한다.", Provider.KAKAO, userAndOauthInfo.getProvider()); + }); + } + + private List getRandomUsers() { + List users = new ArrayList<>(100); + List name = List.of("양재서", "이진우", "안성윤", "최희진", "아우신얀", "강병준", "이의찬", "이수민", "이주원"); + + for (int i = 0; i < 100; ++i) { + User user = User.builder() + .username("jayang" + i) + .name(name.get(i % name.size())) + .password((i % 2 == 0) ? null : "password" + i) + .passwordUpdatedAt((i % 2 == 0) ? null : now()) + .profileVisibility(ProfileVisibility.PUBLIC) + .phone("010-1111-1" + String.format("%03d", i)) + .role(Role.USER) + .locked((i % 10 == 0)) + .notifySetting(NotifySetting.of(true, true, true)) + .deletedAt(null) + .build(); + + users.add(user); + } + + return users; + } + + private List getRandomOauths(Collection users) { + List oauths = new ArrayList<>(users.size()); + + for (User user : users) { + Oauth oauth = Oauth.of(Provider.KAKAO, "providerId" + user.getId(), user); + oauths.add(oauth); + } + + return oauths; + } + + private void bulkInsertUser(Collection users) { + String sql = String.format(""" + INSERT INTO `%s` (username, name, password, password_updated_at, profile_image_url, phone, role, profile_visibility, locked, created_at, updated_at, account_book_notify, feed_notify, chat_notify, deleted_at) + VALUES (:username, :name, :password, :passwordUpdatedAt, :profileImageUrl, :phone, '1', '0', :locked, now(), now(), 1, 1, 1, :deletedAt) + """, USER_TABLE); + SqlParameterSource[] params = users.stream() + .map(BeanPropertySqlParameterSource::new) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, params); + } + + private void bulkInsertOauth(Collection oauths) { + String sql = String.format(""" + INSERT INTO `%s` (provider, oauth_id, user_id, created_at, deleted_at) + VALUES (1, :oauthId, :user.id, now(), NULL) + """, OAUTH_TABLE); + SqlParameterSource[] params = oauths.stream() + .map(BeanPropertySqlParameterSource::new) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, params); + } + + @Setter + @Getter + public static class UserAndOauthInfo { + private Long userId; + private String username; + private String name; + private String phone; + private Long oauthId; + private Provider provider; + + public UserAndOauthInfo() { + } + + public UserAndOauthInfo(Long userId, String username, String name, String phone, Long oauthId, Provider provider) { + this.userId = userId; + this.username = username; + this.name = name; + this.phone = phone; + this.oauthId = oauthId; + this.provider = provider; + } + + @Override + public String toString() { + return "UserAndOauthInfo{" + + "userId=" + userId + + ", username='" + username + '\'' + + ", name='" + name + '\'' + + ", phone='" + phone + '\'' + + ", oauthId=" + oauthId + + ", provider=" + provider + + '}'; + } + } +} From 6bb492bc15af69a8500f16461ea7d596a30d49fe Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 9 May 2024 12:54:38 +0900 Subject: [PATCH 075/152] =?UTF-8?q?=E2=9C=A8=20=EB=8B=B9=EC=9B=94=20?= =?UTF-8?q?=EB=AA=A9=ED=91=9C=20=EA=B8=88=EC=95=A1=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?API=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 당월 목표 금액 등록/수정 swagger 작성 * test: 쿼리 파라미터 유효성 검사 에러 응답 테스트 케이스 작성 * test: param 유효성 검사 테스트 케이스 추가 * test: param null인 경우 400 테스트 추가 * feat: handler_method_validation_exception 전역 예외 핸들러 추가 * feat: target_amount_usecase 클래스 생성 * feat: target amount domain delete 쿼리 수정 및 delete_at 필드 제거, amount 수정 메서드 추가 * fix: amount 타입 wrapper -> primitive 타입으로 수정 * refactor: @request_param -> dto로 리팩토링 * test: expected 400 -> 422 에러로 수정 * docs: update_param_req swagger 문서 작성 * feat: target amount 에러 코드 및 예외 작성 * test: 당월에 해당하는 요청인지 검증하는 테스트 추가 * feat: 당월이 아닐 시 예외처리 * feat: 당월 목표 금액 조회 domain service 메서드 추가 * rename: domain service와 repository 메서드 명 수정 * rename: save -> create * test: 당월 목표 금액 등록 통합 테스트 작성 * refactor: target_amount_save_service 분리 * test: displayname 400 -> 422 에러로 수정 * rename: 패키지명 ledge -> ledger * fix: put mapping value 추가 * style: if문 중괄호 추가 --- .../api/apis/ledger/api/TargetAmountApi.java | 25 +++ .../controller/TargetAmountController.java | 45 +++++ .../api/apis/ledger/dto/TargetAmountDto.java | 27 +++ .../service/TargetAmountSaveService.java | 38 ++++ .../ledger/usecase/TargetAmountUseCase.java | 21 +++ .../handler/GlobalExceptionHandler.java | 11 ++ .../TargetAmountControllerUnitTest.java | 164 ++++++++++++++++++ .../TargetAmountIntegrationTest.java | 91 ++++++++++ .../domains/target/domain/TargetAmount.java | 19 +- .../exception/TargetAmountErrorCode.java | 28 +++ .../exception/TargetAmountErrorException.java | 21 +++ .../repository/TargetAmountRepository.java | 10 +- .../target/service/TargetAmountService.java | 10 +- 13 files changed, 500 insertions(+), 10 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountIntegrationTest.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorException.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java new file mode 100644 index 000000000..268a779e4 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.api.apis.ledger.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.ledger.dto.TargetAmountDto; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +@Tag(name = "목표금액 API") +public interface TargetAmountApi { + @Operation(summary = "당월 목표 금액 등록/수정", method = "PUT") + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "목표 금액 등록 실패", value = """ + { + "code": "4004", + "message": "당월 목표 금액에 대한 요청이 아닙니다." + } + """) + })) + ResponseEntity putTargetAmount(TargetAmountDto.UpdateParamReq request, @AuthenticationPrincipal SecurityUserDetails user); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java new file mode 100644 index 000000000..ea7cefb2b --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java @@ -0,0 +1,45 @@ +package kr.co.pennyway.api.apis.ledger.controller; + +import kr.co.pennyway.api.apis.ledger.api.TargetAmountApi; +import kr.co.pennyway.api.apis.ledger.dto.TargetAmountDto; +import kr.co.pennyway.api.apis.ledger.usecase.TargetAmountUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; +import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/targets") +public class TargetAmountController implements TargetAmountApi { + private final TargetAmountUseCase targetAmountUseCase; + + @Override + @PutMapping("") + @PreAuthorize("isAuthenticated()") + public ResponseEntity putTargetAmount(@Validated TargetAmountDto.UpdateParamReq request, @AuthenticationPrincipal SecurityUserDetails user) { + if (!isValidDateForYearAndMonth(request.date())) { + throw new TargetAmountErrorException(TargetAmountErrorCode.INVALID_TARGET_AMOUNT_DATE); + } + + targetAmountUseCase.updateTargetAmount(user.getUserId(), request.date(), request.amount()); + return ResponseEntity.ok(SuccessResponse.noContent()); + } + + private boolean isValidDateForYearAndMonth(LocalDate date) { + LocalDate now = LocalDate.now(); + return date.getYear() == now.getYear() && date.getMonth() == now.getMonth(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java new file mode 100644 index 000000000..62074b583 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java @@ -0,0 +1,27 @@ +package kr.co.pennyway.api.apis.ledger.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public class TargetAmountDto { + @Schema(title = "목표 금액 등록/수정 요청 파라미터") + public record UpdateParamReq( + @Schema(description = "등록하려는 목표 금액 날짜 (당일)", example = "2024-05-08", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "date 값은 필수입니다.") + @JsonSerialize(using = LocalDateSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate date, + @Schema(description = "등록하려는 목표 금액 (0이상의 정수)", example = "100000", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "amount 값은 필수입니다.") + @Min(value = 0, message = "amount 값은 0 이상이어야 합니다.") + Integer amount + ) { + + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java new file mode 100644 index 000000000..846d47c72 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java @@ -0,0 +1,38 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.target.service.TargetAmountService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TargetAmountSaveService { + private final UserService userService; + private final TargetAmountService targetAmountService; + + /** + * 사용자에게 당월 목표 금액이 있으면 amount를 수정하고, 없으면 새로 생성한다. + */ + @Transactional + public void saveTargetAmount(Long userId, LocalDate date, Integer amount) { + Optional targetAmount = targetAmountService.readTargetAmountThatMonth(userId, date); + + if (targetAmount.isPresent()) { + targetAmount.get().updateAmount(amount); + } else { + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + targetAmountService.createTargetAmount(TargetAmount.of(amount, user)); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java new file mode 100644 index 000000000..f56756458 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.api.apis.ledger.usecase; + +import kr.co.pennyway.api.apis.ledger.service.TargetAmountSaveService; +import kr.co.pennyway.common.annotation.UseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class TargetAmountUseCase { + private final TargetAmountSaveService targetAmountSaveService; + + @Transactional + public void updateTargetAmount(Long userId, LocalDate date, Integer amount) { + targetAmountSaveService.saveTargetAmount(userId, date, amount); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java index d022d0017..3475651ca 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java @@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException; @@ -84,6 +85,16 @@ protected ErrorResponse handleMissingServletRequestParameterException(MissingSer return ErrorResponse.of(code, e.getMessage()); } + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(HandlerMethodValidationException.class) + @JsonView(CustomJsonView.Common.class) + protected ErrorResponse handleHandlerMethodValidationException(HandlerMethodValidationException e) { + log.warn("handleHandlerMethodValidationException : {}", e.getMessage()); + String code = String.valueOf(StatusCode.BAD_REQUEST.getCode() * 10 + ReasonCode.MISSING_REQUIRED_PARAMETER.getCode()); + + return ErrorResponse.of(code, e.getMessage()); + } + /** * API 호출 시 외부 서버와 통신 중 예외가 발생한 경우 * diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java new file mode 100644 index 000000000..7b5c740d8 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java @@ -0,0 +1,164 @@ +package kr.co.pennyway.api.apis.ledger.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.ledger.usecase.TargetAmountUseCase; +import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = {TargetAmountController.class}) +@ActiveProfiles("test") +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +public class TargetAmountControllerUnitTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private TargetAmountUseCase targetAmountUseCase; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(post("/**").with(csrf())) + .defaultRequest(put("/**").with(csrf())) + .defaultRequest(delete("/**").with(csrf())) + .build(); + } + + @Order(1) + @Nested + @DisplayName("당월 목표 금액 등록/수정") + class PutTargetAmount { + @Test + @DisplayName("date가 'yyyy-MM-dd' 형식이 아닐 경우 422 Unprocessable Entity 에러 응답을 반환한다.") + @WithMockUser + void putTargetAmountWithInvalidDateFormat() throws Exception { + // given + String date = "2024/05/08"; + Integer amount = 100000; + + // when + ResultActions result = performPutTargetAmount(date, amount); + + // then + result + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("date가 null인 경우 422 Unprocessable Entity 에러 응답을 반환한다.") + @WithMockUser + void putTargetAmountWithNullDate() throws Exception { + // given + Integer amount = 100000; + + // when + ResultActions result = performPutTargetAmount(null, amount); + + // then + result + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("date가 당월 날짜가 아닌 경우 400 Bad Request 에러 응답을 반환한다.") + @WithMockUser + void putTargetAmountWithInvalidDate() throws Exception { + // given + String date = "1999-05-19"; + Integer amount = 100000; + + // when + ResultActions result = performPutTargetAmount(date, amount); + + // then + result + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(TargetAmountErrorCode.INVALID_TARGET_AMOUNT_DATE.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(TargetAmountErrorCode.INVALID_TARGET_AMOUNT_DATE.getExplainError())); + } + + @Test + @DisplayName("amount가 null인 경우 422 Unprocessable Entity 에러 응답을 반환한다.") + @WithMockUser + void putTargetAmountWithInvalidAmountFormat() throws Exception { + // given + String date = "2024-05-08"; + + // when + ResultActions result1 = performPutTargetAmount(date, null); + + // then + result1 + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("amount가 0보다 작은 경우 422 Unprocessable Entity 에러 응답을 반환한다.") + @WithMockUser + void putTargetAmountWithNegativeAmount() throws Exception { + // given + String date = "2024-05-08"; + Integer negativeAmount = -100000; + + // when + ResultActions result = performPutTargetAmount(date, negativeAmount); + + // then + result + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("정상적인 요청이 들어왔을 때 200 OK 응답을 반환한다.") + @WithSecurityMockUser + void putTargetAmountWithValidRequest() throws Exception { + // given + String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + Integer amount = 100000; + + // when + ResultActions result = performPutTargetAmount(date, amount); + + // then + result + .andDo(print()) + .andExpect(status().isOk()); + } + + + private ResultActions performPutTargetAmount(String date, Integer amount) throws Exception { + return mockMvc.perform(put("/v2/targets") + .param("date", date) + .param("amount", String.valueOf(amount)) + ); + } + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountIntegrationTest.java new file mode 100644 index 000000000..bacadae06 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountIntegrationTest.java @@ -0,0 +1,91 @@ +package kr.co.pennyway.api.apis.ledger.controller; + +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.target.service.TargetAmountService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Slf4j +@ExternalApiIntegrationTest +@AutoConfigureMockMvc +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +public class TargetAmountIntegrationTest extends ExternalApiDBTestConfig { + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserService userService; + + @Autowired + private TargetAmountService targetAmountService; + + @Nested + @Order(1) + @DisplayName("당월 목표 금액 등록/수정") + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class PutTargetAmount { + @Order(1) + @Test + @DisplayName("당월 목표 금액 entity가 존재하지 않을 경우 새로 생성한다.") + @WithSecurityMockUser(userId = "1") + @Transactional + void putTargetAmountNotFound() throws Exception { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + + // when + ResultActions result = performPutTargetAmount(date, 100000); + + // then + result.andExpect(status().isOk()); + assertNotNull(targetAmountService.readTargetAmountThatMonth(user.getId(), LocalDate.now()).orElse(null)); + } + + @Order(2) + @Test + @DisplayName("당월 목표 금액 entity가 존재하는 경우 amount를 수정한다.") + @WithSecurityMockUser(userId = "2") + @Transactional + void putTargetAmountFound() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + TargetAmount targetAmount = targetAmountService.createTargetAmount(TargetAmount.of(100000, user)); + + String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + + // when + ResultActions result = performPutTargetAmount(date, 200000); + + // then + result.andExpect(status().isOk()); + assertEquals(200000, targetAmount.getAmount()); + } + + private ResultActions performPutTargetAmount(String date, Integer amount) throws Exception { + return mockMvc.perform(put("/v2/targets") + .param("date", date) + .param("amount", amount.toString())); + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java index 8bb7acd8e..032836287 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java @@ -8,31 +8,36 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.SQLDelete; -import java.time.LocalDateTime; - @Entity @Getter @Table(name = "target_amount") @NoArgsConstructor(access = AccessLevel.PROTECTED) -@SQLDelete(sql = "UPDATE target_amount SET deleted_at = NOW() WHERE id = ?") +@SQLDelete(sql = "UPDATE target_amount SET amount = -1 WHERE id = ?") public class TargetAmount extends DateAuditable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - private Integer amount; - private LocalDateTime deletedAt; + private int amount; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; - private TargetAmount(Integer amount, User user) { + private TargetAmount(int amount, User user) { this.amount = amount; this.user = user; } - public static TargetAmount of(Integer amount, User user) { + public static TargetAmount of(int amount, User user) { return new TargetAmount(amount, user); } + + public void updateAmount(Integer amount) { + this.amount = amount; + } + + public boolean isAllocatedAmount() { + return this.amount >= 0; + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java new file mode 100644 index 000000000..e6c84d9a8 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.domain.domains.target.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum TargetAmountErrorCode implements BaseErrorCode { + /* 400 BAD_REQUEST */ + INVALID_TARGET_AMOUNT_DATE(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "당월 목표 금액에 대한 요청이 아닙니다."), + ; + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorException.java new file mode 100644 index 000000000..669ab9b96 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorException.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.domain.domains.target.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class TargetAmountErrorException extends GlobalErrorException { + private final TargetAmountErrorCode targetAmountErrorCode; + + public TargetAmountErrorException(TargetAmountErrorCode targetAmountErrorCode) { + super(targetAmountErrorCode); + this.targetAmountErrorCode = targetAmountErrorCode; + } + + public CausedBy causedBy() { + return targetAmountErrorCode.causedBy(); + } + + public String getExplainError() { + return targetAmountErrorCode.getExplainError(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java index 293648728..c441bd791 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java @@ -1,7 +1,13 @@ package kr.co.pennyway.domain.domains.target.repository; +import kr.co.pennyway.domain.common.repository.ExtendedRepository; import kr.co.pennyway.domain.domains.target.domain.TargetAmount; -import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; -public interface TargetAmountRepository extends JpaRepository { +import java.time.LocalDate; +import java.util.Optional; + +public interface TargetAmountRepository extends ExtendedRepository { + @Query("SELECT ta FROM TargetAmount ta WHERE ta.user.id = :userId AND YEAR(ta.createdAt) = YEAR(:date) AND MONTH(ta.createdAt) = MONTH(:date)") + Optional findByUserIdThatMonth(Long userId, LocalDate date); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java index b97d444ed..965eeeaae 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java @@ -7,6 +7,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.util.Optional; + @Slf4j @DomainService @RequiredArgsConstructor @@ -14,7 +17,12 @@ public class TargetAmountService { private final TargetAmountRepository targetAmountRepository; @Transactional - public TargetAmount save(TargetAmount targetAmount) { + public TargetAmount createTargetAmount(TargetAmount targetAmount) { return targetAmountRepository.save(targetAmount); } + + @Transactional(readOnly = true) + public Optional readTargetAmountThatMonth(Long userId, LocalDate date) { + return targetAmountRepository.findByUserIdThatMonth(userId, date); + } } From 0a479d28f124fcaffbba1661b70cbdb4528eb06d Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 9 May 2024 14:18:24 +0900 Subject: [PATCH 076/152] =?UTF-8?q?=E2=9C=A8=20=EC=9B=94=EB=B3=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A7=80=EC=B6=9C=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20API=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 지출 내역 조회 스웨거 문서 작성 * feat: 지출(spending) 컨트롤러 작성 * fix: 패키지명 ledge -> ledger * rename: at_month -> at_year_and_month 메서드명 수정 * feat: spending entity get_day() 메서드 추가 * feat: 지출 내역 조회 dto 정의 * feat: 월별 지출 내역 조회 usecase 구현 * rename: save -> create_spending() * fix: 지출내역 jpa repository -> extended repository * feat: spending domain service 내 read_spendings() 추가 * chore: querydsl-core 의존성 api로 수정 * chore: querydsl-jpa 의존성 api로 수정 * fix: spending 조회 시 query dsl 메서드 사용 * test: 지출 내역 테스트를 위한 batch fixture 정의 * test: 월별 지출 내역 조회 통합 테스트 작성 * fix: spending_fixture query 수정 * fix: spending_fixture query category 랜덤 수정 * fix: dto validation 추가 * refactor: spending search service 분리 * refactor: spending mapper 분리 * refactor: spending_mapper 내부 메서드 분리 * rename: spending mapper calculate 메서드 주석 추가 * docs: spending search res dto 스웨거 문서 작성 * docs: 지출 내역 조회 api 스웨거 문서 200 응답 포맷 추가 * test: 월별 지출 내역 조회 수행 시간 측정 * feat: 지출 entity sql_restriction 추가 --- .../api/apis/ledger/api/SpendingApi.java | 27 +++++++ .../ledger/controller/SpendingController.java | 30 ++++++++ .../apis/ledger/dto/SpendingSearchRes.java | 73 +++++++++++++++++++ .../apis/ledger/mapper/SpendingMapper.java | 64 ++++++++++++++++ .../ledger/service/SpendingSearchService.java | 41 +++++++++++ .../apis/ledger/usecase/SpendingUseCase.java | 26 +++++++ .../SpendingUseCaseIntegrationTest.java | 68 +++++++++++++++++ .../api/config/fixture/SpendingFixture.java | 63 ++++++++++++++++ pennyway-domain/build.gradle | 4 +- .../domains/spending/domain/Spending.java | 6 ++ .../repository/SpendingRepository.java | 4 +- .../spending/service/SpendingService.java | 12 ++- 12 files changed, 413 insertions(+), 5 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingUseCaseIntegrationTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java new file mode 100644 index 000000000..d76d8e3e4 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java @@ -0,0 +1,27 @@ +package kr.co.pennyway.api.apis.ledger.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.SchemaProperty; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "지출 내역 API") +public interface SpendingApi { + @Operation(summary = "지출 내역 조회", method = "GET", description = "사용자의 해당 년/월 지출 내역을 조회하고 월/일별 지출 총합을 반환합니다.") + @Parameters({ + @Parameter(name = "year", description = "년도", required = true, in = ParameterIn.HEADER), + @Parameter(name = "month", description = "월", required = true, in = ParameterIn.HEADER) + }) + @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "spendings", schema = @Schema(implementation = SpendingSearchRes.Month.class)))) + ResponseEntity getSpendingListAtYearAndMonth(@RequestParam("year") int year, @RequestParam("date") int month, @AuthenticationPrincipal SecurityUserDetails user); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java new file mode 100644 index 000000000..76f98aa4c --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.api.apis.ledger.controller; + +import kr.co.pennyway.api.apis.ledger.api.SpendingApi; +import kr.co.pennyway.api.apis.ledger.usecase.SpendingUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/spendings") +public class SpendingController implements SpendingApi { + private final SpendingUseCase spendingUseCase; + + @Override + @GetMapping("") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getSpendingListAtYearAndMonth(@RequestParam("year") int year, @RequestParam("month") int month, @AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from("spendings", spendingUseCase.getSpendingsAtYearAndMonth(user.getUserId(), year, month))); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java new file mode 100644 index 000000000..283cd2dbf --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java @@ -0,0 +1,73 @@ +package kr.co.pennyway.api.apis.ledger.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +public class SpendingSearchRes { + @Builder + @Schema(title = "월별 지출 내역 조회 응답") + public record Month( + @Schema(description = "년도", example = "2024") + int year, + @Schema(description = "월", example = "5") + int month, + @Schema(description = "월별 총 지출 금액", example = "100000") + int monthlyTotalAmount, + @Schema(description = "일별 지출 내역") + List dailySpendings + ) { + } + + @Builder + @Schema(title = "일별 지출 내역 조회 응답") + public record Daily( + @Schema(description = "일") + int day, + @Schema(description = "일별 총 지출 금액") + int dailyTotalAmount, + @Schema(description = "개별 지출 내역") + List individuals + ) { + } + + @Builder + @Schema(title = "개별 지출 내역 조회 응답") + public record Individual( + @Schema(description = "지출 ID") + @NotNull + Long id, + @Schema(description = "지출 금액") + @NotNull + Integer amount, + @Schema(description = "지출 아이콘") + @NotNull + SpendingCategory icon, + @Schema(description = "지출 일시", example = "2024-05-09") + @NotNull + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDateTime spendAt, + @Schema(description = "계좌명. 없으면 빈 문자열") + String accountName, + @Schema(description = "메모. 없으면 빈 문자열") + String memo + ) { + public Individual(Long id, Integer amount, SpendingCategory icon, LocalDateTime spendAt, String accountName, String memo) { + this.id = id; + this.amount = amount; + this.icon = icon; + this.spendAt = spendAt; + this.accountName = Objects.toString(accountName, ""); + this.memo = Objects.toString(memo, ""); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java new file mode 100644 index 000000000..b3397217e --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java @@ -0,0 +1,64 @@ +package kr.co.pennyway.api.apis.ledger.mapper; + +import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.domains.spending.domain.Spending; + +import java.util.List; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; + +@Mapper +public class SpendingMapper { + public static SpendingSearchRes.Month toSpendingSearchResMonth(List spendings, int year, int month) { + ConcurrentMap> groupSpendingsByDay = spendings.stream().collect(Collectors.groupingByConcurrent(Spending::getDay)); + + List dailySpendings = groupSpendingsByDay.entrySet().stream() + .map(entry -> toSpendingSearchResDaily(entry.getKey(), entry.getValue())) + .toList(); + + return SpendingSearchRes.Month.builder() + .year(year) + .month(month) + .monthlyTotalAmount(calculateMonthlyTotalAmount(groupSpendingsByDay)) + .dailySpendings(dailySpendings) + .build(); + } + + private static SpendingSearchRes.Daily toSpendingSearchResDaily(int day, List spendings) { + List individuals = spendings.stream() + .map(SpendingMapper::toSpendingSearchResIndividual) + .toList(); + + return SpendingSearchRes.Daily.builder() + .day(day) + .dailyTotalAmount(calculateDailyTotalAmount(spendings)) + .individuals(individuals) + .build(); + } + + private static SpendingSearchRes.Individual toSpendingSearchResIndividual(Spending spending) { + return SpendingSearchRes.Individual.builder() + .id(spending.getId()) + .amount(spending.getAmount()) + .icon(spending.getCategory()) + .spendAt(spending.getSpendAt()) + .accountName(spending.getAccountName()) + .memo(spending.getMemo()) + .build(); + } + + /** + * 월별 지출 내역의 총 금액을 계산하는 메서드 + */ + private static int calculateMonthlyTotalAmount(ConcurrentMap> spendings) { + return spendings.values().stream().flatMap(List::stream).mapToInt(Spending::getAmount).sum(); + } + + /** + * 하루 지출 내역의 총 금액을 계산하는 메서드 + */ + private static int calculateDailyTotalAmount(List spendings) { + return spendings.stream().mapToInt(Spending::getAmount).sum(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java new file mode 100644 index 000000000..19b7cd9be --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java @@ -0,0 +1,41 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import com.querydsl.core.types.Predicate; +import kr.co.pennyway.domain.common.repository.QueryHandler; +import kr.co.pennyway.domain.domains.spending.domain.QSpending; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SpendingSearchService { + private final SpendingService spendingService; + + private final QUser user = QUser.user; + private final QSpending spending = QSpending.spending; + + /** + * 사용자의 해당 년/월 지출 내역을 조회하는 메서드 + */ + @Transactional(readOnly = true) + public List readSpendings(Long userId, int year, int month) { + Predicate predicate = spending.user.id.eq(userId) + .and(spending.spendAt.year().eq(year)) + .and(spending.spendAt.month().eq(month)); + + QueryHandler queryHandler = query -> query.leftJoin(user).on(spending.user.eq(user)); + + Sort sort = Sort.by(Sort.Order.desc("spendAt")); + + return spendingService.readSpendings(predicate, queryHandler, sort); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java new file mode 100644 index 000000000..562e15a01 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.api.apis.ledger.usecase; + +import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.api.apis.ledger.mapper.SpendingMapper; +import kr.co.pennyway.api.apis.ledger.service.SpendingSearchService; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class SpendingUseCase { + private final SpendingSearchService spendingSearchService; + + @Transactional(readOnly = true) + public SpendingSearchRes.Month getSpendingsAtYearAndMonth(Long userId, int year, int month) { + List spendings = spendingSearchService.readSpendings(userId, year, month); + + return SpendingMapper.toSpendingSearchResMonth(spendings, year, month); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingUseCaseIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingUseCaseIntegrationTest.java new file mode 100644 index 000000000..5360976de --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingUseCaseIntegrationTest.java @@ -0,0 +1,68 @@ +package kr.co.pennyway.api.apis.ledger.integration; + +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.SpendingFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Slf4j +@ExternalApiIntegrationTest +@AutoConfigureMockMvc +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +public class SpendingUseCaseIntegrationTest extends ExternalApiDBTestConfig { + + @Autowired + private MockMvc mockMvc; + @Autowired + private UserService userService; + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + @Order(1) + @Nested + @DisplayName("월별 지출 내역 조회") + class GetSpendingListAtYearAndMonth { + @Test + @DisplayName("월별 지출 내역 조회") + @WithSecurityMockUser + void getSpendingListAtYearAndMonthSuccess() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + SpendingFixture.bulkInsertSpending(user, 150, jdbcTemplate); + + // when + long before = System.currentTimeMillis(); + ResultActions resultActions = performGetSpendingListAtYearAndMonthSuccess(); + long after = System.currentTimeMillis(); + + // then + resultActions + .andDo(print()) + .andExpect(status().isOk()); + log.debug("수행 시간: {}ms", after - before); + } + + private ResultActions performGetSpendingListAtYearAndMonthSuccess() throws Exception { + LocalDate now = LocalDate.now(); + return mockMvc.perform(MockMvcRequestBuilders.get("/v2/spendings") + .param("year", String.valueOf(now.getYear())) + .param("month", String.valueOf(now.getMonthValue()))); + } + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java new file mode 100644 index 000000000..6941f9144 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java @@ -0,0 +1,63 @@ +package kr.co.pennyway.api.config.fixture; + +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +public class SpendingFixture { + private static final String SPENDING_TABLE = "spending"; + + public static void bulkInsertSpending(User user, int capacity, NamedParameterJdbcTemplate jdbcTemplate) { + Collection spendings = getRandomSpendings(user, capacity); + + String sql = String.format(""" + INSERT INTO `%s` (amount, category, spend_at, account_name, memo, user_id, spending_custom_category_id, created_at, updated_at, deleted_at) + VALUES (:amount, 1+FLOOR(RAND()*11), :spendAt, :accountName, :memo, :user.id, null, NOW(), NOW(), null) + """, SPENDING_TABLE); + SqlParameterSource[] params = spendings.stream() + .map(BeanPropertySqlParameterSource::new) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, params); + } + + private static List getRandomSpendings(User user, int capacity) { + List spending = new ArrayList<>(capacity); + + for (int i = 0; i < 100; i++) { + spending.add(Spending.builder() + .amount(ThreadLocalRandom.current().nextInt(100, 10000001)) + .category(SpendingCategory.FOOD) + .spendAt(getRandomSpendAt()) + .accountName(getRandomAccountName()) + .memo((i % 5 == 0) ? "메모" : null) + .user(user) + .spendingCustomCategory(null) + .build() + ); + } + + return spending; + } + + private static LocalDateTime getRandomSpendAt() { + LocalDate now = LocalDate.now(); + int year = now.getYear(), month = now.getMonthValue(); + int day = ThreadLocalRandom.current().nextInt(1, now.lengthOfMonth() + 1); + return LocalDateTime.of(year, month, day, 0, 0, 0); + } + + private static String getRandomAccountName() { + List accountNames = List.of("현금", "카드", "통장", "월급통장", "적금", "보험", "투자", "기타"); + return accountNames.get(ThreadLocalRandom.current().nextInt(0, accountNames.size())); + } +} diff --git a/pennyway-domain/build.gradle b/pennyway-domain/build.gradle index c7b559878..57f2ecd65 100644 --- a/pennyway-domain/build.gradle +++ b/pennyway-domain/build.gradle @@ -11,8 +11,8 @@ dependencies { api group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa', version: '3.2.3' /* QueryDsl */ - implementation 'com.querydsl:querydsl-core:5.0.0' - implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + api 'com.querydsl:querydsl-core:5.0.0' + api 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api:2.1.1" annotationProcessor "jakarta.persistence:jakarta.persistence-api:3.1.0" diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java index cf1c1757a..220d4bfe0 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java @@ -10,6 +10,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; import java.time.LocalDateTime; @@ -17,6 +18,7 @@ @Getter @Table(name = "spending") @NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLRestriction("deleted_at IS NULL") @SQLDelete(sql = "UPDATE spending SET deleted_at = NOW() WHERE id = ?") public class Spending extends DateAuditable { @Id @@ -50,4 +52,8 @@ private Spending(Integer amount, SpendingCategory category, LocalDateTime spendA this.user = user; this.spendingCustomCategory = spendingCustomCategory; } + + public int getDay() { + return spendAt.getDayOfMonth(); + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java index 27178dce3..64c8302d3 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java @@ -1,7 +1,7 @@ package kr.co.pennyway.domain.domains.spending.repository; +import kr.co.pennyway.domain.common.repository.ExtendedRepository; import kr.co.pennyway.domain.domains.spending.domain.Spending; -import org.springframework.data.jpa.repository.JpaRepository; -public interface SpendingRepository extends JpaRepository { +public interface SpendingRepository extends ExtendedRepository { } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java index fa7df42fb..583c8875a 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -1,12 +1,17 @@ package kr.co.pennyway.domain.domains.spending.service; +import com.querydsl.core.types.Predicate; import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.common.repository.QueryHandler; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.repository.SpendingRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Slf4j @DomainService @RequiredArgsConstructor @@ -14,7 +19,12 @@ public class SpendingService { private final SpendingRepository spendingRepository; @Transactional - public Spending save(Spending spending) { + public Spending createSpending(Spending spending) { return spendingRepository.save(spending); } + + @Transactional(readOnly = true) + public List readSpendings(Predicate predicate, QueryHandler queryHandler, Sort sort) { + return spendingRepository.findList(predicate, queryHandler, sort); + } } From 6132737ff5ed747b105f22cac2df8468e8c06abc Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 9 May 2024 23:07:29 +0900 Subject: [PATCH 077/152] =?UTF-8?q?=E2=9C=A8=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=A7=80=EC=B6=9C=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=93=B1=EB=A1=9D=20API=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 공통된 카테고리 정보를 반환하기 위한 dto * feat: spending entity get_category() 분기 처리 메서드 추가 * fix: get_category type에 맞추어 기존 코드 타입 변환 * fix: custom category인 경우를 핸들링 하기 위한 is_custom 필드 추가 * fix: 사용자 정의/서비스 제공 카테고리에 따른 dto 정보 수정 * fix: category icon 필드 타입 spending_category로 수정 * docs: api 스웨거 문서 작성 * feat: 지출 카테고리 dto 정의 * refactor: spending category api 분리 * feat: 지출 카테고리 추가 use case 작성 * test: 유효하지 않은 카테고리명을 입력하면 422 Unprocessable Entity 에러 응답을 반환한다 * fix: name 쿼리 파라미터 @not_empty -> @not_blank * test: controller unit test 유효성 검사 추가 * feat: icon other 값 예외 처리 * feat: spending exception 정의 && other 아이콘 예외 처리 * test: other icon 입력 예외처리 테스트 * rename: save -> create_spending_custom_category * fix: spending_custom_category 테이블명 수정 * docs: param query object 스웨거 상에서 안 보이도록 처리 * docs: 성공 응답 스키마 추가 * docs: 지출 카테고리 응답 icon 필드 예시 수정 && 응답 key값 추가 --- .../apis/ledger/api/SpendingCategoryApi.java | 34 +++++ .../SpendingCategoryController.java | 39 ++++++ .../apis/ledger/dto/SpendingCategoryDto.java | 55 ++++++++ .../apis/ledger/dto/SpendingSearchRes.java | 8 +- .../apis/ledger/mapper/SpendingMapper.java | 2 +- .../usecase/SpendingCategoryUseCase.java | 32 +++++ .../SpendingCategoryControllerUnitTest.java | 126 ++++++++++++++++++ .../domains/spending/domain/Spending.java | 16 +++ .../domain/SpendingCustomCategory.java | 4 +- .../domains/spending/dto/CategoryInfo.java | 42 ++++++ .../spending/exception/SpendingErrorCode.java | 29 ++++ .../exception/SpendingErrorException.java | 22 +++ .../SpendingCustomCategoryService.java | 2 +- 13 files changed, 404 insertions(+), 7 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingCategoryDto.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorException.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java new file mode 100644 index 000000000..f73fb4193 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java @@ -0,0 +1,34 @@ +package kr.co.pennyway.api.apis.ledger.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.SchemaProperty; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; + +@Tag(name = "지출 카테고리 API") +public interface SpendingCategoryApi { + @Operation(summary = "지출 내역 카테고리 등록", method = "POST", description = "사용자 커스텀 지출 카테고리를 생성합니다.") + @Parameters({ + @Parameter(name = "name", description = "카테고리 이름", required = true, in = ParameterIn.QUERY), + @Parameter(name = "icon", description = "카테고리 아이콘. 대문자만 허용합니다.", required = true, in = ParameterIn.QUERY, examples = { + @ExampleObject(name = "식사", value = "FOOD"), @ExampleObject(name = "교통", value = "TRANSPORTATION"), @ExampleObject(name = "뷰티/패션", value = "BEAUTY_OR_FASHION"), + @ExampleObject(name = "편의점/마트", value = "CONVENIENCE_STORE"), @ExampleObject(name = "교육", value = "EDUCATION"), @ExampleObject(name = "생활", value = "LIVING"), + @ExampleObject(name = "건강", value = "HEALTH"), @ExampleObject(name = "취미/여가", value = "HOBBY"), @ExampleObject(name = "여행/숙박", value = "TRAVEL"), + @ExampleObject(name = "술/유흥", value = "ALCOHOL_OR_ENTERTAINMENT"), @ExampleObject(name = "회비/경조사", value = "MEMBERSHIP_OR_FAMILY_EVENT") + }), + @Parameter(name = "param", hidden = true) + }) + @ApiResponse(responseCode = "200", description = "지출 카테고리 등록 성공", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "spendingCategory", schema = @Schema(implementation = SpendingCategoryDto.Res.class)))) + ResponseEntity postSpendingCategory(@Validated SpendingCategoryDto.CreateParamReq param, @AuthenticationPrincipal SecurityUserDetails user); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java new file mode 100644 index 000000000..f4fa9e51a --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java @@ -0,0 +1,39 @@ +package kr.co.pennyway.api.apis.ledger.controller; + +import kr.co.pennyway.api.apis.ledger.api.SpendingCategoryApi; +import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; +import kr.co.pennyway.api.apis.ledger.usecase.SpendingCategoryUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/spending-categories") +public class SpendingCategoryController implements SpendingCategoryApi { + private final SpendingCategoryUseCase spendingCategoryUseCase; + + @Override + @PostMapping("") + @PreAuthorize("isAuthenticated()") + public ResponseEntity postSpendingCategory(@Validated SpendingCategoryDto.CreateParamReq param, @AuthenticationPrincipal SecurityUserDetails user) { + if (param.icon().equals(SpendingCategory.OTHER)) { + throw new SpendingErrorException(SpendingErrorCode.INVALID_ICON); + } + + SpendingCategoryDto.Res spendingCategory = spendingCategoryUseCase.createSpendingCategory(user.getUserId(), param.name(), param.icon()); + return ResponseEntity.ok(SuccessResponse.from("spendingCategory", spendingCategory)); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingCategoryDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingCategoryDto.java new file mode 100644 index 000000000..dec3d08cf --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingCategoryDto.java @@ -0,0 +1,55 @@ +package kr.co.pennyway.api.apis.ledger.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import org.springframework.util.StringUtils; + +import java.util.Objects; + +public class SpendingCategoryDto { + public record CreateParamReq( + @NotBlank(message = "카테고리 이름은 필수입니다.") + @Size(max = 15, message = "카테고리 이름은 15자 이하로 입력해주세요.") + String name, + @NotNull(message = "카테고리 아이콘은 필수입니다.") + SpendingCategory icon + ) { + } + + @Schema(title = "지출 카테고리 정보") + public record Res( + @Schema(description = "사용자 정의 카테고리 여부") + boolean isCustom, + @Schema(description = "카테고리 ID. 사용자 정의 카테고리가 아니라면 -1, 사용자 정의 카테고리라면 0 이상의 값을 갖는다.") + Long id, + @Schema(description = "카테고리 이름") + String name, + @Schema(description = "카테고리 아이콘", example = "FOOD", examples = {"FOOD", "TRANSPORTATION", "BEAUTY_OR_FASHION", "CONVENIENCE_STORE", "EDUCATION", "LIVING", "HEALTH", "HOBBY", "TRAVEL", "ALCOHOL_OR_ENTERTAINMENT", "MEMBERSHIP_OR_FAMILY_EVENT"}) + SpendingCategory icon + ) { + public Res { + Objects.requireNonNull(id, "id는 null일 수 없습니다."); + Objects.requireNonNull(icon, "icon은 null일 수 없습니다."); + + if (isCustom && id < 0 || !isCustom && id != -1) { + throw new IllegalArgumentException("isCustom과 id 정보가 일치하지 않습니다."); + } + + if (isCustom && icon.equals(SpendingCategory.OTHER)) { + throw new IllegalArgumentException("사용자 정의 카테고리는 OTHER가 될 수 없습니다."); + } + + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name은 null이거나 빈 문자열일 수 없습니다."); + } + } + + public static Res from(CategoryInfo category) { + return new Res(category.isCustom(), category.id(), category.name(), category.icon()); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java index 283cd2dbf..e91e3c377 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java @@ -48,9 +48,9 @@ public record Individual( @Schema(description = "지출 금액") @NotNull Integer amount, - @Schema(description = "지출 아이콘") + @Schema(description = "지출 카테고리 아이콘") @NotNull - SpendingCategory icon, + SpendingCategory category, @Schema(description = "지출 일시", example = "2024-05-09") @NotNull @JsonSerialize(using = LocalDateTimeSerializer.class) @@ -61,10 +61,10 @@ public record Individual( @Schema(description = "메모. 없으면 빈 문자열") String memo ) { - public Individual(Long id, Integer amount, SpendingCategory icon, LocalDateTime spendAt, String accountName, String memo) { + public Individual(Long id, Integer amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo) { this.id = id; this.amount = amount; - this.icon = icon; + this.category = category; this.spendAt = spendAt; this.accountName = Objects.toString(accountName, ""); this.memo = Objects.toString(memo, ""); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java index b3397217e..fd69fd299 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java @@ -41,7 +41,7 @@ private static SpendingSearchRes.Individual toSpendingSearchResIndividual(Spendi return SpendingSearchRes.Individual.builder() .id(spending.getId()) .amount(spending.getAmount()) - .icon(spending.getCategory()) + .category(spending.getCategory().icon()) .spendAt(spending.getSpendAt()) .accountName(spending.getAccountName()) .memo(spending.getMemo()) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java new file mode 100644 index 000000000..c9e38371d --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java @@ -0,0 +1,32 @@ +package kr.co.pennyway.api.apis.ledger.usecase; + +import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; +import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class SpendingCategoryUseCase { + private final UserService userService; + private final SpendingCustomCategoryService spendingCustomCategoryService; + + @Transactional + public SpendingCategoryDto.Res createSpendingCategory(Long userId, String categoryName, SpendingCategory icon) { + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + + SpendingCustomCategory category = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategory.of(categoryName, icon, user)); + + return SpendingCategoryDto.Res.from(CategoryInfo.of(category.getId(), category.getName(), category.getIcon())); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java new file mode 100644 index 000000000..e2cd29942 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java @@ -0,0 +1,126 @@ +package kr.co.pennyway.api.apis.ledger.controller; + +import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; +import kr.co.pennyway.api.apis.ledger.usecase.SpendingCategoryUseCase; +import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = {SpendingCategoryController.class}) +@ActiveProfiles("test") +public class SpendingCategoryControllerUnitTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + private SpendingCategoryUseCase spendingCategoryUseCase; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(post("/**").with(csrf())) + .build(); + } + + @Test + @DisplayName("유효하지 않은 카테고리명을 입력하면 422 Unprocessable Entity 에러 응답을 반환한다.") + @WithSecurityMockUser + void postSpendingCategoryWithInvalidName() throws Exception { + // given + String icon = "FOOD"; + String whiteSpaceName = " "; + String sixteenLengthName = "1234567890123456"; + + // when + ResultActions result1 = performPostSpendingCategory(whiteSpaceName, icon); + ResultActions result2 = performPostSpendingCategory(sixteenLengthName, icon); + + // then + result1.andDo(print()).andExpect(status().isUnprocessableEntity()); + result2.andDo(print()).andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("유효하지 않은 아이콘을 입력하면 422 Unprocessable Entity 에러 응답을 반환한다.") + @WithSecurityMockUser + void postSpendingCategoryWithInvalidIcon() throws Exception { + // given + String name = "식비"; + String whiteSpaceIcon = " "; + String invalidIcon = "INVALID"; + String lowerCaseIcon = "food"; + + // when + ResultActions result1 = performPostSpendingCategory(name, whiteSpaceIcon); + ResultActions result2 = performPostSpendingCategory(name, invalidIcon); + ResultActions result3 = performPostSpendingCategory(name, lowerCaseIcon); + + // then + result1.andDo(print()).andExpect(status().isUnprocessableEntity()); + result2.andDo(print()).andExpect(status().isUnprocessableEntity()); + result3.andDo(print()).andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("OTHER 아이콘을 입력하면 400 BAD_REQUEST 에러 응답을 반환한다.") + @WithSecurityMockUser + void postSpendingCategoryWithOtherIcon() throws Exception { + // given + String name = "식비"; + String icon = "OTHER"; + + // when + ResultActions result = performPostSpendingCategory(name, icon); + + // then + result + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(SpendingErrorCode.INVALID_ICON.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(SpendingErrorCode.INVALID_ICON.getExplainError())); + } + + + @Test + @DisplayName("카테고리명과 아이콘을 입력하면 200 OK 응답을 반환한다.") + @WithSecurityMockUser + void postSpendingCategory() throws Exception { + // given + String name = "식비"; + String icon = "FOOD"; + given(spendingCategoryUseCase.createSpendingCategory(any(), any(), any())).willReturn(SpendingCategoryDto.Res.from(CategoryInfo.of(1L, name, SpendingCategory.FOOD))); + + // when + ResultActions result = performPostSpendingCategory(name, icon); + + // then + result.andDo(print()).andExpect(status().isOk()); + } + + private ResultActions performPostSpendingCategory(String name, String icon) throws Exception { + return mockMvc.perform(post("/v2/spending-categories") + .param("name", name) + .param("icon", icon)); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java index 220d4bfe0..498e8fe6a 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java @@ -3,6 +3,7 @@ import jakarta.persistence.*; import kr.co.pennyway.domain.common.converter.SpendingIconConverter; import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; import lombok.AccessLevel; @@ -56,4 +57,19 @@ private Spending(Integer amount, SpendingCategory category, LocalDateTime spendA public int getDay() { return spendAt.getDayOfMonth(); } + + /** + * 지출 내역의 소비 카테고리를 조회하는 메서드
+ * SpendingCategory가 OTHER일 경우 SpendingCustomCategory를 정보를 조회하여 반환한다. + * + * @return {@link CategoryInfo} + */ + public CategoryInfo getCategory() { + if (this.category.equals(SpendingCategory.OTHER)) { + SpendingCustomCategory category = getSpendingCustomCategory(); + return CategoryInfo.of(category.getId(), category.getName(), category.getIcon()); + } + + return CategoryInfo.of(-1L, this.category.getType(), this.category); + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java index b79fbdf1e..484a2b5a4 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java @@ -8,13 +8,15 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; import java.time.LocalDateTime; @Entity @Getter -@Table(name = "spending_category") +@Table(name = "spending_custom_category") @NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLRestriction("deleted_at IS NULL") @SQLDelete(sql = "UPDATE spending_category SET deleted_at = NOW() WHERE id = ?") public class SpendingCustomCategory extends DateAuditable { @Id diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java new file mode 100644 index 000000000..f79750607 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.domain.domains.spending.dto; + +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import org.springframework.util.StringUtils; + +import java.util.Objects; + +/** + * 지출 카테고리 정보를 담은 DTO + * + * @param isCustom boolean : 사용자 정의 카테고리 여부 + * @param id Long : 카테고리 ID. 사용자 정의 카테고리가 아니라면 -1, 사용자 정의 카테고리라면 0 이상의 값을 갖는다. + * @param name String : 카테고리 이름 + * @param icon String : 카테고리 아이콘 + */ +public record CategoryInfo( + boolean isCustom, + Long id, + String name, + SpendingCategory icon +) { + public CategoryInfo { + Objects.requireNonNull(id, "id는 null일 수 없습니다."); + Objects.requireNonNull(icon, "icon은 null일 수 없습니다."); + + if (isCustom && id < 0 || !isCustom && id != -1) { + throw new IllegalArgumentException("isCustom과 id 정보가 일치하지 않습니다."); + } + + if (isCustom && icon.equals(SpendingCategory.OTHER)) { + throw new IllegalArgumentException("사용자 정의 카테고리는 OTHER가 될 수 없습니다."); + } + + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name은 null이거나 빈 문자열일 수 없습니다."); + } + } + + public static CategoryInfo of(Long id, String name, SpendingCategory icon) { + return new CategoryInfo(id != null, id, name, icon); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java new file mode 100644 index 000000000..6f650a3b2 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java @@ -0,0 +1,29 @@ +package kr.co.pennyway.domain.domains.spending.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SpendingErrorCode implements BaseErrorCode { + /* 400 Bad Request */ + INVALID_ICON(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorException.java new file mode 100644 index 000000000..74eb92be4 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorException.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.domain.domains.spending.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class SpendingErrorException extends GlobalErrorException { + private final SpendingErrorCode errorCode; + + public SpendingErrorException(SpendingErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + @Override + public CausedBy causedBy() { + return errorCode.causedBy(); + } + + public String getExplainError() { + return errorCode.getExplainError(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java index 5ac8aff8e..550bae001 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java @@ -14,7 +14,7 @@ public class SpendingCustomCategoryService { private final SpendingCustomCategoryRepository spendingCustomCategoryRepository; @Transactional - public SpendingCustomCategory save(SpendingCustomCategory spendingCustomCategory) { + public SpendingCustomCategory createSpendingCustomCategory(SpendingCustomCategory spendingCustomCategory) { return spendingCustomCategoryRepository.save(spendingCustomCategory); } } From 0dd02c8830764a4c92fa8c1a0486af88268642e9 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 10 May 2024 12:34:50 +0900 Subject: [PATCH 078/152] =?UTF-8?q?=E2=9C=A8=20=EC=A7=80=EC=B6=9C=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EB=93=B1=EB=A1=9D=20API=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 소비 내역 등록 controller unit test * feat: 지출 등록 dto 생성 * feat: dto 내 to_entity() 메서드 정의 * test: given() 추가 및 mockmvc csrf 처리 * test: 101자리 랜덤 문자열 생성 방법 수정 * feat: 요청 icon이 other이면서, category_id가 -1인 경우 예외 처리 * fix: to_spending_search_res_individual private -> public * feat: 동작하도록 usecase 지출 생성 코드 작성 * feat: 지출 entity 내 커스텀 카테고리 매핑 메서드 및 생성자 유효성 검사 추가 * feat: custom_category not_found 예외 추가 * feat: spending_custom_category 조회 domain service 메서드 추가 * rename: 에러 응답 상세화 * fix: category_info 정적 팩토리 메서드 내 is_custom 판단 스니펫 수정 * test: request의 categoryId가 -1인 경우, spendingCustomCategory가 null인 Spending을 생성한다 * test: request의 categoryId가 -1이 아닌 경우, spendingCustomCategory를 참조하는 Spending을 생성한다 * test: 기존 카테고리에 등록하는 경우, icon 정보를 other로 수정 * fix: category 정보 상세한 응답으로 변경 * feat: request의 category_id, icon 조합 검증 * fix: 서비스/사용자 카테고리에 따른 to_entity() 분리 * refactor: spending_save_service 분리 * refactor: 직관적인 코드를 위해 request 내 custom_category 요청 여부를 판단하는 메서드 추가 * test: 자원 검증 시나리오 테스트 추가 * fix: is_custom_category 논리 부정 연산 추가 * feat: 커스텀 카테고리 자원 접근 관리 매니저 생성 * feat: 사용자 id와 커스텀 id가 일치하는 커스텀 지출 카테고리 검사 메서드 추가 * feat: 자원 검증 인가 로직 controller 반영 * docs: 지출 내역 추가 api 문서 작성 * docs: swagger 예외 응답 추가 * test: usecase에 대한 테스트로 수정 --- .../api/apis/ledger/api/SpendingApi.java | 35 +++++ .../ledger/controller/SpendingController.java | 32 +++- .../api/apis/ledger/dto/SpendingReq.java | 76 ++++++++++ .../apis/ledger/dto/SpendingSearchRes.java | 16 +- .../apis/ledger/mapper/SpendingMapper.java | 4 +- .../ledger/service/SpendingSaveService.java | 38 +++++ .../apis/ledger/usecase/SpendingUseCase.java | 19 +++ .../SpendingCategoryManager.java | 29 ++++ .../SpendingControllerUnitTest.java | 141 ++++++++++++++++++ .../SpendingUseCaseIntegrationTest.java | 87 ++++++++++- .../domains/spending/domain/Spending.java | 16 ++ .../domains/spending/dto/CategoryInfo.java | 4 +- .../spending/exception/SpendingErrorCode.java | 6 +- .../SpendingCustomCategoryRepository.java | 3 + .../SpendingCustomCategoryService.java | 12 ++ 15 files changed, 500 insertions(+), 18 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSaveService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java index d76d8e3e4..56ced453c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java @@ -5,18 +5,53 @@ import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.SchemaProperty; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "지출 내역 API") public interface SpendingApi { + @Operation(summary = "지출 내역 추가", method = "POST", description = """ + 사용자의 지출 내역을 추가하고 추가된 지출 내역을 반환합니다.
+ 서비스에서 제공하는 지출 카테고리를 사용하는 경우 categoryId는 -1이어야 하며, icon은 OTHER가 될 수 없습니다.
+ 사용자가 정의한 지출 카테고리를 사용하는 경우 categoryId는 -1이 아니어야 하며, icon은 OTHER여야 합니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "spending", schema = @Schema(implementation = SpendingSearchRes.Individual.class)))), + @ApiResponse(responseCode = "400", description = "지출 카테고리 ID와 아이콘의 조합이 올바르지 않습니다.", content = @Content(examples = { + @ExampleObject(name = "카테고리 id, 아이콘 조합 오류", description = "categoryId가 -1인데 icon이 OTHER이거나, categoryId가 -1이 아닌데 icon이 OTHER가 아닙니다.", + value = """ + { + "code": "4005", + "message": "icon의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다." + } + """ + ) + })), + @ApiResponse(responseCode = "403", description = "지출 카테고리에 대한 권한이 없습니다.", content = @Content(examples = { + @ExampleObject(name = "지출 카테고리 권한 오류", description = "지출 카테고리에 대한 권한이 없습니다.", + value = """ + { + "code": "4030", + "message": "ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN" + } + """ + ) + })) + }) + ResponseEntity postSpending(@RequestBody @Validated SpendingReq request, @AuthenticationPrincipal SecurityUserDetails user); + @Operation(summary = "지출 내역 조회", method = "GET", description = "사용자의 해당 년/월 지출 내역을 조회하고 월/일별 지출 총합을 반환합니다.") @Parameters({ @Parameter(name = "year", description = "년도", required = true, in = ParameterIn.HEADER), diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java index 76f98aa4c..872b8a165 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java @@ -1,18 +1,20 @@ package kr.co.pennyway.api.apis.ledger.controller; import kr.co.pennyway.api.apis.ledger.api.SpendingApi; +import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.apis.ledger.usecase.SpendingUseCase; import kr.co.pennyway.api.common.response.SuccessResponse; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; @Slf4j @RestController @@ -21,10 +23,32 @@ public class SpendingController implements SpendingApi { private final SpendingUseCase spendingUseCase; + @Override + @PostMapping("") + @PreAuthorize("isAuthenticated() and @spendingCategoryManager.hasPermission(#user.getUserId(), #request.categoryId())") + public ResponseEntity postSpending(@RequestBody @Validated SpendingReq request, @AuthenticationPrincipal SecurityUserDetails user) { + if (!isValidCategoryIdAndIcon(request.categoryId(), request.icon())) { + throw new SpendingErrorException(SpendingErrorCode.INVALID_ICON_WITH_CATEGORY_ID); + } + + return ResponseEntity.ok(SuccessResponse.from("spending", spendingUseCase.createSpending(user.getUserId(), request))); + } + @Override @GetMapping("") @PreAuthorize("isAuthenticated()") public ResponseEntity getSpendingListAtYearAndMonth(@RequestParam("year") int year, @RequestParam("month") int month, @AuthenticationPrincipal SecurityUserDetails user) { return ResponseEntity.ok(SuccessResponse.from("spendings", spendingUseCase.getSpendingsAtYearAndMonth(user.getUserId(), year, month))); } + + /** + * categoryId가 -1이면 서비스에서 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 OTHER가 될 수 없고,
+ * categoryId가 -1이 아니면 사용자가 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 OTHER임을 확인한다. + * + * @param categoryId : 사용자가 정의한 카테고리 ID + * @param icon : 지출 내역으로 저장하려는 카테고리의 아이콘 + */ + private boolean isValidCategoryIdAndIcon(Long categoryId, SpendingCategory icon) { + return (categoryId.equals(-1L) && !icon.equals(SpendingCategory.OTHER) || categoryId > 0 && icon.equals(SpendingCategory.OTHER)); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java new file mode 100644 index 000000000..23093cb08 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java @@ -0,0 +1,76 @@ +package kr.co.pennyway.api.apis.ledger.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import jakarta.validation.constraints.Size; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; + +import java.time.LocalDate; + +@Schema(title = "지출 내역 추가 요청") +public record SpendingReq( + @Schema(description = "지출 금액. int 범위 최대값까지 허용", example = "10000") + @Min(value = 1, message = "지출 금액은 1 이상이어야 합니다.") + int amount, + @Schema(description = "지출 카테고리 ID. 사용자가 정의한 카테고리가 아닌 경우 -1. icon이 OTHER이면서 categoryId가 -1일 수는 없다.", example = "-1") + @NotNull(message = "지출 카테고리 ID는 필수입니다.") + @Min(value = -1, message = "지출 카테고리 ID는 -1 이상이어야 합니다.") + Long categoryId, + @Schema(description = "지출 카테고리 아이콘", example = "FOOD") + @NotNull(message = "지출 카테고리 아이콘은 필수입니다.") + SpendingCategory icon, + @Schema(description = "지출 일자", example = "2021-08-01") + @NotNull(message = "지출 일자는 필수입니다.") + @JsonSerialize(using = LocalDateSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd") + @PastOrPresent(message = "지출 일자는 과거 또는 현재여야 합니다.") + LocalDate spendAt, + @Schema(description = "소비처", example = "카페인 수혈") + @Size(max = 20, message = "소비처는 null 혹은 20자 이하로 입력해야 합니다.") + String accountName, + @Schema(description = "메모", example = "아메리카노 1잔") + @Size(max = 100, message = "메모는 null 혹은 100자 이하로 입력해야 합니다.") + String memo +) { + /** + * 서비스에서 제공하는 지출 카테고리를 사용하는 지출 내역으로 변환 + */ + public Spending toEntity(User user) { + return Spending.builder() + .amount(amount) + .category(icon) + .spendAt(spendAt.atStartOfDay()) + .accountName(accountName) + .memo(memo) + .user(user) + .build(); + } + + /** + * 사용자가 정의한 지출 카테고리를 사용하는 지출 내역으로 변환 + */ + public Spending toEntity(User user, SpendingCustomCategory spendingCustomCategory) { + return Spending.builder() + .amount(amount) + .category(icon) + .spendAt(spendAt.atStartOfDay()) + .accountName(accountName) + .memo(memo) + .user(user) + .spendingCustomCategory(spendingCustomCategory) + .build(); + } + + @Schema(hidden = true) + public boolean isCustomCategory() { + return !categoryId.equals(-1L); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java index e91e3c377..ac33279db 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java @@ -5,7 +5,7 @@ import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; -import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; import lombok.Builder; import java.time.LocalDateTime; @@ -48,20 +48,20 @@ public record Individual( @Schema(description = "지출 금액") @NotNull Integer amount, - @Schema(description = "지출 카테고리 아이콘") + @Schema(description = "지출 카테고리 정보") @NotNull - SpendingCategory category, - @Schema(description = "지출 일시", example = "2024-05-09") + CategoryInfo category, + @Schema(description = "지출 일시", pattern = "yyyy-MM-dd HH:mm:ss", example = "2021-08-01 00:00:00") @NotNull @JsonSerialize(using = LocalDateTimeSerializer.class) - @JsonFormat(pattern = "yyyy-MM-dd") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime spendAt, - @Schema(description = "계좌명. 없으면 빈 문자열") + @Schema(description = "계좌명. 없으면 빈 문자열", example = "카페인 수혈") String accountName, - @Schema(description = "메모. 없으면 빈 문자열") + @Schema(description = "메모. 없으면 빈 문자열", example = "아메리카노 1잔") String memo ) { - public Individual(Long id, Integer amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo) { + public Individual(Long id, Integer amount, CategoryInfo category, LocalDateTime spendAt, String accountName, String memo) { this.id = id; this.amount = amount; this.category = category; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java index fd69fd299..741a74f89 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java @@ -37,11 +37,11 @@ private static SpendingSearchRes.Daily toSpendingSearchResDaily(int day, List new SpendingErrorException(SpendingErrorCode.NOT_FOUND_CUSTOM_CATEGORY)); + + spending = spendingService.createSpending(request.toEntity(user, customCategory)); + } + + return spending; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java index 562e15a01..e760fd3ef 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java @@ -1,10 +1,16 @@ package kr.co.pennyway.api.apis.ledger.usecase; +import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; import kr.co.pennyway.api.apis.ledger.mapper.SpendingMapper; +import kr.co.pennyway.api.apis.ledger.service.SpendingSaveService; import kr.co.pennyway.api.apis.ledger.service.SpendingSearchService; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; @@ -15,8 +21,21 @@ @UseCase @RequiredArgsConstructor public class SpendingUseCase { + private final SpendingSaveService spendingSaveService; private final SpendingSearchService spendingSearchService; + private final UserService userService; + + + @Transactional + public SpendingSearchRes.Individual createSpending(Long userId, SpendingReq request) { + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + + Spending spending = spendingSaveService.createSpending(user, request); + + return SpendingMapper.toSpendingSearchResIndividual(spending); + } + @Transactional(readOnly = true) public SpendingSearchRes.Month getSpendingsAtYearAndMonth(Long userId, int year, int month) { List spendings = spendingSearchService.readSpendings(userId, year, month); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java new file mode 100644 index 000000000..a8e9aa81e --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java @@ -0,0 +1,29 @@ +package kr.co.pennyway.api.common.security.authorization; + +import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component("spendingCategoryManager") +@RequiredArgsConstructor +public class SpendingCategoryManager { + private final SpendingCustomCategoryService spendingCustomCategoryService; + + /** + * 사용자가 커스텀 지출 카테고리에 대한 권한이 있는지 확인한다.
+ * -1L이면 서비스에서 제공하는 기본 카테고리를 사용하는 것이므로 무시한다. + * + * @return 권한이 있으면 true, 없으면 false + */ + @Transactional(readOnly = true) + public boolean hasPermission(Long userId, Long categoryId) { + if (categoryId.equals(-1L)) { + return true; + } + + return spendingCustomCategoryService.isExistsSpendingCustomCategory(userId, categoryId); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java new file mode 100644 index 000000000..32aa052a4 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java @@ -0,0 +1,141 @@ +package kr.co.pennyway.api.apis.ledger.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; +import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.api.apis.ledger.usecase.SpendingUseCase; +import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.time.LocalDate; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ActiveProfiles("test") +@WebMvcTest(controllers = SpendingController.class) +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +public class SpendingControllerUnitTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private SpendingUseCase spendingUseCase; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(post("/**").with(csrf())) + .build(); + } + + @Order(1) + @Nested + @DisplayName("지출 내역 추가하기") + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class postSpending { + @Test + @DisplayName("금액이 0이하의 정수인 경우 422 Unprocessable Entity를 반환한다.") + @WithSecurityMockUser + void whenAmountIsZeroOrNegative() throws Exception { + // given + int amount = 0; + SpendingReq request = new SpendingReq(amount, -1L, SpendingCategory.FOOD, LocalDate.now(), "소비처", "메모"); + given(spendingUseCase.createSpending(1L, request)).willReturn(SpendingSearchRes.Individual.builder().build()); + + // when + ResultActions result = performPostSpending(request); + + // then + result.andDo(print()).andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("아이콘이 OTHER이면서 categoryId가 -1인 경우 400 Bad Request를 반환한다.") + @WithSecurityMockUser + void whenCategoryIsNotDefined() throws Exception { + // given + Long categoryId = -1L; + SpendingCategory icon = SpendingCategory.OTHER; + SpendingReq request = new SpendingReq(10000, categoryId, icon, LocalDate.now(), "소비처", "메모"); + given(spendingUseCase.createSpending(1L, request)).willReturn(SpendingSearchRes.Individual.builder().build()); + + // when + ResultActions result = performPostSpending(request); + + // then + result.andDo(print()).andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("지출일이 현재보다 미래인 경우 422 Unprocessable Entity를 반환한다.") + @WithSecurityMockUser + void whenSpendAtIsFuture() throws Exception { + // given + LocalDate spendAt = LocalDate.now().plusDays(1); + SpendingReq request = new SpendingReq(10000, -1L, SpendingCategory.FOOD, spendAt, "소비처", "메모"); + given(spendingUseCase.createSpending(1L, request)).willReturn(SpendingSearchRes.Individual.builder().build()); + + // when + ResultActions result = performPostSpending(request); + + // then + result.andDo(print()).andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("소비처가 null이 아니면서 20자를 초과하는 경우 422 Unprocessable Entity를 반환한다.") + @WithSecurityMockUser + void whenAccountNameIsNotNullAndOver20() throws Exception { + // given + String accountName = "123456789012345678901"; + SpendingReq request = new SpendingReq(10000, -1L, SpendingCategory.FOOD, LocalDate.now(), accountName, "메모"); + given(spendingUseCase.createSpending(1L, request)).willReturn(SpendingSearchRes.Individual.builder().build()); + + // when + ResultActions result = performPostSpending(request); + + // then + result.andDo(print()).andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("메모가 null이 아니면서 100자를 초과하는 경우 422 Unprocessable Entity를 반환한다.") + @WithSecurityMockUser + void whenMemoIsNotNullAndOver100() throws Exception { + // given + String memo = RandomStringUtils.random(101); + SpendingReq request = new SpendingReq(10000, -1L, SpendingCategory.FOOD, LocalDate.now(), "소비처", memo); + given(spendingUseCase.createSpending(1L, request)).willReturn(SpendingSearchRes.Individual.builder().build()); + + // when + ResultActions result = performPostSpending(request); + + // then + result.andDo(print()).andExpect(status().isUnprocessableEntity()); + } + + private ResultActions performPostSpending(SpendingReq request) throws Exception { + return mockMvc.perform(post("/v2/spendings") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + } + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingUseCaseIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingUseCaseIntegrationTest.java index 5360976de..bb5a90e8a 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingUseCaseIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingUseCaseIntegrationTest.java @@ -1,10 +1,17 @@ package kr.co.pennyway.api.apis.ledger.integration; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; +import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.api.apis.ledger.usecase.SpendingUseCase; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; import kr.co.pennyway.api.config.fixture.SpendingFixture; import kr.co.pennyway.api.config.fixture.UserFixture; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.extern.slf4j.Slf4j; @@ -15,9 +22,11 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import static org.springframework.test.util.AssertionErrors.assertEquals; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -30,17 +39,93 @@ public class SpendingUseCaseIntegrationTest extends ExternalApiDBTestConfig { @Autowired private MockMvc mockMvc; @Autowired + private ObjectMapper objectMapper; + @Autowired private UserService userService; @Autowired + private SpendingUseCase spendingUseCase; + @Autowired + private SpendingCustomCategoryService spendingCustomCategoryService; + @Autowired private NamedParameterJdbcTemplate jdbcTemplate; @Order(1) @Nested + @DisplayName("지출 내역 추가하기") + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class CreateSpending { + @Order(1) + @Test + @DisplayName("request의 categoryId가 -1인 경우, spendingCustomCategory가 null인 Spending을 생성한다.") + @WithSecurityMockUser(userId = "1") + @Transactional + void createSpendingSuccess() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + SpendingReq request = new SpendingReq(10000, -1L, SpendingCategory.FOOD, LocalDate.now(), "소비처", "메모"); + + // when + SpendingSearchRes.Individual result = spendingUseCase.createSpending(user.getId(), request); + + // then + assertEquals("isCustom이 false이어야 한다.", false, result.category().isCustom()); + assertEquals("categoryId가 -1이어야 한다.", -1L, result.category().id()); + assertEquals("icon이 FOOD이어야 한다.", SpendingCategory.FOOD, result.category().icon()); + } + + @Order(2) + @Test + @DisplayName("request의 categoryId가 -1이 아닌 경우, spendingCustomCategory를 참조하는 Spending을 생성한다.") + @WithSecurityMockUser(userId = "2") + @Transactional + void createSpendingWithCustomCategorySuccess() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + SpendingCustomCategory category = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategory.of("잉여비", SpendingCategory.LIVING, user)); + SpendingReq request = new SpendingReq(10000, category.getId(), SpendingCategory.OTHER, LocalDate.now(), "소비처", "메모"); + + // when + SpendingSearchRes.Individual result = spendingUseCase.createSpending(user.getId(), request); + + // then + assertEquals("isCustom이 true이어야 한다.", true, result.category().isCustom()); + assertEquals("categoryId가 spendingCustomCategory의 id와 같아야 한다.", category.getId(), result.category().id()); + assertEquals("icon이 spendingCustomCategory의 icon과 같아야 한다.", category.getIcon(), result.category().icon()); + } + + @Order(3) + @Test + @DisplayName("사용자가 categoryId에 해당하는 카테고리 정보의 소유자가 아닌 경우, 403 Forbidden을 반환한다.") + @WithSecurityMockUser(userId = "3") + @Transactional + void createSpendingWithInvalidCustomCategory() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + SpendingReq request = new SpendingReq(10000, 1000L, SpendingCategory.OTHER, LocalDate.now(), "소비처", "메모"); + + // when + ResultActions resultActions = performCreateSpendingSuccess(request); + + // then + resultActions.andDo(print()).andExpect(status().isForbidden()); + } + + private ResultActions performCreateSpendingSuccess(SpendingReq req) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders + .post("/v2/spendings") + .contentType("application/json") + .content(objectMapper.writeValueAsString(req))); + } + } + + @Order(2) + @Nested @DisplayName("월별 지출 내역 조회") class GetSpendingListAtYearAndMonth { @Test @DisplayName("월별 지출 내역 조회") - @WithSecurityMockUser + @WithSecurityMockUser(userId = "4") + @Transactional void getSpendingListAtYearAndMonthSuccess() throws Exception { // given User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java index 498e8fe6a..cbf9a54e4 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java @@ -45,6 +45,12 @@ public class Spending extends DateAuditable { @Builder private Spending(Integer amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo, User user, SpendingCustomCategory spendingCustomCategory) { + if (category.equals(SpendingCategory.OTHER) && spendingCustomCategory == null) { + throw new IllegalArgumentException("OTHER 아이콘의 경우 SpendingCustomCategory는 null일 수 없습니다."); + } else if (!category.equals(SpendingCategory.OTHER) && spendingCustomCategory != null) { + throw new IllegalArgumentException("OTHER 아이콘이 아닌 경우 SpendingCustomCategory는 null이어야 합니다."); + } + this.amount = amount; this.category = category; this.spendAt = spendAt; @@ -72,4 +78,14 @@ public CategoryInfo getCategory() { return CategoryInfo.of(-1L, this.category.getType(), this.category); } + + public void updateSpendingCustomCategory(SpendingCustomCategory spendingCustomCategory) { + if (this.category.equals(SpendingCategory.OTHER) && spendingCustomCategory == null) { + throw new IllegalArgumentException("OTHER 아이콘의 경우 SpendingCustomCategory는 null일 수 없습니다."); + } else if (!this.category.equals(SpendingCategory.OTHER) && spendingCustomCategory != null) { + throw new IllegalArgumentException("OTHER 아이콘이 아닌 경우 SpendingCustomCategory는 null이어야 합니다."); + } + + this.spendingCustomCategory = spendingCustomCategory; + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java index f79750607..9369127ff 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java @@ -24,7 +24,7 @@ public record CategoryInfo( Objects.requireNonNull(icon, "icon은 null일 수 없습니다."); if (isCustom && id < 0 || !isCustom && id != -1) { - throw new IllegalArgumentException("isCustom과 id 정보가 일치하지 않습니다."); + throw new IllegalArgumentException("isCustom이 " + isCustom + "일 때 id는 " + (isCustom ? "0 이상" : "-1") + "이어야 합니다."); } if (isCustom && icon.equals(SpendingCategory.OTHER)) { @@ -37,6 +37,6 @@ public record CategoryInfo( } public static CategoryInfo of(Long id, String name, SpendingCategory icon) { - return new CategoryInfo(id != null, id, name, icon); + return new CategoryInfo(!id.equals(-1L), id, name, icon); } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java index 6f650a3b2..dd017deee 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java @@ -11,7 +11,11 @@ @RequiredArgsConstructor public enum SpendingErrorCode implements BaseErrorCode { /* 400 Bad Request */ - INVALID_ICON(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."); + INVALID_ICON(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."), + INVALID_ICON_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "icon의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."), + + /* 404 Not Found */ + NOT_FOUND_CUSTOM_CATEGORY(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "존재하지 않는 커스텀 카테고리입니다."); private final StatusCode statusCode; private final ReasonCode reasonCode; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java index 2e36eaa61..cdb8ce9ba 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java @@ -2,6 +2,9 @@ import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; public interface SpendingCustomCategoryRepository extends JpaRepository { + @Transactional(readOnly = true) + boolean existsByIdAndUser_Id(Long id, Long userId); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java index 550bae001..5523d6359 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java @@ -7,6 +7,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Slf4j @DomainService @RequiredArgsConstructor @@ -17,4 +19,14 @@ public class SpendingCustomCategoryService { public SpendingCustomCategory createSpendingCustomCategory(SpendingCustomCategory spendingCustomCategory) { return spendingCustomCategoryRepository.save(spendingCustomCategory); } + + @Transactional(readOnly = true) + public Optional readSpendingCustomCategory(Long id) { + return spendingCustomCategoryRepository.findById(id); + } + + @Transactional(readOnly = true) + public boolean isExistsSpendingCustomCategory(Long userId, Long categoryId) { + return spendingCustomCategoryRepository.existsByIdAndUser_Id(categoryId, userId); + } } From e1fadf14508c658eac291dba853489182f5639f7 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 10 May 2024 14:47:56 +0900 Subject: [PATCH 079/152] =?UTF-8?q?=E2=9C=A8=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=A0=95=EC=9D=98=20=EC=A7=80=EC=B6=9C=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=A1=B0=ED=9A=8C=20API=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 지출 카테고리 조회 controller 메서드 추가 * feat: 지출 카테고리 조회 usecase 작성 * feat: 지출 카테고리 조회 domain service 추가 * fix: 상태 검사를 위한 조건문 생성자로 이동 * rename: icon -> category * feat: category converter 필드에 정의 * docs: 지출 카테고리 조회 swagger 문서 작성 --- .../api/apis/ledger/api/SpendingCategoryApi.java | 9 +++++---- .../ledger/controller/SpendingCategoryController.java | 8 ++++++++ .../apis/ledger/usecase/SpendingCategoryUseCase.java | 11 +++++++++++ ...nConverter.java => SpendingCategoryConverter.java} | 6 +++--- .../domain/domains/spending/domain/Spending.java | 4 ++-- .../spending/domain/SpendingCustomCategory.java | 8 ++++++-- .../repository/SpendingCustomCategoryRepository.java | 5 +++++ .../service/SpendingCustomCategoryService.java | 6 ++++++ 8 files changed, 46 insertions(+), 11 deletions(-) rename pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/{SpendingIconConverter.java => SpendingCategoryConverter.java} (52%) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java index f73fb4193..015bd1fd7 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java @@ -4,10 +4,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.SchemaProperty; +import io.swagger.v3.oas.annotations.media.*; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; @@ -31,4 +28,8 @@ public interface SpendingCategoryApi { }) @ApiResponse(responseCode = "200", description = "지출 카테고리 등록 성공", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "spendingCategory", schema = @Schema(implementation = SpendingCategoryDto.Res.class)))) ResponseEntity postSpendingCategory(@Validated SpendingCategoryDto.CreateParamReq param, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "사용자 정의 지출 카테고리 조회", method = "GET", description = "사용자가 생성한 지출 카테고리 목록을 조회합니다.") + @ApiResponse(responseCode = "200", description = "지출 카테고리 조회 성공", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "spendingCategories", array = @ArraySchema(schema = @Schema(implementation = SpendingCategoryDto.Res.class))))) + ResponseEntity getSpendingCategories(@AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java index f4fa9e51a..f629b965d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java @@ -14,6 +14,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -36,4 +37,11 @@ public ResponseEntity postSpendingCategory(@Validated SpendingCategoryDto.Cre SpendingCategoryDto.Res spendingCategory = spendingCategoryUseCase.createSpendingCategory(user.getUserId(), param.name(), param.icon()); return ResponseEntity.ok(SuccessResponse.from("spendingCategory", spendingCategory)); } + + @Override + @GetMapping("") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getSpendingCategories(@AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from("spendingCategories", spendingCategoryUseCase.getSpendingCategories(user.getUserId()))); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java index c9e38371d..f8cdfb824 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java @@ -14,6 +14,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Slf4j @UseCase @RequiredArgsConstructor @@ -29,4 +31,13 @@ public SpendingCategoryDto.Res createSpendingCategory(Long userId, String catego return SpendingCategoryDto.Res.from(CategoryInfo.of(category.getId(), category.getName(), category.getIcon())); } + + @Transactional(readOnly = true) + public List getSpendingCategories(Long userId) { + List categories = spendingCustomCategoryService.readSpendingCustomCategories(userId); + + return categories.stream() + .map(category -> SpendingCategoryDto.Res.from(CategoryInfo.of(category.getId(), category.getName(), category.getIcon()))) + .toList(); + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingIconConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingCategoryConverter.java similarity index 52% rename from pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingIconConverter.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingCategoryConverter.java index 67460ac39..b9e567b88 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingIconConverter.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingCategoryConverter.java @@ -4,10 +4,10 @@ import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; @Converter -public class SpendingIconConverter extends AbstractLegacyEnumAttributeConverter { - private static final String ENUM_NAME = "지출 아이콘"; +public class SpendingCategoryConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "지출 카테고리"; - public SpendingIconConverter() { + public SpendingCategoryConverter() { super(SpendingCategory.class, false, ENUM_NAME); } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java index cbf9a54e4..3b99317eb 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java @@ -1,7 +1,7 @@ package kr.co.pennyway.domain.domains.spending.domain; import jakarta.persistence.*; -import kr.co.pennyway.domain.common.converter.SpendingIconConverter; +import kr.co.pennyway.domain.common.converter.SpendingCategoryConverter; import kr.co.pennyway.domain.common.model.DateAuditable; import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; @@ -27,7 +27,7 @@ public class Spending extends DateAuditable { private Long id; private Integer amount; - @Convert(converter = SpendingIconConverter.class) + @Convert(converter = SpendingCategoryConverter.class) private SpendingCategory category; private LocalDateTime spendAt; private String accountName; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java index 484a2b5a4..74ae6085f 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java @@ -1,6 +1,7 @@ package kr.co.pennyway.domain.domains.spending.domain; import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.SpendingCategoryConverter; import kr.co.pennyway.domain.common.model.DateAuditable; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; @@ -24,6 +25,7 @@ public class SpendingCustomCategory extends DateAuditable { private Long id; private String name; + @Convert(converter = SpendingCategoryConverter.class) private SpendingCategory icon; private LocalDateTime deletedAt; @@ -32,14 +34,16 @@ public class SpendingCustomCategory extends DateAuditable { private User user; private SpendingCustomCategory(String name, SpendingCategory icon, User user) { + if (icon.equals(SpendingCategory.OTHER)) { + throw new IllegalArgumentException("OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."); + } + this.name = name; this.icon = icon; this.user = user; } public static SpendingCustomCategory of(String name, SpendingCategory icon, User user) { - if (icon.equals(SpendingCategory.OTHER)) - throw new IllegalArgumentException("OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."); return new SpendingCustomCategory(name, icon, user); } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java index cdb8ce9ba..b56b65c3b 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java @@ -4,7 +4,12 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + public interface SpendingCustomCategoryRepository extends JpaRepository { @Transactional(readOnly = true) boolean existsByIdAndUser_Id(Long id, Long userId); + + @Transactional(readOnly = true) + List findAllByUser_Id(Long userId); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java index 5523d6359..ebe526245 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java @@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Optional; @Slf4j @@ -25,6 +26,11 @@ public Optional readSpendingCustomCategory(Long id) { return spendingCustomCategoryRepository.findById(id); } + @Transactional(readOnly = true) + public List readSpendingCustomCategories(Long userId) { + return spendingCustomCategoryRepository.findAllByUser_Id(userId); + } + @Transactional(readOnly = true) public boolean isExistsSpendingCustomCategory(Long userId, Long categoryId) { return spendingCustomCategoryRepository.existsByIdAndUser_Id(categoryId, userId); From 9275a4bbef47a3ce17f96d35b57cd0b4af598e25 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sat, 11 May 2024 23:51:23 +0900 Subject: [PATCH 080/152] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20QueryDsl=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20Repository=EC=9D=98=20Dto=20=EB=B6=88?= =?UTF-8?q?=EB=B3=80=EC=84=B1=20=EB=B3=B4=EC=9E=A5=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: query dsl extended repository dto 불변식 유지를 위해 linked_hash_map 자료 구조 분기 처리 * test: hash map 테스트 케이스 추가 * rename: query_handler 사용 목적을 담은 주석 수정 * test: 테스트용 dto @to_string() 작성 --- .../repository/QueryDslSearchRepository.java | 12 +++- .../QueryDslSearchRepositoryImpl.java | 13 +++- .../common/repository/QueryHandler.java | 2 +- .../UserExtendedRepositoryTest.java | 68 +++++++++++++++---- 4 files changed, 76 insertions(+), 19 deletions(-) diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java index a76da5e4d..5a525ea09 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -13,6 +14,7 @@ * QueryDsl을 이용한 검색 조건을 처리하는 기본적인 메서드를 선언한 인터페이스 * * @author YANG JAESEO + * @version 1.1 */ public interface QueryDslSearchRepository { @@ -87,11 +89,13 @@ public interface QueryDslSearchRepository { Page findPage(Predicate predicate, QueryHandler queryHandler, Pageable pageable); /** - * 검색 조건에 해당하는 DTO 리스트를 조회하는 메서드 + * 검색 조건에 해당하는 DTO 리스트를 조회하는 메서드
+ * bindings가 {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다.
+ * 만약 bindings가 삽입 순서를 보장하지 않을 경우, Dto는 기본 생성자와 setter 메서드를 제공해야 하며, 모든 필드의 final 키워드를 제거해야 한다. * * @param predicate : 검색 조건 * @param type : 조회할 도메인(혹은 DTO) 타입 - * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드 + * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드. {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입해야 한다. * @param queryHandler : 검색 조건에 추가적으로 적용할 조건 * @param sort : 정렬 조건 * @@ -134,10 +138,12 @@ public interface QueryDslSearchRepository { /** * 검색 조건에 해당하는 DTO 페이지를 조회하는 메서드 + * bindings가 {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다.
+ * 만약 bindings가 삽입 순서를 보장하지 않을 경우, Dto는 기본 생성자와 setter 메서드를 제공해야 하며, 모든 필드의 final 키워드를 제거해야 한다. * * @param predicate : 검색 조건 * @param type : 조회할 도메인(혹은 DTO) 타입 - * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드 + * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드. {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입해야 한다. * @param queryHandler : 검색 조건에 추가적으로 적용할 조건 * @param pageable : 페이지 정보 * diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java index a63690482..2c751efeb 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java @@ -15,6 +15,7 @@ import org.springframework.data.querydsl.SimpleEntityPathResolver; import org.springframework.util.Assert; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -54,7 +55,13 @@ public Page findPage(Predicate predicate, QueryHandler queryHandler, Pageable @Override public

List

selectList(Predicate predicate, Class

type, Map> bindings, QueryHandler queryHandler, Sort sort) { - return this.buildWithoutSelect(predicate, bindings, queryHandler, sort).select(Projections.bean(type, bindings)).fetch(); + JPAQuery query = this.buildWithoutSelect(predicate, bindings, queryHandler, sort); + + if (bindings instanceof LinkedHashMap) { + return query.select(Projections.constructor(type, bindings.values().toArray(new Expression[0]))).fetch(); + } + + return query.select(Projections.bean(type, bindings)).fetch(); } @Override @@ -66,6 +73,10 @@ public

Page

selectPage(Predicate predicate, Class

type, Map(query.select(Projections.constructor(type, bindings.values().toArray(new Expression[0]))).fetch(), pageable, totalSize); + } + return new PageImpl<>(query.select(Projections.bean(type, bindings)).fetch(), pageable, totalSize); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java index b0ae575be..93af7feee 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java @@ -3,7 +3,7 @@ import com.querydsl.jpa.impl.JPAQuery; /** - * QueryDsl을 이용한 검색 조건을 처리하는 기본적인 메서드를 선언한 인터페이스 + * QueryDsl의 명시적 조인을 위한 함수형 인터페이스 * * @author YANG JAESEO */ diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java index fdfd79a81..b2e4c1e6c 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java @@ -132,16 +132,17 @@ public void findPage() { @Test @DisplayName(""" Dto selectList 테스트: 사용자 이름이 양재서인 사용자의 username, name, phone 그리고 연동된 Oauth 정보를 조회한다. + LinkedHashMap을 사용하여 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다. """) @Transactional - public void selectList() { + public void selectListUseLinkedHashMap() { // given Predicate predicate = qUser.name.eq("양재서"); QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id)); Sort sort = null; - Map> bindings = new HashMap<>(); + Map> bindings = new LinkedHashMap<>(); bindings.put("userId", qUser.id); bindings.put("username", qUser.username); @@ -153,6 +154,39 @@ public void selectList() { // when List userAndOauthInfos = userRepository.selectList(predicate, UserAndOauthInfo.class, bindings, queryHandler, sort); + // then + userAndOauthInfos.forEach(userAndOauthInfo -> { + log.debug("userAndOauthInfo: {}", userAndOauthInfo); + assertEquals("이름이 양재서인 사용자만 조회되어야 한다.", "양재서", userAndOauthInfo.name()); + assertEquals("provider는 KAKAO여야 한다.", Provider.KAKAO, userAndOauthInfo.provider()); + }); + } + + @Test + @DisplayName(""" + Dto selectList 테스트: 사용자 이름이 양재서인 사용자의 username, name, phone 그리고 연동된 Oauth 정보를 조회한다. + HashMap을 사용하더라도 Dto의 setter를 명시하고 final 키워드를 제거하면 결과를 조회할 수 있다. + """) + @Transactional + public void selectListUseHashMap() { + // given + Predicate predicate = qUser.name.eq("양재서"); + + QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id)); + Sort sort = null; + + Map> bindings = new HashMap<>(); + + bindings.put("userId", qUser.id); + bindings.put("username", qUser.username); + bindings.put("name", qUser.name); + bindings.put("phone", qUser.phone); + bindings.put("oauthId", qOauth.id); + bindings.put("provider", qOauth.provider); + + // when + List userAndOauthInfos = userRepository.selectList(predicate, UserAndOauthInfoNotImmutable.class, bindings, queryHandler, sort); + // then userAndOauthInfos.forEach(userAndOauthInfo -> { log.debug("userAndOauthInfo: {}", userAndOauthInfo); @@ -218,9 +252,24 @@ private void bulkInsertOauth(Collection oauths) { jdbcTemplate.batchUpdate(sql, params); } + public record UserAndOauthInfo(Long userId, String username, String name, String phone, Long oauthId, + Provider provider) { + @Override + public String toString() { + return "UserAndOauthInfo{" + + "userId=" + userId + + ", username='" + username + '\'' + + ", name='" + name + '\'' + + ", phone='" + phone + '\'' + + ", oauthId=" + oauthId + + ", provider=" + provider + + '}'; + } + } + @Setter @Getter - public static class UserAndOauthInfo { + public static class UserAndOauthInfoNotImmutable { private Long userId; private String username; private String name; @@ -228,21 +277,12 @@ public static class UserAndOauthInfo { private Long oauthId; private Provider provider; - public UserAndOauthInfo() { - } - - public UserAndOauthInfo(Long userId, String username, String name, String phone, Long oauthId, Provider provider) { - this.userId = userId; - this.username = username; - this.name = name; - this.phone = phone; - this.oauthId = oauthId; - this.provider = provider; + public UserAndOauthInfoNotImmutable() { } @Override public String toString() { - return "UserAndOauthInfo{" + + return "UserAndOauthInfoNotImmutable{" + "userId=" + userId + ", username='" + username + '\'' + ", name='" + name + '\'' + From 509d5f2025b948889585dd237184ecf7332bb666 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sun, 12 May 2024 20:40:07 +0900 Subject: [PATCH 081/152] =?UTF-8?q?docs:=20=EC=A0=84=EC=B2=B4=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EA=B7=B8=EB=A3=B9=20=EC=B6=94=EA=B0=80=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../co/pennyway/api/config/SwaggerConfig.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java index 174c3bcbf..a3c63fbdc 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import lombok.RequiredArgsConstructor; +import org.springdoc.core.models.GroupedOpenApi; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; @@ -42,6 +43,56 @@ public OpenAPI openAPI() { .components(securitySchemes()); } + @Bean + public GroupedOpenApi allApi() { + String[] targets = {"kr.co.pennyway.api.apis"}; + + return GroupedOpenApi.builder() + .packagesToScan(targets) + .group("전체 보기") + .build(); + } + + @Bean + public GroupedOpenApi authApi() { + String[] targets = {"kr.co.pennyway.api.apis.auth"}; + + return GroupedOpenApi.builder() + .packagesToScan(targets) + .group("사용자 인증") + .build(); + } + + @Bean + public GroupedOpenApi userApi() { + String[] targets = {"kr.co.pennyway.api.apis.users"}; + + return GroupedOpenApi.builder() + .packagesToScan(targets) + .group("사용자 기본 기능") + .build(); + } + + @Bean + public GroupedOpenApi ledgerApi() { + String[] targets = {"kr.co.pennyway.api.apis.ledger"}; + + return GroupedOpenApi.builder() + .packagesToScan(targets) + .group("지출 관리") + .build(); + } + + @Bean + public GroupedOpenApi backOfficeApi() { + String[] targets = {"kr.co.pennyway.api.apis.question"}; + + return GroupedOpenApi.builder() + .packagesToScan(targets) + .group("백오피스") + .build(); + } + @Bean ForwardedHeaderFilter forwardedHeaderFilter() { return new ForwardedHeaderFilter(); From 9a55439f048639c5210ddb6b5d0d63363dff327b Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sun, 12 May 2024 20:49:58 +0900 Subject: [PATCH 082/152] =?UTF-8?q?=F0=9F=90=9B=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EA=B3=84=EC=A0=95=20=EC=82=AD=EC=A0=9C=20=ED=9B=84?= =?UTF-8?q?=20=EB=8F=99=EC=9D=BC=ED=95=9C=20OAuth=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=9D=B4=ED=9B=84=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20=20(#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: oauth_id, provider로 oauth 조회 시, deleted_at is null 조건 where 절 추가 * rename: 메서드에 deleted_at이 null인 경우만 조회함을 주석으로 명시 * fix: user_oauth_sign_service read_user 시 is_deleted 체크 제거 * test: domain service deleted_at 처리 대응 -> assert_true 대신 assert_null * test: oauth repository query 정상 동작 확인 --- .../auth/service/UserOauthSignService.java | 1 - .../UserAuthControllerIntegrationTest.java | 10 ++- .../oauth/repository/OauthRepository.java | 2 +- .../domains/oauth/service/OauthService.java | 5 +- .../oauth/repository/OauthRepositoryTest.java | 61 +++++++++++++++++++ 5 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java index a69e12acb..fea0b0300 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java @@ -29,7 +29,6 @@ public class UserOauthSignService { @Transactional(readOnly = true) public User readUser(String oauthId, Provider provider) { return oauthService.readOauthByOauthIdAndProvider(oauthId, provider) - .filter(o -> !o.isDeleted()) .map(Oauth::getUser) .orElse(null); } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java index 028da08c3..161ef355c 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java @@ -399,8 +399,7 @@ void unlinkWithOnlyOauthSignedUser() throws Exception { @DisplayName("연동된 Oauth가 1개이고 일반 회원 이력이 있는 경우에는 연동 해제에 성공한다.") void unlinkWithGeneralSignedUser() throws Exception { // given - User user = UserFixture.GENERAL_USER.toUser(); - userService.createUser(user); + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); Oauth oauth = mappingOauthWithUser(user, Provider.KAKAO); @@ -409,7 +408,7 @@ void unlinkWithGeneralSignedUser() throws Exception { // then result.andExpect(status().isOk()).andDo(print()); - assertTrue(oauthService.readOauthByOauthIdAndProvider(oauth.getOauthId(), Provider.KAKAO).get().isDeleted()); + assertNull(oauthService.readOauthByOauthIdAndProvider(oauth.getOauthId(), Provider.KAKAO).orElse(null)); } @Test @@ -419,8 +418,7 @@ void unlinkWithGeneralSignedUser() throws Exception { @DisplayName("연동된 Oauth가 2개 이상이고 일반 회원 이력이 없는 경우에는 연동 해제에 성공한다.") void unlinkWithMultipleOauthSignedUser() throws Exception { // given - User user = UserFixture.OAUTH_USER.toUser(); - userService.createUser(user); + User user = userService.createUser(UserFixture.OAUTH_USER.toUser()); Oauth kakao = mappingOauthWithUser(user, Provider.KAKAO); Oauth google = mappingOauthWithUser(user, Provider.GOOGLE); @@ -430,7 +428,7 @@ void unlinkWithMultipleOauthSignedUser() throws Exception { // then result.andExpect(status().isOk()).andDo(print()); - assertTrue(oauthService.readOauthByOauthIdAndProvider(kakao.getOauthId(), Provider.KAKAO).get().isDeleted()); + assertNull(oauthService.readOauthByOauthIdAndProvider(kakao.getOauthId(), Provider.KAKAO).orElse(null)); } private ResultActions performOauthUnlink(Provider provider) throws Exception { diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java index 928cf1a93..042c58cbc 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java @@ -11,7 +11,7 @@ import java.util.Set; public interface OauthRepository extends JpaRepository { - Optional findByOauthIdAndProvider(String oauthId, Provider provider); + Optional findByOauthIdAndProviderAndDeletedAtIsNull(String oauthId, Provider provider); Optional findByUser_IdAndProvider(Long userId, Provider provider); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java index b895294bb..775e334a3 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java @@ -25,9 +25,12 @@ public Optional readOauth(Long id) { return oauthRepository.findById(id); } + /** + * oauthId와 provider로 Oauth를 조회한다. 이 때, deletedAt이 null인 Oauth만 조회한다. + */ @Transactional(readOnly = true) public Optional readOauthByOauthIdAndProvider(String oauthId, Provider provider) { - return oauthRepository.findByOauthIdAndProvider(oauthId, provider); + return oauthRepository.findByOauthIdAndProviderAndDeletedAtIsNull(oauthId, provider); } @Transactional(readOnly = true) diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java new file mode 100644 index 000000000..3b0a887ec --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java @@ -0,0 +1,61 @@ +package kr.co.pennyway.domain.domains.oauth.repository; + +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = JpaConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +public class OauthRepositoryTest extends ContainerMySqlTestConfig { + @Autowired + private UserRepository userRepository; + + @Autowired + private OauthRepository oauthRepository; + + @Test + @DisplayName("soft delete된 다른 user_id를 가지면서, 같은 oauth_id, provider를 갖는 정보가 존재해도, 하나의 결과만을 반환한다.") + @Transactional + public void test() { + // given + User user = User.builder().username("jayang").name("Yang").phone("010-0000-0000").role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).locked(Boolean.FALSE).build(); + Oauth oauth = Oauth.of(Provider.KAKAO, "oauth_id", user); + + User newUser = User.builder().username("jayang").name("Yang").phone("010-0000-0000").role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).locked(Boolean.FALSE).build(); + Oauth newOauth = Oauth.of(Provider.KAKAO, "oauth_id", user); + + // when (소셜 회원가입 ⇾ 회원 탈퇴 ⇾ 동일 정보 소셜 회원가입 ⇾ 조회 성공) + userRepository.save(user); + oauthRepository.save(oauth); + log.debug("user: {}, oauth: {}", user, oauth); + + userRepository.delete(user); + oauthRepository.delete(oauth); + + userRepository.save(newUser); + oauthRepository.save(newOauth); + log.debug("newUser: {}, newOauth: {}", newUser, newOauth); + + // then + assertDoesNotThrow(() -> oauthRepository.findByOauthIdAndProviderAndDeletedAtIsNull(newOauth.getOauthId(), newOauth.getProvider())); + } +} From 5555b0e3514b397c58e3ce25e2fc173ce61dc66d Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 13 May 2024 10:06:55 +0900 Subject: [PATCH 083/152] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1=20=EC=8B=9C=20`@AuthenticatePrin?= =?UTF-8?q?cipal`=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=8C=80=EC=9D=91=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 인증 필터에서 token 파싱 후 id값 로깅 * feat: @with_security_mock_user 대신 .with(user())로 사용자 인증 대체 * fix: security_user_details get_password() 비검사 예외 발생 제거 -> null 반환 * test: user_auth_controller 통합 테스트 메서드 순서 및 seucurity mock user 어노테이션 제거 * refactor: 통합테스트 패키지를 controller -> integration으로 수정 * test: 테스트 메서드 순서 지정 어노테이션 제거 --- .../authentication/SecurityUserDetails.java | 2 +- .../filter/JwtAuthenticationFilter.java | 1 + .../AuthControllerIntegrationTest.java | 2 +- .../OAuthControllerIntegrationTest.java | 2 +- .../UserAuthControllerIntegrationTest.java | 84 +++++++------------ ...=> SpendingControllerIntegrationTest.java} | 58 ++++++------- 6 files changed, 65 insertions(+), 84 deletions(-) rename pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/{controller => integration}/AuthControllerIntegrationTest.java (99%) rename pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/{controller => integration}/OAuthControllerIntegrationTest.java (99%) rename pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/{controller => integration}/UserAuthControllerIntegrationTest.java (91%) rename pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/{SpendingUseCaseIntegrationTest.java => SpendingControllerIntegrationTest.java} (72%) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java index 6ec85b6ee..57240ffc1 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java @@ -57,7 +57,7 @@ public Collection getAuthorities() { @Override public String getPassword() { - throw new UnsupportedOperationException(); + return null; } @Override diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java index 79a08b871..9166f83fb 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/filter/JwtAuthenticationFilter.java @@ -101,6 +101,7 @@ private String resolveAccessToken(HttpServletRequest request, HttpServletRespons private UserDetails getUserDetails(String accessToken) { JwtClaims claims = accessTokenProvider.getJwtClaimsFromToken(accessToken); String userId = (String) claims.getClaims().get(AccessTokenClaimKeys.USER_ID.getValue()); + log.debug("User ID: {}", userId); return userDetailService.loadUserByUsername(userId); } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java similarity index 99% rename from pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java rename to pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java index c0f9de416..d54bd115c 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.api.apis.auth.controller; +package kr.co.pennyway.api.apis.auth.integration; import com.fasterxml.jackson.databind.ObjectMapper; import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java similarity index 99% rename from pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java rename to pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java index f4805e232..344406cb5 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.api.apis.auth.controller; +package kr.co.pennyway.api.apis.auth.integration; import com.fasterxml.jackson.databind.ObjectMapper; import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java similarity index 91% rename from pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java rename to pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java index 161ef355c..d14cf4125 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/UserAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java @@ -1,9 +1,10 @@ -package kr.co.pennyway.api.apis.auth.controller; +package kr.co.pennyway.api.apis.auth.integration; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.Cookie; import kr.co.pennyway.api.apis.auth.dto.SignInReq; import kr.co.pennyway.api.apis.auth.helper.OauthOidcHelper; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenProvider; import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaim; @@ -11,7 +12,6 @@ import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; import kr.co.pennyway.api.config.fixture.UserFixture; -import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService; import kr.co.pennyway.domain.common.redis.refresh.RefreshToken; import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; @@ -32,6 +32,7 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; @@ -42,6 +43,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -78,7 +80,6 @@ public class UserAuthControllerIntegrationTest extends ExternalApiDBTestConfig { @Nested @Order(1) - @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @DisplayName("로그아웃") class SignOut { private String expectedAccessToken; @@ -100,7 +101,6 @@ void setUp() { expectedRefreshToken = refreshTokenProvider.generateToken(RefreshTokenClaim.of(user.getId(), Role.USER.getType())); } - @Order(1) @Test @DisplayName("Scenario #1 유효한 accessToken과 refreshToken이 있다면, accessToken은 forbiddenToken으로, refreshToken은 삭제한다.") void validAccessTokenAndValidRefreshToken() throws Exception { @@ -118,7 +118,6 @@ void validAccessTokenAndValidRefreshToken() throws Exception { assertThrows(IllegalArgumentException.class, () -> refreshTokenService.delete(userId, expectedRefreshToken)); } - @Order(2) @Test @DisplayName("Scenario #2 유효한 accessToken만 존재한다면, accessToken만 forbiddenToken으로 만든다.") void validAccessTokenWithoutRefreshToken() throws Exception { @@ -130,7 +129,6 @@ void validAccessTokenWithoutRefreshToken() throws Exception { assertTrue(forbiddenTokenService.isForbidden(expectedAccessToken)); } - @Order(3) @Test @DisplayName("Scenario #2-1 유효한 accessToken과 다른 사용자의 유효한 refreshToken이 있다면, 401 에러를 반환한다. accessToken이 forbidden 처리되지 않으며, 사용자와 다른 사용자의 refreshToken 정보 모두 삭제되지 않는다.") void validAccessTokenAndWithOutOwnershipRefreshToken() throws Exception { @@ -154,7 +152,6 @@ void validAccessTokenAndWithOutOwnershipRefreshToken() throws Exception { assertFalse(forbiddenTokenService.isForbidden(expectedAccessToken)); } - @Order(4) @Test @DisplayName("Scenario #2-2 유효한 accessToken과 유효하지 않은 refreshToken이 있다면, 401 에러를 반환한다. accessToken이 forbidden 처리되지 않으며, refreshToken 정보는 삭제되지 않는다.") void validAccessTokenAndInvalidRefreshToken() throws Exception { @@ -177,7 +174,6 @@ void validAccessTokenAndInvalidRefreshToken() throws Exception { assertFalse(forbiddenTokenService.isForbidden(expectedAccessToken)); } - @Order(5) @Test @DisplayName("Scenario #2-3 유효한 accessToken, 유효한 refreshToken을 가진 사용자가 refresh 하기 전의 refreshToken을 사용하는 경우, accessToken을 forbidden에 등록하고 refreshToken을 cache에서 제거한다. (refreshToken 탈취 대체 시나리오)") void validAccessTokenAndOldRefreshToken() throws Exception { @@ -200,7 +196,6 @@ void validAccessTokenAndOldRefreshToken() throws Exception { assertTrue(forbiddenTokenService.isForbidden(expectedAccessToken)); } - @Order(6) @Test @DisplayName("Scenario #3 유효하지 않은 accessToken과 유효한 refreshToken이 있다면 401 에러를 반환한다.") void invalidAccessTokenAndValidRefreshToken() throws Exception { @@ -216,7 +211,6 @@ void invalidAccessTokenAndValidRefreshToken() throws Exception { result.andExpect(status().isUnauthorized()).andDo(print()); } - @Order(7) @Test @DisplayName("Scenario #4 유효하지 않은 accessToken과 유효하지 않은 refreshToken이 있다면 401 에러를 반환한다.") void invalidAccessTokenAndInvalidRefreshToken() throws Exception { @@ -235,45 +229,39 @@ private MockHttpServletRequestBuilder performSignOut() { } @Nested - @Order(2) @DisplayName("소셜 계정 연동") - @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class LinkOauth { - @Order(1) @Test @DisplayName("provider로 로그인한 이력이 없다면, 사용자는 계정 연동에 성공한다.") - @WithSecurityMockUser(userId = "8") @Transactional void linkOauthWithNoHistory() throws Exception { // given - User user = UserFixture.GENERAL_USER.toUser(); - userService.createUser(user); + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + Provider expectedProvider = Provider.KAKAO; given(oauthOidcHelper.getPayload(expectedProvider, "oauthId", "idToken", "nonce")).willReturn(new OidcDecodePayload("iss", "aud", "oauthId", "email")); // when - ResultActions result = performLinkOauth(expectedProvider, "oauthId"); + ResultActions result = performLinkOauth(expectedProvider, "oauthId", user); // then result.andExpect(status().isOk()).andDo(print()); assertTrue(oauthService.isExistOauthAccount(user.getId(), expectedProvider)); } - @Order(2) @Test @DisplayName("provider로 로그인한 이력이 있다면, 사용자는 계정 연동에 실패하고 409 에러를 반환한다.") - @WithSecurityMockUser(userId = "9") @Transactional void linkOauthWithHistory() throws Exception { // given - User user = UserFixture.GENERAL_USER.toUser(); - userService.createUser(user); + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + Provider expectedProvider = Provider.KAKAO; oauthService.createOauth(Oauth.of(expectedProvider, "oauthId", user)); given(oauthOidcHelper.getPayload(expectedProvider, "oauthId", "idToken", "nonce")).willReturn(new OidcDecodePayload("iss", "aud", "oauthId", "email")); // when - ResultActions result = performLinkOauth(expectedProvider, "oauthId"); + ResultActions result = performLinkOauth(expectedProvider, "oauthId", user); // then result.andExpect(status().isConflict()) @@ -282,23 +270,21 @@ void linkOauthWithHistory() throws Exception { .andDo(print()); } - @Order(3) @Test @DisplayName("해당 provider가 soft delete된 이력이 존재한다면, deleted_at을 null로 업데이트하고 최신 oauth_id를 반영하여 계정 연동에 성공한다.") - @WithSecurityMockUser(userId = "10") @Transactional void linkOauthWithDeletedHistory() throws Exception { // given - User user = UserFixture.GENERAL_USER.toUser(); - userService.createUser(user); + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + Provider expectedProvider = Provider.KAKAO; - Oauth oauth = Oauth.of(expectedProvider, "oauthId", user); - oauthService.createOauth(oauth); + Oauth oauth = oauthService.createOauth(Oauth.of(expectedProvider, "oauthId", user)); oauthService.deleteOauth(oauth); + given(oauthOidcHelper.getPayload(expectedProvider, "newOauthId", "idToken", "nonce")).willReturn(new OidcDecodePayload("iss", "aud", "newOauthId", "email")); // when - ResultActions result = performLinkOauth(expectedProvider, "newOauthId"); + ResultActions result = performLinkOauth(expectedProvider, "newOauthId", user); // then result.andExpect(status().isOk()).andDo(print()); @@ -309,11 +295,14 @@ void linkOauthWithDeletedHistory() throws Exception { log.info("연동된 Oauth 정보 : {}", savedOauth); } - private ResultActions performLinkOauth(Provider provider, String oauthId) throws Exception { + private ResultActions performLinkOauth(Provider provider, String oauthId, User requestUser) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); SignInReq.Oauth request = new SignInReq.Oauth(oauthId, "idToken", "nonce"); + return mockMvc.perform(put("/v1/link-oauth") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) + .with(user(userDetails)) .queryParam("provider", provider.name()) .content(objectMapper.writeValueAsString(request))); } @@ -322,20 +311,16 @@ private ResultActions performLinkOauth(Provider provider, String oauthId) throws @Nested @Order(5) @DisplayName("소셜 연동 해제") - @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class OauthUnlinkTest { @Test - @Order(1) - @WithSecurityMockUser(userId = "11") @Transactional @DisplayName("제공자로 연동한 이력이 존재하지 않으면 404 에러가 발생한다.") void unlinkWithNoOauth() throws Exception { // given - User user = UserFixture.GENERAL_USER.toUser(); - userService.createUser(user); + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); // when - ResultActions result = performOauthUnlink(Provider.KAKAO); + ResultActions result = performOauthUnlink(Provider.KAKAO, user); // then result @@ -346,20 +331,17 @@ void unlinkWithNoOauth() throws Exception { } @Test - @Order(2) - @WithSecurityMockUser(userId = "12") @Transactional @DisplayName("제공자로 연동한 이력이 soft delete 되어 있으면 404 에러가 발생한다.") void unlinkWithSoftDeletedOauth() throws Exception { // given - User user = UserFixture.GENERAL_USER.toUser(); - userService.createUser(user); + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); Oauth oauth = mappingOauthWithUser(user, Provider.KAKAO); oauthService.deleteOauth(oauth); // when - ResultActions result = performOauthUnlink(Provider.KAKAO); + ResultActions result = performOauthUnlink(Provider.KAKAO, user); // then result @@ -370,19 +352,16 @@ void unlinkWithSoftDeletedOauth() throws Exception { } @Test - @Order(3) - @WithSecurityMockUser(userId = "13") @Transactional @DisplayName("연동된 Oauth가 1개이고 일반 회원 이력이 없는 경우에는 409 에러가 발생한다.") void unlinkWithOnlyOauthSignedUser() throws Exception { // given - User user = UserFixture.OAUTH_USER.toUser(); - userService.createUser(user); + User user = userService.createUser(UserFixture.OAUTH_USER.toUser()); mappingOauthWithUser(user, Provider.KAKAO); // when - ResultActions result = performOauthUnlink(Provider.KAKAO); + ResultActions result = performOauthUnlink(Provider.KAKAO, user); // then result @@ -393,8 +372,6 @@ void unlinkWithOnlyOauthSignedUser() throws Exception { } @Test - @Order(4) - @WithSecurityMockUser(userId = "14") @Transactional @DisplayName("연동된 Oauth가 1개이고 일반 회원 이력이 있는 경우에는 연동 해제에 성공한다.") void unlinkWithGeneralSignedUser() throws Exception { @@ -404,7 +381,7 @@ void unlinkWithGeneralSignedUser() throws Exception { Oauth oauth = mappingOauthWithUser(user, Provider.KAKAO); // when - ResultActions result = performOauthUnlink(Provider.KAKAO); + ResultActions result = performOauthUnlink(Provider.KAKAO, user); // then result.andExpect(status().isOk()).andDo(print()); @@ -412,8 +389,6 @@ void unlinkWithGeneralSignedUser() throws Exception { } @Test - @Order(5) - @WithSecurityMockUser(userId = "15") @Transactional @DisplayName("연동된 Oauth가 2개 이상이고 일반 회원 이력이 없는 경우에는 연동 해제에 성공한다.") void unlinkWithMultipleOauthSignedUser() throws Exception { @@ -424,15 +399,18 @@ void unlinkWithMultipleOauthSignedUser() throws Exception { Oauth google = mappingOauthWithUser(user, Provider.GOOGLE); // when - ResultActions result = performOauthUnlink(Provider.KAKAO); + ResultActions result = performOauthUnlink(Provider.KAKAO, user); // then result.andExpect(status().isOk()).andDo(print()); assertNull(oauthService.readOauthByOauthIdAndProvider(kakao.getOauthId(), Provider.KAKAO).orElse(null)); } - private ResultActions performOauthUnlink(Provider provider) throws Exception { + private ResultActions performOauthUnlink(Provider provider, User requestUser) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + return mockMvc.perform(MockMvcRequestBuilders.delete("/v1/link-oauth") + .with(user(userDetails)) .param("provider", provider.name())); } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingUseCaseIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java similarity index 72% rename from pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingUseCaseIntegrationTest.java rename to pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java index bb5a90e8a..56454ef6c 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingUseCaseIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java @@ -2,13 +2,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; -import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; -import kr.co.pennyway.api.apis.ledger.usecase.SpendingUseCase; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; import kr.co.pennyway.api.config.fixture.SpendingFixture; import kr.co.pennyway.api.config.fixture.UserFixture; -import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; @@ -19,6 +17,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @@ -26,15 +25,16 @@ import java.time.LocalDate; -import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @Slf4j @ExternalApiIntegrationTest @AutoConfigureMockMvc @TestClassOrder(ClassOrderer.OrderAnnotation.class) -public class SpendingUseCaseIntegrationTest extends ExternalApiDBTestConfig { +public class SpendingControllerIntegrationTest extends ExternalApiDBTestConfig { @Autowired private MockMvc mockMvc; @@ -43,8 +43,6 @@ public class SpendingUseCaseIntegrationTest extends ExternalApiDBTestConfig { @Autowired private UserService userService; @Autowired - private SpendingUseCase spendingUseCase; - @Autowired private SpendingCustomCategoryService spendingCustomCategoryService; @Autowired private NamedParameterJdbcTemplate jdbcTemplate; @@ -52,12 +50,9 @@ public class SpendingUseCaseIntegrationTest extends ExternalApiDBTestConfig { @Order(1) @Nested @DisplayName("지출 내역 추가하기") - @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class CreateSpending { - @Order(1) @Test @DisplayName("request의 categoryId가 -1인 경우, spendingCustomCategory가 null인 Spending을 생성한다.") - @WithSecurityMockUser(userId = "1") @Transactional void createSpendingSuccess() throws Exception { // given @@ -65,18 +60,19 @@ void createSpendingSuccess() throws Exception { SpendingReq request = new SpendingReq(10000, -1L, SpendingCategory.FOOD, LocalDate.now(), "소비처", "메모"); // when - SpendingSearchRes.Individual result = spendingUseCase.createSpending(user.getId(), request); + ResultActions result = performCreateSpendingSuccess(request, user); // then - assertEquals("isCustom이 false이어야 한다.", false, result.category().isCustom()); - assertEquals("categoryId가 -1이어야 한다.", -1L, result.category().id()); - assertEquals("icon이 FOOD이어야 한다.", SpendingCategory.FOOD, result.category().icon()); + result.andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.spending.amount").value(10000)) + .andExpect(jsonPath("$.data.spending.category.isCustom").value(false)) + .andExpect(jsonPath("$.data.spending.category.id").value(-1)) + .andExpect(jsonPath("$.data.spending.category.icon").value(SpendingCategory.FOOD.name())); } - @Order(2) @Test @DisplayName("request의 categoryId가 -1이 아닌 경우, spendingCustomCategory를 참조하는 Spending을 생성한다.") - @WithSecurityMockUser(userId = "2") @Transactional void createSpendingWithCustomCategorySuccess() throws Exception { // given @@ -85,18 +81,19 @@ void createSpendingWithCustomCategorySuccess() throws Exception { SpendingReq request = new SpendingReq(10000, category.getId(), SpendingCategory.OTHER, LocalDate.now(), "소비처", "메모"); // when - SpendingSearchRes.Individual result = spendingUseCase.createSpending(user.getId(), request); + ResultActions result = performCreateSpendingSuccess(request, user); // then - assertEquals("isCustom이 true이어야 한다.", true, result.category().isCustom()); - assertEquals("categoryId가 spendingCustomCategory의 id와 같아야 한다.", category.getId(), result.category().id()); - assertEquals("icon이 spendingCustomCategory의 icon과 같아야 한다.", category.getIcon(), result.category().icon()); + result.andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.spending.amount").value(10000)) + .andExpect(jsonPath("$.data.spending.category.isCustom").value(true)) + .andExpect(jsonPath("$.data.spending.category.id").value(category.getId())) + .andExpect(jsonPath("$.data.spending.category.icon").value(category.getIcon().name())); } - @Order(3) @Test @DisplayName("사용자가 categoryId에 해당하는 카테고리 정보의 소유자가 아닌 경우, 403 Forbidden을 반환한다.") - @WithSecurityMockUser(userId = "3") @Transactional void createSpendingWithInvalidCustomCategory() throws Exception { // given @@ -104,16 +101,19 @@ void createSpendingWithInvalidCustomCategory() throws Exception { SpendingReq request = new SpendingReq(10000, 1000L, SpendingCategory.OTHER, LocalDate.now(), "소비처", "메모"); // when - ResultActions resultActions = performCreateSpendingSuccess(request); + ResultActions result = performCreateSpendingSuccess(request, user); // then - resultActions.andDo(print()).andExpect(status().isForbidden()); + result.andDo(print()).andExpect(status().isForbidden()); } - private ResultActions performCreateSpendingSuccess(SpendingReq req) throws Exception { + private ResultActions performCreateSpendingSuccess(SpendingReq req, User requestUser) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + return mockMvc.perform(MockMvcRequestBuilders .post("/v2/spendings") .contentType("application/json") + .with(user(userDetails)) .content(objectMapper.writeValueAsString(req))); } } @@ -124,7 +124,6 @@ private ResultActions performCreateSpendingSuccess(SpendingReq req) throws Excep class GetSpendingListAtYearAndMonth { @Test @DisplayName("월별 지출 내역 조회") - @WithSecurityMockUser(userId = "4") @Transactional void getSpendingListAtYearAndMonthSuccess() throws Exception { // given @@ -133,7 +132,7 @@ void getSpendingListAtYearAndMonthSuccess() throws Exception { // when long before = System.currentTimeMillis(); - ResultActions resultActions = performGetSpendingListAtYearAndMonthSuccess(); + ResultActions resultActions = performGetSpendingListAtYearAndMonthSuccess(user); long after = System.currentTimeMillis(); // then @@ -143,9 +142,12 @@ void getSpendingListAtYearAndMonthSuccess() throws Exception { log.debug("수행 시간: {}ms", after - before); } - private ResultActions performGetSpendingListAtYearAndMonthSuccess() throws Exception { + private ResultActions performGetSpendingListAtYearAndMonthSuccess(User requestUser) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); LocalDate now = LocalDate.now(); + return mockMvc.perform(MockMvcRequestBuilders.get("/v2/spendings") + .with(user(userDetails)) .param("year", String.valueOf(now.getYear())) .param("month", String.valueOf(now.getMonthValue()))); } From dfe87a1233247fa38a587c44e5f193a9849997cf Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 15 May 2024 19:13:31 +0900 Subject: [PATCH 084/152] =?UTF-8?q?=E2=9C=A8=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EB=85=84/=EC=9B=94=20=EB=B3=84=20=EC=A7=80=EC=B6=9C=20?= =?UTF-8?q?=EC=B4=9D=ED=95=A9=20=EB=B0=8F=20=EB=AA=A9=ED=91=9C=20=EA=B8=88?= =?UTF-8?q?=EC=95=A1=20=EC=A1=B0=ED=9A=8C=20API=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 목표 금액 조회 요청 파라미터 dto 추가 * feat: 목표 금액 조회 응답 dto 정의 * fix: date 쿼리 validation 추가 * feat: controller 단일 및 전체 내역 조회 * feat: domain module 사용자 년/월 별 총 지출 금액을 반환하는 dto 정의 * feat: spending custom repository 정의 && 사용자의 특정 년/월 총 지출 금액 조회 메서드 추가 * feat: 사용자 년/월 지출 금액 조회 usecas 반영 * fix: target_amount_usecase 목표 금액 리스트 조회 및 매퍼 클래스 사용 * feat: domain service 내 user_id로 목표 금액 설정 이력 조회 * fix: 목표 금액 및 총 지출 금액 조회 dto 이름 및 필드 수정 * fix: 전체 소비 금액 조회 optional ㅊ처리 * feat: 목표 금액 상세 정보를 위한 내부 dto 정의 * fix: dto null guard 구문 추가 * feat: to_with_total_spending_res mapper 메서드 추가 * feat: to_with_total_spendings_res mapper 메서드 추가 * fix: 날짜 범위 계산식 수정 * feat: target_amount entity to_string() 정의 * refactor: entity -> dto 매핑을 위해 자료구조를 list -> map으로 변경 * refactor: target_amount_mapper 함수 분리 및 메서드 명 일부 수정 * rename: target_amount_info dto 정적 팩토리 메서드 주석 추가 * rename: with_total_spending_res diff_amount 필드 notnull 조건 추가 * feat: view type 열거 타입 정의 * feat: view_type_converter 정의 및 web_config 등록 * fix: 단일 조회 시 path_parameter로 date 받도록 수정 * fix: view_type 제거 * docs: controller 스웨거 문서 작성 * fix: use_case 내 불필요한 종속성 제거 * test: user_fixture 사용자 가입일 지정하여 저장하는 메서드 추가 * fix: spending_fixture 소비일 랜덤 생성 로직 수정 * test: target_amount fixture 정의 * test: user created_at 필드를 업데이트하는 메서드로 수정 * test: 사용자 임의의 년/월 지출 총합 및 목표 금액 조회 테스트 * fix: 임의의 년/월 조회 시 group by 절 추가 * fix: 모든 기록 조회 쿼리에서 sort 조건 수정 * chore: domain.yml 테스트 환경에서 jdbc 로그 debug로 설정 * test: month 길이 계산 로직 수정 * fix: mapper 내에서 month_length 길이 계산 로직 수정 * test: user_account_use_case_test jpa_query_factory mock bean 주입 * chore: jpa_query_factory 테스트 환경 설정용 빈 등록 * test: jpa_query_factory 빈 주입 * fix: target_amount_mapper @required_args_contstructor 제거 --- .../api/apis/ledger/api/TargetAmountApi.java | 25 +++- .../controller/TargetAmountController.java | 24 +++- .../api/apis/ledger/dto/TargetAmountDto.java | 67 ++++++++++ .../ledger/mapper/TargetAmountMapper.java | 95 +++++++++++++++ .../ledger/usecase/TargetAmountUseCase.java | 34 ++++++ ...TargetAmountControllerIntegrationTest.java | 114 ++++++++++++++++++ .../users/usecase/UserAccountUseCaseTest.java | 6 +- .../api/config/fixture/SpendingFixture.java | 14 ++- .../config/fixture/TargetAmountFixture.java | 60 +++++++++ .../api/config/fixture/UserFixture.java | 18 +++ .../spending/dto/TotalSpendingAmount.java | 16 +++ .../repository/SpendingCustomRepository.java | 9 ++ .../SpendingCustomRepositoryImpl.java | 40 ++++++ .../repository/SpendingRepository.java | 2 +- .../spending/service/SpendingService.java | 33 +++++ .../domains/target/domain/TargetAmount.java | 5 + .../repository/TargetAmountRepository.java | 6 + .../target/service/TargetAmountService.java | 6 + .../src/main/resources/application-domain.yml | 6 +- .../pennyway/domain/config/TestJpaConfig.java | 18 +++ .../oauth/repository/OauthRepositoryTest.java | 5 + .../UserExtendedRepositoryTest.java | 7 +- .../user/repository/UserSoftDeleteTest.java | 5 + 23 files changed, 596 insertions(+), 19 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountControllerIntegrationTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/TargetAmountFixture.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java index 268a779e4..526ea269a 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java @@ -1,14 +1,20 @@ package kr.co.pennyway.api.apis.ledger.api; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.*; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import kr.co.pennyway.api.apis.ledger.dto.TargetAmountDto; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; + +import java.time.LocalDate; @Tag(name = "목표금액 API") public interface TargetAmountApi { @@ -22,4 +28,19 @@ public interface TargetAmountApi { """) })) ResponseEntity putTargetAmount(TargetAmountDto.UpdateParamReq request, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "임의의 년/월에 대한 목표 금액 및 총 사용 금액 조회", method = "GET", description = "일수는 무시하고 년/월 정보만 사용한다. 일반적으로 당월 정보 요청에 사용하는 API이다.") + @Parameter(name = "date", description = "현재 날짜(yyyy-MM-dd)", required = true, example = "2024-05-08", in = ParameterIn.PATH) + @ApiResponse(responseCode = "200", description = "목표 금액 및 총 사용 금액 조회 성공", content = @Content( + schemaProperties = @SchemaProperty(name = "targetAmount", schema = @Schema(implementation = TargetAmountDto.WithTotalSpendingRes.class)))) + ResponseEntity getTargetAmountAndTotalSpending(@PathVariable LocalDate date, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "사용자 가입 이후 현재까지의 목표 금액 및 총 사용 금액 리스트 조회", method = "GET", description = "일수는 무시하고 년/월 정보만 사용한다. 데이터가 존재하지 않을 때 더미 값을 사용하며, 최신 데이터 순으로 정렬된 응답을 반환한다.") + @Parameters({ + @Parameter(name = "date", description = "현재 날짜(yyyy-MM-dd)", required = true, in = ParameterIn.QUERY), + @Parameter(name = "param", hidden = true) + }) + @ApiResponse(responseCode = "200", description = "목표 금액 및 총 사용 금액 리스트 조회 성공", content = @Content( + schemaProperties = @SchemaProperty(name = "targetAmounts", array = @ArraySchema(schema = @Schema(implementation = TargetAmountDto.WithTotalSpendingRes.class))))) + ResponseEntity getTargetAmountsAndTotalSpendings(@Validated TargetAmountDto.GetParamReq param, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java index ea7cefb2b..fae379e2c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java @@ -13,9 +13,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.time.LocalDate; @@ -29,15 +27,29 @@ public class TargetAmountController implements TargetAmountApi { @Override @PutMapping("") @PreAuthorize("isAuthenticated()") - public ResponseEntity putTargetAmount(@Validated TargetAmountDto.UpdateParamReq request, @AuthenticationPrincipal SecurityUserDetails user) { - if (!isValidDateForYearAndMonth(request.date())) { + public ResponseEntity putTargetAmount(@Validated TargetAmountDto.UpdateParamReq param, @AuthenticationPrincipal SecurityUserDetails user) { + if (!isValidDateForYearAndMonth(param.date())) { throw new TargetAmountErrorException(TargetAmountErrorCode.INVALID_TARGET_AMOUNT_DATE); } - targetAmountUseCase.updateTargetAmount(user.getUserId(), request.date(), request.amount()); + targetAmountUseCase.updateTargetAmount(user.getUserId(), param.date(), param.amount()); return ResponseEntity.ok(SuccessResponse.noContent()); } + @Override + @GetMapping("/{date}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getTargetAmountAndTotalSpending(@PathVariable LocalDate date, @AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from("targetAmount", targetAmountUseCase.getTargetAmountAndTotalSpending(user.getUserId(), date))); + } + + @Override + @GetMapping("") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getTargetAmountsAndTotalSpendings(@Validated TargetAmountDto.GetParamReq param, @AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from("targetAmounts", targetAmountUseCase.getTargetAmountsAndTotalSpendings(user.getUserId(), param.date()))); + } + private boolean isValidDateForYearAndMonth(LocalDate date) { LocalDate now = LocalDate.now(); return date.getYear() == now.getYear() && date.getMonth() == now.getMonth(); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java index 62074b583..8e7ce42bb 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java @@ -6,6 +6,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import lombok.Builder; import java.time.LocalDate; @@ -24,4 +27,68 @@ public record UpdateParamReq( ) { } + + @Schema(title = "목표 금액 조회 요청 파라미터", hidden = true) + public record GetParamReq( + @Schema(description = "조회하려는 목표 금액 날짜 (당일)", example = "2024-05-08", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "date 값은 필수입니다.") + @JsonSerialize(using = LocalDateSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd") + @PastOrPresent(message = "date 값은 과거 또는 현재 날짜여야 합니다.") + LocalDate date + ) { + + } + + @Builder + @Schema(title = "목표 금액 및 총 지출 금액 조회 응답") + public record WithTotalSpendingRes( + @Schema(description = "조회 년도", example = "2024", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "year 값은 필수입니다.") + Integer year, + @Schema(description = "조회 월", example = "5", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "month 값은 필수입니다.") + Integer month, + @Schema(description = "목표 금액", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "targetAmount 값은 필수입니다.") + TargetAmountInfo targetAmount, + @Schema(description = "총 지출 금액", example = "100000", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "totalSpending 값은 필수입니다.") + Integer totalSpending, + @Schema(description = "목표 금액과 총 지출 금액의 차액(총 치줄 금액 - 목표 금액). 양수면 초과, 음수면 절약", example = "-50000", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "diffAmount 값은 필수입니다.") + Integer diffAmount + ) { + } + + @Schema(title = "목표 금액 상세 정보") + public record TargetAmountInfo( + @Schema(description = "목표 금액 pk. 실제 저장된 데이터가 아니라면 -1", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "id 값은 필수입니다.") + Long id, + @Schema(description = "목표 금액. -1이면 설정한 목표 금액이 존재하지 않음을 의미한다.", example = "50000", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "amount 값은 필수입니다.") + Integer amount + ) { + public TargetAmountInfo { + if (id == null) { + id = -1L; + } + + if (amount == null) { + amount = -1; + } + } + + /** + * {@link TargetAmount} -> {@link TargetAmountInfo} 변환하는 메서드
+ * 만약, 인자로 들어온 값이 null이라면 모든 값을 -1로 초기화한 더미 데이터를 반환한다. + */ + public static TargetAmountInfo from(TargetAmount targetAmount) { + if (targetAmount == null) { + return new TargetAmountInfo(-1L, -1); + } + return new TargetAmountInfo(targetAmount.getId(), targetAmount.getAmount()); + } + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java new file mode 100644 index 000000000..cd9fbb434 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java @@ -0,0 +1,95 @@ +package kr.co.pennyway.api.apis.ledger.mapper; + +import kr.co.pennyway.api.apis.ledger.dto.TargetAmountDto; +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Mapper +public class TargetAmountMapper { + /** + * TargetAmount와 TotalSpendingAmount를 이용하여 WithTotalSpendingRes를 생성한다. + * + * @param targetAmount {@link TargetAmount} : 값이 없을 경우 null + * @param totalSpending {@link TotalSpendingAmount} : 값이 없을 경우 null + */ + public static TargetAmountDto.WithTotalSpendingRes toWithTotalSpendingResponse(TargetAmount targetAmount, TotalSpendingAmount totalSpending, LocalDate date) { + Integer totalSpendingAmount = (totalSpending != null) ? totalSpending.totalSpending() : 0; + + return createWithTotalSpendingRes(targetAmount, totalSpendingAmount, date); + } + + /** + * TargetAmount와 TotalSpendingAmount를 이용하여 WithTotalSpendingRes 리스트를 생성한다.
+ * startAt부터 endAt까지의 날짜에 대한 WithTotalSpendingRes를 생성하며, 임의의 날짜에 대한 정보가 없을 경우 더미 데이터를 생성한다. + * + * @param startAt : 조회 시작 날짜. 이유가 없다면 사용자 생성 날짜를 사용한다. + * @param endAt : 조회 종료 날짜. 이유가 없다면 현재 날짜이며, 클라이언트로 부터 받은 날짜를 사용한다. + */ + public static List toWithTotalSpendingResponses(List targetAmounts, List totalSpendings, LocalDate startAt, LocalDate endAt) { + int monthLength = (endAt.getYear() - startAt.getYear()) * 12 + (endAt.getMonthValue() - startAt.getMonthValue()); + + Map targetAmountsByDates = toYearMonthMap(targetAmounts, targetAmount -> YearMonth.of(targetAmount.getCreatedAt().getYear(), targetAmount.getCreatedAt().getMonthValue()), Function.identity()); + Map totalSpendingAmounts = toYearMonthMap(totalSpendings, totalSpendingAmount -> YearMonth.of(totalSpendingAmount.year(), totalSpendingAmount.month()), TotalSpendingAmount::totalSpending); + + return createWithTotalSpendingResponses(targetAmountsByDates, totalSpendingAmounts, startAt, monthLength).stream() + .sorted(Comparator.comparing(TargetAmountDto.WithTotalSpendingRes::year).reversed() + .thenComparing(Comparator.comparing(TargetAmountDto.WithTotalSpendingRes::month).reversed())) + .toList(); + } + + private static List createWithTotalSpendingResponses(Map targetAmounts, Map totalSpendings, LocalDate startAt, int monthLength) { + List withTotalSpendingResponses = new ArrayList<>(monthLength + 1); + + for (int i = 0; i < monthLength + 1; i++) { + LocalDate date = startAt.plusMonths(i); + YearMonth yearMonth = YearMonth.of(date.getYear(), date.getMonthValue()); + + TargetAmount targetAmount = targetAmounts.getOrDefault(yearMonth, null); + Integer totalSpending = totalSpendings.getOrDefault(yearMonth, 0); + + withTotalSpendingResponses.add(createWithTotalSpendingRes(targetAmount, totalSpending, date)); + } + + return withTotalSpendingResponses; + } + + private static TargetAmountDto.WithTotalSpendingRes createWithTotalSpendingRes(TargetAmount targetAmount, Integer totalSpending, LocalDate date) { + TargetAmountDto.TargetAmountInfo targetAmountInfo = TargetAmountDto.TargetAmountInfo.from(targetAmount); + + return TargetAmountDto.WithTotalSpendingRes.builder() + .year(date.getYear()) + .month(date.getMonthValue()) + .targetAmount(targetAmountInfo) + .totalSpending(totalSpending) + .diffAmount((targetAmountInfo.amount() == -1) ? 0 : totalSpending - targetAmountInfo.amount()) + .build(); + } + + /** + * List를 YearMonth를 key로 하는 Map으로 변환한다. + * + * @param keyMapper : YearMonth로 변환할 Function + * @param valueMapper : Value로 변환할 Function + */ + private static Map toYearMonthMap(List list, Function keyMapper, Function valueMapper) { + return list.stream().collect( + Collectors.toMap( + keyMapper, + valueMapper, + (existing, replacement) -> existing + ) + ); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java index f56756458..214de1bec 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java @@ -1,21 +1,55 @@ package kr.co.pennyway.api.apis.ledger.usecase; +import kr.co.pennyway.api.apis.ledger.dto.TargetAmountDto; +import kr.co.pennyway.api.apis.ledger.mapper.TargetAmountMapper; import kr.co.pennyway.api.apis.ledger.service.TargetAmountSaveService; import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.target.service.TargetAmountService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.List; +import java.util.Optional; @Slf4j @UseCase @RequiredArgsConstructor public class TargetAmountUseCase { + private final UserService userService; + private final TargetAmountService targetAmountService; + private final SpendingService spendingService; + private final TargetAmountSaveService targetAmountSaveService; @Transactional public void updateTargetAmount(Long userId, LocalDate date, Integer amount) { targetAmountSaveService.saveTargetAmount(userId, date, amount); } + + @Transactional(readOnly = true) + public TargetAmountDto.WithTotalSpendingRes getTargetAmountAndTotalSpending(Long userId, LocalDate date) { + Optional targetAmount = targetAmountService.readTargetAmountThatMonth(userId, date); + Optional totalSpending = spendingService.readTotalSpendingAmountByUserId(userId, date); + + return TargetAmountMapper.toWithTotalSpendingResponse(targetAmount.orElse(null), totalSpending.orElse(null), date); + } + + @Transactional(readOnly = true) + public List getTargetAmountsAndTotalSpendings(Long userId, LocalDate date) { + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + + List targetAmounts = targetAmountService.readTargetAmountsByUserId(userId); + List totalSpendings = spendingService.readTotalSpendingsAmountByUserId(userId); + + return TargetAmountMapper.toWithTotalSpendingResponses(targetAmounts, totalSpendings, user.getCreatedAt().toLocalDate(), date); + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountControllerIntegrationTest.java new file mode 100644 index 000000000..9a057b9d1 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountControllerIntegrationTest.java @@ -0,0 +1,114 @@ +package kr.co.pennyway.api.apis.ledger.integration; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.SpendingFixture; +import kr.co.pennyway.api.config.fixture.TargetAmountFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Slf4j +@ExternalApiIntegrationTest +@AutoConfigureMockMvc +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +public class TargetAmountControllerIntegrationTest extends ExternalApiDBTestConfig { + @Autowired + private MockMvc mockMvc; + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + @Autowired + private UserService userService; + @PersistenceContext + private EntityManager em; + + private User createUserWithCreatedAt(LocalDateTime createdAt, NamedParameterJdbcTemplate jdbcTemplate) { + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + Long userId = user.getId(); + + UserFixture.updateUserCreatedAt(user, createdAt, jdbcTemplate); + em.flush(); + em.clear(); + + return userService.readUser(userId).orElseThrow(); + } + + @Order(1) + @Nested + @DisplayName("임의의 년/월에 대한 사용자 목표 금액 및 지출 총합 조회") + class GetTargetAmountAndTotalSpending { + @Test + @DisplayName("특정 년/월에 대한 사용자 목표 금액 및 지출 총합 조회") + @Transactional + void getTargetAmountAndTotalSpending() throws Exception { + // given + User user = createUserWithCreatedAt(LocalDateTime.now().minusYears(2), jdbcTemplate); + SpendingFixture.bulkInsertSpending(user, 300, jdbcTemplate); + TargetAmountFixture.bulkInsertTargetAmount(user, jdbcTemplate); + + // when + ResultActions result = performGetTargetAmountAndTotalSpending(user, LocalDate.now()); + + // then + result.andDo(print()) + .andExpect(status().isOk()); + } + + private ResultActions performGetTargetAmountAndTotalSpending(User requestUser, LocalDate date) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + + return mockMvc.perform(MockMvcRequestBuilders.get("/v2/targets/{date}", date) + .with(user(userDetails))); + } + } + + @Order(2) + @Nested + @DisplayName("사용자 목표 금액 및 지출 총합 전체 기록 조회") + class GetTargetAmountsAndTotalSpendings { + @Test + @DisplayName("사용자 목표 금액 및 지출 총합 조회") + @Transactional + void getTargetAmountsAndTotalSpendings() throws Exception { + // given + User user = createUserWithCreatedAt(LocalDateTime.now().minusYears(2).plusMonths(2), jdbcTemplate); + SpendingFixture.bulkInsertSpending(user, 300, jdbcTemplate); + TargetAmountFixture.bulkInsertTargetAmount(user, jdbcTemplate); + + // when + ResultActions result = performGetTargetAmountsAndTotalSpendings(user, LocalDate.now()); + + // then + result.andDo(print()) + .andExpect(status().isOk()); + } + + private ResultActions performGetTargetAmountsAndTotalSpendings(User requestUser, LocalDate date) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + + return mockMvc.perform(MockMvcRequestBuilders.get("/v2/targets") + .with(user(userDetails)) + .param("date", date.toString())); + } + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java index ff6cd83d4..567939fc7 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java @@ -1,5 +1,6 @@ package kr.co.pennyway.api.apis.users.usecase; +import com.querydsl.jpa.impl.JPAQueryFactory; import kr.co.pennyway.api.apis.users.dto.DeviceDto; import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; @@ -29,7 +30,6 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; import org.springframework.transaction.annotation.Transactional; @@ -47,7 +47,6 @@ JpaConfig.class, UserAccountUseCase.class, DeviceRegisterService.class, UserProfileUpdateService.class, UserDeleteService.class, UserService.class, DeviceService.class, OauthService.class}) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@ActiveProfiles("test") @TestClassOrder(ClassOrderer.OrderAnnotation.class) class UserAccountUseCaseTest extends ExternalApiDBTestConfig { @Autowired @@ -65,6 +64,9 @@ class UserAccountUseCaseTest extends ExternalApiDBTestConfig { @MockBean private PasswordEncoderHelper passwordEncoderHelper; + @MockBean + private JPAQueryFactory queryFactory; + @Order(1) @Nested @DisplayName("[1] 디바이스 등록 테스트") diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java index 6941f9144..0807f431a 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java @@ -37,7 +37,7 @@ private static List getRandomSpendings(User user, int capacity) { spending.add(Spending.builder() .amount(ThreadLocalRandom.current().nextInt(100, 10000001)) .category(SpendingCategory.FOOD) - .spendAt(getRandomSpendAt()) + .spendAt(getRandomSpendAt(user)) .accountName(getRandomAccountName()) .memo((i % 5 == 0) ? "메모" : null) .user(user) @@ -49,10 +49,14 @@ private static List getRandomSpendings(User user, int capacity) { return spending; } - private static LocalDateTime getRandomSpendAt() { - LocalDate now = LocalDate.now(); - int year = now.getYear(), month = now.getMonthValue(); - int day = ThreadLocalRandom.current().nextInt(1, now.lengthOfMonth() + 1); + private static LocalDateTime getRandomSpendAt(User user) { + LocalDate startAt = user.getCreatedAt().toLocalDate(); + LocalDate endAt = LocalDate.now(); + + int year = ThreadLocalRandom.current().nextInt(startAt.getYear(), endAt.getYear() + 1); + int month = (year == endAt.getYear()) ? ThreadLocalRandom.current().nextInt(1, endAt.getMonthValue() + 1) : ThreadLocalRandom.current().nextInt(1, 13); + int day = ThreadLocalRandom.current().nextInt(1, 29); + return LocalDateTime.of(year, month, day, 0, 0, 0); } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/TargetAmountFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/TargetAmountFixture.java new file mode 100644 index 000000000..a950b46d2 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/TargetAmountFixture.java @@ -0,0 +1,60 @@ +package kr.co.pennyway.api.config.fixture; + +import kr.co.pennyway.domain.domains.user.domain.User; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +public class TargetAmountFixture { + private static final String TARGET_AMOUNT_TABLE = "target_amount"; + + public static void bulkInsertTargetAmount(User user, NamedParameterJdbcTemplate jdbcTemplate) { + Collection targetAmounts = getRandomTargetAmounts(user); + + String sql = String.format(""" + INSERT INTO `%s` (amount, user_id, created_at, updated_at) + VALUES (:amount, :userId, :createdAt, :updatedAt) + """, TARGET_AMOUNT_TABLE); + SqlParameterSource[] params = targetAmounts.stream() + .map(mockTargetAmount -> new MapSqlParameterSource() + .addValue("amount", mockTargetAmount.amount) + .addValue("userId", mockTargetAmount.userId) + .addValue("createdAt", mockTargetAmount.createdAt) + .addValue("updatedAt", mockTargetAmount.updatedAt)) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, params); + } + + /** + * 사용자의 임의의 년/월에 대한 목표 금액을 생성하는 메서드 (짝수 달에만 생성) + */ + private static List getRandomTargetAmounts(User user) { + List targetAmounts = new ArrayList<>(); + LocalDate startAt = user.getCreatedAt().toLocalDate(), endAt = LocalDate.now(); + int monthLength = (endAt.getYear() - startAt.getYear()) * 12 + (endAt.getMonthValue() - startAt.getMonthValue()); + + for (int i = 0; i < monthLength + 1; i += 2) { + targetAmounts.add(MockTargetAmount.of( + ThreadLocalRandom.current().nextInt(100, 10000001), + LocalDateTime.of(startAt.plusMonths(i).getYear(), startAt.plusMonths(i).getMonth(), 1, 0, 0, 0), + LocalDateTime.of(startAt.plusMonths(i).getYear(), startAt.plusMonths(i).getMonth(), 1, 0, 0, 0), + user.getId() + )); + } + + return targetAmounts; + } + + private record MockTargetAmount(int amount, LocalDateTime createdAt, LocalDateTime updatedAt, Long userId) { + public static MockTargetAmount of(int amount, LocalDateTime createdAt, LocalDateTime updatedAt, Long userId) { + return new MockTargetAmount(amount, createdAt, updatedAt, userId); + } + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java index c96e3851e..53e7201fa 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java @@ -5,7 +5,11 @@ import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; import kr.co.pennyway.domain.domains.user.type.Role; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import java.time.LocalDateTime; import java.util.List; public enum UserFixture { @@ -42,6 +46,20 @@ public static SecurityUserDetails createSecurityUser(Long userId) { .build(); } + /** + * 사용자의 가입일을 수정하는 메서드 + */ + public static void updateUserCreatedAt(User user, LocalDateTime createdAt, NamedParameterJdbcTemplate jdbcTemplate) { + String sql = "UPDATE user SET created_at = :createdAt WHERE id = :id"; + + SqlParameterSource[] params = new SqlParameterSource[1]; + params[0] = new MapSqlParameterSource() + .addValue("createdAt", createdAt) + .addValue("id", user.getId()); + + jdbcTemplate.batchUpdate(sql, params); + } + public User toUser() { return User.builder() .username(username) diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java new file mode 100644 index 000000000..21f7db28d --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java @@ -0,0 +1,16 @@ +package kr.co.pennyway.domain.domains.spending.dto; + +/** + * 사용자의 해당 년/월 총 지출 금액을 담는 DTO + */ +public record TotalSpendingAmount( + Integer year, + Integer month, + Integer totalSpending +) { + public TotalSpendingAmount(Integer year, Integer month, Integer totalSpending) { + this.year = year; + this.month = month; + this.totalSpending = totalSpending; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java new file mode 100644 index 000000000..8687e8589 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java @@ -0,0 +1,9 @@ +package kr.co.pennyway.domain.domains.spending.repository; + +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; + +import java.util.Optional; + +public interface SpendingCustomRepository { + Optional findTotalSpendingAmountByUserId(Long userId, int year, int month); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java new file mode 100644 index 000000000..7b6ed3ade --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java @@ -0,0 +1,40 @@ +package kr.co.pennyway.domain.domains.spending.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.domain.domains.spending.domain.QSpending; +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class SpendingCustomRepositoryImpl implements SpendingCustomRepository { + private final JPAQueryFactory queryFactory; + + private final QUser user = QUser.user; + private final QSpending spending = QSpending.spending; + + @Override + public Optional findTotalSpendingAmountByUserId(Long userId, int year, int month) { + TotalSpendingAmount result = queryFactory.select( + Projections.constructor( + TotalSpendingAmount.class, + spending.spendAt.year(), + spending.spendAt.month(), + spending.amount.sum() + ) + ).from(user) + .leftJoin(spending).on(user.id.eq(spending.user.id)) + .where(user.id.eq(userId) + .and(spending.spendAt.year().eq(year)) + .and(spending.spendAt.month().eq(month))) + .groupBy(spending.spendAt.year(), spending.spendAt.month()) + .fetchOne(); + + return Optional.ofNullable(result); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java index 64c8302d3..766163aeb 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java @@ -3,5 +3,5 @@ import kr.co.pennyway.domain.common.repository.ExtendedRepository; import kr.co.pennyway.domain.domains.spending.domain.Spending; -public interface SpendingRepository extends ExtendedRepository { +public interface SpendingRepository extends ExtendedRepository, SpendingCustomRepository { } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java index 583c8875a..d9af37b08 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -1,16 +1,24 @@ package kr.co.pennyway.domain.domains.spending.service; +import com.querydsl.core.types.Expression; import com.querydsl.core.types.Predicate; import kr.co.pennyway.common.annotation.DomainService; import kr.co.pennyway.domain.common.repository.QueryHandler; +import kr.co.pennyway.domain.domains.spending.domain.QSpending; import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; import kr.co.pennyway.domain.domains.spending.repository.SpendingRepository; +import kr.co.pennyway.domain.domains.user.domain.QUser; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Sort; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; @Slf4j @DomainService @@ -18,6 +26,9 @@ public class SpendingService { private final SpendingRepository spendingRepository; + private final QUser user = QUser.user; + private final QSpending spending = QSpending.spending; + @Transactional public Spending createSpending(Spending spending) { return spendingRepository.save(spending); @@ -27,4 +38,26 @@ public Spending createSpending(Spending spending) { public List readSpendings(Predicate predicate, QueryHandler queryHandler, Sort sort) { return spendingRepository.findList(predicate, queryHandler, sort); } + + @Transactional(readOnly = true) + public Optional readTotalSpendingAmountByUserId(Long userId, LocalDate date) { + return spendingRepository.findTotalSpendingAmountByUserId(userId, date.getYear(), date.getMonthValue()); + } + + @Transactional(readOnly = true) + public List readTotalSpendingsAmountByUserId(Long userId) { + Predicate predicate = user.id.eq(userId); + + QueryHandler queryHandler = query -> query.leftJoin(spending).on(user.id.eq(spending.user.id)) + .groupBy(spending.spendAt.year(), spending.spendAt.month()); + + Sort sort = Sort.by(Sort.Order.desc("year(spendAt)"), Sort.Order.desc("month(spendAt)")); + + Map> bindings = new LinkedHashMap<>(); + bindings.put("year", spending.spendAt.year()); + bindings.put("month", spending.spendAt.month()); + bindings.put("totalSpending", spending.amount.sum()); + + return spendingRepository.selectList(predicate, TotalSpendingAmount.class, bindings, queryHandler, sort); + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java index 032836287..64926653a 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java @@ -40,4 +40,9 @@ public void updateAmount(Integer amount) { public boolean isAllocatedAmount() { return this.amount >= 0; } + + @Override + public String toString() { + return "TargetAmount(id=" + this.getId() + ", amount=" + this.getAmount() + ")"; + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java index c441bd791..b6fcdc8ea 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java @@ -3,11 +3,17 @@ import kr.co.pennyway.domain.common.repository.ExtendedRepository; import kr.co.pennyway.domain.domains.target.domain.TargetAmount; import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.List; import java.util.Optional; public interface TargetAmountRepository extends ExtendedRepository { + @Transactional(readOnly = true) @Query("SELECT ta FROM TargetAmount ta WHERE ta.user.id = :userId AND YEAR(ta.createdAt) = YEAR(:date) AND MONTH(ta.createdAt) = MONTH(:date)") Optional findByUserIdThatMonth(Long userId, LocalDate date); + + @Transactional(readOnly = true) + List findByUser_Id(Long userId); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java index 965eeeaae..61fc452d3 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java @@ -8,6 +8,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.List; import java.util.Optional; @Slf4j @@ -25,4 +26,9 @@ public TargetAmount createTargetAmount(TargetAmount targetAmount) { public Optional readTargetAmountThatMonth(Long userId, LocalDate date) { return targetAmountRepository.findByUserIdThatMonth(userId, date); } + + @Transactional(readOnly = true) + public List readTargetAmountsByUserId(Long userId) { + return targetAmountRepository.findByUser_Id(userId); + } } diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml index afa6c4277..462eec76f 100644 --- a/pennyway-domain/src/main/resources/application-domain.yml +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -77,4 +77,8 @@ spring: show-sql: true properties: hibernate: - dialect: org.hibernate.dialect.MySQLDialect \ No newline at end of file + dialect: org.hibernate.dialect.MySQLDialect + +logging: + level: + org.springframework.jdbc: debug \ No newline at end of file diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java new file mode 100644 index 000000000..da92ea51f --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java @@ -0,0 +1,18 @@ +package kr.co.pennyway.domain.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class TestJpaConfig { + @PersistenceContext + private EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java index 3b0a887ec..e60a46286 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java @@ -1,5 +1,6 @@ package kr.co.pennyway.domain.domains.oauth.repository; +import com.querydsl.jpa.impl.JPAQueryFactory; import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; import kr.co.pennyway.domain.config.JpaConfig; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; @@ -14,6 +15,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; import org.springframework.transaction.annotation.Transactional; @@ -32,6 +34,9 @@ public class OauthRepositoryTest extends ContainerMySqlTestConfig { @Autowired private OauthRepository oauthRepository; + @MockBean + private JPAQueryFactory jpaQueryFactory; + @Test @DisplayName("soft delete된 다른 user_id를 가지면서, 같은 oauth_id, provider를 갖는 정보가 존재해도, 하나의 결과만을 반환한다.") @Transactional diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java index b2e4c1e6c..e9ee1ccf8 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java @@ -5,6 +5,7 @@ import kr.co.pennyway.domain.common.repository.QueryHandler; import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.TestJpaConfig; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; import kr.co.pennyway.domain.domains.oauth.domain.QOauth; import kr.co.pennyway.domain.domains.oauth.type.Provider; @@ -22,6 +23,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -39,10 +41,11 @@ import static org.springframework.test.util.AssertionErrors.*; @Slf4j -@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create", "logging.level.org.springframework.jdbc=debug"}) -@ContextConfiguration(classes = JpaConfig.class) +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = {JpaConfig.class}) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ActiveProfiles("test") +@Import(TestJpaConfig.class) public class UserExtendedRepositoryTest extends ContainerMySqlTestConfig { private static final String USER_TABLE = "user"; private static final String OAUTH_TABLE = "oauth"; diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java index a535d29b1..f07317cba 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java @@ -1,5 +1,6 @@ package kr.co.pennyway.domain.domains.user.repository; +import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; import kr.co.pennyway.domain.config.JpaConfig; @@ -13,6 +14,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; import org.springframework.transaction.annotation.Transactional; @@ -30,6 +32,9 @@ public class UserSoftDeleteTest extends ContainerMySqlTestConfig { @Autowired private EntityManager em; + @MockBean + private JPAQueryFactory jpaQueryFactory; + private User user; @BeforeEach From 5a36c031fbdc1250a364bf6794086e157552f9cf Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 15 May 2024 22:48:56 +0900 Subject: [PATCH 085/152] =?UTF-8?q?=E2=9C=A8=20=EB=8B=B9=EC=9B=94=20?= =?UTF-8?q?=EB=AA=A9=ED=91=9C=20=EA=B8=88=EC=95=A1=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?API=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: target_amount delete controller 정의 * feat: target_amount 404 에러 코드 추가 * feat: target_amount 삭제 usecase 추가 * feat: target_amount 삭제 domain service 메서드 추가 * refactor: target amount 조회 로직 수정 * docs: 삭제 api swagger 작성 * fix: is_allocated_amount 논리 부정 제거 * docs: 스웨거 파라미터 주석 추가 * fix: parameter in import 추가 --- .../api/apis/ledger/api/TargetAmountApi.java | 18 ++++++++++++++++++ .../controller/TargetAmountController.java | 12 ++++++++++++ .../ledger/usecase/TargetAmountUseCase.java | 15 +++++++++++++-- .../exception/TargetAmountErrorCode.java | 4 +++- .../target/service/TargetAmountService.java | 5 +++++ 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java index 526ea269a..e83922ec4 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java @@ -43,4 +43,22 @@ public interface TargetAmountApi { @ApiResponse(responseCode = "200", description = "목표 금액 및 총 사용 금액 리스트 조회 성공", content = @Content( schemaProperties = @SchemaProperty(name = "targetAmounts", array = @ArraySchema(schema = @Schema(implementation = TargetAmountDto.WithTotalSpendingRes.class))))) ResponseEntity getTargetAmountsAndTotalSpendings(@Validated TargetAmountDto.GetParamReq param, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "당월 목표 금액 삭제", method = "DELETE") + @Parameter(name = "date", description = "삭제하려는 목표 금액 날짜 (yyyy-MM-dd)", required = true, example = "2024-05-08", in = ParameterIn.PATH) + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "목표 금액 삭제 실패", value = """ + { + "code": "4004", + "message": "당월 목표 금액에 대한 요청이 아닙니다." + } + """), + @ExampleObject(name = "목표 금액 조회 실패", description = "목표 금액 데이터가 없거나, 이미 삭제(amount=-1)인 경우", value = """ + { + "code": "4040", + "message": "해당 월의 목표 금액이 존재하지 않습니다." + } + """) + })) + ResponseEntity deleteTargetAmount(@PathVariable LocalDate date, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java index fae379e2c..4214961fb 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java @@ -50,6 +50,18 @@ public ResponseEntity getTargetAmountsAndTotalSpendings(@Validated TargetAmou return ResponseEntity.ok(SuccessResponse.from("targetAmounts", targetAmountUseCase.getTargetAmountsAndTotalSpendings(user.getUserId(), param.date()))); } + @Override + @DeleteMapping("/{date}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity deleteTargetAmount(@PathVariable LocalDate date, @AuthenticationPrincipal SecurityUserDetails user) { + if (!isValidDateForYearAndMonth(date)) { + throw new TargetAmountErrorException(TargetAmountErrorCode.INVALID_TARGET_AMOUNT_DATE); + } + + targetAmountUseCase.deleteTargetAmount(user.getUserId(), date); + return ResponseEntity.ok(SuccessResponse.noContent()); + } + private boolean isValidDateForYearAndMonth(LocalDate date) { LocalDate now = LocalDate.now(); return date.getYear() == now.getYear() && date.getMonth() == now.getMonth(); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java index 214de1bec..034fbce7d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java @@ -4,10 +4,12 @@ import kr.co.pennyway.api.apis.ledger.mapper.TargetAmountMapper; import kr.co.pennyway.api.apis.ledger.service.TargetAmountSaveService; import kr.co.pennyway.common.annotation.UseCase; -import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; +import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorException; import kr.co.pennyway.domain.domains.target.service.TargetAmountService; +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; @@ -52,4 +54,13 @@ public List getTargetAmountsAndTotalSpendi return TargetAmountMapper.toWithTotalSpendingResponses(targetAmounts, totalSpendings, user.getCreatedAt().toLocalDate(), date); } + + @Transactional + public void deleteTargetAmount(Long userId, LocalDate date) { + TargetAmount targetAmount = targetAmountService.readTargetAmountThatMonth(userId, date) + .filter(TargetAmount::isAllocatedAmount) + .orElseThrow(() -> new TargetAmountErrorException(TargetAmountErrorCode.NOT_FOUND_TARGET_AMOUNT)); + + targetAmountService.deleteTargetAmount(targetAmount); + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java index e6c84d9a8..bfbe486ad 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java @@ -10,7 +10,9 @@ public enum TargetAmountErrorCode implements BaseErrorCode { /* 400 BAD_REQUEST */ INVALID_TARGET_AMOUNT_DATE(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "당월 목표 금액에 대한 요청이 아닙니다."), - ; + + /* 404 NOT_FOUND */ + NOT_FOUND_TARGET_AMOUNT(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "해당 월의 목표 금액이 존재하지 않습니다."); private final StatusCode statusCode; private final ReasonCode reasonCode; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java index 61fc452d3..7ce094ff9 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java @@ -31,4 +31,9 @@ public Optional readTargetAmountThatMonth(Long userId, LocalDate d public List readTargetAmountsByUserId(Long userId) { return targetAmountRepository.findByUser_Id(userId); } + + @Transactional + public void deleteTargetAmount(TargetAmount targetAmount) { + targetAmountRepository.delete(targetAmount); + } } From a714d6e125ae9961d8c8b439295c210e2e26db60 Mon Sep 17 00:00:00 2001 From: DinoDeveloper <79460319+asn6878@users.noreply.github.com> Date: Thu, 16 May 2024 00:34:14 +0900 Subject: [PATCH 086/152] =?UTF-8?q?=E2=9C=A8=20=EC=A7=80=EC=B6=9C=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 상세조회 controller 작성 * feat: 존재하지 않는 지출 내역 에러코드 추가 * feat: 접근할 수 없는 지출 내역 에러코드 추가 * feat: 지출내역 개별 조회 메소드 추가 * feat: spendingsearchservice 작성 * feat: usecase 작성 * docs: swagger 문서 작성 * fix: 컨벤션에 맞추어 응답 key 수정 * feat: 인가 처리를 위한 spendingmanager 작성 * refactor: 기존 usecase의 인가 로직 제거 * refactor: searchservice 로직 usecase로 이동 * fix: 오탈자 및 잘못된 파라미터 순서 수정 * fix: 잘못된 로그 제거 * fix: 403 에러 문서 및 코드 삭제 --------- Co-authored-by: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> --- .../api/apis/ledger/api/SpendingApi.java | 17 +++++++++++++ .../ledger/controller/SpendingController.java | 7 ++++++ .../apis/ledger/usecase/SpendingUseCase.java | 12 +++++++++ .../authorization/SpendingManager.java | 25 +++++++++++++++++++ .../spending/exception/SpendingErrorCode.java | 2 ++ .../repository/SpendingRepository.java | 3 +++ .../spending/service/SpendingService.java | 14 +++++++++-- 7 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingManager.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java index 56ced453c..f86fd9717 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java @@ -17,6 +17,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -59,4 +60,20 @@ public interface SpendingApi { }) @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "spendings", schema = @Schema(implementation = SpendingSearchRes.Month.class)))) ResponseEntity getSpendingListAtYearAndMonth(@RequestParam("year") int year, @RequestParam("date") int month, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "지출 내역 상세 조회", method = "GET", description = "지출 내역의 ID값으로 해당 지출의 상세 내역을 반환합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "spending", schema = @Schema(implementation = SpendingSearchRes.Individual.class)))), + @ApiResponse(responseCode = "404", description = "NOT_FOUND", content = @Content(examples = { + @ExampleObject(name = "지출 내역 조회 오류", + value = """ + { + "code": "4040", + "message": "존재하지 않는 지출 내역입니다." + } + """ + ) + })) + }) + ResponseEntity getSpendingDetail(@PathVariable Long spendingId, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java index 872b8a165..82d2e0700 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java @@ -41,6 +41,13 @@ public ResponseEntity getSpendingListAtYearAndMonth(@RequestParam("year") int return ResponseEntity.ok(SuccessResponse.from("spendings", spendingUseCase.getSpendingsAtYearAndMonth(user.getUserId(), year, month))); } + @Override + @GetMapping("/{spendingId}") + @PreAuthorize("isAuthenticated() and @spendingManager.hasPermission(#user.getUserId(), #spendingId)") + public ResponseEntity getSpendingDetail(@PathVariable Long spendingId, @AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from("spending", spendingUseCase.getSpedingDetail(user.getUserId(), spendingId))); + } + /** * categoryId가 -1이면 서비스에서 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 OTHER가 될 수 없고,
* categoryId가 -1이 아니면 사용자가 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 OTHER임을 확인한다. diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java index e760fd3ef..bee7fb6d6 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java @@ -7,6 +7,9 @@ import kr.co.pennyway.api.apis.ledger.service.SpendingSearchService; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; @@ -23,6 +26,7 @@ public class SpendingUseCase { private final SpendingSaveService spendingSaveService; private final SpendingSearchService spendingSearchService; + private final SpendingService spendingService; private final UserService userService; @@ -42,4 +46,12 @@ public SpendingSearchRes.Month getSpendingsAtYearAndMonth(Long userId, int year, return SpendingMapper.toSpendingSearchResMonth(spendings, year, month); } + + @Transactional(readOnly = true) + public SpendingSearchRes.Individual getSpedingDetail(Long userId, Long spendingId) { + Spending spending = spendingService.readSpending(spendingId) + .orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_SPENDING)); + + return SpendingMapper.toSpendingSearchResIndividual(spending); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingManager.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingManager.java new file mode 100644 index 000000000..05a41a712 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingManager.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.api.common.security.authorization; + +import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component("spendingManager") +@RequiredArgsConstructor +public class SpendingManager { + private final SpendingService spendingService; + + /** + * 사용자가 해당 상세 지출 내역에 대한 권한이 있는지 확인한다.
+ * + * @return 권한이 있으면 true, 없으면 false + */ + @Transactional(readOnly = true) + public boolean hasPermission(Long userId, Long spendingId) { + return spendingService.isExistsSpending(userId, spendingId); + } +} + diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java index dd017deee..992599488 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java @@ -15,8 +15,10 @@ public enum SpendingErrorCode implements BaseErrorCode { INVALID_ICON_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "icon의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."), /* 404 Not Found */ + NOT_FOUND_SPENDING(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "존재하지 않는 지출 내역입니다."), NOT_FOUND_CUSTOM_CATEGORY(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "존재하지 않는 커스텀 카테고리입니다."); + private final StatusCode statusCode; private final ReasonCode reasonCode; private final String message; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java index 766163aeb..90fc7a6e3 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java @@ -2,6 +2,9 @@ import kr.co.pennyway.domain.common.repository.ExtendedRepository; import kr.co.pennyway.domain.domains.spending.domain.Spending; +import org.springframework.transaction.annotation.Transactional; public interface SpendingRepository extends ExtendedRepository, SpendingCustomRepository { + @Transactional(readOnly = true) + boolean existsByIdAndUser_Id(Long id, Long userId); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java index d9af37b08..15e571b66 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -35,8 +35,8 @@ public Spending createSpending(Spending spending) { } @Transactional(readOnly = true) - public List readSpendings(Predicate predicate, QueryHandler queryHandler, Sort sort) { - return spendingRepository.findList(predicate, queryHandler, sort); + public Optional readSpending(Long spendingId) { + return spendingRepository.findById(spendingId); } @Transactional(readOnly = true) @@ -44,6 +44,11 @@ public Optional readTotalSpendingAmountByUserId(Long userId return spendingRepository.findTotalSpendingAmountByUserId(userId, date.getYear(), date.getMonthValue()); } + @Transactional(readOnly = true) + public List readSpendings(Predicate predicate, QueryHandler queryHandler, Sort sort) { + return spendingRepository.findList(predicate, queryHandler, sort); + } + @Transactional(readOnly = true) public List readTotalSpendingsAmountByUserId(Long userId) { Predicate predicate = user.id.eq(userId); @@ -60,4 +65,9 @@ public List readTotalSpendingsAmountByUserId(Long userId) { return spendingRepository.selectList(predicate, TotalSpendingAmount.class, bindings, queryHandler, sort); } + + @Transactional(readOnly = true) + public boolean isExistsSpending(Long userId, Long spendingId) { + return spendingRepository.existsByIdAndUser_Id(spendingId, userId); + } } From 90069ca843979c55d2bd9dcadd5ec9a2657a136f Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 16 May 2024 19:52:51 +0900 Subject: [PATCH 087/152] =?UTF-8?q?=F0=9F=90=9B=20=EC=A7=80=EC=B6=9C=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=ED=94=BD=EC=8A=A4=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 지출 내역 조회 @parameter type header -> query && 지출내역 상세 조회 @parameter 추가 * fix: 소비 내역 조회 시, 월 소비 금액 총합 데이터 제거 * fix: 응답 키 spendings -> spending --- .../kr/co/pennyway/api/apis/ledger/api/SpendingApi.java | 7 ++++--- .../api/apis/ledger/controller/SpendingController.java | 2 +- .../pennyway/api/apis/ledger/dto/SpendingSearchRes.java | 2 -- .../pennyway/api/apis/ledger/mapper/SpendingMapper.java | 8 -------- 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java index f86fd9717..0b2a1cf30 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java @@ -55,13 +55,14 @@ public interface SpendingApi { @Operation(summary = "지출 내역 조회", method = "GET", description = "사용자의 해당 년/월 지출 내역을 조회하고 월/일별 지출 총합을 반환합니다.") @Parameters({ - @Parameter(name = "year", description = "년도", required = true, in = ParameterIn.HEADER), - @Parameter(name = "month", description = "월", required = true, in = ParameterIn.HEADER) + @Parameter(name = "year", description = "년도", example = "2024", required = true, in = ParameterIn.QUERY), + @Parameter(name = "month", description = "월", example = "5", required = true, in = ParameterIn.QUERY) }) - @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "spendings", schema = @Schema(implementation = SpendingSearchRes.Month.class)))) + @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "spending", schema = @Schema(implementation = SpendingSearchRes.Month.class)))) ResponseEntity getSpendingListAtYearAndMonth(@RequestParam("year") int year, @RequestParam("date") int month, @AuthenticationPrincipal SecurityUserDetails user); @Operation(summary = "지출 내역 상세 조회", method = "GET", description = "지출 내역의 ID값으로 해당 지출의 상세 내역을 반환합니다.") + @Parameter(name = "spendingId", description = "지출 내역 ID", example = "1", required = true, in = ParameterIn.PATH) @ApiResponses({ @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "spending", schema = @Schema(implementation = SpendingSearchRes.Individual.class)))), @ApiResponse(responseCode = "404", description = "NOT_FOUND", content = @Content(examples = { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java index 82d2e0700..3e009a4ba 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java @@ -38,7 +38,7 @@ public ResponseEntity postSpending(@RequestBody @Validated SpendingReq reques @GetMapping("") @PreAuthorize("isAuthenticated()") public ResponseEntity getSpendingListAtYearAndMonth(@RequestParam("year") int year, @RequestParam("month") int month, @AuthenticationPrincipal SecurityUserDetails user) { - return ResponseEntity.ok(SuccessResponse.from("spendings", spendingUseCase.getSpendingsAtYearAndMonth(user.getUserId(), year, month))); + return ResponseEntity.ok(SuccessResponse.from("spending", spendingUseCase.getSpendingsAtYearAndMonth(user.getUserId(), year, month))); } @Override diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java index ac33279db..6b85dbd6e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java @@ -20,8 +20,6 @@ public record Month( int year, @Schema(description = "월", example = "5") int month, - @Schema(description = "월별 총 지출 금액", example = "100000") - int monthlyTotalAmount, @Schema(description = "일별 지출 내역") List dailySpendings ) { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java index 741a74f89..6c57bdc73 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java @@ -20,7 +20,6 @@ public static SpendingSearchRes.Month toSpendingSearchResMonth(List sp return SpendingSearchRes.Month.builder() .year(year) .month(month) - .monthlyTotalAmount(calculateMonthlyTotalAmount(groupSpendingsByDay)) .dailySpendings(dailySpendings) .build(); } @@ -48,13 +47,6 @@ public static SpendingSearchRes.Individual toSpendingSearchResIndividual(Spendin .build(); } - /** - * 월별 지출 내역의 총 금액을 계산하는 메서드 - */ - private static int calculateMonthlyTotalAmount(ConcurrentMap> spendings) { - return spendings.values().stream().flatMap(List::stream).mapToInt(Spending::getAmount).sum(); - } - /** * 하루 지출 내역의 총 금액을 계산하는 메서드 */ From e0459064bca33f5fa39a42dc1c826d979e5fc8f3 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 20 May 2024 17:30:53 +0900 Subject: [PATCH 088/152] =?UTF-8?q?=E2=9C=A8=20Firebase=20Cloud=20Messagin?= =?UTF-8?q?g=20=EA=B8=B0=EB=B3=B8=20=EC=84=A4=EC=A0=95=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: firebase build 종속성 추가 * chore: fcm_config import selector group 설정 * chore: external-api infra_config에서 fcm_config 빈 스캔 * chore: firebase admin key 환경변수 등록 * feat: fcm_config 정의 * chore: firebase admin-key .gitignore 등록 * fix: fcm_config 실행 환경에서 test 제거 * rename: fcm config @profile todo 주석 추가 * chore: cd pipeline 내 fcm admin sdk json 파일 생성 step 추가 * chore: cd pipeline json 삽입 동작 확인 * chore: json name, dir 속성 분리 * chore: resource firebase 디렉토리 .gitkeep 생성 및 .gitignore 범위 수정 * chore: feature 브랜치 cd 감지 제거 --- .github/workflows/deploy.yml | 14 ++++-- .../co/pennyway/api/config/InfraConfig.java | 5 ++ pennyway-infra/.gitignore | 5 +- pennyway-infra/build.gradle | 3 ++ .../importer/PennywayInfraConfigGroup.java | 3 +- .../co/pennyway/infra/config/FcmConfig.java | 46 +++++++++++++++++++ .../src/main/resources/application-infra.yml | 5 ++ .../src/main/resources/firebase/.gitkeep | 0 8 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/config/FcmConfig.java create mode 100644 pennyway-infra/src/main/resources/firebase/.gitkeep diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 724c74584..ce37ee9cd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -44,21 +44,29 @@ jobs: java-version: '17' distribution: 'temurin' - # 3. Build Gradle + # 3. FCM Admin SDK 파일 생성 + - name: Create Json + uses: jsdaniell/create-json@v1.2.2 + with: + name: ${{ secrets.FIREBASE_ADMIN_SDK_FILE }} + json: ${{ secrets.FIREBASE_ADMIN_SDK }} + dir: ${{ secrets.FIREBASE_ADMIN_SDK_DIR }} + + # 4. Build Gradle - name: Build Gradle run: | chmod +x ./gradlew ./gradlew build --stacktrace --info -x test shell: bash - # 4. Docker 이미지 build 및 push + # 5. Docker 이미지 build 및 push - name: docker build and push run: | docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} docker build -t pennyway/pennyway-was . docker push pennyway/pennyway-was - # 5. AWS SSM을 통한 Run-Command (Docker 이미지 pull 후 docker-compose를 통한 실행) + # 6. AWS SSM을 통한 Run-Command (Docker 이미지 pull 후 docker-compose를 통한 실행) - name: AWS SSM Send-Command uses: peterkimzz/aws-ssm-send-command@master id: ssm diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java index e62ecea67..eabed9518 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/InfraConfig.java @@ -1,5 +1,7 @@ package kr.co.pennyway.api.config; +import kr.co.pennyway.infra.common.importer.EnablePennywayInfraConfig; +import kr.co.pennyway.infra.common.importer.PennywayInfraConfigGroup; import kr.co.pennyway.infra.common.properties.AppleOidcProperties; import kr.co.pennyway.infra.common.properties.GoogleOidcProperties; import kr.co.pennyway.infra.common.properties.KakaoOidcProperties; @@ -14,5 +16,8 @@ GoogleOidcProperties.class, KakaoOidcProperties.class }) +@EnablePennywayInfraConfig({ + PennywayInfraConfigGroup.FCM +}) public class InfraConfig { } diff --git a/pennyway-infra/.gitignore b/pennyway-infra/.gitignore index b63da4551..2c8ebbcf1 100644 --- a/pennyway-infra/.gitignore +++ b/pennyway-infra/.gitignore @@ -39,4 +39,7 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +### Firebase admin credentials ### +**/resources/firebase/pennyway** \ No newline at end of file diff --git a/pennyway-infra/build.gradle b/pennyway-infra/build.gradle index d3213ec95..9cedc519c 100644 --- a/pennyway-infra/build.gradle +++ b/pennyway-infra/build.gradle @@ -28,4 +28,7 @@ dependencies { /* mail */ implementation 'org.springframework.boot:spring-boot-starter-mail:3.2.3' + + /* firebase */ + implementation 'com.google.firebase:firebase-admin:9.2.0' } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigGroup.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigGroup.java index 44cd79d98..028b82c79 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigGroup.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/importer/PennywayInfraConfigGroup.java @@ -1,12 +1,13 @@ package kr.co.pennyway.infra.common.importer; +import kr.co.pennyway.infra.config.FcmConfig; import lombok.Getter; import lombok.RequiredArgsConstructor; @Getter @RequiredArgsConstructor public enum PennywayInfraConfigGroup { - ; + FCM(FcmConfig.class); private final Class configClass; } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/FcmConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/FcmConfig.java new file mode 100644 index 000000000..a127a5e3e --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/FcmConfig.java @@ -0,0 +1,46 @@ +package kr.co.pennyway.infra.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import jakarta.annotation.PostConstruct; +import kr.co.pennyway.infra.common.importer.PennywayInfraConfig; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; + +@Slf4j +@Profile({"local", "dev", "prod"}) +// TODO: 2024.05.17 우선 테스트 통과를 위해 임시로 처리함. Push Notification 기능 테스트 시 문제가 발생하면 수정이 필요함. +public class FcmConfig implements PennywayInfraConfig { + private final ClassPathResource firebaseResource; + private final String projectId; + + public FcmConfig(@Value("${app.firebase.config.file}") String firebaseConfigPath, + @Value("${app.firebase.project.id}") String projectId) { + this.firebaseResource = new ClassPathResource(firebaseConfigPath); + this.projectId = projectId; + } + + @PostConstruct + public void init() throws IOException { + FirebaseOptions option = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(firebaseResource.getInputStream())) + .build(); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(option); + log.info("FirebaseApp is initialized"); + } + } + + @Bean + FirebaseMessaging firebaseMessaging() { + return FirebaseMessaging.getInstance(FirebaseApp.getInstance()); + } +} diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index cf0b4cc83..763469f98 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -25,6 +25,11 @@ app: port: 587 username: ${MAIL_USERNAME:pennyway} password: ${MAIL_PASSWORD:password} + firebase: + config: + file: ${FIREBASE_CONFIG_FILE:firebase-adminsdk.json} + project: + id: ${FIREBASE_PROJECT_ID:pennyway-12345} pennyway: server: diff --git a/pennyway-infra/src/main/resources/firebase/.gitkeep b/pennyway-infra/src/main/resources/firebase/.gitkeep new file mode 100644 index 000000000..e69de29bb From a59c590fcf698da7fb4c6f5a52e465cac660dd6a Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 23 May 2024 11:41:09 +0900 Subject: [PATCH 089/152] =?UTF-8?q?=E2=9C=A8=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A1=9C=EA=B7=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Interceptor=20=EB=B0=8F=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EA=B5=AC=EC=B6=95=20=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 로그인 로그 redis entity 정의 * feat: 로그 redis repository 작성 * fix: sign event log repository crud -> list_crud 상속으로 변경 * feat: sign_event_log_service 정의. cr 작업만 수행 * feat: sign_in_log 복합키 클래스 설정 * feat: sign_in_log entity 정의 * feat: sign_in_log repo & service 클래스 생성 * feat: web_mvc_config sign_event_log_interceptor 등록 * refactor: jwt_claims_parser_util 분리 * feat: ip_address_header type 정의 * feat: ip_address_header converter 정의 * feat: sign_in_log entity에 app_version, ip_address_header 필드 추가 * fix: sign_event_log app_version, ip_addr_header 필드 추가 * feat: sign_event interceptor 정의 * fix: sign_in_log_id 클래스 직렬화 구현 * fix: sign_in_log entity에 @entity 선언 * test: web_mvc_config 내 service 의존성 문제로 인한 controller unit test 실패 해결 * fix: interceptor response auth header 존재 여부 검증 추가 --- .../api/apis/auth/helper/JwtAuthHelper.java | 34 +--- .../apis/auth/usecase/UserAuthUseCase.java | 3 +- .../interceptor/SignEventLogInterceptor.java | 125 ++++++++++++ .../security/jwt/JwtClaimsParserUtil.java | 35 ++++ .../kr/co/pennyway/api/config/WebConfig.java | 15 ++ .../controller/AuthCheckControllerTest.java | 180 +++++++++--------- .../AuthControllerValidationTest.java | 6 +- .../SpendingCategoryControllerUnitTest.java | 6 +- .../SpendingControllerUnitTest.java | 6 +- .../TargetAmountControllerUnitTest.java | 6 +- .../controller/QuestionControllerTest.java | 6 +- .../UserAccountControllerUnitTest.java | 6 +- .../converter/IpAddressHeaderConverter.java | 13 ++ .../common/redis/sign/SignEventLog.java | 45 +++++ .../redis/sign/SignEventLogRepository.java | 6 + .../redis/sign/SignEventLogService.java | 23 +++ .../domain/domains/sign/domain/SignInLog.java | 43 +++++ .../domains/sign/domain/SignInLogId.java | 35 ++++ .../sign/repository/SignInLogRepository.java | 8 + .../sign/service/SignInLogService.java | 11 ++ .../domains/sign/type/IpAddressHeader.java | 19 ++ 21 files changed, 505 insertions(+), 126 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/interceptor/SignEventLogInterceptor.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/JwtClaimsParserUtil.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/IpAddressHeaderConverter.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLog.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogService.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLog.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLogId.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/repository/SignInLogRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/service/SignInLogService.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/type/IpAddressHeader.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java index dd35ff14f..52466c823 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelper.java @@ -2,6 +2,7 @@ import kr.co.pennyway.api.common.annotation.AccessTokenStrategy; import kr.co.pennyway.api.common.annotation.RefreshTokenStrategy; +import kr.co.pennyway.api.common.security.jwt.JwtClaimsParserUtil; import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaim; @@ -20,7 +21,6 @@ import java.time.Duration; import java.time.LocalDateTime; -import java.util.function.Function; @Slf4j @Helper @@ -42,34 +42,6 @@ public JwtAuthHelper( this.forbiddenTokenService = forbiddenTokenService; } - /** - * JwtClaims에서 key에 해당하는 값을 반환하는 메서드 - * - * @return key에 해당하는 값이 없거나, 타입이 일치하지 않을 경우 null을 반환한다. - */ - @SuppressWarnings("unchecked") - public T getClaimsValue(JwtClaims claims, String key, Class type) { - Object value = claims.getClaims().get(key); - if (value != null && type.isAssignableFrom(value.getClass())) { - return (T) value; - } - return null; - } - - /** - * JwtClaims에서 valueConverter를 이용하여 key에 해당하는 값을 반환하는 메서드 - * - * @param valueConverter : String 타입의 값을 T 타입으로 변환하는 함수 - * @return key에 해당하는 값이 없을 경우 null을 반환한다. - */ - public T getClaimsValue(JwtClaims claims, String key, Function valueConverter) { - Object value = claims.getClaims().get(key); - if (value != null) { - return valueConverter.apply((String) value); - } - return null; - } - /** * 사용자 정보 기반으로 access token과 refresh token을 생성하는 메서드
* refresh token은 redis에 저장된다. @@ -88,8 +60,8 @@ public Jwts createToken(User user) { public Pair refresh(String refreshToken) { JwtClaims claims = refreshTokenProvider.getJwtClaimsFromToken(refreshToken); - Long userId = getClaimsValue(claims, RefreshTokenClaimKeys.USER_ID.getValue(), Long::parseLong); - String role = getClaimsValue(claims, RefreshTokenClaimKeys.ROLE.getValue(), String.class); + Long userId = JwtClaimsParserUtil.getClaimsValue(claims, RefreshTokenClaimKeys.USER_ID.getValue(), Long::parseLong); + String role = JwtClaimsParserUtil.getClaimsValue(claims, RefreshTokenClaimKeys.ROLE.getValue(), String.class); log.debug("refresh token userId : {}, role : {}", userId, role); RefreshToken newRefreshToken; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java index e076c52a9..a3ac87289 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java @@ -6,6 +6,7 @@ import kr.co.pennyway.api.apis.auth.helper.JwtAuthHelper; import kr.co.pennyway.api.apis.auth.helper.OauthOidcHelper; import kr.co.pennyway.api.apis.auth.service.UserOauthSignService; +import kr.co.pennyway.api.common.security.jwt.JwtClaimsParserUtil; import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaimKeys; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; @@ -33,7 +34,7 @@ public class UserAuthUseCase { public AuthStateDto isSignIn(String authHeader) { String accessToken = accessTokenProvider.resolveToken(authHeader); JwtClaims claims = accessTokenProvider.getJwtClaimsFromToken(accessToken); - Long userId = jwtAuthHelper.getClaimsValue(claims, AccessTokenClaimKeys.USER_ID.getValue(), Long::parseLong); + Long userId = JwtClaimsParserUtil.getClaimsValue(claims, AccessTokenClaimKeys.USER_ID.getValue(), Long::parseLong); log.info("auth_id {} 사용자는 로그인 중입니다.", userId); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/interceptor/SignEventLogInterceptor.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/interceptor/SignEventLogInterceptor.java new file mode 100644 index 000000000..4f0f99e9a --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/interceptor/SignEventLogInterceptor.java @@ -0,0 +1,125 @@ +package kr.co.pennyway.api.common.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.co.pennyway.api.common.security.jwt.JwtClaimsParserUtil; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaimKeys; +import kr.co.pennyway.domain.common.redis.sign.SignEventLog; +import kr.co.pennyway.domain.common.redis.sign.SignEventLogService; +import kr.co.pennyway.domain.domains.sign.type.IpAddressHeader; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.NonNull; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; + +import java.time.LocalDateTime; +import java.util.regex.Pattern; + +@Slf4j +public class SignEventLogInterceptor implements HandlerInterceptor { + /** + *

+ * User-Agent에서 앱 버전을 추출하기 위한 정규식 패턴이다. + * User-Agent는 "AppName/Version (platform; os; deviceModel)" 형식으로 되어있는 문자열이다. + *

+ */ + private static final Pattern pattern = Pattern.compile("^(\\w+)/(\\d+\\.\\d+) \\((\\w+); (\\w+ \\d+\\.\\d+); (\\w+\\d+,\\d+)\\)$"); + private final SignEventLogService signEventLogService; + private final JwtProvider accessTokenProvider; + + public SignEventLogInterceptor(SignEventLogService signEventLogService, JwtProvider accessTokenProvider) { + this.signEventLogService = signEventLogService; + this.accessTokenProvider = accessTokenProvider; + } + + @Override + public void postHandle(@NonNull HttpServletRequest request, HttpServletResponse response, @NonNull Object handler, ModelAndView modelAndView) { + if (response.getStatus() != 200 || response.getHeader(HttpHeaders.AUTHORIZATION) == null) { + return; + } + + String accessToken = response.getHeader(HttpHeaders.AUTHORIZATION); + Long userId = JwtClaimsParserUtil.getClaimsValue(accessTokenProvider.getJwtClaimsFromToken(accessToken), AccessTokenClaimKeys.USER_ID.getValue(), Long::parseLong); + + UserAgentInfo userAgent = getUserAgentInfo(request.getHeader(HttpHeaders.USER_AGENT)); + Pair ipAddress = getClientIP(request); + + SignEventLog signEventLog = SignEventLog.builder() + .userId(userId) + .ipAddressHeader(ipAddress.getKey().getType()) + .ipAddress(ipAddress.getValue()) + .appVersion(userAgent.appVersion()) + .deviceModel(userAgent.deviceModel()) + .os(userAgent.os()) + .signedAt(LocalDateTime.now()) + .build(); + log.debug("SignEventLog: {}", signEventLog); + + signEventLogService.create(signEventLog); + } + + private Pair getClientIP(HttpServletRequest request) { + IpAddressHeader headerType = IpAddressHeader.X_FORWARDED_FOR; + String ip = request.getHeader(headerType.getType()); + + if (ip == null) { + headerType = IpAddressHeader.PROXY_CLIENT_IP; + ip = request.getHeader(headerType.getType()); + } + if (ip == null) { + headerType = IpAddressHeader.WL_PROXY_CLIENT_IP; + ip = request.getHeader(headerType.getType()); + } + if (ip == null) { + headerType = IpAddressHeader.HTTP_CLIENT_IP; + ip = request.getHeader(headerType.getType()); + } + if (ip == null) { + headerType = IpAddressHeader.HTTP_X_FORWARDED_FOR; + ip = request.getHeader(headerType.getType()); + } + if (ip == null) { + headerType = IpAddressHeader.REMOTE_ADDR; + ip = request.getRemoteAddr(); + } + + return Pair.of(headerType, ip); + } + + private UserAgentInfo getUserAgentInfo(String userAgent) { + var matcher = pattern.matcher(userAgent); + if (!matcher.matches()) { + return new UserAgentInfo("", "", "", "", ""); + } + + return new UserAgentInfo( + matcher.group(1), + matcher.group(2), + matcher.group(3), + matcher.group(5), + matcher.group(4) + ); + } + + private record UserAgentInfo( + String appName, + String appVersion, + String platform, + String deviceModel, + String os + ) { + @Override + public String toString() { + return "UserAgentInfo{" + + "appName='" + appName + '\'' + + ", appVersion='" + appVersion + '\'' + + ", platform='" + platform + '\'' + + ", deviceModel='" + deviceModel + '\'' + + ", os='" + os + '\'' + + '}'; + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/JwtClaimsParserUtil.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/JwtClaimsParserUtil.java new file mode 100644 index 000000000..2109b844d --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/JwtClaimsParserUtil.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.api.common.security.jwt; + +import kr.co.pennyway.infra.common.jwt.JwtClaims; + +import java.util.function.Function; + +public class JwtClaimsParserUtil { + /** + * JwtClaims에서 key에 해당하는 값을 반환하는 메서드 + * + * @return key에 해당하는 값이 없거나, 타입이 일치하지 않을 경우 null을 반환한다. + */ + @SuppressWarnings("unchecked") + public static T getClaimsValue(JwtClaims claims, String key, Class type) { + Object value = claims.getClaims().get(key); + if (value != null && type.isAssignableFrom(value.getClass())) { + return (T) value; + } + return null; + } + + /** + * JwtClaims에서 valueConverter를 이용하여 key에 해당하는 값을 반환하는 메서드 + * + * @param valueConverter : String 타입의 값을 T 타입으로 변환하는 함수 + * @return key에 해당하는 값이 없을 경우 null을 반환한다. + */ + public static T getClaimsValue(JwtClaims claims, String key, Function valueConverter) { + Object value = claims.getClaims().get(key); + if (value != null) { + return valueConverter.apply((String) value); + } + return null; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java index 2572264f5..3040f7d8f 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java @@ -3,12 +3,21 @@ import kr.co.pennyway.api.common.converter.NotifyTypeConverter; import kr.co.pennyway.api.common.converter.ProviderConverter; import kr.co.pennyway.api.common.converter.VerificationTypeConverter; +import kr.co.pennyway.api.common.interceptor.SignEventLogInterceptor; +import kr.co.pennyway.domain.common.redis.sign.SignEventLogService; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration +@RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { + private final SignEventLogService signEventLogService; + private final JwtProvider accessTokenProvider; + @Override public void addFormatters(FormatterRegistry registrar) { @@ -16,4 +25,10 @@ public void addFormatters(FormatterRegistry registrar) { registrar.addConverter(new VerificationTypeConverter()); registrar.addConverter(new NotifyTypeConverter()); } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new SignEventLogInterceptor(signEventLogService, accessTokenProvider)) + .addPathPatterns("/v1/auth/sign-in", "/v1/auth/oauth/sign-up", "/v1/auth/refresh"); + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java index 6a37e0ffb..5da38c89b 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java @@ -1,106 +1,110 @@ package kr.co.pennyway.api.apis.auth.controller; -import static kr.co.pennyway.common.exception.ReasonCode.*; -import static org.mockito.BDDMockito.*; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.apis.auth.usecase.AuthCheckUseCase; +import kr.co.pennyway.api.config.WebConfig; +import kr.co.pennyway.common.exception.StatusCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; -import com.fasterxml.jackson.databind.ObjectMapper; - -import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; -import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; -import kr.co.pennyway.api.apis.auth.usecase.AuthCheckUseCase; -import kr.co.pennyway.common.exception.StatusCode; -import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; -import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import static kr.co.pennyway.common.exception.ReasonCode.REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(controllers = {AuthCheckController.class}) +@WebMvcTest(controllers = {AuthCheckController.class}, excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) @ActiveProfiles("local") class AuthCheckControllerTest { - private final String inputPhone = "010-1234-5678"; - private final String expectedUsername = "pennyway"; - private final String code = "123456"; - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @MockBean - private AuthCheckUseCase authCheckUseCase; - - @BeforeEach - void setUp(WebApplicationContext webApplicationContext) { - this.mockMvc = MockMvcBuilders - .webAppContextSetup(webApplicationContext) - .defaultRequest(post("/**").with(csrf())) - .build(); - } - - @Test - @DisplayName("일반 회원의 휴대폰 번호로 아이디를 찾을 때 200 응답을 반환한다.") - void findUsername() throws Exception { - // given - given(authCheckUseCase.findUsername(new PhoneVerificationDto.VerifyCodeReq(inputPhone, code))).willReturn( - new AuthFindDto.FindUsernameRes(expectedUsername)); - - // when - ResultActions resultActions = findUsernameRequest(inputPhone, code); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.user.username").value(expectedUsername)); - } - - @Test - @DisplayName("일반 회원이 아닌 휴대폰 번호로 아이디를 찾을 때 404 응답을 반환한다.") - void findUsernameIfUserNotFound() throws Exception { - // given - String phone = "010-1111-1111"; - given(authCheckUseCase.findUsername(new PhoneVerificationDto.VerifyCodeReq(phone, code))).willThrow(new UserErrorException(UserErrorCode.NOT_FOUND)); - - // when - ResultActions resultActions = findUsernameRequest(phone, code); - - // then - resultActions - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(UserErrorCode.NOT_FOUND.causedBy().getCode())) - .andExpect(jsonPath("$.message").value(UserErrorCode.NOT_FOUND.getExplainError())); - } - - @Test - @DisplayName("휴대폰 번호와 코드를 입력하지 않았을 때 422 응답을 반환한다.") - void findUsernameIfInputIsEmpty() throws Exception { - // when - ResultActions resultActions = findUsernameRequest("", ""); - - // then - resultActions - .andExpect(status().is4xxClientError()) - .andExpect(jsonPath("$.code").value( - String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY.getCode()))) - .andExpect(jsonPath("$.message").value(StatusCode.UNPROCESSABLE_CONTENT.name())); - } - - private ResultActions findUsernameRequest(String phone, String code) throws Exception { - return mockMvc.perform(get("/v1/find/username") - .param("phone", phone) - .param("code", code)); - } + private final String inputPhone = "010-1234-5678"; + private final String expectedUsername = "pennyway"; + private final String code = "123456"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private AuthCheckUseCase authCheckUseCase; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(post("/**").with(csrf())) + .build(); + } + + @Test + @DisplayName("일반 회원의 휴대폰 번호로 아이디를 찾을 때 200 응답을 반환한다.") + void findUsername() throws Exception { + // given + given(authCheckUseCase.findUsername(new PhoneVerificationDto.VerifyCodeReq(inputPhone, code))).willReturn( + new AuthFindDto.FindUsernameRes(expectedUsername)); + + // when + ResultActions resultActions = findUsernameRequest(inputPhone, code); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.username").value(expectedUsername)); + } + + @Test + @DisplayName("일반 회원이 아닌 휴대폰 번호로 아이디를 찾을 때 404 응답을 반환한다.") + void findUsernameIfUserNotFound() throws Exception { + // given + String phone = "010-1111-1111"; + given(authCheckUseCase.findUsername(new PhoneVerificationDto.VerifyCodeReq(phone, code))).willThrow(new UserErrorException(UserErrorCode.NOT_FOUND)); + + // when + ResultActions resultActions = findUsernameRequest(phone, code); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(UserErrorCode.NOT_FOUND.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(UserErrorCode.NOT_FOUND.getExplainError())); + } + + @Test + @DisplayName("휴대폰 번호와 코드를 입력하지 않았을 때 422 응답을 반환한다.") + void findUsernameIfInputIsEmpty() throws Exception { + // when + ResultActions resultActions = findUsernameRequest("", ""); + + // then + resultActions + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("$.code").value( + String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY.getCode()))) + .andExpect(jsonPath("$.message").value(StatusCode.UNPROCESSABLE_CONTENT.name())); + } + + private ResultActions findUsernameRequest(String phone, String code) throws Exception { + return mockMvc.perform(get("/v1/find/username") + .param("phone", phone) + .param("code", code)); + } } \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java index e2d44e040..9c0716649 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java @@ -5,6 +5,7 @@ import kr.co.pennyway.api.apis.auth.usecase.AuthUseCase; import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.api.common.util.CookieUtil; +import kr.co.pennyway.api.config.WebConfig; import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -12,6 +13,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; import org.springframework.test.context.ActiveProfiles; @@ -28,7 +31,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -@WebMvcTest(controllers = {AuthController.class}) +@WebMvcTest(controllers = {AuthController.class}, excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) @ActiveProfiles("local") public class AuthControllerValidationTest { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java index e2cd29942..9a37c3c2a 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java @@ -2,6 +2,7 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; import kr.co.pennyway.api.apis.ledger.usecase.SpendingCategoryUseCase; +import kr.co.pennyway.api.config.WebConfig; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; @@ -12,6 +13,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -26,7 +29,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(controllers = {SpendingCategoryController.class}) +@WebMvcTest(controllers = {SpendingCategoryController.class}, excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) @ActiveProfiles("test") public class SpendingCategoryControllerUnitTest { @Autowired diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java index 32aa052a4..84a63a0a7 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java @@ -4,6 +4,7 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; import kr.co.pennyway.api.apis.ledger.usecase.SpendingUseCase; +import kr.co.pennyway.api.config.WebConfig; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import org.apache.commons.lang3.RandomStringUtils; @@ -11,6 +12,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -26,7 +29,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @ActiveProfiles("test") -@WebMvcTest(controllers = SpendingController.class) +@WebMvcTest(controllers = SpendingController.class, excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) @TestClassOrder(ClassOrderer.OrderAnnotation.class) public class SpendingControllerUnitTest { @Autowired diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java index 7b5c740d8..df046780c 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java @@ -2,12 +2,15 @@ import com.fasterxml.jackson.databind.ObjectMapper; import kr.co.pennyway.api.apis.ledger.usecase.TargetAmountUseCase; +import kr.co.pennyway.api.config.WebConfig; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -24,7 +27,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(controllers = {TargetAmountController.class}) +@WebMvcTest(controllers = {TargetAmountController.class}, excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) @ActiveProfiles("test") @TestClassOrder(ClassOrderer.OrderAnnotation.class) public class TargetAmountControllerUnitTest { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/question/controller/QuestionControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/question/controller/QuestionControllerTest.java index 257b305e4..a72785c70 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/question/controller/QuestionControllerTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/question/controller/QuestionControllerTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import kr.co.pennyway.api.apis.question.dto.QuestionReq; import kr.co.pennyway.api.apis.question.usecase.QuestionUseCase; +import kr.co.pennyway.api.config.WebConfig; import kr.co.pennyway.domain.domains.question.domain.QuestionCategory; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -10,6 +11,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -22,7 +25,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(controllers = QuestionController.class) +@WebMvcTest(controllers = QuestionController.class, excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) @ActiveProfiles("local") public class QuestionControllerTest { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java index d75956cd3..13ce6af13 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java @@ -4,6 +4,7 @@ import kr.co.pennyway.api.apis.users.dto.DeviceDto; import kr.co.pennyway.api.apis.users.dto.UserProfileUpdateDto; import kr.co.pennyway.api.apis.users.usecase.UserAccountUseCase; +import kr.co.pennyway.api.config.WebConfig; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; import kr.co.pennyway.common.exception.StatusCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; @@ -12,6 +13,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -29,7 +32,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(controllers = {UserAccountController.class}) +@WebMvcTest(controllers = {UserAccountController.class}, excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) @ActiveProfiles("local") @TestClassOrder(ClassOrderer.OrderAnnotation.class) public class UserAccountControllerUnitTest { diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/IpAddressHeaderConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/IpAddressHeaderConverter.java new file mode 100644 index 000000000..912aeaf83 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/IpAddressHeaderConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.sign.type.IpAddressHeader; + +@Converter +public class IpAddressHeaderConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "IP 주소 헤더"; + + public IpAddressHeaderConverter() { + super(IpAddressHeader.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLog.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLog.java new file mode 100644 index 000000000..bcb5edf13 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLog.java @@ -0,0 +1,45 @@ +package kr.co.pennyway.domain.common.redis.sign; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +import java.time.LocalDateTime; + +@Getter +@RedisHash(value = "signEventLog", timeToLive = 60 * 60 * 24) +public class SignEventLog { + @Id + private final Long userId; + private final LocalDateTime signedAt; + private final String ipAddress; + private final String ipAddressHeader; + private final String appVersion; + private final String deviceModel; + private final String os; + + @Builder + public SignEventLog(Long userId, LocalDateTime signedAt, String ipAddress, String ipAddressHeader, String appVersion, String deviceModel, String os) { + this.userId = userId; + this.signedAt = signedAt; + this.ipAddress = ipAddress; + this.ipAddressHeader = ipAddressHeader; + this.appVersion = appVersion; + this.deviceModel = deviceModel; + this.os = os; + } + + @Override + public String toString() { + return "SignEventLog{" + + "userId=" + userId + + ", signedAt=" + signedAt + + ", ipAddress='" + ipAddress + '\'' + + ", ipAddressHeader='" + ipAddressHeader + '\'' + + ", appVersion='" + appVersion + '\'' + + ", deviceModel='" + deviceModel + '\'' + + ", os='" + os + '\'' + + '}'; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogRepository.java new file mode 100644 index 000000000..9acf112fa --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogRepository.java @@ -0,0 +1,6 @@ +package kr.co.pennyway.domain.common.redis.sign; + +import org.springframework.data.repository.ListCrudRepository; + +public interface SignEventLogRepository extends ListCrudRepository { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogService.java new file mode 100644 index 000000000..e3b32e791 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogService.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.domain.common.redis.sign; + +import kr.co.pennyway.common.annotation.DomainService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class SignEventLogService { + private final SignEventLogRepository signEventLogRepository; + + public void create(SignEventLog signEventLog) { + signEventLogRepository.save(signEventLog); + log.debug("로그 저장 : {}", signEventLog); + } + + public List findAll() { + return signEventLogRepository.findAll(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLog.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLog.java new file mode 100644 index 000000000..09a33e177 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLog.java @@ -0,0 +1,43 @@ +package kr.co.pennyway.domain.domains.sign.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.IpAddressHeaderConverter; +import kr.co.pennyway.domain.domains.sign.type.IpAddressHeader; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "sign_in_log") +@IdClass(SignInLogId.class) +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class SignInLog { + @Id + private LocalDateTime signedAt; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; + private String ipAddress; + @Convert(converter = IpAddressHeaderConverter.class) + private IpAddressHeader ipAddressHeader; + private String appVersion; + private String deviceModel; + private String os; + + @Builder + public SignInLog(LocalDateTime signedAt, Long userId, String ipAddress, IpAddressHeader ipAddressHeader, String appVersion, String deviceModel, String os) { + this.signedAt = signedAt; + this.userId = userId; + this.ipAddress = ipAddress; + this.ipAddressHeader = ipAddressHeader; + this.appVersion = appVersion; + this.deviceModel = deviceModel; + this.os = os; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLogId.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLogId.java new file mode 100644 index 000000000..7903632a3 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLogId.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.domain.domains.sign.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Transient; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class SignInLogId implements Serializable { + @Serial + @Transient + private static final long serialVersionUID = 1L; + + @Column(name = "signed_at") + private LocalDateTime signedAt; + @Column(name = "id") + private Long id; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SignInLogId that)) return false; + return signedAt.equals(that.signedAt) && id.equals(that.id); + } + + @Override + public int hashCode() { + return signedAt.hashCode() + id.hashCode(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/repository/SignInLogRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/repository/SignInLogRepository.java new file mode 100644 index 000000000..498a55d9e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/repository/SignInLogRepository.java @@ -0,0 +1,8 @@ +package kr.co.pennyway.domain.domains.sign.repository; + +import kr.co.pennyway.domain.domains.sign.domain.SignInLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SignInLogRepository extends JpaRepository { + +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/service/SignInLogService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/service/SignInLogService.java new file mode 100644 index 000000000..b55ee8247 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/service/SignInLogService.java @@ -0,0 +1,11 @@ +package kr.co.pennyway.domain.domains.sign.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.sign.repository.SignInLogRepository; +import lombok.RequiredArgsConstructor; + +@DomainService +@RequiredArgsConstructor +public class SignInLogService { + private final SignInLogRepository signInLogRepository; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/type/IpAddressHeader.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/type/IpAddressHeader.java new file mode 100644 index 000000000..fbaa150e6 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/type/IpAddressHeader.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.domains.sign.type; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum IpAddressHeader implements LegacyCommonType { + X_FORWARDED_FOR("0", "X-Forwarded-For"), + PROXY_CLIENT_IP("1", "Proxy-Client-IP"), + WL_PROXY_CLIENT_IP("2", "WL-Proxy-Client-IP"), + HTTP_CLIENT_IP("3", "HTTP_CLIENT_IP"), + HTTP_X_FORWARDED_FOR("4", "HTTP_X_FORWARDED_FOR"), + REMOTE_ADDR("5", "REMOTE_ADDR"); + + private final String code; + private final String type; +} From f1aa7259c425908f7f1559b76a8900f6527e587f Mon Sep 17 00:00:00 2001 From: Jinwoo Lee Date: Tue, 28 May 2024 16:14:06 +0900 Subject: [PATCH 090/152] =?UTF-8?q?=E2=9C=A8=20S3=20=EC=A0=95=EC=A0=81=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Presigned-URL=20=EB=B0=9C=EA=B8=89=20API?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: s3 관련 의존성 추가 * feat: s3-client 빈 생성을 위한 s3-config 클래스 선언 * feat: s3-presigner 빈 선언 * feat: s3 presigned-url 발급을 위한 provider 선언 및 발급 메서드 선언 * fix: s3 접근을 위한 credentials 선언 및 적용 * feat: s3 presigned-url 발급을 위한 api 명세 정의 * feat: s3의 object key 생성을 위한 uuid util 클래스 정의 * feat: presigned-url 발급을 위한 dto 선언 및 api 수정 * feat: object-key 생성을 위한 템플릿 및 타입 선언 * fix: s3-bucket-name 환경 변수 추가 및 관련 로직 수정 * feat: presigned-url 발급을 위한 service 및 usecase 정의 * fix: storage-usecase 패키지 이동 * fix: storage-controller 접근 권한 수정 * fix: storage 관련 예외 및 에러 코드 선언 및 예외 처리 로직 수정 * test: presigned-url 발급을 위한 api 테스트 로직 작성 * chore: presgined-url response-dto description 삭제 * fix: user error-code와 중복된 코드 제거 * fix: presgined-url 발급 요청 시 chat-id 속성 삭제 및 aws-s3-provider 주석 구체화 * docs: swagger 응답 구체화 * refactor: object-key 생성을 위한 url-generator 정의 * refactor: object-key 생성을 위한 url-generator 적용 * docs: aws-s3-provider 메서드들의 매개변수 관련 설명 추가 * fix: s3 관련 예외 위치 수정 * fix: presigned-url 발급 api swagger 응답에서 코드 제거 * fix: presigned-url 발급을 위한 dto명 변경 * test: storage-controller 테스트 profile 수정 * fix: presigned-url 발급을 위한 dto명 변경 * fix: presigned-url 발급을 위한 dto명 변경 * test: 테스트를 위한 user 설정 변경 * test: exclude-filters 추가 --- .../api/apis/storage/api/StorageApi.java | 50 +++++++ .../storage/controller/StorageController.java | 29 ++++ .../api/apis/storage/dto/PresignedUrlDto.java | 38 +++++ .../apis/storage/usecase/StorageUseCase.java | 20 +++ .../TargetAmountIntegrationTest.java | 138 ++++++++++-------- .../controller/StorageControllerTest.java | 95 ++++++++++++ .../kr/co/pennyway/common/util/UUIDUtil.java | 7 + pennyway-infra/build.gradle | 2 + .../infra/client/aws/s3/AwsS3Provider.java | 93 ++++++++++++ .../aws/s3/ChatProfileUrlGenerator.java | 26 ++++ .../infra/client/aws/s3/ChatUrlGenerator.java | 20 +++ .../aws/s3/ChatroomProfileUrlGenerator.java | 22 +++ .../infra/client/aws/s3/FeedUrlGenerator.java | 18 +++ .../client/aws/s3/ObjectKeyTemplate.java | 18 +++ .../infra/client/aws/s3/ObjectKeyType.java | 28 ++++ .../client/aws/s3/ProfileUrlGenerator.java | 22 +++ .../infra/client/aws/s3/UrlGenerator.java | 15 ++ .../client/aws/s3/UrlGeneratorFactory.java | 23 +++ .../common/exception/StorageErrorCode.java | 31 ++++ .../common/exception/StorageException.java | 22 +++ .../co/pennyway/infra/config/AwsS3Config.java | 50 +++++++ .../src/main/resources/application-infra.yml | 8 + 22 files changed, 712 insertions(+), 63 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/api/StorageApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/controller/StorageController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/usecase/StorageUseCase.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java create mode 100644 pennyway-common/src/main/java/kr/co/pennyway/common/util/UUIDUtil.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatProfileUrlGenerator.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatUrlGenerator.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatroomProfileUrlGenerator.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/FeedUrlGenerator.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyTemplate.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ProfileUrlGenerator.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGenerator.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGeneratorFactory.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageErrorCode.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageException.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AwsS3Config.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/api/StorageApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/api/StorageApi.java new file mode 100644 index 000000000..f6838b0b1 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/api/StorageApi.java @@ -0,0 +1,50 @@ +package kr.co.pennyway.api.apis.storage.api; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; + +@Tag(name = "[S3 이미지 저장을 위한 Presigned URL 발급 API]") +public interface StorageApi { + @Operation(summary = "S3 이미지 저장을 위한 Presigned URL 발급", description = "S3에 이미지를 저장하기 위한 Presigned URL을 발급합니다.") + @Parameters({ + @Parameter(name = "type", description = "이미지 종류", required = true, in = ParameterIn.QUERY, examples = { + @ExampleObject(value = "PROFILE"), + @ExampleObject(value = "FEED"), + @ExampleObject(value = "CHATROOM_PROFILE"), + @ExampleObject(value = "CHAT"), + @ExampleObject(value = "CHAT_PROFILE") + }), + @Parameter(name = "ext", description = "파일 확장자", required = true, examples = { + @ExampleObject(value = "jpg"), + @ExampleObject(value = "png"), + @ExampleObject(value = "jpeg") + }), + @Parameter(name = "userId", description = "사용자 ID", example = "1"), + @Parameter(name = "chatroomId", description = "채팅방 ID", example = "12345678-1234-5678-1234-567812345678"), + @Parameter(name = "request", hidden = true) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = PresignedUrlDto.Res.class))), + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "필수 파라미터 누락", value = """ + { + "code": "4001", + "message": "필수 파라미터가 누락되었습니다." + } + """) + })), + }) + ResponseEntity getPresignedUrl(@Validated PresignedUrlDto.Req req); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/controller/StorageController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/controller/StorageController.java new file mode 100644 index 000000000..490239f20 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/controller/StorageController.java @@ -0,0 +1,29 @@ +package kr.co.pennyway.api.apis.storage.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import kr.co.pennyway.api.apis.storage.api.StorageApi; +import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; +import kr.co.pennyway.api.apis.storage.usecase.StorageUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/storage") +public class StorageController implements StorageApi { + private final StorageUseCase storageUseCase; + + @Override + @GetMapping("/presigned-url") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getPresignedUrl(@Validated PresignedUrlDto.Req request) { + return ResponseEntity.ok(storageUseCase.getPresignedUrl(request)); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java new file mode 100644 index 000000000..db2bbf1bc --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java @@ -0,0 +1,38 @@ +package kr.co.pennyway.api.apis.storage.dto; + +import java.net.URI; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public class PresignedUrlDto { + @Schema(title = "S3 이미지 저장을 위한 Presigned URL 발급 요청 DTO", description = "S3에 이미지를 저장하기 위한 Presigned URL을 발급 요청을 위한 DTO") + public record Req( + @Schema(description = "이미지 종류", example = "PROFILE/FEED/CHATROOM_PROFILE/CHAT/CHAT_PROFILE") + @NotBlank(message = "이미지 종류는 필수입니다.") + String type, + @Schema(description = "파일 확장자", example = "jpg/png/jpeg") + @NotBlank(message = "파일 확장자는 필수입니다.") + String ext, + @Schema(description = "사용자 ID", example = "1") + String userId, + @Schema(description = "채팅방 ID", example = "12345678-1234-5678-1234-567812345678") + String chatroomId + ) { + } + + @Schema(title = "S3 이미지 저장을 위한 Presigned URL 발급 응답 DTO") + public record Res( + @Schema(description = "Presigned URL") + URI presignedUrl + ) { + /** + * Presigned URL 발급 응답 객체 생성 + * + * @param presignedUrl String : Presigned URL + */ + public static Res of(URI presignedUrl) { + return new Res(presignedUrl); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/usecase/StorageUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/usecase/StorageUseCase.java new file mode 100644 index 000000000..a4d97d3a4 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/usecase/StorageUseCase.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.api.apis.storage.usecase; + +import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class StorageUseCase { + private final AwsS3Provider awsS3Provider; + + public PresignedUrlDto.Res getPresignedUrl(PresignedUrlDto.Req request) { + return PresignedUrlDto.Res.of( + awsS3Provider.generatedPresignedUrl(request.type(), request.ext(), request.userId(), request.chatroomId()) + ); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountIntegrationTest.java index bacadae06..4b5055484 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountIntegrationTest.java @@ -1,5 +1,29 @@ package kr.co.pennyway.api.apis.ledger.controller; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; import kr.co.pennyway.api.config.fixture.UserFixture; @@ -9,83 +33,71 @@ import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @Slf4j @ExternalApiIntegrationTest @AutoConfigureMockMvc @TestClassOrder(ClassOrderer.OrderAnnotation.class) public class TargetAmountIntegrationTest extends ExternalApiDBTestConfig { - @Autowired - private MockMvc mockMvc; + @Autowired + private MockMvc mockMvc; - @Autowired - private UserService userService; + @Autowired + private UserService userService; - @Autowired - private TargetAmountService targetAmountService; + @Autowired + private TargetAmountService targetAmountService; - @Nested - @Order(1) - @DisplayName("당월 목표 금액 등록/수정") - @TestMethodOrder(MethodOrderer.OrderAnnotation.class) - class PutTargetAmount { - @Order(1) - @Test - @DisplayName("당월 목표 금액 entity가 존재하지 않을 경우 새로 생성한다.") - @WithSecurityMockUser(userId = "1") - @Transactional - void putTargetAmountNotFound() throws Exception { - // given - User user = UserFixture.GENERAL_USER.toUser(); - userService.createUser(user); - String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + @Nested + @Order(1) + @DisplayName("당월 목표 금액 등록/수정") + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class PutTargetAmount { + @Order(1) + @Test + @DisplayName("당월 목표 금액 entity가 존재하지 않을 경우 새로 생성한다.") + @WithSecurityMockUser + @Transactional + void putTargetAmountNotFound() throws Exception { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); - // when - ResultActions result = performPutTargetAmount(date, 100000); + // when + ResultActions result = performPutTargetAmount(date, 100000, user); - // then - result.andExpect(status().isOk()); - assertNotNull(targetAmountService.readTargetAmountThatMonth(user.getId(), LocalDate.now()).orElse(null)); - } + // then + result.andExpect(status().isOk()); + assertNotNull(targetAmountService.readTargetAmountThatMonth(user.getId(), LocalDate.now()).orElse(null)); + } - @Order(2) - @Test - @DisplayName("당월 목표 금액 entity가 존재하는 경우 amount를 수정한다.") - @WithSecurityMockUser(userId = "2") - @Transactional - void putTargetAmountFound() throws Exception { - // given - User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); - TargetAmount targetAmount = targetAmountService.createTargetAmount(TargetAmount.of(100000, user)); + @Order(2) + @Test + @DisplayName("당월 목표 금액 entity가 존재하는 경우 amount를 수정한다.") + @WithSecurityMockUser + @Transactional + void putTargetAmountFound() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + TargetAmount targetAmount = targetAmountService.createTargetAmount(TargetAmount.of(100000, user)); - String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); - // when - ResultActions result = performPutTargetAmount(date, 200000); + // when + ResultActions result = performPutTargetAmount(date, 200000, user); - // then - result.andExpect(status().isOk()); - assertEquals(200000, targetAmount.getAmount()); - } + // then + result.andExpect(status().isOk()); + assertEquals(200000, targetAmount.getAmount()); + } - private ResultActions performPutTargetAmount(String date, Integer amount) throws Exception { - return mockMvc.perform(put("/v2/targets") - .param("date", date) - .param("amount", amount.toString())); - } - } + private ResultActions performPutTargetAmount(String date, Integer amount, User requestUser) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + return mockMvc.perform(put("/v2/targets") + .with(user(userDetails)) + .param("date", date) + .param("amount", amount.toString())); + } + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java new file mode 100644 index 000000000..fee9b137d --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java @@ -0,0 +1,95 @@ +package kr.co.pennyway.api.apis.storage.controller; + +import static org.mockito.BDDMockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; +import kr.co.pennyway.api.apis.storage.usecase.StorageUseCase; +import kr.co.pennyway.api.config.WebConfig; +import kr.co.pennyway.infra.common.exception.StorageErrorCode; +import kr.co.pennyway.infra.common.exception.StorageException; + +@WebMvcTest(controllers = StorageController.class, excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) +@ActiveProfiles("test") +class StorageControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + private StorageUseCase storageUseCase; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(post("/**").with(csrf())) + .build(); + } + + @Test + @DisplayName("Type이 PROFILE이고, UserId가 NULL일 때 400 응답을 반환한다.") + void getPresignedUrlWithNullUserId() throws Exception { + // given + PresignedUrlDto.Req request = new PresignedUrlDto.Req("PROFILE", "jpg", null, null); + given(storageUseCase.getPresignedUrl(request)).willThrow(new StorageException(StorageErrorCode.MISSING_REQUIRED_PARAMETER)); + + // when + ResultActions resultActions = getPresignedUrlRequest(request); + + // then + resultActions.andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Type이 CHAT이고, ChatroomId가 NULL일 때 400 응답을 반환한다.") + void getPresignedUrlWithNullChatroomId() throws Exception { + // given + PresignedUrlDto.Req request = new PresignedUrlDto.Req("CHAT", "jpg", "userId", null); + given(storageUseCase.getPresignedUrl(request)).willThrow(new StorageException(StorageErrorCode.MISSING_REQUIRED_PARAMETER)); + + // when + ResultActions resultActions = getPresignedUrlRequest(request); + + // then + resultActions.andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Type이 CHATROOM_PROFILE이고, ChatroomId가 NULL일 때 400 응답을 반환한다.") + void getPresignedUrlWithNullChatroomIdForChatroomProfile() throws Exception { + // given + PresignedUrlDto.Req request = new PresignedUrlDto.Req("CHATROOM_PROFILE", "jpg", "userId", null); + given(storageUseCase.getPresignedUrl(request)).willThrow(new StorageException(StorageErrorCode.MISSING_REQUIRED_PARAMETER)); + + // when + ResultActions resultActions = getPresignedUrlRequest(request); + + // then + resultActions.andExpect(status().isBadRequest()); + } + + private ResultActions getPresignedUrlRequest(PresignedUrlDto.Req request) throws Exception { + return mockMvc.perform(get("/v1/storage/presigned-url") + .param("type", request.type()) + .param("ext", request.ext()) + .param("userId", request.userId()) + .param("chatRoomId", request.chatroomId())); + } +} \ No newline at end of file diff --git a/pennyway-common/src/main/java/kr/co/pennyway/common/util/UUIDUtil.java b/pennyway-common/src/main/java/kr/co/pennyway/common/util/UUIDUtil.java new file mode 100644 index 000000000..ccc51fff2 --- /dev/null +++ b/pennyway-common/src/main/java/kr/co/pennyway/common/util/UUIDUtil.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.common.util; + +public class UUIDUtil { + public static String generateUUID() { + return java.util.UUID.randomUUID().toString(); + } +} diff --git a/pennyway-infra/build.gradle b/pennyway-infra/build.gradle index 9cedc519c..390e57f7c 100644 --- a/pennyway-infra/build.gradle +++ b/pennyway-infra/build.gradle @@ -25,10 +25,12 @@ dependencies { /* aws */ implementation platform("software.amazon.awssdk:bom:2.25.26") implementation 'software.amazon.awssdk:sns:2.25.26' + implementation 'software.amazon.awssdk:s3:2.25.26' /* mail */ implementation 'org.springframework.boot:spring-boot-starter-mail:3.2.3' /* firebase */ implementation 'com.google.firebase:firebase-admin:9.2.0' + } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java new file mode 100644 index 000000000..031eca226 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java @@ -0,0 +1,93 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.net.URI; +import java.time.Duration; +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Component; + +import kr.co.pennyway.infra.common.exception.StorageErrorCode; +import kr.co.pennyway.infra.common.exception.StorageException; +import kr.co.pennyway.infra.config.AwsS3Config; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AwsS3Provider { + private static final Set extensionSet = Set.of("jpg", "png", "jpeg"); + + private final AwsS3Config awsS3Config; + private final S3Presigner s3Presigner; + + /** + * type에 해당하는 확장자를 가진 파일을 S3에 저장하기 위한 Presigned URL을 생성한다. + * @param type : ObjectKeyType (PROFILE, FEED, CHATROOM_PROFILE, CHAT, CHAT_PROFILE) + * @param ext : 파일 확장자 (jpg, png, jpeg) + * @param userId : 사용자 ID (PK) - PROFILE, CHAT_PROFILE + * @param chatroomId : 채팅방 ID (PK) - CHATROOM_PROFILE, CHAT, CHAT_PROFILE + * @return Presigned URL + * @throws Exception + */ + public URI generatedPresignedUrl(String type, String ext, String userId, String chatroomId) { + try { + if (!extensionSet.contains(ext)) { + throw new StorageException(StorageErrorCode.INVALID_EXTENSION); + } + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(awsS3Config.getBucketName()) + .key(generateObjectKey(type, ext, userId, chatroomId)) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(r -> r.putObjectRequest(putObjectRequest) + .signatureDuration(Duration.ofMinutes(10))); + + return presignedRequest.url().toURI(); + } catch (Exception e) { + log.error("Presigned URL 생성 중 오류 발생", e); + throw new StorageException(StorageErrorCode.MISSING_REQUIRED_PARAMETER); + } + } + + /** + * type에 해당하는 ObjectKeyTemplate을 적용하여 ObjectKey(S3에 저장하기 위한 정적 파일의 경로 및 이름)를 생성한다. + * @param type : ObjectKeyType (PROFILE, FEED, CHATROOM_PROFILE, CHAT, CHAT_PROFILE) + * @param ext : 파일 확장자 (jpg, png, jpeg) + * @param userId : 사용자 ID (PK) - PROFILE, CHAT_PROFILE + * @param chatroomId : 채팅방 ID (PK) - CHATROOM_PROFILE, CHAT, CHAT_PROFILE + * @return ObjectKey + */ + private String generateObjectKey(String type, String ext, String userId, String chatroomId) { + ObjectKeyTemplate objectKeyTemplate = new ObjectKeyTemplate(ObjectKeyType.valueOf(type).getTemplate()); + Map variables = generateObjectKeyVariables(type, ext, userId, chatroomId); + String objectKey = objectKeyTemplate.apply(variables); + return objectKey; + + } + + /** + * ObjectKey에 사용될 변수들을 Template에 적용하기 위한 Map에 담아 반환한다. + * @param type : ObjectKeyType (PROFILE, FEED, CHATROOM_PROFILE, CHAT, CHAT_PROFILE) + * @param ext : 파일 확장자 (jpg, png, jpeg) + * @param userId : 사용자 ID (PK) - PROFILE, CHAT_PROFILE + * @param chatroomId : 채팅방 ID (PK) - CHATROOM_PROFILE, CHAT, CHAT_PROFILE + * @return + */ + private Map generateObjectKeyVariables(String type, String ext, String userId, String chatroomId) { + ObjectKeyType objectType; + try { + objectType = ObjectKeyType.valueOf(type); + } catch (IllegalArgumentException e) { + throw new StorageException(StorageErrorCode.INVALID_TYPE); + } + + UrlGenerator urlGenerator = UrlGeneratorFactory.getUrlGenerator(objectType); + return urlGenerator.generate(type, ext, userId, chatroomId); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatProfileUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatProfileUrlGenerator.java new file mode 100644 index 000000000..8cd66b54d --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatProfileUrlGenerator.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.HashMap; +import java.util.Map; + +import kr.co.pennyway.common.util.UUIDUtil; + +public class ChatProfileUrlGenerator implements UrlGenerator { + @Override + public Map generate(String ext, String userId, String chatId, String chatroomId) { + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (chatroomId == null) { + chatroomId = UUIDUtil.generateUUID(); + } + Map variablesMap = new HashMap<>(); + variablesMap.put("uuid", UUIDUtil.generateUUID()); + variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis())); + variablesMap.put("ext", ext); + variablesMap.put("chatroom_id", chatroomId); + variablesMap.put("user_id", userId); + return variablesMap; + } +} + diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatUrlGenerator.java new file mode 100644 index 000000000..54e446bff --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatUrlGenerator.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.HashMap; +import java.util.Map; + +import kr.co.pennyway.common.util.UUIDUtil; + +public class ChatUrlGenerator implements UrlGenerator { + @Override + public Map generate(String ext, String userId, String chatId, String chatroomId) { + Map variablesMap = new HashMap<>(); + variablesMap.put("uuid", UUIDUtil.generateUUID()); + variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis())); + variablesMap.put("ext", ext); + variablesMap.put("chatroom_id", chatroomId); + variablesMap.put("chat_id", UUIDUtil.generateUUID()); + return variablesMap; + } +} + diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatroomProfileUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatroomProfileUrlGenerator.java new file mode 100644 index 000000000..98604d31f --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatroomProfileUrlGenerator.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.HashMap; +import java.util.Map; + +import kr.co.pennyway.common.util.UUIDUtil; + +public class ChatroomProfileUrlGenerator implements UrlGenerator { + @Override + public Map generate(String ext, String userId, String chatId, String chatroomId) { + if (chatroomId == null) { + chatroomId = UUIDUtil.generateUUID(); + } + Map variablesMap = new HashMap<>(); + variablesMap.put("uuid", UUIDUtil.generateUUID()); + variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis())); + variablesMap.put("ext", ext); + variablesMap.put("chatroom_id", chatroomId); + return variablesMap; + } +} + diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/FeedUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/FeedUrlGenerator.java new file mode 100644 index 000000000..4f4e630be --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/FeedUrlGenerator.java @@ -0,0 +1,18 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.HashMap; +import java.util.Map; + +import kr.co.pennyway.common.util.UUIDUtil; + +public class FeedUrlGenerator implements UrlGenerator { + @Override + public Map generate(String type, String ext, String userId, String chatroomId) { + Map variablesMap = new HashMap<>(); + variablesMap.put("uuid", UUIDUtil.generateUUID()); + variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis())); + variablesMap.put("ext", ext); + variablesMap.put("feed_id", UUIDUtil.generateUUID()); + return variablesMap; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyTemplate.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyTemplate.java new file mode 100644 index 000000000..da14b2a7c --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyTemplate.java @@ -0,0 +1,18 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.Map; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class ObjectKeyTemplate { + private String template; + + public String apply(Map variables) { + String result = template; + for (Map.Entry entry : variables.entrySet()) { + result = result.replace("{" + entry.getKey() + "}", entry.getValue()); + } + return result; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java new file mode 100644 index 000000000..8a60ca23d --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum ObjectKeyType { + PROFILE("1", "PROFILE", "/delete/profile/{userId}/{uuid}_{timestamp}.{ext}"), + FEED("2", "FEED", "/delete/feed/{feed_id}/{uuid}_{timestamp}.{ext}"), + CHATROOM_PROFILE("3", "CHATROOM_PROFILE", "/delete/chatroom/{chatroom_id}/{uuid}_{timestamp}.{ext}"), + CHAT("4", "CHAT", "/delete/chatroom/{chatroom_id}/chat/{chat_id}/{uuid}_{timestamp}.{ext}"), + CHAT_PROFILE("5", "CHAT_PROFILE", "/delete/chatroom/{chatroom_id}/chat_profile//{uuid}_{timestamp}.{ext}"); + + private final String code; + private final String type; + private final String template; + + public String getCode() { + return code; + } + + public String getType() { + return type; + } + + public String getTemplate() { + return template; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ProfileUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ProfileUrlGenerator.java new file mode 100644 index 000000000..00dbb45df --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ProfileUrlGenerator.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.HashMap; +import java.util.Map; + +import kr.co.pennyway.common.util.UUIDUtil; + +public class ProfileUrlGenerator implements UrlGenerator { + @Override + public Map generate(String type, String ext, String userId, String chatroomId) { + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + Map variablesMap = new HashMap<>(); + variablesMap.put("type", type); + variablesMap.put("ext", ext); + variablesMap.put("userId", userId); + variablesMap.put("uuid", UUIDUtil.generateUUID()); + variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis())); + return variablesMap; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGenerator.java new file mode 100644 index 000000000..a55037584 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGenerator.java @@ -0,0 +1,15 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.Map; + +public interface UrlGenerator { + /** + * type에 해당하는 ObjectKeyTemplate을 적용하여 ObjectKey(S3에 저장하기 위한 정적 파일의 경로 및 이름)를 생성한다. + * @param type + * @param ext + * @param userId + * @param chatroomId + * @return ObjectKey + */ + Map generate(String type, String ext, String userId, String chatroomId); +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGeneratorFactory.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGeneratorFactory.java new file mode 100644 index 000000000..ac143541e --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGeneratorFactory.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import kr.co.pennyway.infra.common.exception.StorageErrorCode; +import kr.co.pennyway.infra.common.exception.StorageException; + +public class UrlGeneratorFactory { + public static UrlGenerator getUrlGenerator(ObjectKeyType type) { + switch (type) { + case PROFILE: + return new ProfileUrlGenerator(); + case FEED: + return new FeedUrlGenerator(); + case CHATROOM_PROFILE: + return new ChatroomProfileUrlGenerator(); + case CHAT: + return new ChatUrlGenerator(); + case CHAT_PROFILE: + return new ChatProfileUrlGenerator(); + default: + throw new StorageException(StorageErrorCode.INVALID_TYPE); + } + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageErrorCode.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageErrorCode.java new file mode 100644 index 000000000..6a511aab2 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageErrorCode.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.infra.common.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum StorageErrorCode implements BaseErrorCode { + // 400 Bad Request + MISSING_REQUIRED_PARAMETER(StatusCode.BAD_REQUEST, ReasonCode.MISSING_REQUIRED_PARAMETER, "필수 파라미터가 누락되었습니다."), + INVALID_EXTENSION(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "지원하지 않는 확장자입니다."), + INVALID_TYPE(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "지원하지 않는 타입입니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageException.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageException.java new file mode 100644 index 000000000..8a960245b --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageException.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.infra.common.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class StorageException extends GlobalErrorException { + private final StorageErrorCode errorCode; + + public StorageException(StorageErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + @Override + public CausedBy causedBy() { + return errorCode.causedBy(); + } + + public StorageErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AwsS3Config.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AwsS3Config.java new file mode 100644 index 000000000..3478c5f72 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AwsS3Config.java @@ -0,0 +1,50 @@ +package kr.co.pennyway.infra.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import lombok.Getter; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Getter +@Configuration +public class AwsS3Config { + private final String accessKey; + private final String secretKey; + private final String region; + private final String bucketName; + + public AwsS3Config( + @Value("${spring.cloud.aws.s3.credentials.access-key}") String accessKey, + @Value("${spring.cloud.aws.s3.credentials.secret-key}") String secretKey, + @Value("${spring.cloud.aws.s3.region.static}") String region, + @Value("${spring.cloud.aws.s3.bucket.name}") String bucketName + ) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.region = region; + this.bucketName = bucketName; + } + + public String getBucketName() { + return bucketName; + } + + @Bean + public AwsCredentials awsS3Credentials() { + return AwsBasicCredentials.create(accessKey, secretKey); + } + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsS3Credentials())) + .build(); + } +} diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index 763469f98..ce8f4bdbd 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -17,6 +17,14 @@ spring: secret-key: ${AWS_SNS_SECRET_KEY:secret-key} region: static: ${AWS_SNS_REGION:republic-of-korea-1} + s3: + credentials: + access-key: ${AWS_S3_ACCESS_KEY:access-key} + secret-key: ${AWS_S3_SECRET_KEY:secret-key} + region: + static: ${AWS_S3_REGION:ap-northeast-2} + bucket: + name: ${AWS_S3_BUCKET_NAME:pennyway} app: question-address: ${ADMIN_ADDRESS:team.collabu@gmail.com} From 13a7043abd26bc67fa5efcce87929574e30b568b Mon Sep 17 00:00:00 2001 From: DinoDeveloper <79460319+asn6878@users.noreply.github.com> Date: Wed, 29 May 2024 09:05:17 +0900 Subject: [PATCH 091/152] =?UTF-8?q?=E2=9C=A8=20=EC=A7=80=EC=B6=9C=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=82=AD=EC=A0=9C=20API=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 삭제 api 작성 * docs: swagger 문서 작성 * fix: 인증 처리 이동에 의한 파라미터수정 * docs: 지출 삭제 swagger 403 예외 추가 * test: 삭제 테스트코드 작성 * feat: fixture 메소드 추가 및 삭제 테스트코드 assertion 추가 * feat: fixturefixture 상수 변경 --- .../api/apis/ledger/api/SpendingApi.java | 15 +++++ .../ledger/controller/SpendingController.java | 9 +++ .../apis/ledger/usecase/SpendingUseCase.java | 8 +++ .../SpendingControllerIntegrationTest.java | 58 +++++++++++++++++++ .../api/config/fixture/SpendingFixture.java | 42 ++++++++++++-- .../spending/service/SpendingService.java | 9 ++- 6 files changed, 134 insertions(+), 7 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java index 0b2a1cf30..d4b62248d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java @@ -77,4 +77,19 @@ public interface SpendingApi { })) }) ResponseEntity getSpendingDetail(@PathVariable Long spendingId, @AuthenticationPrincipal SecurityUserDetails user); + + + @Operation(summary = "지출 내역 삭제", method = "DELETE", description = "지출 내역의 ID값으로 해당 지출 내역을 삭제 합니다.") + @Parameter(name = "spendingId", description = "지출 내역 ID", example = "1", required = true, in = ParameterIn.PATH) + @ApiResponse(responseCode = "403", description = "지출 카테고리에 대한 권한이 없습니다.", content = @Content(examples = { + @ExampleObject(name = "지출 카테고리 권한 오류", description = "지출 카테고리에 대한 권한이 없습니다.", + value = """ + { + "code": "4030", + "message": "ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN" + } + """ + ) + })) + ResponseEntity deleteSpending(@PathVariable Long spendingId, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java index 3e009a4ba..bb57aefd4 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java @@ -48,6 +48,15 @@ public ResponseEntity getSpendingDetail(@PathVariable Long spendingId, @Authe return ResponseEntity.ok(SuccessResponse.from("spending", spendingUseCase.getSpedingDetail(user.getUserId(), spendingId))); } + @Override + @DeleteMapping("/{spendingId}") + @PreAuthorize("isAuthenticated() and @spendingManager.hasPermission(#user.getUserId(), #spendingId)") + public ResponseEntity deleteSpending(@PathVariable Long spendingId, @AuthenticationPrincipal SecurityUserDetails user) { + spendingUseCase.deleteSpending(spendingId); + + return ResponseEntity.ok(SuccessResponse.noContent()); + } + /** * categoryId가 -1이면 서비스에서 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 OTHER가 될 수 없고,
* categoryId가 -1이 아니면 사용자가 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 OTHER임을 확인한다. diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java index bee7fb6d6..6ef5e6410 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java @@ -54,4 +54,12 @@ public SpendingSearchRes.Individual getSpedingDetail(Long userId, Long spendingI return SpendingMapper.toSpendingSearchResIndividual(spending); } + + @Transactional + public void deleteSpending(Long spendingId) { + Spending spending = spendingService.readSpending(spendingId) + .orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_SPENDING)); + + spendingService.deleteSpending(spending); + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java index 56454ef6c..66c20df37 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java @@ -7,8 +7,10 @@ import kr.co.pennyway.api.config.ExternalApiIntegrationTest; import kr.co.pennyway.api.config.fixture.SpendingFixture; import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; @@ -43,10 +45,13 @@ public class SpendingControllerIntegrationTest extends ExternalApiDBTestConfig { @Autowired private UserService userService; @Autowired + private SpendingService spendingService; + @Autowired private SpendingCustomCategoryService spendingCustomCategoryService; @Autowired private NamedParameterJdbcTemplate jdbcTemplate; + @Order(1) @Nested @DisplayName("지출 내역 추가하기") @@ -142,6 +147,7 @@ void getSpendingListAtYearAndMonthSuccess() throws Exception { log.debug("수행 시간: {}ms", after - before); } + private ResultActions performGetSpendingListAtYearAndMonthSuccess(User requestUser) throws Exception { UserDetails userDetails = SecurityUserDetails.from(requestUser); LocalDate now = LocalDate.now(); @@ -152,4 +158,56 @@ private ResultActions performGetSpendingListAtYearAndMonthSuccess(User requestUs .param("month", String.valueOf(now.getMonthValue()))); } } + + @Order(4) + @Nested + @DisplayName("지출 내역 삭제") + class DeleteSpending { + + @Test + @DisplayName("지출 내역 삭제 성공") + @Transactional + void deleteSpendingSuccess() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + Spending spending = SpendingFixture.GENERAL_SPENDING.toSpending(user); + spendingService.createSpending(spending); + + // when + ResultActions resultActions = performDeleteSpendingSuccess(user, spending.getId()); + + // then + resultActions + .andDo(print()) + .andExpect(status().isOk()); + Assertions.assertTrue(spendingService.readSpending(spending.getId()).isEmpty()); + } + + @Test + @DisplayName("사용자가 spendingId에 해당하는 지출 내역의 소유자가 아닌 경우, 403 Forbidden을 반환한다.") + @Transactional + void deleteSpendingForbidden() throws Exception { + // given + User user1 = userService.createUser(UserFixture.GENERAL_USER.toUser()); + Spending spending = SpendingFixture.GENERAL_SPENDING.toSpending(user1); + spendingService.createSpending(spending); + User user2 = userService.createUser(UserFixture.GENERAL_USER.toUser()); + + // when + ResultActions resultActions = performDeleteSpendingSuccess(user2, spending.getId()); + + // then + resultActions + .andDo(print()) + .andExpect(status().isForbidden()); + } + + private ResultActions performDeleteSpendingSuccess(User requestUser, Long spendingId) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + + return mockMvc.perform(MockMvcRequestBuilders.delete("/v2/spendings/{spendingId}", spendingId) + .with(user(userDetails))); + } + } + } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java index 0807f431a..185084a1f 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java @@ -1,5 +1,6 @@ package kr.co.pennyway.api.config.fixture; +import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; @@ -14,8 +15,28 @@ import java.util.List; import java.util.concurrent.ThreadLocalRandom; -public class SpendingFixture { - private static final String SPENDING_TABLE = "spending"; +public enum SpendingFixture { + GENERAL_SPENDING(10000, SpendingCategory.FOOD, LocalDateTime.now(), "카페인 수혈", "아메리카노 1잔", UserFixture.GENERAL_USER.toUser()); + + private final int amount; + private final SpendingCategory category; + private final LocalDateTime spendAt; + private final String accountName; + private final String memo; + private final User user; + + SpendingFixture(int amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo, User user) { + this.amount = amount; + this.category = category; + this.spendAt = spendAt; + this.accountName = accountName; + this.memo = memo; + this.user = user; + } + + public static SpendingReq toSpendingReq(User user) { + return new SpendingReq(10000, -1L, SpendingCategory.FOOD, LocalDate.now(), "카페인 수혈", "아메리카노 1잔"); + } public static void bulkInsertSpending(User user, int capacity, NamedParameterJdbcTemplate jdbcTemplate) { Collection spendings = getRandomSpendings(user, capacity); @@ -23,7 +44,7 @@ public static void bulkInsertSpending(User user, int capacity, NamedParameterJdb String sql = String.format(""" INSERT INTO `%s` (amount, category, spend_at, account_name, memo, user_id, spending_custom_category_id, created_at, updated_at, deleted_at) VALUES (:amount, 1+FLOOR(RAND()*11), :spendAt, :accountName, :memo, :user.id, null, NOW(), NOW(), null) - """, SPENDING_TABLE); + """, "spending"); SqlParameterSource[] params = spendings.stream() .map(BeanPropertySqlParameterSource::new) .toArray(SqlParameterSource[]::new); @@ -56,7 +77,7 @@ private static LocalDateTime getRandomSpendAt(User user) { int year = ThreadLocalRandom.current().nextInt(startAt.getYear(), endAt.getYear() + 1); int month = (year == endAt.getYear()) ? ThreadLocalRandom.current().nextInt(1, endAt.getMonthValue() + 1) : ThreadLocalRandom.current().nextInt(1, 13); int day = ThreadLocalRandom.current().nextInt(1, 29); - + return LocalDateTime.of(year, month, day, 0, 0, 0); } @@ -64,4 +85,15 @@ private static String getRandomAccountName() { List accountNames = List.of("현금", "카드", "통장", "월급통장", "적금", "보험", "투자", "기타"); return accountNames.get(ThreadLocalRandom.current().nextInt(0, accountNames.size())); } -} + + public Spending toSpending(User user) { + return Spending.builder() + .amount(amount) + .category(category) + .spendAt(spendAt) + .accountName(accountName) + .memo(memo) + .user(user) + .build(); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java index 15e571b66..8e7d83044 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -47,8 +47,8 @@ public Optional readTotalSpendingAmountByUserId(Long userId @Transactional(readOnly = true) public List readSpendings(Predicate predicate, QueryHandler queryHandler, Sort sort) { return spendingRepository.findList(predicate, queryHandler, sort); - } - + } + @Transactional(readOnly = true) public List readTotalSpendingsAmountByUserId(Long userId) { Predicate predicate = user.id.eq(userId); @@ -70,4 +70,9 @@ public List readTotalSpendingsAmountByUserId(Long userId) { public boolean isExistsSpending(Long userId, Long spendingId) { return spendingRepository.existsByIdAndUser_Id(spendingId, userId); } + + @Transactional + public void deleteSpending(Spending spending) { + spendingRepository.delete(spending); + } } From 976c4e657cef2f807a52a037fbff36fbef6b5997 Mon Sep 17 00:00:00 2001 From: DinoDeveloper <79460319+asn6878@users.noreply.github.com> Date: Wed, 29 May 2024 23:59:13 +0900 Subject: [PATCH 092/152] =?UTF-8?q?=E2=9C=A8=20=EC=A7=80=EC=B6=9C=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=88=98=EC=A0=95=20API=20(#93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: usecase단의 필요없는 파라미터 제거 * feat: 지출내역 수정 controller 작성 * feat: usecase 작성 * feat: spending 업데이트 메소드 작성 및 service 작성 * feat: swagger docs 작성 * feat: spending.update 메소드를 customcategory 포함해 업데이트 하도록 수정 * docs: 지출 내역 생성과 중복된 에러응답 제거 * feat: 커스텀 카테고리 여부 분기 로직 고려해 로직 재작성 * refactor: 불필요한 user필드 제거를 위해 req dto toentity 메소드 오버로딩 후 리팩토링 * test: spendingupdateservice 테스트 작성 * test: 지출내역 상세 조회 및 수정 통합테스트 작성 * fix: 잘못 작성된 fixture메소드 제거 * test: spendingfixture 상수값으로 변경 * feat: 통합테스트 피드백 반영 * fix: 충돌 resolve 후 에러 수정 --- .../api/apis/ledger/api/SpendingApi.java | 9 +- .../ledger/controller/SpendingController.java | 13 +- .../api/apis/ledger/dto/SpendingReq.java | 27 +++++ .../ledger/service/SpendingUpdateService.java | 32 +++++ .../apis/ledger/usecase/SpendingUseCase.java | 35 +++++- .../SpendingControllerIntegrationTest.java | 112 +++++++++++++++++- .../service/SpendingUpdateServiceTest.java | 86 ++++++++++++++ .../domains/spending/domain/Spending.java | 9 ++ 8 files changed, 315 insertions(+), 8 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java index d4b62248d..b61289d3e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java @@ -78,7 +78,14 @@ public interface SpendingApi { }) ResponseEntity getSpendingDetail(@PathVariable Long spendingId, @AuthenticationPrincipal SecurityUserDetails user); - + @Operation(summary = "지출 내역 수정", method = "PUT", description = """ + 사용자의 지출 내역을 수정하고 수정된 지출 내역을 반환합니다.
+ 서비스에서 제공하는 지출 카테고리를 사용하는 경우 categoryId는 -1이어야 하며, icon은 OTHER가 될 수 없습니다.
+ 사용자가 정의한 지출 카테고리를 사용하는 경우 categoryId는 -1이 아니어야 하며, icon은 OTHER여야 합니다. + """) + @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "spending", schema = @Schema(implementation = SpendingSearchRes.Individual.class)))) + ResponseEntity updateSpending(@PathVariable Long spendingId, @RequestBody @Validated SpendingReq request, @AuthenticationPrincipal SecurityUserDetails user); + @Operation(summary = "지출 내역 삭제", method = "DELETE", description = "지출 내역의 ID값으로 해당 지출 내역을 삭제 합니다.") @Parameter(name = "spendingId", description = "지출 내역 ID", example = "1", required = true, in = ParameterIn.PATH) @ApiResponse(responseCode = "403", description = "지출 카테고리에 대한 권한이 없습니다.", content = @Content(examples = { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java index bb57aefd4..7648ecddd 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java @@ -45,7 +45,18 @@ public ResponseEntity getSpendingListAtYearAndMonth(@RequestParam("year") int @GetMapping("/{spendingId}") @PreAuthorize("isAuthenticated() and @spendingManager.hasPermission(#user.getUserId(), #spendingId)") public ResponseEntity getSpendingDetail(@PathVariable Long spendingId, @AuthenticationPrincipal SecurityUserDetails user) { - return ResponseEntity.ok(SuccessResponse.from("spending", spendingUseCase.getSpedingDetail(user.getUserId(), spendingId))); + return ResponseEntity.ok(SuccessResponse.from("spending", spendingUseCase.getSpedingDetail(spendingId))); + } + + @Override + @PutMapping("/{spendingId}") + @PreAuthorize("isAuthenticated() and @spendingManager.hasPermission(#user.getUserId(), #spendingId)") + public ResponseEntity updateSpending(@PathVariable Long spendingId, @RequestBody @Validated SpendingReq request, @AuthenticationPrincipal SecurityUserDetails user) { + if (!isValidCategoryIdAndIcon(request.categoryId(), request.icon())) { + throw new SpendingErrorException(SpendingErrorCode.INVALID_ICON_WITH_CATEGORY_ID); + } + + return ResponseEntity.ok(SuccessResponse.from("spending", spendingUseCase.updateSpending(spendingId, request))); } @Override diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java index 23093cb08..a6e475679 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java @@ -69,6 +69,33 @@ public Spending toEntity(User user, SpendingCustomCategory spendingCustomCategor .build(); } + /** + * 지출 내역 수정시 사용되는 user필드가 null인 지출 내역으로 변환 + */ + public Spending toEntity() { + return Spending.builder() + .amount(amount) + .category(icon) + .spendAt(spendAt.atStartOfDay()) + .accountName(accountName) + .memo(memo) + .build(); + } + + /** + * 지출 내역 수정시 사용되는 user필드가 null이며, 사용자 정의 지출 카테고리를 사용하는 지출 내역으로 변환 + */ + public Spending toEntity(SpendingCustomCategory spendingCustomCategory) { + return Spending.builder() + .amount(amount) + .category(icon) + .spendAt(spendAt.atStartOfDay()) + .accountName(accountName) + .memo(memo) + .spendingCustomCategory(spendingCustomCategory) + .build(); + } + @Schema(hidden = true) public boolean isCustomCategory() { return !categoryId.equals(-1L); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java new file mode 100644 index 000000000..08638093e --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java @@ -0,0 +1,32 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; +import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SpendingUpdateService { + private final SpendingCustomCategoryService spendingCustomCategoryService; + + @Transactional + public Spending updateSpending(Spending spending, SpendingReq request) { + if (!request.isCustomCategory()) { + spending.update(request.toEntity()); + } else { + SpendingCustomCategory customCategory = spendingCustomCategoryService.readSpendingCustomCategory(request.categoryId()) + .orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_CUSTOM_CATEGORY)); + spending.update(request.toEntity(customCategory)); + } + + return spending; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java index 6ef5e6410..14ef1995b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java @@ -5,6 +5,7 @@ import kr.co.pennyway.api.apis.ledger.mapper.SpendingMapper; import kr.co.pennyway.api.apis.ledger.service.SpendingSaveService; import kr.co.pennyway.api.apis.ledger.service.SpendingSearchService; +import kr.co.pennyway.api.apis.ledger.service.SpendingUpdateService; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; @@ -26,14 +27,16 @@ public class SpendingUseCase { private final SpendingSaveService spendingSaveService; private final SpendingSearchService spendingSearchService; + private final SpendingUpdateService spendingUpdateService; private final SpendingService spendingService; + private final UserService userService; @Transactional public SpendingSearchRes.Individual createSpending(Long userId, SpendingReq request) { - User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + User user = readUserOrThrow(userId); Spending spending = spendingSaveService.createSpending(user, request); @@ -48,13 +51,21 @@ public SpendingSearchRes.Month getSpendingsAtYearAndMonth(Long userId, int year, } @Transactional(readOnly = true) - public SpendingSearchRes.Individual getSpedingDetail(Long userId, Long spendingId) { - Spending spending = spendingService.readSpending(spendingId) - .orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_SPENDING)); + public SpendingSearchRes.Individual getSpedingDetail(Long spendingId) { + Spending spending = readSpendingOrThrow(spendingId); return SpendingMapper.toSpendingSearchResIndividual(spending); } + @Transactional + public SpendingSearchRes.Individual updateSpending(Long spendingId, SpendingReq request) { + Spending spending = readSpendingOrThrow(spendingId); + + Spending updatedSpending = spendingUpdateService.updateSpending(spending, request); + + return SpendingMapper.toSpendingSearchResIndividual(updatedSpending); + } + @Transactional public void deleteSpending(Long spendingId) { Spending spending = spendingService.readSpending(spendingId) @@ -62,4 +73,20 @@ public void deleteSpending(Long spendingId) { spendingService.deleteSpending(spending); } + + private User readUserOrThrow(Long userId) { + return userService.readUser(userId).orElseThrow( + () -> { + throw new UserErrorException(UserErrorCode.NOT_FOUND); + } + ); + } + + private Spending readSpendingOrThrow(Long spendingId) { + return spendingService.readSpending(spendingId).orElseThrow( + () -> { + throw new SpendingErrorException(SpendingErrorCode.NOT_FOUND_SPENDING); + } + ); + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java index 66c20df37..baa9eed9e 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java @@ -159,8 +159,115 @@ private ResultActions performGetSpendingListAtYearAndMonthSuccess(User requestUs } } + @Order(3) + @Nested + @DisplayName("지출 내역 상세 조회") + class GetSpendingDetail { + @Test + @DisplayName("지출 내역 상세 조회 성공") + @Transactional + void getSpendingDetailSuccess() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + Spending spending = SpendingFixture.GENERAL_SPENDING.toSpending(user); + spendingService.createSpending(spending); + + // when + ResultActions resultActions = performGetSpendingDetailSuccess(user, spending.getId()); + + // then + resultActions + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.spending.id").value(spending.getId())); + } + + @Test + @DisplayName("사용자가 spendingId에 해당하는 지출내역의 작성자가 아닌 수정시 403 Forbidden을 반환한다.") + @Transactional + void getSpendingDetailForbidden() throws Exception { + // given + User user1 = userService.createUser(UserFixture.GENERAL_USER.toUser()); + + Spending spending = SpendingFixture.GENERAL_SPENDING.toSpending(user1); + spendingService.createSpending(spending); + User user2 = userService.createUser(UserFixture.GENERAL_USER.toUser()); + + // when + ResultActions resultActions = performGetSpendingDetailSuccess(user2, spending.getId()); + + // then + resultActions + .andDo(print()) + .andExpect(status().isForbidden()); + } + + private ResultActions performGetSpendingDetailSuccess(User requestUser, Long spendingId) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + + return mockMvc.perform(MockMvcRequestBuilders.get("/v2/spendings/{spendingId}", spendingId) + .with(user(userDetails))); + } + } + @Order(4) @Nested + @DisplayName("지출 내역 수정") + class UpdateSpending { + @Test + @DisplayName("지출 내역 수정 성공") + void updateSpendingSuccess() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + Spending spending = SpendingFixture.GENERAL_SPENDING.toSpending(user); + + SpendingReq request = new SpendingReq(20000, -1L, SpendingCategory.LIVING, LocalDate.now(), "수정된 소비처", "수정된 메모"); + spendingService.createSpending(spending); + + // when + ResultActions resultActions = performUpdateSpending(request, user, spending.getId()); + + // then + resultActions + .andDo(print()) + .andExpect(status().isOk()); + + Spending updatedSpending = spendingService.readSpending(spending.getId()).get(); + Assertions.assertEquals(request.memo(), updatedSpending.getMemo()); + } + + @Test + @DisplayName("사용자가 spendingId에 해당하는 지출내역의 작성자가 아닌 수정시 403 Forbidden을 반환한다.") + @Transactional + void updateSpendingForbidden() throws Exception { + // given + User user1 = userService.createUser(UserFixture.GENERAL_USER.toUser()); + Spending spending = SpendingFixture.GENERAL_SPENDING.toSpending(user1); + spendingService.createSpending(spending); + User user2 = userService.createUser(UserFixture.GENERAL_USER.toUser()); + SpendingReq request = SpendingFixture.toSpendingReq(user2); + + // when + ResultActions resultActions = performUpdateSpending(request, user2, spending.getId()); + + // then + resultActions + .andDo(print()) + .andExpect(status().isForbidden()); + } + + private ResultActions performUpdateSpending(SpendingReq req, User requestUser, Long spendingId) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + + return mockMvc.perform(MockMvcRequestBuilders.put("/v2/spendings/{spendingId}", spendingId) + .contentType("application/json") + .with(user(userDetails)) + .content(objectMapper.writeValueAsString(req))); + } + } + + @Order(5) + @Nested @DisplayName("지출 내역 삭제") class DeleteSpending { @@ -180,6 +287,7 @@ void deleteSpendingSuccess() throws Exception { resultActions .andDo(print()) .andExpect(status().isOk()); + Assertions.assertTrue(spendingService.readSpending(spending.getId()).isEmpty()); } @@ -187,6 +295,7 @@ void deleteSpendingSuccess() throws Exception { @DisplayName("사용자가 spendingId에 해당하는 지출 내역의 소유자가 아닌 경우, 403 Forbidden을 반환한다.") @Transactional void deleteSpendingForbidden() throws Exception { + // given User user1 = userService.createUser(UserFixture.GENERAL_USER.toUser()); Spending spending = SpendingFixture.GENERAL_SPENDING.toSpending(user1); @@ -209,5 +318,4 @@ private ResultActions performDeleteSpendingSuccess(User requestUser, Long spendi .with(user(userDetails))); } } - -} +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java new file mode 100644 index 000000000..9c6e0c764 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java @@ -0,0 +1,86 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; +import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.given; + +@Slf4j +@ExtendWith(MockitoExtension.class) +public class SpendingUpdateServiceTest { + private SpendingUpdateService spendingUpdateService; + @Mock + private SpendingCustomCategoryService spendingCustomCategoryService; + + private Spending spending; + private Spending spendingWithCustomCategory; + private SpendingReq request; + private SpendingReq requestWithCustomCategory; + private User user; + private SpendingCustomCategory customCategory; + + + @BeforeEach + void setUp() { + spendingUpdateService = new SpendingUpdateService(spendingCustomCategoryService); + + request = new SpendingReq(10000, -1L, SpendingCategory.FOOD, LocalDate.now(), "소비처", "메모"); + requestWithCustomCategory = new SpendingReq(10000, 1L, SpendingCategory.OTHER, LocalDate.now(), "소비처", "메모"); + + user = UserFixture.GENERAL_USER.toUser(); + + customCategory = SpendingCustomCategory.of("커스텀카테고리", SpendingCategory.FOOD, user); + + spending = request.toEntity(user); + spendingWithCustomCategory = requestWithCustomCategory.toEntity(user, customCategory); + } + + @DisplayName("없는 사용자 정의 카테고리로 지출 내역을 수정하려고 할 때 SpendingErrorException을 발생시킨다.") + @Test + void testUpdateSpendingWithCustomCategoryNotFound() { + // given + given(spendingCustomCategoryService.readSpendingCustomCategory(1L)).willReturn(Optional.empty()); + + // when - then + SpendingErrorException exception = assertThrows(SpendingErrorException.class, () -> { + spendingUpdateService.updateSpending(spending, requestWithCustomCategory); + }); + log.debug(exception.getExplainError()); + } + + @DisplayName("커스텀 카테고리를 사용한 지출 내역으로 수정할 시, 커스텀 카테고리를 포함하는 지출내역으로 Spending 객체가 수정 된다.") + @Test + void testUpdateSpendingWithCustomCategory() { + // given + given(spendingCustomCategoryService.readSpendingCustomCategory(1L)).willReturn(Optional.of(customCategory)); + + // when - then + assertDoesNotThrow(() -> spendingUpdateService.updateSpending(spending, requestWithCustomCategory)); + assertNotNull(spending.getSpendingCustomCategory()); + } + + @DisplayName("시스템 카테고리를 사용한 지출내역으로 수정할 시, Spending 객체가 수정된다.") + @Test + void testUpdateSpendingWithNonCustomCategory() { + // when - then + assertDoesNotThrow(() -> spendingUpdateService.updateSpending(spending, request)); + assertNull(spending.getSpendingCustomCategory()); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java index 3b99317eb..a3d92bd01 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java @@ -88,4 +88,13 @@ public void updateSpendingCustomCategory(SpendingCustomCategory spendingCustomCa this.spendingCustomCategory = spendingCustomCategory; } + + public void update(Spending spending) { + this.amount = spending.amount; + this.category = spending.category; + this.spendAt = spending.spendAt; + this.accountName = spending.accountName; + this.memo = spending.memo; + this.spendingCustomCategory = spending.spendingCustomCategory; + } } From 69ccdc57b31e76816a81bd316a916ea4324285ee Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 30 May 2024 00:10:13 +0900 Subject: [PATCH 093/152] =?UTF-8?q?=E2=9C=A8=20Redisson=EC=9D=84=20?= =?UTF-8?q?=ED=99=9C=EC=9A=A9=ED=95=9C=20=EB=B6=84=EC=82=B0=20=EB=9D=BD(Di?= =?UTF-8?q?stributed=20Lock)=20=ED=99=98=EA=B2=BD=20=EA=B5=AC=EC=B6=95=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20(#98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: domain 모듈 내 redission 의존성 추가 * feat: redisson config 설정 * feat: 분산락 커스텀 어노테이션 생성 * feat: spring spel 커스텀 파서 유틸 클래스 생성 * fix: 분산락 커스텀 어노테이션 need_same_transaction 필드 추가 * rename: need_same_transaction 주석 추가 * feat: transaction propagation 레벨 지정 추상 팩토리 작성 * rename: need_same_transaction -> new 수정 * fix: redisson_call_new_transaction timout 및 주석 수정 * rename: unit -> timeunit 이름 수정 * feat: 분산락 aop 클래스 구현 * fix: redis_connection_factory & redis_cache_manager 빈 @primary 등록 * test: redisson 설정을 위한 test container redis password 설정 추가 * test: test container domain 모듈 redis password 추가 * test: domain 모듈 db & redis testcontainer config 설정 * test: 분산 락 테스트용 더미 entity 정의 * test: 분산 락 테스트용 더미 service 구현 * test: 분산락 테스트 작성 * fix: return null 수정 * rename: 락 해제 시점 로그 추가 * test: aop unit test 적용 * fix: test_jpa_config query_dsl bean conditional 옵션 적용 * test: 도메인 모듈 통합 테스트 어노테이션 생성 * fix: redis_config scan 경로 지정 * chore: could not safely identify store assignment for repository 이슈 제거 * test: @domain_integration_test 환경 추가 * test: test_coupon_repository 적용 * test: test_coupone service & repository profile test 지정 * test: 분산 락이 없는 테스트 케이스 추가 * test: jwt_auth_helper @redis_unit_test 어노테이션 제거 * test: test_jpa_config_conditional_on_missing_bean 메서드에 @bean 추가 * fix: query_dsl_config @primary 지정 * fix: lock 획득 대기시간 연장 --- .../apis/auth/helper/JwtAuthHelperTest.java | 12 +-- .../api/config/ExternalApiDBTestConfig.java | 2 + pennyway-domain/build.gradle | 3 + .../domain/common/aop/CallTransaction.java | 7 ++ .../common/aop/CallTransactionFactory.java | 15 +++ .../domain/common/aop/DistributedLock.java | 40 ++++++++ .../domain/common/aop/DistributedLockAop.java | 57 ++++++++++++ .../aop/RedissonCallNewTransaction.java | 21 +++++ .../aop/RedissonCallSameTransaction.java | 21 +++++ .../common/redis/RedisPackageLocation.java | 4 + .../common/util/CustomSpringELParser.java | 30 ++++++ .../co/pennyway/domain/config/JpaConfig.java | 3 +- .../domain/config/QueryDslConfig.java | 2 + .../pennyway/domain/config/RedisConfig.java | 6 +- .../domain/config/RedissonConfig.java | 35 +++++++ .../domain/domains/JpaPackageLocation.java | 4 + ...> RefreshTokenServiceIntegrationTest.java} | 6 +- .../redisson/CouponDecreaseLockTest.java | 91 +++++++++++++++++++ .../domain/config/ContainerDBTestConfig.java | 46 ++++++++++ .../config/ContainerRedisTestConfig.java | 2 + .../DomainIntegrationProfileResolver.java | 12 +++ .../domain/config/DomainIntegrationTest.java | 14 +++ .../config/DomainIntegrationTestConfig.java | 16 ++++ .../pennyway/domain/config/TestJpaConfig.java | 4 +- .../domain/domains/coupon/TestCoupon.java | 40 ++++++++ .../coupon/TestCouponDecreaseService.java | 30 ++++++ .../domains/coupon/TestCouponRepository.java | 8 ++ .../co/pennyway/infra/config/CacheConfig.java | 1 + 28 files changed, 513 insertions(+), 19 deletions(-) create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransaction.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLock.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAop.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/RedisPackageLocation.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/CustomSpringELParser.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/JpaPackageLocation.java rename pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/{RefreshTokenServiceUnitTest.java => RefreshTokenServiceIntegrationTest.java} (94%) create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerDBTestConfig.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationProfileResolver.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTest.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTestConfig.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCoupon.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponDecreaseService.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponRepository.java diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java index 7231ce298..bc9ac2cdf 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/helper/JwtAuthHelperTest.java @@ -11,13 +11,11 @@ import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService; import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenServiceImpl; import kr.co.pennyway.domain.config.RedisConfig; -import kr.co.pennyway.domain.config.RedisUnitTest; import kr.co.pennyway.domain.domains.user.type.Role; import kr.co.pennyway.infra.common.exception.JwtErrorCode; import kr.co.pennyway.infra.common.exception.JwtErrorException; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -36,14 +34,13 @@ @Slf4j @ExtendWith(MockitoExtension.class) -@RedisUnitTest @DataRedisTest(properties = "spring.config.location=classpath:application-domain.yml") -@ContextConfiguration(classes = {RedisConfig.class, JwtAuthHelper.class}) +@ContextConfiguration(classes = {RedisConfig.class, JwtAuthHelper.class, RefreshTokenServiceImpl.class}) @ActiveProfiles("test") public class JwtAuthHelperTest extends ExternalApiDBTestConfig { @Autowired private JwtAuthHelper jwtAuthHelper; - + @Autowired private RefreshTokenService refreshTokenService; @Autowired @@ -58,11 +55,6 @@ public class JwtAuthHelperTest extends ExternalApiDBTestConfig { @MockBean private ForbiddenTokenService forbiddenTokenService; - @BeforeEach - void setUp() { - this.refreshTokenService = new RefreshTokenServiceImpl(refreshTokenRepository); - } - @Test @DisplayName("사용자 아이디에 해당하는 리프레시 토큰이 존재할 시, 리프레시 토큰 갱신에 성공한다.") public void RefreshTokenRefreshSuccess() { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java index b03255832..5632616f9 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java @@ -21,6 +21,7 @@ public abstract class ExternalApiDBTestConfig { REDIS_CONTAINER = new RedisContainer(DockerImageName.parse(REDIS_CONTAINER_IMAGE)) .withExposedPorts(6379) + .withCommand("redis-server", "--requirepass testpass") .withReuse(true); MYSQL_CONTAINER = new MySQLContainer<>(DockerImageName.parse(MYSQL_CONTAINER_IMAGE)) @@ -37,6 +38,7 @@ public abstract class ExternalApiDBTestConfig { public static void setRedisProperties(DynamicPropertyRegistry registry) { registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); registry.add("spring.data.redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(6379))); + registry.add("spring.data.redis.password", () -> "testpass"); registry.add("spring.datasource.url", () -> String.format("jdbc:mysql://%s:%s/pennyway?serverTimezone=UTC&characterEncoding=utf8", MYSQL_CONTAINER.getHost(), MYSQL_CONTAINER.getMappedPort(3306))); registry.add("spring.datasource.username", () -> "root"); registry.add("spring.datasource.password", () -> "testpass"); diff --git a/pennyway-domain/build.gradle b/pennyway-domain/build.gradle index 57f2ecd65..a91450122 100644 --- a/pennyway-domain/build.gradle +++ b/pennyway-domain/build.gradle @@ -23,6 +23,9 @@ dependencies { testImplementation "org.testcontainers:testcontainers:1.19.7" testImplementation "org.testcontainers:mysql:1.19.7" testImplementation "com.redis.testcontainers:testcontainers-redis-junit:1.6.4" + + /* Redission */ + implementation 'org.redisson:redisson-spring-boot-starter:3.30.0' } def querydslDir = 'src/main/generated' diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransaction.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransaction.java new file mode 100644 index 000000000..05bdd3e42 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransaction.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.common.aop; + +import org.aspectj.lang.ProceedingJoinPoint; + +public interface CallTransaction { + Object proceed(ProceedingJoinPoint joinPoint) throws Throwable; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java new file mode 100644 index 000000000..da749fa4f --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java @@ -0,0 +1,15 @@ +package kr.co.pennyway.domain.common.aop; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CallTransactionFactory { + private final RedissonCallNewTransaction redissonCallNewTransaction; + private final RedissonCallSameTransaction redissonCallSameTransaction; + + public CallTransaction getCallTransaction(boolean isNewTransaction) { + return isNewTransaction ? redissonCallNewTransaction : redissonCallSameTransaction; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLock.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLock.java new file mode 100644 index 000000000..914b1d6df --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLock.java @@ -0,0 +1,40 @@ +package kr.co.pennyway.domain.common.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DistributedLock { + /** + * Lock 이름 + */ + String key(); + + /** + * Lock 유지 시간 (초) + */ + TimeUnit timeUnit() default TimeUnit.SECONDS; + + /** + * Lock 유지 시간 (DEFAULT: 10초) + * LOCK 획득을 위해 waitTime만큼 대기한다. + */ + long waitTime() default 10L; + + /** + * Lock 임대 시간 (DEFAULT: 5초) + * LOCK 획득 이후 leaseTime이 지나면 LOCK을 해제한다. + */ + long leaseTime() default 5L; + + /** + * 동일한 트랜잭션에서 Lock을 획득할지 여부 (DEFAULT: true)
+ * - true : Propagation.REQUIRES_NEW 전파 방식을 사용하여 새로운 트랜잭션에서 Lock을 획득한다.
+ * - false : Propagation.MANDATORY 전파 방식을 사용하여 동일한 트랜잭션에서 Lock을 획득한다. + */ + boolean needNewTransaction() default true; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAop.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAop.java new file mode 100644 index 000000000..06b3b2284 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAop.java @@ -0,0 +1,57 @@ +package kr.co.pennyway.domain.common.aop; + +import kr.co.pennyway.domain.common.util.CustomSpringELParser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; + +/** + * {@link DistributedLock} 어노테이션을 사용한 메소드에 대한 분산 락 처리를 위한 AOP + */ +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class DistributedLockAop { + private static final String REDISSON_LOCK_PREFIX = "LOCK:"; + + private final RedissonClient redissonClient; + private final CallTransactionFactory callTransactionFactory; + + @Around("@annotation(kr.co.pennyway.domain.common.aop.DistributedLock)") + public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + DistributedLock distributedLock = method.getAnnotation(DistributedLock.class); + + String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key()); + RLock rLock = redissonClient.getLock(key); + + try { + boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit()); + if (!available) { + return false; + } + log.info("{} : Redisson Lock 진입 : {} {}", Thread.currentThread().getId(), method.getName(), key); + + return callTransactionFactory.getCallTransaction(distributedLock.needNewTransaction()).proceed(joinPoint); + } catch (InterruptedException e) { + throw new InterruptedException("Failed to acquire lock: " + key); + } finally { + try { + log.info("{} : Redisson Lock 해제 : {} {}", Thread.currentThread().getId(), method.getName(), key); + rLock.unlock(); + } catch (IllegalMonitorStateException ignored) { + log.error("Redisson lock is already unlocked: {} {}", method.getName(), key); + } + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java new file mode 100644 index 000000000..0749147d3 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.domain.common.aop; + +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class RedissonCallNewTransaction implements CallTransaction { + /** + * 다른 트랜잭션이 실행 중인 경우에도 새로운 트랜잭션을 생성하여 이 메서드를 실행한다. + * 동시성 환경에서 데이터 정합성을 보장하기 위해 트랜잭션 커밋 이후 락이 해제된다. + */ + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java new file mode 100644 index 000000000..d8afb0bf1 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.domain.common.aop; + +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class RedissonCallSameTransaction implements CallTransaction { + /** + * 기존 트랜잭션 내에서 이 메서드를 실행하며, 새로운 트랜잭션을 생성하지 않는다. + * 트랜잭션이 활성화되어 있지 않으면 예외를 발생시킨다. + */ + @Override + @Transactional(propagation = Propagation.MANDATORY, timeout = 2) + public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/RedisPackageLocation.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/RedisPackageLocation.java new file mode 100644 index 000000000..5b017dbfa --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/RedisPackageLocation.java @@ -0,0 +1,4 @@ +package kr.co.pennyway.domain.common.redis; + +public interface RedisPackageLocation { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/CustomSpringELParser.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/CustomSpringELParser.java new file mode 100644 index 000000000..e202b5ac5 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/CustomSpringELParser.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.domain.common.util; + +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +/** + * Spring Expression Language (SpEL)을 사용한 커스텀 EL 파서 + */ +public class CustomSpringELParser { + /** + * SpEL을 사용하여 동적으로 값을 평가한다. + * + * @param parameterNames : 메서드 파라미터 이름 + * @param args : 메서드 파라미터 값 + * @param key : SpEL 표현식 + * @return : 평가된 값 + */ + public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) { + ExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + // 메서드 파라미터 이름과 값을 SpEL 컨텍스트에 변수로 설정 + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + return parser.parseExpression(key).getValue(context, Object.class); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java index 7e03c853a..c068ec90c 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java @@ -2,6 +2,7 @@ import kr.co.pennyway.domain.DomainPackageLocation; import kr.co.pennyway.domain.common.repository.ExtendedRepositoryFactory; +import kr.co.pennyway.domain.domains.JpaPackageLocation; import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @@ -10,6 +11,6 @@ @Configuration @EnableJpaAuditing @EntityScan(basePackageClasses = DomainPackageLocation.class) -@EnableJpaRepositories(basePackageClasses = DomainPackageLocation.class, repositoryFactoryBeanClass = ExtendedRepositoryFactory.class) +@EnableJpaRepositories(basePackageClasses = JpaPackageLocation.class, repositoryFactoryBeanClass = ExtendedRepositoryFactory.class) public class JpaConfig { } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java index 41dc6a1c8..7b40ad85d 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java @@ -5,6 +5,7 @@ import jakarta.persistence.PersistenceContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; @Configuration public class QueryDslConfig { @@ -12,6 +13,7 @@ public class QueryDslConfig { private EntityManager entityManager; @Bean + @Primary public JPAQueryFactory jpaQueryFactory() { return new JPAQueryFactory(entityManager); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java index cd445db49..e485eb36f 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java @@ -3,8 +3,8 @@ import kr.co.pennyway.domain.common.annotation.DomainRedisCacheManager; import kr.co.pennyway.domain.common.annotation.DomainRedisConnectionFactory; import kr.co.pennyway.domain.common.annotation.DomainRedisTemplate; +import kr.co.pennyway.domain.common.redis.RedisPackageLocation; import org.springframework.beans.factory.annotation.Value; -import org.springframework.cache.CacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; @@ -24,7 +24,7 @@ import java.time.Duration; @Configuration -@EnableRedisRepositories +@EnableRedisRepositories(basePackageClasses = RedisPackageLocation.class) @EnableTransactionManagement public class RedisConfig { private final String host; @@ -65,7 +65,7 @@ public RedisConnectionFactory redisConnectionFactory() { @Bean @DomainRedisCacheManager - public CacheManager redisCacheManager(@DomainRedisConnectionFactory RedisConnectionFactory cf) { + public RedisCacheManager redisCacheManager(@DomainRedisConnectionFactory RedisConnectionFactory cf) { RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith( diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java new file mode 100644 index 000000000..1665cc554 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.domain.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + private static final String REDISSON_HOST_PREFIX = "redis://"; + private final String host; + private final int port; + private final String password; + + public RedissonConfig( + @Value("${spring.data.redis.host}") String host, + @Value("${spring.data.redis.port}") int port, + @Value("${spring.data.redis.password}") String password + ) { + this.host = host; + this.port = port; + this.password = password; + } + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress(REDISSON_HOST_PREFIX + host + ":" + port) + .setPassword(password); + return Redisson.create(config); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/JpaPackageLocation.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/JpaPackageLocation.java new file mode 100644 index 000000000..c46f1eca0 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/JpaPackageLocation.java @@ -0,0 +1,4 @@ +package kr.co.pennyway.domain.domains; + +public interface JpaPackageLocation { +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceUnitTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceIntegrationTest.java similarity index 94% rename from pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceUnitTest.java rename to pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceIntegrationTest.java index 06e1f0a23..204755be6 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceUnitTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceIntegrationTest.java @@ -2,7 +2,6 @@ import kr.co.pennyway.domain.config.ContainerRedisTestConfig; import kr.co.pennyway.domain.config.RedisConfig; -import kr.co.pennyway.domain.config.RedisUnitTest; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -17,11 +16,10 @@ import static org.springframework.test.util.AssertionErrors.assertFalse; @Slf4j -@RedisUnitTest @DataRedisTest(properties = "spring.config.location=classpath:application-domain.yml") -@ContextConfiguration(classes = {RedisConfig.class}) +@ContextConfiguration(classes = {RedisConfig.class, RefreshTokenServiceImpl.class}) @ActiveProfiles("test") -public class RefreshTokenServiceUnitTest extends ContainerRedisTestConfig { +public class RefreshTokenServiceIntegrationTest extends ContainerRedisTestConfig { @Autowired private RefreshTokenRepository refreshTokenRepository; private RefreshTokenService refreshTokenService; diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java new file mode 100644 index 000000000..ff622f2fa --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java @@ -0,0 +1,91 @@ +package kr.co.pennyway.domain.common.redisson; + +import kr.co.pennyway.domain.config.ContainerDBTestConfig; +import kr.co.pennyway.domain.config.DomainIntegrationTest; +import kr.co.pennyway.domain.config.TestJpaConfig; +import kr.co.pennyway.domain.domains.coupon.TestCoupon; +import kr.co.pennyway.domain.domains.coupon.TestCouponDecreaseService; +import kr.co.pennyway.domain.domains.coupon.TestCouponRepository; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.context.annotation.Import; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; + +@Slf4j +@DomainIntegrationTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@EntityScan(basePackageClasses = {TestCoupon.class}) +@Import(TestJpaConfig.class) +public class CouponDecreaseLockTest extends ContainerDBTestConfig { + @Autowired + private TestCouponDecreaseService testCouponDecreaseService; + @Autowired + private TestCouponRepository testCouponRepository; + private TestCoupon coupon; + + @BeforeEach + void setUp() { + coupon = new TestCoupon("COUPON_001", 300L); + testCouponRepository.save(coupon); + } + + @Test + @Order(1) + void 쿠폰차감_분산락_적용_동시성_300명_테스트() throws InterruptedException { + // given + int threadCount = 300; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + // when + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + testCouponDecreaseService.decreaseStockWithLock(coupon.getId(), "COUPON_001"); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + + // then + TestCoupon persistedCoupon = testCouponRepository.findById(coupon.getId()).orElseThrow(IllegalArgumentException::new); + assertThat(persistedCoupon.getAvailableStock()).isZero(); + log.debug("잔여 쿠폰 수량: " + persistedCoupon.getAvailableStock()); + } + + @Test + @Order(2) + void 쿠폰차감_분산락_미적용_동시성_300명_테스트() throws InterruptedException { + // given + int threadCount = 300; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + // when + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + testCouponDecreaseService.decreaseStock(coupon.getId()); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + + // then + TestCoupon persistedCoupon = testCouponRepository.findById(coupon.getId()).orElseThrow(IllegalArgumentException::new); + log.debug("잔여 쿠폰 수량: " + persistedCoupon.getAvailableStock()); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerDBTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerDBTestConfig.java new file mode 100644 index 000000000..0548812ab --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerDBTestConfig.java @@ -0,0 +1,46 @@ +package kr.co.pennyway.domain.config; + +import com.redis.testcontainers.RedisContainer; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@ActiveProfiles("test") +public class ContainerDBTestConfig { + private static final String REDIS_CONTAINER_IMAGE = "redis:7.2.4-alpine"; + private static final String MYSQL_CONTAINER_IMAGE = "mysql:8.0.26"; + + private static final RedisContainer REDIS_CONTAINER; + private static final MySQLContainer MYSQL_CONTAINER; + + static { + REDIS_CONTAINER = + new RedisContainer(DockerImageName.parse(REDIS_CONTAINER_IMAGE)) + .withExposedPorts(6379) + .withCommand("redis-server", "--requirepass testpass") + .withReuse(true); + MYSQL_CONTAINER = + new MySQLContainer<>(DockerImageName.parse(MYSQL_CONTAINER_IMAGE)) + .withDatabaseName("pennyway") + .withUsername("root") + .withPassword("testpass") + .withReuse(true); + + REDIS_CONTAINER.start(); + MYSQL_CONTAINER.start(); + } + + @DynamicPropertySource + public static void setRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(6379))); + registry.add("spring.data.redis.password", () -> "testpass"); + registry.add("spring.datasource.url", () -> String.format("jdbc:mysql://%s:%s/pennyway?serverTimezone=UTC&characterEncoding=utf8", MYSQL_CONTAINER.getHost(), MYSQL_CONTAINER.getMappedPort(3306))); + registry.add("spring.datasource.username", () -> "root"); + registry.add("spring.datasource.password", () -> "testpass"); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerRedisTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerRedisTestConfig.java index f21f88613..95d0a0c31 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerRedisTestConfig.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerRedisTestConfig.java @@ -15,6 +15,7 @@ public abstract class ContainerRedisTestConfig { REDIS_CONTAINER = new GenericContainer<>(DockerImageName.parse(REDIS_CONTAINER_NAME)) .withExposedPorts(6379) + .withCommand("redis-server", "--requirepass testpass") .withReuse(true); REDIS_CONTAINER.start(); @@ -24,5 +25,6 @@ public abstract class ContainerRedisTestConfig { public static void setRedisProperties(DynamicPropertyRegistry registry) { registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); registry.add("spring.data.redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(6379))); + registry.add("spring.data.redis.password", () -> "testpass"); } } diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationProfileResolver.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationProfileResolver.java new file mode 100644 index 000000000..bbfc35f0e --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationProfileResolver.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.domain.config; + +import org.springframework.lang.NonNull; +import org.springframework.test.context.ActiveProfilesResolver; + +public class DomainIntegrationProfileResolver implements ActiveProfilesResolver { + @Override + @NonNull + public String[] resolve(@NonNull Class testClass) { + return new String[]{"common", "domain"}; + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTest.java new file mode 100644 index 000000000..7f22c7d7f --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTest.java @@ -0,0 +1,14 @@ +package kr.co.pennyway.domain.config; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest(classes = DomainIntegrationTestConfig.class) +@ActiveProfiles(profiles = {"test"}, resolver = DomainIntegrationProfileResolver.class) +@Documented +public @interface DomainIntegrationTest { +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTestConfig.java new file mode 100644 index 000000000..4d0f499d7 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTestConfig.java @@ -0,0 +1,16 @@ +package kr.co.pennyway.domain.config; + +import kr.co.pennyway.common.PennywayCommonApplication; +import kr.co.pennyway.domain.DomainPackageLocation; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan( + basePackageClasses = { + DomainPackageLocation.class, + PennywayCommonApplication.class + } +) +public class DomainIntegrationTestConfig { +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java index da92ea51f..793f11181 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java @@ -3,6 +3,7 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -12,7 +13,8 @@ public class TestJpaConfig { private EntityManager em; @Bean - public JPAQueryFactory jpaQueryFactory() { + @ConditionalOnMissingBean + public JPAQueryFactory testJpaQueryFactory() { return new JPAQueryFactory(em); } } diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCoupon.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCoupon.java new file mode 100644 index 000000000..878c4dac1 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCoupon.java @@ -0,0 +1,40 @@ +package kr.co.pennyway.domain.domains.coupon; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TestCoupon { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String name; + + /** + * 사용 가능 재고수량 + */ + private long availableStock; + + public TestCoupon(String name, long availableStock) { + this.name = name; + this.availableStock = availableStock; + } + + public void decreaseStock() { + validateStock(); + this.availableStock--; + } + + private void validateStock() { + if (availableStock < 1) { + throw new IllegalArgumentException("재고가 부족합니다."); + } + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponDecreaseService.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponDecreaseService.java new file mode 100644 index 000000000..dcbf52b7c --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponDecreaseService.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.domain.domains.coupon; + +import kr.co.pennyway.domain.common.aop.DistributedLock; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@Component +@ActiveProfiles("test") +@RequiredArgsConstructor +public class TestCouponDecreaseService { + private final TestCouponRepository couponRepository; + + @Transactional + public void decreaseStock(Long couponId) { + TestCoupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰입니다.")); + + coupon.decreaseStock(); + } + + @DistributedLock(key = "#lockName") + public void decreaseStockWithLock(Long couponId, String lockName) { + TestCoupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰입니다.")); + + coupon.decreaseStock(); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponRepository.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponRepository.java new file mode 100644 index 000000000..bccf946ff --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponRepository.java @@ -0,0 +1,8 @@ +package kr.co.pennyway.domain.domains.coupon; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +public interface TestCouponRepository extends JpaRepository { +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java index 6ebf369a6..91e8a08ce 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java @@ -45,6 +45,7 @@ public CacheConfig( } @Bean + @Primary @InfraRedisConnectionFactory public RedisConnectionFactory infraRedisConnectionFactory() { RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); From bc1e446d41ab41a6d9f805cb4c7d915f280142aa Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 30 May 2024 15:17:27 +0900 Subject: [PATCH 094/152] =?UTF-8?q?Batch:=20=E2=9C=A8=20Batch=20=EA=B8=B0?= =?UTF-8?q?=EC=B4=88=20=EC=84=B8=ED=8C=85=20=EB=B0=8F=20CD=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=88=98=EC=A0=95=20(?= =?UTF-8?q?=EC=88=98=EC=A0=95=EB=90=A0=20PR=20=EC=BB=A8=EB=B2=A4=EC=85=98?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EB=82=B4=EC=9A=A9=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?)=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: batch module 생성 * chore: batch 모듈 application 클래스 생성 * chore: batch 라이브러리 추가 * chore: external-api docker script 위치 수정 * chore: batch docker script 생성 * chore: java build 시 비블로킹 방식으로 수정 * chore: api cd gradlew parallel 옵션 추가 && 파일명 수정 * chore: cd 스크립트 external-api docker 경로 수정 * chore: batch cd 스크립트 추가 * fix: batch application 클래스 main 함수 추가 * chore: tag & release 자동화 actions 스크립트 작성 * chore: docker image 경로 수정 * chore: batch module 생성 * chore: batch 모듈 application 클래스 생성 * chore: batch 라이브러리 추가 * chore: external-api docker script 위치 수정 * chore: batch docker script 생성 * chore: java build 시 비블로킹 방식으로 수정 * chore: api cd gradlew parallel 옵션 추가 && 파일명 수정 * chore: cd 스크립트 external-api docker 경로 수정 * chore: batch cd 스크립트 추가 * fix: batch application 클래스 main 함수 추가 * chore: tag & release 자동화 actions 스크립트 작성 * chore: docker image 경로 수정 * rename: 태그 및 릴리즈 자동화 파이프라인 주석 추가 * chore: cd 파이프라인 버전 정보 추출 step 추가 * release: api v1.0.0 출동 --- .github/workflows/create-tag-and-release.yml | 58 ++++++++++++ .../{deploy.yml => deploy-batch.yml} | 31 ++++--- .github/workflows/deploy-external-api.yml | 92 +++++++++++++++++++ Dockerfile | 4 - commitlint.config.js | 30 +++--- pennyway-app-external-api/Dockerfile | 8 ++ pennyway-batch/.gitignore | 42 +++++++++ pennyway-batch/Dockerfile | 8 ++ pennyway-batch/build.gradle | 22 +++++ .../co/pennyway/PennywayBatchApplication.java | 11 +++ .../src/main/resources/application.yml | 23 +++++ settings.gradle | 1 + 12 files changed, 296 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/create-tag-and-release.yml rename .github/workflows/{deploy.yml => deploy-batch.yml} (71%) create mode 100644 .github/workflows/deploy-external-api.yml delete mode 100644 Dockerfile create mode 100644 pennyway-app-external-api/Dockerfile create mode 100644 pennyway-batch/.gitignore create mode 100644 pennyway-batch/Dockerfile create mode 100644 pennyway-batch/build.gradle create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/PennywayBatchApplication.java create mode 100644 pennyway-batch/src/main/resources/application.yml diff --git a/.github/workflows/create-tag-and-release.yml b/.github/workflows/create-tag-and-release.yml new file mode 100644 index 000000000..c8dd652ec --- /dev/null +++ b/.github/workflows/create-tag-and-release.yml @@ -0,0 +1,58 @@ +name: Tag and Release + +on: + pull_request: + types: [ closed ] + +jobs: + extract-info: + # PR이 merge 되었을 때만 실행 (merge가 아닌 close는 제외) + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + repository-projects: write + + steps: + - name: Checkout PR + uses: actions/checkout@v4 + + # PR 제목으로 부터 모듈명 추출 (ex. Api, Batch, Admin, Socket) + - name: extract PR info + id: module_prefix + run: | + PR_TITLE="${{ github.event.pull_request.title }}" + echo "PR title : $PR_TITLE" + if [[ "$PR_TITLE" =~ ^(Api|Batch|Admin|Socket): ]]; then + PREFIX="${BASH_REMATCH[1]}" + echo "Prefix: $PREFIX" + echo "module=$PREFIX" >> $GITHUB_OUTPUT + else + echo "PR title does not match the pattern" + exit 1 + fi + + # 병합된 PR commit 이력으로 부터 버전 추출 (ex. v1.0.0) + - name: version and tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + default_bump: patch + custom_release_rules: release:major, feat:minor:Features, refactor:minor:Refactoring, fix:patch:Bug Fixes, hotfix:patch:Hotfixes, docs:patch:Documentation, style:patch:Styles, perf:patch:Performance Improvements, test:patch:Tests, ci:patch:Continuous Integration, chore:patch:Chores, revert:patch:Reverts + tag_prefix: '${{ steps.module_prefix.outputs.module }}-v' + + # 추출된 버전 및 변경 이력 로그 출력 + - name: check output + run: | + echo "new_tag : ${{ steps.tag_version.outputs.new_tag }}" + echo "change_log : ${{ steps.tag_version.outputs.changelog }}" + + # GitHub Release 생성 + - name: Create a GitHub release + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.tag_version.outputs.new_tag }} + name: ${{ steps.tag_version.outputs.new_tag }} + body: ${{ steps.tag_version.outputs.changelog }} \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy-batch.yml similarity index 71% rename from .github/workflows/deploy.yml rename to .github/workflows/deploy-batch.yml index ce37ee9cd..248d1d513 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy-batch.yml @@ -1,8 +1,9 @@ -name: Continuous Deployment +name: Continuous Deployment - External API on: push: - branches: [ "dev" ] + tags: + - Batch-v*.*.* workflow_dispatch: inputs: logLevel: @@ -37,34 +38,34 @@ jobs: with: ref: ${{ github.event.push.base_ref }} - # 2. 자바 환경 설정 + # 2. 버전 정보 추출 (태그 정보에서 *.*.*만 추출) + - name: Get Version + id: get_version + run: | + RELEASE_VERSION_WITHOUT_V="$(cut -d'v' -f2 <<< ${GITHUB_REF#refs/*/})" + echo "VERSION=$RELEASE_VERSION_WITHOUT_V" >> $GITHUB_OUTPUT + + # 3. 자바 환경 설정 - name: Set up JDK 17 uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' - # 3. FCM Admin SDK 파일 생성 - - name: Create Json - uses: jsdaniell/create-json@v1.2.2 - with: - name: ${{ secrets.FIREBASE_ADMIN_SDK_FILE }} - json: ${{ secrets.FIREBASE_ADMIN_SDK }} - dir: ${{ secrets.FIREBASE_ADMIN_SDK_DIR }} - # 4. Build Gradle - name: Build Gradle run: | chmod +x ./gradlew - ./gradlew build --stacktrace --info -x test + ./gradlew :pennyway-batch:build --parallel --stacktrace --info -x test shell: bash # 5. Docker 이미지 build 및 push - name: docker build and push run: | docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} - docker build -t pennyway/pennyway-was . - docker push pennyway/pennyway-was + docker build -t pennyway/pennyway-batch ./pennyway-batch + docker push pennyway/pennyway-batch:${{ steps.get_version.outputs.VERSION }} + docker push pennyway/pennyway-batch:latest # 6. AWS SSM을 통한 Run-Command (Docker 이미지 pull 후 docker-compose를 통한 실행) - name: AWS SSM Send-Command @@ -79,5 +80,5 @@ jobs: command: | docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} docker system prune -a -f - docker pull pennyway/pennyway-was + docker pull pennyway/pennyway-batch docker-compose up -d \ No newline at end of file diff --git a/.github/workflows/deploy-external-api.yml b/.github/workflows/deploy-external-api.yml new file mode 100644 index 000000000..6da428a24 --- /dev/null +++ b/.github/workflows/deploy-external-api.yml @@ -0,0 +1,92 @@ +name: Continuous Deployment - External API + +on: + push: + tags: + - Api-v*.*.* + workflow_dispatch: + inputs: + logLevel: + description: 'Log level' + required: true + default: 'warning' + type: choice + options: + - info + - warning + - debug + tags: + description: 'Test scenario tags' + required: false + type: boolean + environment: + description: 'Environment to run tests against' + type: environment + required: false + +permissions: + contents: read + +jobs: + deployment: + runs-on: ubuntu-20.04 + + steps: + # 1. Compare branch 코드 내려 받기 + - name: Checkout PR + uses: actions/checkout@v3 + with: + ref: ${{ github.event.push.base_ref }} + + # 2. 버전 정보 추출 (태그 정보에서 *.*.*만 추출) + - name: Get Version + id: get_version + run: | + RELEASE_VERSION_WITHOUT_V="$(cut -d'v' -f2 <<< ${GITHUB_REF#refs/*/})" + echo "VERSION=$RELEASE_VERSION_WITHOUT_V" >> $GITHUB_OUTPUT + + # 3. 자바 환경 설정 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + # 4. FCM Admin SDK 파일 생성 + - name: Create Json + uses: jsdaniell/create-json@v1.2.2 + with: + name: ${{ secrets.FIREBASE_ADMIN_SDK_FILE }} + json: ${{ secrets.FIREBASE_ADMIN_SDK }} + dir: ${{ secrets.FIREBASE_ADMIN_SDK_DIR }} + + # 5. Build Gradle + - name: Build Gradle + run: | + chmod +x ./gradlew + ./gradlew :pennyway-app-external-api:build --parallel --stacktrace --info -x test + shell: bash + + # 6. Docker 이미지 build 및 push + - name: docker build and push + run: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker build -t pennyway/pennyway-was ./pennyway-app-external-api + docker push pennyway/pennyway-was:${{ steps.get_version.outputs.VERSION }} + docker push pennyway/pennyway-was:latest + + # 7. AWS SSM을 통한 Run-Command (Docker 이미지 pull 후 docker-compose를 통한 실행) + - name: AWS SSM Send-Command + uses: peterkimzz/aws-ssm-send-command@master + id: ssm + with: + aws-region: ${{ secrets.AWS_REGION }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + instance-ids: ${{ secrets.AWS_DEV_INSTANCE_ID }} + working-directory: /home/ubuntu + command: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker system prune -a -f + docker pull pennyway/pennyway-was + docker-compose up -d \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index a343f0ec6..000000000 --- a/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM openjdk:17 -ARG JAR_FILE=pennyway-app-external-api/build/libs/*.jar -COPY ${JAR_FILE} app.jar -ENTRYPOINT ["java","-jar","/app.jar","--spring.profiles.active=dev","-Duser.timezone=Asia/Seoul"] \ No newline at end of file diff --git a/commitlint.config.js b/commitlint.config.js index 94b7c3b7e..e8b6e8cdc 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1,17 +1,17 @@ module.exports = { - extends: ["@commitlint/config-conventional"], - rules: { - // 스코프는 컨벤션과 맞지 않기에, 사용하지 않는 것으로 한다. - "scope-empty": [2, "always"], - // 헤더의 길이는 100자로 제한 - "header-max-length": [2, "always", 100], - // 본문의 한 줄은 100자로 제한 - "body-max-line-length": [2, "always", 100], - // 타입은 아래의 태그만 가능 - "type-enum": [ - 2, - "always", - ["feat", "fix", "docs", "rename", "style", "refactor", "test", "chore"], - ], - }, + extends: ["@commitlint/config-conventional"], + rules: { + // 스코프는 컨벤션과 맞지 않기에, 사용하지 않는 것으로 한다. + "scope-empty": [2, "always"], + // 헤더의 길이는 100자로 제한 + "header-max-length": [2, "always", 100], + // 본문의 한 줄은 100자로 제한 + "body-max-line-length": [2, "always", 100], + // 타입은 아래의 태그만 가능 + "type-enum": [ + 2, + "always", + ["feat", "fix", "docs", "rename", "style", "refactor", "test", "chore", "release"], + ], + }, }; \ No newline at end of file diff --git a/pennyway-app-external-api/Dockerfile b/pennyway-app-external-api/Dockerfile new file mode 100644 index 000000000..c9bf20fb6 --- /dev/null +++ b/pennyway-app-external-api/Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:17 +ARG JAR_FILE=./build/libs/*.jar +COPY ${JAR_FILE} app.jar + +ARG PROFILE=dev +ENV PROFILE=${PROFILE} + +ENTRYPOINT ["java","-jar","/app.jar","--spring.profiles.active=${PROFILE}","-Djava.security.egd=file:/dev/./urandom","-Duser.timezone=Asia/Seoul"] \ No newline at end of file diff --git a/pennyway-batch/.gitignore b/pennyway-batch/.gitignore new file mode 100644 index 000000000..b63da4551 --- /dev/null +++ b/pennyway-batch/.gitignore @@ -0,0 +1,42 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/pennyway-batch/Dockerfile b/pennyway-batch/Dockerfile new file mode 100644 index 000000000..c9bf20fb6 --- /dev/null +++ b/pennyway-batch/Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:17 +ARG JAR_FILE=./build/libs/*.jar +COPY ${JAR_FILE} app.jar + +ARG PROFILE=dev +ENV PROFILE=${PROFILE} + +ENTRYPOINT ["java","-jar","/app.jar","--spring.profiles.active=${PROFILE}","-Djava.security.egd=file:/dev/./urandom","-Duser.timezone=Asia/Seoul"] \ No newline at end of file diff --git a/pennyway-batch/build.gradle b/pennyway-batch/build.gradle new file mode 100644 index 000000000..a959d5c10 --- /dev/null +++ b/pennyway-batch/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'java' +} + +bootJar { enabled = true } +jar { enabled = false } + +group = 'kr.co' +version = 'unspecified' + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':pennyway-common') + implementation project(':pennyway-domain') + implementation project(':pennyway-infra') + + implementation 'org.springframework.boot:spring-boot-starter-batch:3.3.0' + testImplementation('org.springframework.batch:spring-batch-test:5.1.2') +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/PennywayBatchApplication.java b/pennyway-batch/src/main/java/kr/co/pennyway/PennywayBatchApplication.java new file mode 100644 index 000000000..b16070a57 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/PennywayBatchApplication.java @@ -0,0 +1,11 @@ +package kr.co.pennyway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PennywayBatchApplication { + public static void main(String[] args) { + SpringApplication.run(PennywayBatchApplication.class, args); + } +} diff --git a/pennyway-batch/src/main/resources/application.yml b/pennyway-batch/src/main/resources/application.yml new file mode 100644 index 000000000..37b946c67 --- /dev/null +++ b/pennyway-batch/src/main/resources/application.yml @@ -0,0 +1,23 @@ +spring: + profiles: + group: + local: common, domain, infra + dev: common, domain, infra + +--- +spring: + config: + activate: + on-profile: local + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: test \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 62e0155b7..69f415d8f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,6 +1,7 @@ rootProject.name = 'pennyway' include 'pennyway-app-external-api' +include 'pennyway-batch' include 'pennyway-domain' include 'pennyway-infra' include 'pennyway-common' From 7c9d43f2cc89f0d0f0e5d79723d1fb22a52f5f10 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 30 May 2024 15:53:30 +0900 Subject: [PATCH 095/152] release: api-v1.0.0 (#100) --- .github/workflows/create-tag-and-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/create-tag-and-release.yml b/.github/workflows/create-tag-and-release.yml index c8dd652ec..8ee285cf6 100644 --- a/.github/workflows/create-tag-and-release.yml +++ b/.github/workflows/create-tag-and-release.yml @@ -40,6 +40,7 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} default_bump: patch + release_branches: main,dev.* custom_release_rules: release:major, feat:minor:Features, refactor:minor:Refactoring, fix:patch:Bug Fixes, hotfix:patch:Hotfixes, docs:patch:Documentation, style:patch:Styles, perf:patch:Performance Improvements, test:patch:Tests, ci:patch:Continuous Integration, chore:patch:Chores, revert:patch:Reverts tag_prefix: '${{ steps.module_prefix.outputs.module }}-v' From 907e8a58391e5c493d31f125a8e591b06c707754 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 30 May 2024 18:20:26 +0900 Subject: [PATCH 096/152] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=B9=8C=EB=93=9C=20=EB=B2=84=EC=A0=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-batch.yml | 3 ++- .github/workflows/deploy-external-api.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-batch.yml b/.github/workflows/deploy-batch.yml index 248d1d513..77022c5c4 100644 --- a/.github/workflows/deploy-batch.yml +++ b/.github/workflows/deploy-batch.yml @@ -63,7 +63,8 @@ jobs: - name: docker build and push run: | docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} - docker build -t pennyway/pennyway-batch ./pennyway-batch + docker build -t pennyway/pennyway-batch:${{ steps.get_version.outputs.VERSION }} ./pennyway-batch + docker build -t pennyway/pennyway-batch:latest ./pennyway-batch docker push pennyway/pennyway-batch:${{ steps.get_version.outputs.VERSION }} docker push pennyway/pennyway-batch:latest diff --git a/.github/workflows/deploy-external-api.yml b/.github/workflows/deploy-external-api.yml index 6da428a24..90182c77a 100644 --- a/.github/workflows/deploy-external-api.yml +++ b/.github/workflows/deploy-external-api.yml @@ -71,7 +71,8 @@ jobs: - name: docker build and push run: | docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} - docker build -t pennyway/pennyway-was ./pennyway-app-external-api + docker build -t pennyway/pennyway-was:${{ steps.get_version.outputs.VERSION }} ./pennyway-app-external-api + docker build -t pennyway/pennyway-was:latest ./pennyway-app-external-api docker push pennyway/pennyway-was:${{ steps.get_version.outputs.VERSION }} docker push pennyway/pennyway-was:latest From 8379b0e5bb455f92695153801b4474354111aac2 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sat, 1 Jun 2024 20:56:08 +0900 Subject: [PATCH 097/152] =?UTF-8?q?Api:=20=F0=9F=90=9B=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=83=9D=EC=84=B1=20&=20=EB=A6=B4=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EC=9E=90=EB=8F=99=ED=99=94=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=ED=9B=84=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?workflows=20call=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * rename: run 이름 call-external-api-deploy로 변경 * chore: api & batch 모듈 workflow_call trigger 추가 * chore: tag 정보 inputs.tags로 수정 * test: 태그 생성 트리거 opened 추가 * test: merged 조건식 임시 제거 * fix: call 인자 전달 시 version -> tag 수정 * rename: batch cd 파이프라인 이름 수정 * chore: workflow_dispatch 제거 * chore: reuse workflow 호출 시 secret key 상속 옵션 추가 * chore: pr 병합 조건문 추가 --- .github/workflows/create-tag-and-release.yml | 21 ++++++++++++- .github/workflows/deploy-batch.yml | 31 +++++--------------- .github/workflows/deploy-external-api.yml | 29 +++++------------- 3 files changed, 35 insertions(+), 46 deletions(-) diff --git a/.github/workflows/create-tag-and-release.yml b/.github/workflows/create-tag-and-release.yml index 8ee285cf6..c506a9cd1 100644 --- a/.github/workflows/create-tag-and-release.yml +++ b/.github/workflows/create-tag-and-release.yml @@ -13,6 +13,9 @@ jobs: contents: write pull-requests: write repository-projects: write + outputs: + module: ${{ steps.module_prefix.outputs.module }} + tag: ${{ steps.tag_version.outputs.new_tag }} steps: - name: Checkout PR @@ -56,4 +59,20 @@ jobs: with: tag: ${{ steps.tag_version.outputs.new_tag }} name: ${{ steps.tag_version.outputs.new_tag }} - body: ${{ steps.tag_version.outputs.changelog }} \ No newline at end of file + body: ${{ steps.tag_version.outputs.changelog }} + + call-external-api-deploy: + needs: extract-info + if: ${{ needs.extract-info.outputs.module == 'Api' }} + uses: ./.github/workflows/deploy-external-api.yml + secrets: inherit + with: + tags: ${{ needs.extract-info.outputs.tag }} + + call-batch-deploy: + needs: extract-info + if: ${{ needs.extract-info.outputs.module == 'Batch' }} + uses: ./.github/workflows/deploy-batch.yml + secrets: inherit + with: + tags: ${{ needs.extract-info.outputs.tag }} \ No newline at end of file diff --git a/.github/workflows/deploy-batch.yml b/.github/workflows/deploy-batch.yml index 248d1d513..913a9b7f7 100644 --- a/.github/workflows/deploy-batch.yml +++ b/.github/workflows/deploy-batch.yml @@ -1,28 +1,12 @@ -name: Continuous Deployment - External API +name: Continuous Deployment - Batch on: - push: - tags: - - Batch-v*.*.* - workflow_dispatch: + workflow_call: inputs: - logLevel: - description: 'Log level' - required: true - default: 'warning' - type: choice - options: - - info - - warning - - debug tags: - description: 'Test scenario tags' - required: false - type: boolean - environment: - description: 'Environment to run tests against' - type: environment - required: false + description: '배포할 Batch 모듈 태그 정보 (Batch-v*.*.*)' + required: true + type: string permissions: contents: read @@ -42,7 +26,7 @@ jobs: - name: Get Version id: get_version run: | - RELEASE_VERSION_WITHOUT_V="$(cut -d'v' -f2 <<< ${GITHUB_REF#refs/*/})" + RELEASE_VERSION_WITHOUT_V="$(cut -d'v' -f2 <<< ${{ inputs.tags }})" echo "VERSION=$RELEASE_VERSION_WITHOUT_V" >> $GITHUB_OUTPUT # 3. 자바 환경 설정 @@ -63,7 +47,8 @@ jobs: - name: docker build and push run: | docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} - docker build -t pennyway/pennyway-batch ./pennyway-batch + docker build -t pennyway/pennyway-batch:${{ steps.get_version.outputs.VERSION }} ./pennyway-batch + docker build -t pennyway/pennyway-batch:latest ./pennyway-batch docker push pennyway/pennyway-batch:${{ steps.get_version.outputs.VERSION }} docker push pennyway/pennyway-batch:latest diff --git a/.github/workflows/deploy-external-api.yml b/.github/workflows/deploy-external-api.yml index 6da428a24..8e7a37006 100644 --- a/.github/workflows/deploy-external-api.yml +++ b/.github/workflows/deploy-external-api.yml @@ -1,28 +1,12 @@ name: Continuous Deployment - External API on: - push: - tags: - - Api-v*.*.* - workflow_dispatch: + workflow_call: inputs: - logLevel: - description: 'Log level' - required: true - default: 'warning' - type: choice - options: - - info - - warning - - debug tags: - description: 'Test scenario tags' - required: false - type: boolean - environment: - description: 'Environment to run tests against' - type: environment - required: false + description: '배포할 Api 모듈 태그 정보 (Api-v*.*.*)' + required: true + type: string permissions: contents: read @@ -42,7 +26,7 @@ jobs: - name: Get Version id: get_version run: | - RELEASE_VERSION_WITHOUT_V="$(cut -d'v' -f2 <<< ${GITHUB_REF#refs/*/})" + RELEASE_VERSION_WITHOUT_V="$(cut -d'v' -f2 <<< ${{ inputs.tags }})" echo "VERSION=$RELEASE_VERSION_WITHOUT_V" >> $GITHUB_OUTPUT # 3. 자바 환경 설정 @@ -71,7 +55,8 @@ jobs: - name: docker build and push run: | docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} - docker build -t pennyway/pennyway-was ./pennyway-app-external-api + docker build -t pennyway/pennyway-was:${{ steps.get_version.outputs.VERSION }} ./pennyway-app-external-api + docker build -t pennyway/pennyway-was:latest ./pennyway-app-external-api docker push pennyway/pennyway-was:${{ steps.get_version.outputs.VERSION }} docker push pennyway/pennyway-was:latest From dc75a332df3e0f04d1c1fcc5b4a3954892e7ee4b Mon Sep 17 00:00:00 2001 From: Jinwoo Lee Date: Sun, 2 Jun 2024 11:35:41 +0900 Subject: [PATCH 098/152] =?UTF-8?q?Api:=20=F0=9F=90=9B=20object-key?= =?UTF-8?q?=EC=9D=98=20depth=20=EC=88=98=EC=A0=95=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../co/pennyway/infra/client/aws/s3/ObjectKeyType.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java index 8a60ca23d..57b5c028a 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java @@ -4,11 +4,11 @@ @RequiredArgsConstructor public enum ObjectKeyType { - PROFILE("1", "PROFILE", "/delete/profile/{userId}/{uuid}_{timestamp}.{ext}"), - FEED("2", "FEED", "/delete/feed/{feed_id}/{uuid}_{timestamp}.{ext}"), - CHATROOM_PROFILE("3", "CHATROOM_PROFILE", "/delete/chatroom/{chatroom_id}/{uuid}_{timestamp}.{ext}"), - CHAT("4", "CHAT", "/delete/chatroom/{chatroom_id}/chat/{chat_id}/{uuid}_{timestamp}.{ext}"), - CHAT_PROFILE("5", "CHAT_PROFILE", "/delete/chatroom/{chatroom_id}/chat_profile//{uuid}_{timestamp}.{ext}"); + PROFILE("1", "PROFILE", "delete/profile/{userId}/{uuid}_{timestamp}.{ext}"), + FEED("2", "FEED", "delete/feed/{feed_id}/{uuid}_{timestamp}.{ext}"), + CHATROOM_PROFILE("3", "CHATROOM_PROFILE", "delete/chatroom/{chatroom_id}/{uuid}_{timestamp}.{ext}"), + CHAT("4", "CHAT", "delete/chatroom/{chatroom_id}/chat/{chat_id}/{uuid}_{timestamp}.{ext}"), + CHAT_PROFILE("5", "CHAT_PROFILE", "delete/chatroom/{chatroom_id}/chat_profile//{uuid}_{timestamp}.{ext}"); private final String code; private final String type; From 24b142702d21add60826957a38efdbea304cd0a6 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 4 Jun 2024 17:17:54 +0900 Subject: [PATCH 099/152] =?UTF-8?q?Api:=20=E2=9C=8F=EF=B8=8F=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EA=B8=88=EC=95=A1=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20API=20?= =?UTF-8?q?=EC=8A=A4=ED=8E=99=20=EB=B3=80=EA=B2=BD=20(#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * fix: target_amount entity is_read 필드 추가 * fix: target_amount 409 예외 코드 추가 * fix: target-amount is_exists 메서드 추가 및 query_dsl impl 구현 * rename: exists 오타 수정 & tx read_only 옵션 추가 * fix: target_amount id & user_id 조건문 탐색 메서드 추가 * fix: target_amount id, user_id exists 메서드 추가 * rename: 매개변수 순서 변경 * fix: target_amount dto query param을 위한 클래스명, 필드 정보, 문서 수정 * docs: target amount api spec 수정 * fix: api 스펙에 맞게 controller 수정 및 자원 검증 로직 추가 * fix: target_amount_info dto is_read 필드 추가 * fix: target-amount 생성 use case 분리 * fix: find_by_id 시 user_id 조건문 제거 * fix: target_amount update 반환 값 수정 및 date 기반 탐색 -> target_amount_id 기반 탐색으로 수정 * fix: authenticated_principal 주입 필요없는 곳에서 제거 * fix: target_amount_save_service 제거 * fix: target_amount 생성 시 분산 락 적용 * style: distributed_lock 어노테이션 위치 수정 * fix: 분산락 prefix 문자열 관리 클래스 분리 * style: update_target_amount 할당문 추가 * docs: target_amount patch 응답 데이터 수정 * fix: lock prefix 수정 * test: target-amount 통합 테스트 파일 통일 및 jdbc bulk query 수정 * fix: target_amount entity 당월 데이터 확인 메서드 추가 * fix: update, delete 시 당월 데이터 여부 검증 로직 추가 * fix: target_amount to_string() year, month 정보 추가 * test: target_amount 날짜 변경 후 cache 제거 * test: 목표 금액 생성 요청 테스트 * fix: target_amount 분산 락 키 spel 문법에 맞게 수정 * test: target_amount 삭제 테스트 케이스 작성 * rename: target_amount 데이터 생성 시 로그 제거 * rename: json 내부 target_amount 필드명 -> target_amount_detail 수정 * fix: target_amount 생성 요청에서 당월 요청 판단 로직 수정 * test: put -> patch 이름 수정 * test: target_amount controller unit test 수정 * fix: success response key 상수처리 * fix: use case 내 불필요한 create 호출 제거 * rename: date_param dto schema 오타 수정 * test: 특정 년/월에 대한 target_amount가 존재하지 않는 경우 404 not found 에러 응답을 반환한다 * fix: target_amount use case not found 시 예외 처리 * docs: target api 조회 시 404 예외 문서화 * docs: target_amount 수정 및 삭제 문서 작성 * fix: lock key의 date에서 day 인자 제거 --- .../api/apis/ledger/api/TargetAmountApi.java | 109 +++++-- .../controller/TargetAmountController.java | 44 +-- .../api/apis/ledger/dto/TargetAmountDto.java | 25 +- .../ledger/mapper/TargetAmountMapper.java | 2 +- .../service/TargetAmountSaveService.java | 28 +- .../ledger/usecase/TargetAmountUseCase.java | 41 ++- .../authorization/TargetAmountManager.java | 26 ++ .../TargetAmountControllerUnitTest.java | 101 ++---- .../TargetAmountIntegrationTest.java | 103 ------ ...TargetAmountControllerIntegrationTest.java | 114 ------- .../TargetAmountIntegrationTest.java | 308 ++++++++++++++++++ .../config/fixture/TargetAmountFixture.java | 30 +- .../domain/common/aop/DistributedLockAop.java | 3 +- .../{aop => redisson}/DistributedLock.java | 2 +- .../redisson/DistributedLockPrefix.java | 8 + .../domains/target/domain/TargetAmount.java | 19 +- .../exception/TargetAmountErrorCode.java | 6 +- .../TargetAmountCustomRepository.java | 7 + .../TargetAmountCustomRepositoryImpl.java | 28 ++ .../repository/TargetAmountRepository.java | 5 +- .../target/service/TargetAmountService.java | 18 +- .../coupon/TestCouponDecreaseService.java | 2 +- 22 files changed, 639 insertions(+), 390 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/TargetAmountManager.java delete mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountIntegrationTest.java delete mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountControllerIntegrationTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java rename pennyway-domain/src/main/java/kr/co/pennyway/domain/common/{aop => redisson}/DistributedLock.java (96%) create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLockPrefix.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java index e83922ec4..333d41f01 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.*; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.co.pennyway.api.apis.ledger.dto.TargetAmountDto; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; @@ -13,26 +14,49 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; import java.time.LocalDate; @Tag(name = "목표금액 API") public interface TargetAmountApi { - @Operation(summary = "당월 목표 금액 등록/수정", method = "PUT") - @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "목표 금액 등록 실패", value = """ - { - "code": "4004", - "message": "당월 목표 금액에 대한 요청이 아닙니다." - } - """) - })) - ResponseEntity putTargetAmount(TargetAmountDto.UpdateParamReq request, @AuthenticationPrincipal SecurityUserDetails user); + @Operation(summary = "당월 목표 금액 더미값 생성", method = "POST", description = "더미값 생성을 위한 API이며, 사용자가 당월 첫 로그인 시 클라이언트에서 호출한다.") + @Parameters({ + @Parameter(name = "year", description = "생성하려는 목표 금액 년도", required = true, example = "2024", in = ParameterIn.QUERY), + @Parameter(name = "month", description = "생성하려는 목표 금액 월", required = true, example = "5", in = ParameterIn.QUERY) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "목표 금액 데이터 생성", content = @Content(schemaProperties = @SchemaProperty(name = "targetAmount", schema = @Schema(implementation = TargetAmountDto.TargetAmountInfo.class)))), + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "목표 금액 삭제 실패", value = """ + { + "code": "4004", + "message": "당월 목표 금액에 대한 요청이 아닙니다." + } + """)})), + @ApiResponse(responseCode = "409", description = "이미 당월 목표 금액이 존재하는 경우", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "목표 금액 생성 실패", value = """ + { + "code": "4091", + "message": "이미 해당 월의 목표 금액 데이터가 존재합니다." + } + """)})) + }) + ResponseEntity postTargetAmount(@RequestParam int year, @RequestParam int month, @AuthenticationPrincipal SecurityUserDetails user); @Operation(summary = "임의의 년/월에 대한 목표 금액 및 총 사용 금액 조회", method = "GET", description = "일수는 무시하고 년/월 정보만 사용한다. 일반적으로 당월 정보 요청에 사용하는 API이다.") @Parameter(name = "date", description = "현재 날짜(yyyy-MM-dd)", required = true, example = "2024-05-08", in = ParameterIn.PATH) - @ApiResponse(responseCode = "200", description = "목표 금액 및 총 사용 금액 조회 성공", content = @Content( - schemaProperties = @SchemaProperty(name = "targetAmount", schema = @Schema(implementation = TargetAmountDto.WithTotalSpendingRes.class)))) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "목표 금액 및 총 사용 금액 조회 성공", content = @Content( + schemaProperties = @SchemaProperty(name = "targetAmount", schema = @Schema(implementation = TargetAmountDto.WithTotalSpendingRes.class)))), + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "목표 금액 조회 실패", description = "목표 금액 데이터가 존재하지 않는 경우. 클라이언트는 POST 호출 시나리오를 진행해야 한다.", value = """ + { + "code": "4040", + "message": "해당 월의 목표 금액이 존재하지 않습니다." + } + """)})) + }) ResponseEntity getTargetAmountAndTotalSpending(@PathVariable LocalDate date, @AuthenticationPrincipal SecurityUserDetails user); @Operation(summary = "사용자 가입 이후 현재까지의 목표 금액 및 총 사용 금액 리스트 조회", method = "GET", description = "일수는 무시하고 년/월 정보만 사용한다. 데이터가 존재하지 않을 때 더미 값을 사용하며, 최신 데이터 순으로 정렬된 응답을 반환한다.") @@ -42,23 +66,50 @@ public interface TargetAmountApi { }) @ApiResponse(responseCode = "200", description = "목표 금액 및 총 사용 금액 리스트 조회 성공", content = @Content( schemaProperties = @SchemaProperty(name = "targetAmounts", array = @ArraySchema(schema = @Schema(implementation = TargetAmountDto.WithTotalSpendingRes.class))))) - ResponseEntity getTargetAmountsAndTotalSpendings(@Validated TargetAmountDto.GetParamReq param, @AuthenticationPrincipal SecurityUserDetails user); + ResponseEntity getTargetAmountsAndTotalSpendings(@Validated TargetAmountDto.DateParam param, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "당월 목표 금액 수정", method = "PATCH") + @Parameters({ + @Parameter(name = "targetAmountId", description = "수정하려는 목표 금액 ID", required = true, example = "1", in = ParameterIn.PATH), + @Parameter(name = "amount", description = "수정하려는 목표 금액", required = true, in = ParameterIn.QUERY, example = "100000"), + @Parameter(name = "param", hidden = true) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "목표 금액 수정 성공", content = @Content(schemaProperties = @SchemaProperty(name = "targetAmount", schema = @Schema(implementation = TargetAmountDto.TargetAmountInfo.class)))), + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "목표 금액 수정 실패", description = "해당 월의 목표 금액이 아닌 경우", value = """ + { + "code": "4004", + "message": "당월 목표 금액에 대한 요청이 아닙니다." + } + """)})), + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "목표 금액 조회 실패", description = "목표 금액 데이터가 없거나, 이미 삭제(amount=-1)인 경우", value = """ + { + "code": "4040", + "message": "해당 월의 목표 금액이 존재하지 않습니다." + } + """)})) + }) + ResponseEntity patchTargetAmount(TargetAmountDto.AmountParam param, @PathVariable Long targetAmountId); @Operation(summary = "당월 목표 금액 삭제", method = "DELETE") - @Parameter(name = "date", description = "삭제하려는 목표 금액 날짜 (yyyy-MM-dd)", required = true, example = "2024-05-08", in = ParameterIn.PATH) - @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "목표 금액 삭제 실패", value = """ - { - "code": "4004", - "message": "당월 목표 금액에 대한 요청이 아닙니다." - } - """), - @ExampleObject(name = "목표 금액 조회 실패", description = "목표 금액 데이터가 없거나, 이미 삭제(amount=-1)인 경우", value = """ - { - "code": "4040", - "message": "해당 월의 목표 금액이 존재하지 않습니다." - } - """) - })) - ResponseEntity deleteTargetAmount(@PathVariable LocalDate date, @AuthenticationPrincipal SecurityUserDetails user); + @Parameter(name = "targetAmountId", description = "삭제하려는 목표 금액 ID", required = true, example = "1", in = ParameterIn.PATH) + @ApiResponses({ + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "목표 금액 삭제 실패", description = "해당 월의 목표 금액이 아닌 경우", value = """ + { + "code": "4004", + "message": "당월 목표 금액에 대한 요청이 아닙니다." + } + """)})), + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "목표 금액 삭제 실패", description = "목표 금액 데이터가 없거나, 이미 삭제(amount=-1)인 경우", value = """ + { + "code": "4040", + "message": "해당 월의 목표 금액이 존재하지 않습니다." + } + """)})) + }) + ResponseEntity deleteTargetAmount(@PathVariable Long targetAmountId); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java index 4214961fb..867ed2976 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java @@ -16,54 +16,54 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDate; +import java.time.YearMonth; @Slf4j @RestController @RequiredArgsConstructor -@RequestMapping("/v2/targets") +@RequestMapping("/v2/target-amounts") public class TargetAmountController implements TargetAmountApi { + private static final String TARGET_AMOUNT = "targetAmount"; + private static final String TARGET_AMOUNTS = "targetAmounts"; private final TargetAmountUseCase targetAmountUseCase; @Override - @PutMapping("") + @PostMapping("") @PreAuthorize("isAuthenticated()") - public ResponseEntity putTargetAmount(@Validated TargetAmountDto.UpdateParamReq param, @AuthenticationPrincipal SecurityUserDetails user) { - if (!isValidDateForYearAndMonth(param.date())) { + public ResponseEntity postTargetAmount(@RequestParam int year, @RequestParam int month, @AuthenticationPrincipal SecurityUserDetails user) { + if (!(year == YearMonth.now().getYear() && month == YearMonth.now().getMonthValue())) { throw new TargetAmountErrorException(TargetAmountErrorCode.INVALID_TARGET_AMOUNT_DATE); } - targetAmountUseCase.updateTargetAmount(user.getUserId(), param.date(), param.amount()); - return ResponseEntity.ok(SuccessResponse.noContent()); + return ResponseEntity.ok(SuccessResponse.from(TARGET_AMOUNT, targetAmountUseCase.createTargetAmount(user.getUserId(), year, month))); } @Override @GetMapping("/{date}") @PreAuthorize("isAuthenticated()") public ResponseEntity getTargetAmountAndTotalSpending(@PathVariable LocalDate date, @AuthenticationPrincipal SecurityUserDetails user) { - return ResponseEntity.ok(SuccessResponse.from("targetAmount", targetAmountUseCase.getTargetAmountAndTotalSpending(user.getUserId(), date))); + return ResponseEntity.ok(SuccessResponse.from(TARGET_AMOUNT, targetAmountUseCase.getTargetAmountAndTotalSpending(user.getUserId(), date))); } @Override @GetMapping("") @PreAuthorize("isAuthenticated()") - public ResponseEntity getTargetAmountsAndTotalSpendings(@Validated TargetAmountDto.GetParamReq param, @AuthenticationPrincipal SecurityUserDetails user) { - return ResponseEntity.ok(SuccessResponse.from("targetAmounts", targetAmountUseCase.getTargetAmountsAndTotalSpendings(user.getUserId(), param.date()))); + public ResponseEntity getTargetAmountsAndTotalSpendings(@Validated TargetAmountDto.DateParam param, @AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from(TARGET_AMOUNTS, targetAmountUseCase.getTargetAmountsAndTotalSpendings(user.getUserId(), param.date()))); } @Override - @DeleteMapping("/{date}") - @PreAuthorize("isAuthenticated()") - public ResponseEntity deleteTargetAmount(@PathVariable LocalDate date, @AuthenticationPrincipal SecurityUserDetails user) { - if (!isValidDateForYearAndMonth(date)) { - throw new TargetAmountErrorException(TargetAmountErrorCode.INVALID_TARGET_AMOUNT_DATE); - } - - targetAmountUseCase.deleteTargetAmount(user.getUserId(), date); - return ResponseEntity.ok(SuccessResponse.noContent()); + @PatchMapping("/{target_amount_id}") + @PreAuthorize("isAuthenticated() and @targetAmountManager.hasPermission(principal.userId, #targetAmountId)") + public ResponseEntity patchTargetAmount(@Validated TargetAmountDto.AmountParam param, @PathVariable("target_amount_id") Long targetAmountId) { + return ResponseEntity.ok(SuccessResponse.from(TARGET_AMOUNT, targetAmountUseCase.updateTargetAmount(targetAmountId, param.amount()))); } - - private boolean isValidDateForYearAndMonth(LocalDate date) { - LocalDate now = LocalDate.now(); - return date.getYear() == now.getYear() && date.getMonth() == now.getMonth(); + + @Override + @DeleteMapping("/{target_amount_id}") + @PreAuthorize("isAuthenticated() and @targetAmountManager.hasPermission(principal.userId, #targetAmountId)") + public ResponseEntity deleteTargetAmount(@PathVariable("target_amount_id") Long targetAmountId) { + targetAmountUseCase.deleteTargetAmount(targetAmountId); + return ResponseEntity.ok(SuccessResponse.noContent()); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java index 8e7ce42bb..29cb580bf 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java @@ -13,13 +13,8 @@ import java.time.LocalDate; public class TargetAmountDto { - @Schema(title = "목표 금액 등록/수정 요청 파라미터") - public record UpdateParamReq( - @Schema(description = "등록하려는 목표 금액 날짜 (당일)", example = "2024-05-08", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "date 값은 필수입니다.") - @JsonSerialize(using = LocalDateSerializer.class) - @JsonFormat(pattern = "yyyy-MM-dd") - LocalDate date, + @Schema(title = "목표 금액의 amount 유효성 검사를 위한 요청 파라미터", hidden = true) + public record AmountParam( @Schema(description = "등록하려는 목표 금액 (0이상의 정수)", example = "100000", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(message = "amount 값은 필수입니다.") @Min(value = 0, message = "amount 값은 0 이상이어야 합니다.") @@ -28,8 +23,8 @@ public record UpdateParamReq( } - @Schema(title = "목표 금액 조회 요청 파라미터", hidden = true) - public record GetParamReq( + @Schema(title = "목표 금액의 date 유효성 검사를 위한 요청 파라미터", hidden = true) + public record DateParam( @Schema(description = "조회하려는 목표 금액 날짜 (당일)", example = "2024-05-08", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(message = "date 값은 필수입니다.") @JsonSerialize(using = LocalDateSerializer.class) @@ -50,8 +45,8 @@ public record WithTotalSpendingRes( @NotNull(message = "month 값은 필수입니다.") Integer month, @Schema(description = "목표 금액", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "targetAmount 값은 필수입니다.") - TargetAmountInfo targetAmount, + @NotNull(message = "targetAmountDetail 값은 필수입니다.") + TargetAmountInfo targetAmountDetail, @Schema(description = "총 지출 금액", example = "100000", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(message = "totalSpending 값은 필수입니다.") Integer totalSpending, @@ -68,7 +63,9 @@ public record TargetAmountInfo( Long id, @Schema(description = "목표 금액. -1이면 설정한 목표 금액이 존재하지 않음을 의미한다.", example = "50000", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(message = "amount 값은 필수입니다.") - Integer amount + Integer amount, + @Schema(description = "사용자 확인 여부", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + boolean isRead ) { public TargetAmountInfo { if (id == null) { @@ -86,9 +83,9 @@ public record TargetAmountInfo( */ public static TargetAmountInfo from(TargetAmount targetAmount) { if (targetAmount == null) { - return new TargetAmountInfo(-1L, -1); + return new TargetAmountInfo(-1L, -1, false); } - return new TargetAmountInfo(targetAmount.getId(), targetAmount.getAmount()); + return new TargetAmountInfo(targetAmount.getId(), targetAmount.getAmount(), targetAmount.isRead()); } } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java index cd9fbb434..f14342adc 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java @@ -71,7 +71,7 @@ private static TargetAmountDto.WithTotalSpendingRes createWithTotalSpendingRes(T return TargetAmountDto.WithTotalSpendingRes.builder() .year(date.getYear()) .month(date.getMonthValue()) - .targetAmount(targetAmountInfo) + .targetAmountDetail(targetAmountInfo) .totalSpending(totalSpending) .diffAmount((targetAmountInfo.amount() == -1) ? 0 : totalSpending - targetAmountInfo.amount()) .build(); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java index 846d47c72..5f0a1c33f 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java @@ -1,38 +1,30 @@ package kr.co.pennyway.api.apis.ledger.service; +import kr.co.pennyway.domain.common.redisson.DistributedLock; import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; +import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorException; import kr.co.pennyway.domain.domains.target.service.TargetAmountService; import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; -import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; -import java.util.Optional; @Slf4j @Service @RequiredArgsConstructor public class TargetAmountSaveService { - private final UserService userService; private final TargetAmountService targetAmountService; - /** - * 사용자에게 당월 목표 금액이 있으면 amount를 수정하고, 없으면 새로 생성한다. - */ - @Transactional - public void saveTargetAmount(Long userId, LocalDate date, Integer amount) { - Optional targetAmount = targetAmountService.readTargetAmountThatMonth(userId, date); - - if (targetAmount.isPresent()) { - targetAmount.get().updateAmount(amount); - } else { - User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); - targetAmountService.createTargetAmount(TargetAmount.of(amount, user)); + @DistributedLock(key = "#key.concat(#user.getId()).concat('_').concat(#date.getYear()).concat('-').concat(#date.getMonthValue())") + public TargetAmount createTargetAmount(String key, User user, LocalDate date) { + if (targetAmountService.isExistsTargetAmountThatMonth(user.getId(), date)) { + log.info("{}에 대한 날짜의 목표 금액이 이미 존재합니다.", date); + throw new TargetAmountErrorException(TargetAmountErrorCode.ALREADY_EXIST_TARGET_AMOUNT); } + + return targetAmountService.createTargetAmount(TargetAmount.of(-1, user)); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java index 034fbce7d..7d1e27b07 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java @@ -4,12 +4,13 @@ import kr.co.pennyway.api.apis.ledger.mapper.TargetAmountMapper; import kr.co.pennyway.api.apis.ledger.service.TargetAmountSaveService; import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.common.redisson.DistributedLockPrefix; +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.target.domain.TargetAmount; import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorException; import kr.co.pennyway.domain.domains.target.service.TargetAmountService; -import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; @@ -33,16 +34,20 @@ public class TargetAmountUseCase { private final TargetAmountSaveService targetAmountSaveService; @Transactional - public void updateTargetAmount(Long userId, LocalDate date, Integer amount) { - targetAmountSaveService.saveTargetAmount(userId, date, amount); + public TargetAmountDto.TargetAmountInfo createTargetAmount(Long userId, int year, int month) { + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + + TargetAmount targetAmount = targetAmountSaveService.createTargetAmount(DistributedLockPrefix.TARGET_AMOUNT_USER, user, LocalDate.of(year, month, 1)); + + return TargetAmountDto.TargetAmountInfo.from(targetAmount); } @Transactional(readOnly = true) public TargetAmountDto.WithTotalSpendingRes getTargetAmountAndTotalSpending(Long userId, LocalDate date) { - Optional targetAmount = targetAmountService.readTargetAmountThatMonth(userId, date); + TargetAmount targetAmount = targetAmountService.readTargetAmountThatMonth(userId, date).orElseThrow(() -> new TargetAmountErrorException(TargetAmountErrorCode.NOT_FOUND_TARGET_AMOUNT)); Optional totalSpending = spendingService.readTotalSpendingAmountByUserId(userId, date); - return TargetAmountMapper.toWithTotalSpendingResponse(targetAmount.orElse(null), totalSpending.orElse(null), date); + return TargetAmountMapper.toWithTotalSpendingResponse(targetAmount, totalSpending.orElse(null), date); } @Transactional(readOnly = true) @@ -54,13 +59,31 @@ public List getTargetAmountsAndTotalSpendi return TargetAmountMapper.toWithTotalSpendingResponses(targetAmounts, totalSpendings, user.getCreatedAt().toLocalDate(), date); } - + + @Transactional + public TargetAmountDto.TargetAmountInfo updateTargetAmount(Long targetAmountId, Integer amount) { + TargetAmount targetAmount = targetAmountService.readTargetAmount(targetAmountId) + .orElseThrow(() -> new TargetAmountErrorException(TargetAmountErrorCode.NOT_FOUND_TARGET_AMOUNT)); + + if (!targetAmount.isThatMonth()) { + throw new TargetAmountErrorException(TargetAmountErrorCode.INVALID_TARGET_AMOUNT_DATE); + } + + targetAmount.updateAmount(amount); + + return TargetAmountDto.TargetAmountInfo.from(targetAmount); + } + @Transactional - public void deleteTargetAmount(Long userId, LocalDate date) { - TargetAmount targetAmount = targetAmountService.readTargetAmountThatMonth(userId, date) + public void deleteTargetAmount(Long targetAmountId) { + TargetAmount targetAmount = targetAmountService.readTargetAmount(targetAmountId) .filter(TargetAmount::isAllocatedAmount) .orElseThrow(() -> new TargetAmountErrorException(TargetAmountErrorCode.NOT_FOUND_TARGET_AMOUNT)); + if (!targetAmount.isThatMonth()) { + throw new TargetAmountErrorException(TargetAmountErrorCode.INVALID_TARGET_AMOUNT_DATE); + } + targetAmountService.deleteTargetAmount(targetAmount); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/TargetAmountManager.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/TargetAmountManager.java new file mode 100644 index 000000000..f66b1e91f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/TargetAmountManager.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.api.common.security.authorization; + +import kr.co.pennyway.domain.domains.target.service.TargetAmountService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component("targetAmountManager") +@RequiredArgsConstructor +public class TargetAmountManager { + private final TargetAmountService targetAmountService; + + /** + * 사용자가 해당 TargetAmount에 대한 권한이 있는지 확인한다. + * + * @param userId 사용자 ID + * @param targetAmountId TargetAmount ID + * @return 권한 여부 + */ + @Transactional(readOnly = true) + public boolean hasPermission(Long userId, Long targetAmountId) { + return targetAmountService.isExistsTargetAmountByIdAndUserId(targetAmountId, userId); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java index df046780c..b585a78af 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java @@ -1,9 +1,11 @@ package kr.co.pennyway.api.apis.ledger.controller; -import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.ledger.dto.TargetAmountDto; import kr.co.pennyway.api.apis.ledger.usecase.TargetAmountUseCase; import kr.co.pennyway.api.config.WebConfig; +import kr.co.pennyway.api.config.fixture.UserFixture; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; @@ -18,9 +20,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; - +import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -35,9 +35,6 @@ public class TargetAmountControllerUnitTest { @Autowired private MockMvc mockMvc; - @Autowired - private ObjectMapper objectMapper; - @MockBean private TargetAmountUseCase targetAmountUseCase; @@ -51,53 +48,19 @@ void setUp(WebApplicationContext webApplicationContext) { .build(); } - @Order(1) @Nested - @DisplayName("당월 목표 금액 등록/수정") - class PutTargetAmount { - @Test - @DisplayName("date가 'yyyy-MM-dd' 형식이 아닐 경우 422 Unprocessable Entity 에러 응답을 반환한다.") - @WithMockUser - void putTargetAmountWithInvalidDateFormat() throws Exception { - // given - String date = "2024/05/08"; - Integer amount = 100000; - - // when - ResultActions result = performPutTargetAmount(date, amount); - - // then - result - .andDo(print()) - .andExpect(status().isUnprocessableEntity()); - } - - @Test - @DisplayName("date가 null인 경우 422 Unprocessable Entity 에러 응답을 반환한다.") - @WithMockUser - void putTargetAmountWithNullDate() throws Exception { - // given - Integer amount = 100000; - - // when - ResultActions result = performPutTargetAmount(null, amount); - - // then - result - .andDo(print()) - .andExpect(status().isUnprocessableEntity()); - } - + @DisplayName("당월 목표 금액 등록") + class PostTargetAmount { @Test - @DisplayName("date가 당월 날짜가 아닌 경우 400 Bad Request 에러 응답을 반환한다.") + @DisplayName("오늘 날짜에 대한 요청이 아니면 400 Bad Request 에러 응답을 반환한다.") @WithMockUser - void putTargetAmountWithInvalidDate() throws Exception { + void postTargetAmountNotThatMonth() throws Exception { // given - String date = "1999-05-19"; - Integer amount = 100000; + int year = 2024; + int month = 5; // when - ResultActions result = performPutTargetAmount(date, amount); + ResultActions result = performPostTargetAmount(year, month); // then result @@ -107,37 +70,40 @@ void putTargetAmountWithInvalidDate() throws Exception { .andExpect(jsonPath("$.message").value(TargetAmountErrorCode.INVALID_TARGET_AMOUNT_DATE.getExplainError())); } + private ResultActions performPostTargetAmount(int year, int month) throws Exception { + return mockMvc.perform(post("/v2/target-amounts") + .param("year", String.valueOf(year)) + .param("month", String.valueOf(month)) + ); + } + } + + @Nested + @DisplayName("당월 목표 금액 수정") + class PutTargetAmount { @Test @DisplayName("amount가 null인 경우 422 Unprocessable Entity 에러 응답을 반환한다.") @WithMockUser void putTargetAmountWithInvalidAmountFormat() throws Exception { - // given - String date = "2024-05-08"; - // when - ResultActions result1 = performPutTargetAmount(date, null); + ResultActions result1 = performPutTargetAmount(1L, null); // then - result1 - .andDo(print()) - .andExpect(status().isUnprocessableEntity()); + result1.andDo(print()).andExpect(status().isUnprocessableEntity()); } @Test @DisplayName("amount가 0보다 작은 경우 422 Unprocessable Entity 에러 응답을 반환한다.") - @WithMockUser + @WithSecurityMockUser void putTargetAmountWithNegativeAmount() throws Exception { // given - String date = "2024-05-08"; Integer negativeAmount = -100000; // when - ResultActions result = performPutTargetAmount(date, negativeAmount); + ResultActions result = performPutTargetAmount(1L, negativeAmount); // then - result - .andDo(print()) - .andExpect(status().isUnprocessableEntity()); + result.andDo(print()).andExpect(status().isUnprocessableEntity()); } @Test @@ -145,22 +111,19 @@ void putTargetAmountWithNegativeAmount() throws Exception { @WithSecurityMockUser void putTargetAmountWithValidRequest() throws Exception { // given - String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); Integer amount = 100000; + given(targetAmountUseCase.updateTargetAmount(1L, amount)).willReturn(TargetAmountDto.TargetAmountInfo.from(TargetAmount.of(amount, UserFixture.GENERAL_USER.toUser()))); // when - ResultActions result = performPutTargetAmount(date, amount); + ResultActions result = performPutTargetAmount(1L, amount); // then - result - .andDo(print()) - .andExpect(status().isOk()); + result.andDo(print()).andExpect(status().isOk()); } - private ResultActions performPutTargetAmount(String date, Integer amount) throws Exception { - return mockMvc.perform(put("/v2/targets") - .param("date", date) + private ResultActions performPutTargetAmount(Long targetAmountId, Integer amount) throws Exception { + return mockMvc.perform(patch("/v2/target-amounts/{target_amount_id}", targetAmountId) .param("amount", String.valueOf(amount)) ); } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountIntegrationTest.java deleted file mode 100644 index 4b5055484..000000000 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountIntegrationTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package kr.co.pennyway.api.apis.ledger.controller; - -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; - -import org.junit.jupiter.api.ClassOrderer; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.MethodOrderer; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestClassOrder; -import org.junit.jupiter.api.TestMethodOrder; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; -import org.springframework.transaction.annotation.Transactional; - -import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; -import kr.co.pennyway.api.config.ExternalApiDBTestConfig; -import kr.co.pennyway.api.config.ExternalApiIntegrationTest; -import kr.co.pennyway.api.config.fixture.UserFixture; -import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; -import kr.co.pennyway.domain.domains.target.domain.TargetAmount; -import kr.co.pennyway.domain.domains.target.service.TargetAmountService; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.service.UserService; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@ExternalApiIntegrationTest -@AutoConfigureMockMvc -@TestClassOrder(ClassOrderer.OrderAnnotation.class) -public class TargetAmountIntegrationTest extends ExternalApiDBTestConfig { - @Autowired - private MockMvc mockMvc; - - @Autowired - private UserService userService; - - @Autowired - private TargetAmountService targetAmountService; - - @Nested - @Order(1) - @DisplayName("당월 목표 금액 등록/수정") - @TestMethodOrder(MethodOrderer.OrderAnnotation.class) - class PutTargetAmount { - @Order(1) - @Test - @DisplayName("당월 목표 금액 entity가 존재하지 않을 경우 새로 생성한다.") - @WithSecurityMockUser - @Transactional - void putTargetAmountNotFound() throws Exception { - // given - User user = UserFixture.GENERAL_USER.toUser(); - userService.createUser(user); - String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); - - // when - ResultActions result = performPutTargetAmount(date, 100000, user); - - // then - result.andExpect(status().isOk()); - assertNotNull(targetAmountService.readTargetAmountThatMonth(user.getId(), LocalDate.now()).orElse(null)); - } - - @Order(2) - @Test - @DisplayName("당월 목표 금액 entity가 존재하는 경우 amount를 수정한다.") - @WithSecurityMockUser - @Transactional - void putTargetAmountFound() throws Exception { - // given - User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); - TargetAmount targetAmount = targetAmountService.createTargetAmount(TargetAmount.of(100000, user)); - - String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); - - // when - ResultActions result = performPutTargetAmount(date, 200000, user); - - // then - result.andExpect(status().isOk()); - assertEquals(200000, targetAmount.getAmount()); - } - - private ResultActions performPutTargetAmount(String date, Integer amount, User requestUser) throws Exception { - UserDetails userDetails = SecurityUserDetails.from(requestUser); - return mockMvc.perform(put("/v2/targets") - .with(user(userDetails)) - .param("date", date) - .param("amount", amount.toString())); - } - } -} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountControllerIntegrationTest.java deleted file mode 100644 index 9a057b9d1..000000000 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountControllerIntegrationTest.java +++ /dev/null @@ -1,114 +0,0 @@ -package kr.co.pennyway.api.apis.ledger.integration; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; -import kr.co.pennyway.api.config.ExternalApiDBTestConfig; -import kr.co.pennyway.api.config.ExternalApiIntegrationTest; -import kr.co.pennyway.api.config.fixture.SpendingFixture; -import kr.co.pennyway.api.config.fixture.TargetAmountFixture; -import kr.co.pennyway.api.config.fixture.UserFixture; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.service.UserService; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.time.LocalDateTime; - -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@Slf4j -@ExternalApiIntegrationTest -@AutoConfigureMockMvc -@TestClassOrder(ClassOrderer.OrderAnnotation.class) -public class TargetAmountControllerIntegrationTest extends ExternalApiDBTestConfig { - @Autowired - private MockMvc mockMvc; - @Autowired - private NamedParameterJdbcTemplate jdbcTemplate; - @Autowired - private UserService userService; - @PersistenceContext - private EntityManager em; - - private User createUserWithCreatedAt(LocalDateTime createdAt, NamedParameterJdbcTemplate jdbcTemplate) { - User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); - Long userId = user.getId(); - - UserFixture.updateUserCreatedAt(user, createdAt, jdbcTemplate); - em.flush(); - em.clear(); - - return userService.readUser(userId).orElseThrow(); - } - - @Order(1) - @Nested - @DisplayName("임의의 년/월에 대한 사용자 목표 금액 및 지출 총합 조회") - class GetTargetAmountAndTotalSpending { - @Test - @DisplayName("특정 년/월에 대한 사용자 목표 금액 및 지출 총합 조회") - @Transactional - void getTargetAmountAndTotalSpending() throws Exception { - // given - User user = createUserWithCreatedAt(LocalDateTime.now().minusYears(2), jdbcTemplate); - SpendingFixture.bulkInsertSpending(user, 300, jdbcTemplate); - TargetAmountFixture.bulkInsertTargetAmount(user, jdbcTemplate); - - // when - ResultActions result = performGetTargetAmountAndTotalSpending(user, LocalDate.now()); - - // then - result.andDo(print()) - .andExpect(status().isOk()); - } - - private ResultActions performGetTargetAmountAndTotalSpending(User requestUser, LocalDate date) throws Exception { - UserDetails userDetails = SecurityUserDetails.from(requestUser); - - return mockMvc.perform(MockMvcRequestBuilders.get("/v2/targets/{date}", date) - .with(user(userDetails))); - } - } - - @Order(2) - @Nested - @DisplayName("사용자 목표 금액 및 지출 총합 전체 기록 조회") - class GetTargetAmountsAndTotalSpendings { - @Test - @DisplayName("사용자 목표 금액 및 지출 총합 조회") - @Transactional - void getTargetAmountsAndTotalSpendings() throws Exception { - // given - User user = createUserWithCreatedAt(LocalDateTime.now().minusYears(2).plusMonths(2), jdbcTemplate); - SpendingFixture.bulkInsertSpending(user, 300, jdbcTemplate); - TargetAmountFixture.bulkInsertTargetAmount(user, jdbcTemplate); - - // when - ResultActions result = performGetTargetAmountsAndTotalSpendings(user, LocalDate.now()); - - // then - result.andDo(print()) - .andExpect(status().isOk()); - } - - private ResultActions performGetTargetAmountsAndTotalSpendings(User requestUser, LocalDate date) throws Exception { - UserDetails userDetails = SecurityUserDetails.from(requestUser); - - return mockMvc.perform(MockMvcRequestBuilders.get("/v2/targets") - .with(user(userDetails)) - .param("date", date.toString())); - } - } -} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java new file mode 100644 index 000000000..bdc2cf446 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java @@ -0,0 +1,308 @@ +package kr.co.pennyway.api.apis.ledger.integration; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.SpendingFixture; +import kr.co.pennyway.api.config.fixture.TargetAmountFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.target.service.TargetAmountService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Slf4j +@ExternalApiIntegrationTest +@AutoConfigureMockMvc +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +public class TargetAmountIntegrationTest extends ExternalApiDBTestConfig { + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserService userService; + + @Autowired + private TargetAmountService targetAmountService; + @PersistenceContext + private EntityManager em; + + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + private User createUserWithCreatedAt(LocalDateTime createdAt, NamedParameterJdbcTemplate jdbcTemplate) { + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + Long userId = user.getId(); + + UserFixture.updateUserCreatedAt(user, createdAt, jdbcTemplate); + em.flush(); + em.clear(); + + return userService.readUser(userId).orElseThrow(); + } + + @Nested + @DisplayName("당월 목표 금액 등록") + class PostTargetAmount { + @Test + @DisplayName("사용자에게 당월 목표 기록이 존재할 시 409 Conflict 에러 응답을 반환한다.") + void postTargetAmountConflict() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + TargetAmount targetAmount = targetAmountService.createTargetAmount(TargetAmountFixture.GENERAL_TARGET_AMOUNT.toTargetAmount(user)); + log.debug("targetAmountInfo: {}", targetAmount); + + // when + ResultActions result = performPostTargetAmount(user, LocalDate.now()); + + // then + result.andDo(print()) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("사용자에게 당월 목표 기록이 존재하지 않을 시 200 OK 응답을 반환한다.") + void postTargetAmountOk() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + + // when + ResultActions result = performPostTargetAmount(user, LocalDate.now()); + + // then + result.andDo(print()) + .andExpect(status().isOk()); + } + + private ResultActions performPostTargetAmount(User requestUser, LocalDate date) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + + return mockMvc.perform(MockMvcRequestBuilders.post("/v2/target-amounts") + .with(user(userDetails)) + .param("year", String.valueOf(date.getYear())) + .param("month", String.valueOf(date.getMonthValue()))); + } + } + + @Nested + @DisplayName("임의의 년/월에 대한 사용자 목표 금액 및 지출 총합 조회") + class GetTargetAmountAndTotalSpending { + @Test + @DisplayName("특정 년/월에 대한 사용자 목표 금액 및 지출 총합 조회") + @Transactional + void getTargetAmountAndTotalSpending() throws Exception { + // given + User user = createUserWithCreatedAt(LocalDateTime.now().minusYears(2), jdbcTemplate); + SpendingFixture.bulkInsertSpending(user, 300, jdbcTemplate); + TargetAmountFixture.bulkInsertTargetAmount(user, jdbcTemplate); + + // when + ResultActions result = performGetTargetAmountAndTotalSpending(user, LocalDate.now()); + + // then + result.andDo(print()) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("특정 년/월에 대한 사용자 목표 금액이 존재하지 않는 경우, 404 Not Found 에러 응답을 반환한다.") + @Transactional + void getTargetAmountAndTotalSpendingNotFound() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + + // when + ResultActions result = performGetTargetAmountAndTotalSpending(user, LocalDate.now()); + + // then + result.andDo(print()).andExpect(status().isNotFound()); + } + + private ResultActions performGetTargetAmountAndTotalSpending(User requestUser, LocalDate date) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + + return mockMvc.perform(MockMvcRequestBuilders.get("/v2/target-amounts/{date}", date) + .with(user(userDetails))); + } + } + + @Nested + @DisplayName("사용자 목표 금액 및 지출 총합 전체 기록 조회") + class GetTargetAmountsAndTotalSpendings { + @Test + @DisplayName("사용자 목표 금액 및 지출 총합 조회") + @Transactional + void getTargetAmountsAndTotalSpendings() throws Exception { + // given + User user = createUserWithCreatedAt(LocalDateTime.now().minusYears(2).plusMonths(2), jdbcTemplate); + SpendingFixture.bulkInsertSpending(user, 300, jdbcTemplate); + TargetAmountFixture.bulkInsertTargetAmount(user, jdbcTemplate); + + // when + ResultActions result = performGetTargetAmountsAndTotalSpendings(user, LocalDate.now()); + + // then + result.andDo(print()) + .andExpect(status().isOk()); + } + + private ResultActions performGetTargetAmountsAndTotalSpendings(User requestUser, LocalDate date) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + + return mockMvc.perform(MockMvcRequestBuilders.get("/v2/target-amounts") + .with(user(userDetails)) + .param("date", date.toString())); + } + } + + @Nested + @DisplayName("당월 목표 금액 수정") + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class PatchTargetAmount { + @Test + @DisplayName("당월 목표 금액 pk에 대한 접근 권한이 없는 경우 403 Forbidden 에러 응답을 반환한다.") + @Transactional + void patchTargetAmountForbidden() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + + // when + ResultActions result = performPatchTargetAmount(1000L, 100000, user); + + // then + result.andDo(print()).andExpect(status().isForbidden()); + } + + @Test + @DisplayName("당월 목표 금액 pk에 대한 접근 권한이 있지만, 당월 데이터가 아닌 경우 400 Bad Request 에러 응답을 반환한다.") + @Transactional + void patchTargetAmountNotThatMonth() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + TargetAmount targetAmount = targetAmountService.createTargetAmount(TargetAmount.of(100000, user)); + TargetAmountFixture.convertCreatedAt(targetAmount, LocalDateTime.now().minusMonths(1), jdbcTemplate, em); + targetAmount = targetAmountService.readTargetAmount(targetAmount.getId()).orElseThrow(); + + // when + ResultActions result = performPatchTargetAmount(targetAmount.getId(), 200000, user); + + // then + result.andDo(print()).andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("당월 목표 금액 pk에 대한 접근 권한이 있고, 당월 데이터인 경우 200 OK 응답을 반환한다.") + @Transactional + void patchTargetAmountCorrect() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + TargetAmount targetAmount = targetAmountService.createTargetAmount(TargetAmount.of(100000, user)); + + // when + ResultActions result = performPatchTargetAmount(targetAmount.getId(), 200000, user); + + // then + result.andDo(print()).andExpect(status().isOk()); + assertEquals(200000, targetAmountService.readTargetAmount(targetAmount.getId()).orElseThrow().getAmount()); + } + + private ResultActions performPatchTargetAmount(Long targetAmountId, Integer amount, User requestUser) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + return mockMvc.perform(patch("/v2/target-amounts/{target_amount_id}", targetAmountId) + .with(user(userDetails)) + .param("amount", amount.toString())); + } + } + + @Nested + @DisplayName("당월 목표 금액 삭제") + class DeleteTargetAmount { + @Test + @DisplayName("당월 목표 금액 pk에 대한 접근 권한이 없는 경우 403 Forbidden 에러 응답을 반환한다.") + @Transactional + void deleteTargetAmountForbidden() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + + // when + ResultActions result = performDeleteTargetAmount(1000L, user); + + // then + result.andDo(print()).andExpect(status().isForbidden()); + } + + @Test + @DisplayName("당월 목표 금액 pk에 대한 접근 권한이 있지만, 당월 데이터가 아닌 경우 400 Bad Request 에러 응답을 반환한다.") + @Transactional + void deleteTargetAmountNotThatMonth() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + TargetAmount targetAmount = targetAmountService.createTargetAmount(TargetAmount.of(100000, user)); + TargetAmountFixture.convertCreatedAt(targetAmount, LocalDateTime.now().minusMonths(1), jdbcTemplate, em); + targetAmount = targetAmountService.readTargetAmount(targetAmount.getId()).orElseThrow(); + + // when + ResultActions result = performDeleteTargetAmount(targetAmount.getId(), user); + + // then + result.andDo(print()).andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("당월 목표 금액 pk에 대한 접근 권한이 있지만, amount가 이미 -1인 경우 404 Not Found 에러 응답을 반환한다.") + @Transactional + void deleteTargetAmountNotFound() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + TargetAmount targetAmount = targetAmountService.createTargetAmount(TargetAmount.of(-1, user)); + + // when + ResultActions result = performDeleteTargetAmount(targetAmount.getId(), user); + + // then + result.andDo(print()).andExpect(status().isNotFound()); + } + + @Test + @DisplayName("당월 목표 금액 pk에 대한 접근 권한이 있고, 당월 데이터인 경우 200 OK 응답을 반환한다.") + @Transactional + void deleteTargetAmountCorrect() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + TargetAmount targetAmount = targetAmountService.createTargetAmount(TargetAmount.of(100000, user)); + + // when + ResultActions result = performDeleteTargetAmount(targetAmount.getId(), user); + + // then + result.andDo(print()).andExpect(status().isOk()); + } + + private ResultActions performDeleteTargetAmount(Long targetAmountId, User requestUser) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + return mockMvc.perform(MockMvcRequestBuilders.delete("/v2/target-amounts/{target_amount_id}", targetAmountId) + .with(user(userDetails))); + } + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/TargetAmountFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/TargetAmountFixture.java index a950b46d2..a663e6254 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/TargetAmountFixture.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/TargetAmountFixture.java @@ -1,5 +1,7 @@ package kr.co.pennyway.api.config.fixture; +import jakarta.persistence.EntityManager; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; import kr.co.pennyway.domain.domains.user.domain.User; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; @@ -12,15 +14,24 @@ import java.util.List; import java.util.concurrent.ThreadLocalRandom; -public class TargetAmountFixture { +public enum TargetAmountFixture { + GENERAL_TARGET_AMOUNT(10000, true); + private static final String TARGET_AMOUNT_TABLE = "target_amount"; + private final int amount; + private final boolean isRead; + + TargetAmountFixture(int amount, boolean isRead) { + this.amount = amount; + this.isRead = isRead; + } public static void bulkInsertTargetAmount(User user, NamedParameterJdbcTemplate jdbcTemplate) { Collection targetAmounts = getRandomTargetAmounts(user); String sql = String.format(""" - INSERT INTO `%s` (amount, user_id, created_at, updated_at) - VALUES (:amount, :userId, :createdAt, :updatedAt) + INSERT INTO `%s` (amount, is_read, user_id, created_at, updated_at) + VALUES (:amount, true, :userId, :createdAt, :updatedAt) """, TARGET_AMOUNT_TABLE); SqlParameterSource[] params = targetAmounts.stream() .map(mockTargetAmount -> new MapSqlParameterSource() @@ -52,6 +63,19 @@ private static List getRandomTargetAmounts(User user) { return targetAmounts; } + public static void convertCreatedAt(TargetAmount targetAmount, LocalDateTime dateTime, NamedParameterJdbcTemplate jdbcTemplate, EntityManager em) { + String sql = String.format("UPDATE `%s` SET created_at = :createdAt WHERE id = :id", TARGET_AMOUNT_TABLE); + SqlParameterSource param = new MapSqlParameterSource() + .addValue("createdAt", dateTime) + .addValue("id", targetAmount.getId()); + jdbcTemplate.update(sql, param); + em.clear(); + } + + public TargetAmount toTargetAmount(User user) { + return TargetAmount.of(amount, user); + } + private record MockTargetAmount(int amount, LocalDateTime createdAt, LocalDateTime updatedAt, Long userId) { public static MockTargetAmount of(int amount, LocalDateTime createdAt, LocalDateTime updatedAt, Long userId) { return new MockTargetAmount(amount, createdAt, updatedAt, userId); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAop.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAop.java index 06b3b2284..a635f7df7 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAop.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAop.java @@ -1,5 +1,6 @@ package kr.co.pennyway.domain.common.aop; +import kr.co.pennyway.domain.common.redisson.DistributedLock; import kr.co.pennyway.domain.common.util.CustomSpringELParser; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,7 +27,7 @@ public class DistributedLockAop { private final RedissonClient redissonClient; private final CallTransactionFactory callTransactionFactory; - @Around("@annotation(kr.co.pennyway.domain.common.aop.DistributedLock)") + @Around("@annotation(kr.co.pennyway.domain.common.redisson.DistributedLock)") public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLock.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLock.java similarity index 96% rename from pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLock.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLock.java index 914b1d6df..b0bd55832 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLock.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLock.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.domain.common.aop; +package kr.co.pennyway.domain.common.redisson; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLockPrefix.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLockPrefix.java new file mode 100644 index 000000000..ba1b14788 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLockPrefix.java @@ -0,0 +1,8 @@ +package kr.co.pennyway.domain.common.redisson; + +/** + * 분산 락을 위한 prefix + */ +public class DistributedLockPrefix { + public static final String TARGET_AMOUNT_USER = "TargetAmount_User_"; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java index 64926653a..aa4f21367 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java @@ -8,17 +8,20 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.SQLDelete; +import java.time.YearMonth; + @Entity @Getter @Table(name = "target_amount") @NoArgsConstructor(access = AccessLevel.PROTECTED) -@SQLDelete(sql = "UPDATE target_amount SET amount = -1 WHERE id = ?") +@SQLDelete(sql = "UPDATE target_amount SET amount = -1, is_read = 1 WHERE id = ?") public class TargetAmount extends DateAuditable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private int amount; + private boolean isRead; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") @@ -27,6 +30,7 @@ public class TargetAmount extends DateAuditable { private TargetAmount(int amount, User user) { this.amount = amount; this.user = user; + this.isRead = false; } public static TargetAmount of(int amount, User user) { @@ -35,14 +39,25 @@ public static TargetAmount of(int amount, User user) { public void updateAmount(Integer amount) { this.amount = amount; + this.isRead = true; } public boolean isAllocatedAmount() { return this.amount >= 0; } + /** + * 해당 TargetAmount가 당월 데이터인지 확인한다. + * + * @return 당월 데이터라면 true, 아니라면 false + */ + public boolean isThatMonth() { + YearMonth yearMonth = YearMonth.now(); + return this.getCreatedAt().getYear() == yearMonth.getYear() && this.getCreatedAt().getMonth() == yearMonth.getMonth(); + } + @Override public String toString() { - return "TargetAmount(id=" + this.getId() + ", amount=" + this.getAmount() + ")"; + return "TargetAmount(id=" + this.getId() + ", amount=" + this.getAmount() + ", year = " + this.getCreatedAt().getYear() + ", month = " + this.getCreatedAt().getMonthValue() + ")"; } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java index bfbe486ad..2a85e2351 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java @@ -12,7 +12,11 @@ public enum TargetAmountErrorCode implements BaseErrorCode { INVALID_TARGET_AMOUNT_DATE(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "당월 목표 금액에 대한 요청이 아닙니다."), /* 404 NOT_FOUND */ - NOT_FOUND_TARGET_AMOUNT(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "해당 월의 목표 금액이 존재하지 않습니다."); + NOT_FOUND_TARGET_AMOUNT(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "해당 월의 목표 금액이 존재하지 않습니다."), + + /* 409 Conflict */ + ALREADY_EXIST_TARGET_AMOUNT(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 해당 월의 목표 금액 데이터가 존재합니다."), + ; private final StatusCode statusCode; private final ReasonCode reasonCode; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java new file mode 100644 index 000000000..4b117acc2 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.domains.target.repository; + +import java.time.LocalDate; + +public interface TargetAmountCustomRepository { + boolean existsByUserIdThatMonth(Long userId, LocalDate date); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java new file mode 100644 index 000000000..a5e0a2daf --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.domain.domains.target.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.domain.domains.target.domain.QTargetAmount; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; + +@Repository +@RequiredArgsConstructor +public class TargetAmountCustomRepositoryImpl implements TargetAmountCustomRepository { + private final JPAQueryFactory queryFactory; + + private final QUser user = QUser.user; + private final QTargetAmount targetAmount = QTargetAmount.targetAmount; + + @Override + public boolean existsByUserIdThatMonth(Long userId, LocalDate date) { + return queryFactory.selectOne().from(targetAmount) + .innerJoin(user).on(targetAmount.user.id.eq(user.id)) + .where(user.id.eq(userId) + .and(targetAmount.createdAt.year().eq(date.getYear())) + .and(targetAmount.createdAt.month().eq(date.getMonthValue()))) + .fetchFirst() != null; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java index b6fcdc8ea..1a3405763 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java @@ -9,11 +9,14 @@ import java.util.List; import java.util.Optional; -public interface TargetAmountRepository extends ExtendedRepository { +public interface TargetAmountRepository extends ExtendedRepository, TargetAmountCustomRepository { @Transactional(readOnly = true) @Query("SELECT ta FROM TargetAmount ta WHERE ta.user.id = :userId AND YEAR(ta.createdAt) = YEAR(:date) AND MONTH(ta.createdAt) = MONTH(:date)") Optional findByUserIdThatMonth(Long userId, LocalDate date); @Transactional(readOnly = true) List findByUser_Id(Long userId); + + @Transactional(readOnly = true) + boolean existsByIdAndUser_Id(Long id, Long userId); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java index 7ce094ff9..96ac0cb43 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java @@ -22,6 +22,11 @@ public TargetAmount createTargetAmount(TargetAmount targetAmount) { return targetAmountRepository.save(targetAmount); } + @Transactional(readOnly = true) + public Optional readTargetAmount(Long id) { + return targetAmountRepository.findById(id); + } + @Transactional(readOnly = true) public Optional readTargetAmountThatMonth(Long userId, LocalDate date) { return targetAmountRepository.findByUserIdThatMonth(userId, date); @@ -31,7 +36,18 @@ public Optional readTargetAmountThatMonth(Long userId, LocalDate d public List readTargetAmountsByUserId(Long userId) { return targetAmountRepository.findByUser_Id(userId); } - + + @Transactional(readOnly = true) + public boolean isExistsTargetAmountThatMonth(Long userId, LocalDate date) { + return targetAmountRepository.existsByUserIdThatMonth(userId, date); + } + + @Transactional(readOnly = true) + public boolean isExistsTargetAmountByIdAndUserId(Long id, Long userId) { + return targetAmountRepository.existsByIdAndUser_Id(id, userId); + } + + @Transactional public void deleteTargetAmount(TargetAmount targetAmount) { targetAmountRepository.delete(targetAmount); diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponDecreaseService.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponDecreaseService.java index dcbf52b7c..9cad14ca1 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponDecreaseService.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponDecreaseService.java @@ -1,6 +1,6 @@ package kr.co.pennyway.domain.domains.coupon; -import kr.co.pennyway.domain.common.aop.DistributedLock; +import kr.co.pennyway.domain.common.redisson.DistributedLock; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.test.context.ActiveProfiles; From 7210b0fb2b5bf33a5a6b73a5390ed216b4475866 Mon Sep 17 00:00:00 2001 From: DinoDeveloper <79460319+asn6878@users.noreply.github.com> Date: Wed, 5 Jun 2024 11:13:04 +0900 Subject: [PATCH 100/152] =?UTF-8?q?fix:=20=EB=AC=B8=EC=9D=98=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=9D=91=EB=8B=B5=EC=86=8D=EB=8F=84=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20(#106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/co/pennyway/infra/common/event/MailEventHandling.java | 4 +++- .../src/main/java/kr/co/pennyway/infra/config/MailConfig.java | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/MailEventHandling.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/MailEventHandling.java index 3ada82e56..a42f5d827 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/MailEventHandling.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/MailEventHandling.java @@ -4,6 +4,7 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionalEventListener; @@ -21,8 +22,9 @@ public class MailEventHandling { * @param event {@link MailEvent} */ @TransactionalEventListener + @Async public void handleMailEvent(MailEvent event) { - log.info("handleMailEvent: {}", event); + log.info("문의 메일 전송 이벤트 발생: {}", event); googleMailSender.sendMail(event.email(), event.content(), event.category()); } } \ No newline at end of file diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MailConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MailConfig.java index 57d0a7dcd..93e088d3c 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MailConfig.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MailConfig.java @@ -5,10 +5,12 @@ import org.springframework.context.annotation.Configuration; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.scheduling.annotation.EnableAsync; import java.util.Properties; @Configuration +@EnableAsync public class MailConfig { @Value("${app.mail.host}") private String host; @@ -43,4 +45,4 @@ private Properties getMailProperties() { return properties; } -} +} \ No newline at end of file From 00ac66b7680de7055f7e7fa801d44f7e491d1f72 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 5 Jun 2024 16:06:35 +0900 Subject: [PATCH 101/152] =?UTF-8?q?docs:=20=F0=9F=93=9D=20Readme-v0.0.3=20?= =?UTF-8?q?(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * docs: root readme 수정 * docs: external-api 모듈 docs 수정 --- README.md | 35 ++++++++++++++++------------- pennyway-app-external-api/README.md | 8 +++++++ 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 42eade435..d65281acf 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ > 지출 관리 SNS 플랫폼 -| Version # | Revision Date | Description | Author | -|:---------:|:-------------:|:------------------------------|:------:| -| v0.0.1 | 2024.03.07 | 프로젝트 기본 설명 작성 | 양재서 | -| v0.0.2 | 2024.03.29 | ERD 추가, 라이브러리 버전 수정, Infra 추가 | 양재서 | +| Version # | Revision Date | Description | Author | +|:---------:|:-------------:|:-------------------------------------|:------:| +| v0.0.1 | 2024.03.07 | 프로젝트 기본 설명 작성 | 양재서 | +| v0.0.2 | 2024.03.29 | ERD 추가, 라이브러리 버전 수정, Infra 추가 | 양재서 | +| v0.0.3 | 2024.04.05 | ERD 수정, 기술 스택 추가, Infra 및 아키텍처 추가/수정 | 양재서 |
@@ -62,14 +63,15 @@ > 💡 angular commit convention -- feat: 신규 기능 추가 -- fix: 버그 수정 -- docs: 문서 수정 -- rename: 주석, 로그, 변수명 등 수정 -- style: 코드 포맷팅, 세미콜론 누락 (코드 변경 없는 경우) -- refactor: 코드 리팩토링 -- test: 테스트 코드, 리펙토링 테스트 코드 추가 -- chore: 빌드 업무 수정, 패키지 매니저 수정 +- release: 배포 버전 업데이트 (major) +- feat: 신규 기능 추가 (minor) +- refactor: 코드 리팩토링 (minor) +- fix: 버그 수정 (patch) +- docs: 문서 수정 (patch) +- rename: 주석, 로그, 변수명 등 수정 (patch) +- style: 코드 포맷팅, 세미콜론 누락 (코드 변경 없는 경우) (patch) +- test: 테스트 코드, 리펙토링 테스트 코드 추가 (patch) +- chore: 빌드 업무 수정, 패키지 매니저 수정 (patch)
@@ -84,19 +86,19 @@ ### 2️⃣ Infrastructure Architecture
- +
### 3️⃣ Multi Module Architecture
- +
### 4️⃣ ERD
- +

@@ -109,10 +111,13 @@ - SpringBoot 3.2.3 - Spring Boot Starter Security 3.2.4 - Spring Data JPA 3.2.3 +- Spring Data Redis +- Spring Boot Redisson 3.30.0 - QueryDsl 5.0.0 - Spring Doc Open API 2.4.0 - Lombok 1.18.30 - [JUnit 5](https://junit.org/junit5/docs/current/user-guide/) +- testcontainers 1.19.7 - jjwt 0.12.5 - httpclient5 5.2.25.RELEASE - OpenFeign 4.0.6 diff --git a/pennyway-app-external-api/README.md b/pennyway-app-external-api/README.md index 7375e153a..aa1257d2d 100644 --- a/pennyway-app-external-api/README.md +++ b/pennyway-app-external-api/README.md @@ -7,6 +7,14 @@ - 웹 및 security 관련 라이브러리 의존성을 갖는다. - Presentation Layer에 해당하는 Controller와 핵심 비즈니스 로직을 처리하는 Usecase를 포함한다. +### 📌 Architecture + +
+ +
+ +- Facade 패턴을 사용하여 Controller와 Service 계층을 분리하여 단위 테스트를 용이하게 한다. + ### 🏷️ Directory Structure ```agsl From 35710d091e5358bf2596b9da554c08fa36dc781f Mon Sep 17 00:00:00 2001 From: Jinwoo Lee Date: Wed, 5 Jun 2024 17:16:33 +0900 Subject: [PATCH 102/152] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=93=B1=EB=A1=9D=20=EC=9A=94=EC=B2=AD=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 사용자 프로필 이미지 등록 요청 dto 정의 * feat: 사용자 프로필 이미지 등록 요청 api 설계 * feat: user entity에 profile-image-url 수정 로직 추가 * feat: 사용자 프로필 이미지 등록을 위한 usecase 정의 * feat: s3 파일 존재 여부 반환 로직 구현 * feat: storage 저장 실패 시 에러코드 정의 * feat: s3 파일 복사 로직 구현 * feat: 사용자 프로필 이미지 저장 api 구현 * feat: 사용자 프로필 이미지 원본 저장 시 storage-class 적용 * fix: 이미지 리사이징을 위한 storage-class 수정 * docs: 프로필 이미지 등록 swagger 응답 케이스 추가 * test: 사용자 프로필 이미지 등록 api 테스트 코드 작성 * test: user-account-usecase에 aws-s3-provider mockbean 적용 * fix: 프로필 이미지 등록 메서드 put으로 변경 * docs: 프로필 이미지 등록 성공 시 예시 응답 제거 * docs: 프로필 이미지 등록 swagger parameter 제거 * fix: request dto validate 어노테이션 추가 및 tab-character 제거 * rename: s3 파일 존재 여부 메서드명 수정 * fix: 프로필 이미지 dto에 regex 패턴 검증 로직 추가 * test: 테스트 케이스 이미지 경로 수정 * fix: 프로필 이미지 등록 요청 dto 정적 팩토리 메서드 삭제 * refactor: 사용자 프로필 이미지 경로 prefix 환경변수 처리 * refactor: s3 object-key regex 상수로 분리 * feat: s3 object-key regex 클래스 분리 및 정적 변수로 선언 --- .../api/apis/storage/dto/PresignedUrlDto.java | 60 +- .../apis/storage/service/StorageService.java | 19 + .../api/apis/users/api/UserAccountApi.java | 24 +- .../controller/UserAccountController.java | 8 + .../apis/users/dto/UserProfileUpdateDto.java | 9 + .../service/UserProfileUpdateService.java | 21 + .../users/usecase/UserAccountUseCase.java | 10 +- .../UserAccountControllerUnitTest.java | 51 +- .../users/usecase/UserAccountUseCaseTest.java | 987 +++++++++--------- .../domain/domains/user/domain/User.java | 7 +- .../infra/client/aws/s3/AwsS3Provider.java | 211 ++-- .../infra/client/aws/s3/ObjectKeyPattern.java | 35 + .../infra/client/aws/s3/ObjectKeyType.java | 79 +- .../common/exception/StorageErrorCode.java | 6 +- .../co/pennyway/infra/config/AwsS3Config.java | 73 +- .../src/main/resources/application-infra.yml | 2 + 16 files changed, 954 insertions(+), 648 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/service/StorageService.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyPattern.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java index db2bbf1bc..c86397767 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java @@ -1,38 +1,38 @@ package kr.co.pennyway.api.apis.storage.dto; -import java.net.URI; - import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import java.net.URI; + public class PresignedUrlDto { - @Schema(title = "S3 이미지 저장을 위한 Presigned URL 발급 요청 DTO", description = "S3에 이미지를 저장하기 위한 Presigned URL을 발급 요청을 위한 DTO") - public record Req( - @Schema(description = "이미지 종류", example = "PROFILE/FEED/CHATROOM_PROFILE/CHAT/CHAT_PROFILE") - @NotBlank(message = "이미지 종류는 필수입니다.") - String type, - @Schema(description = "파일 확장자", example = "jpg/png/jpeg") - @NotBlank(message = "파일 확장자는 필수입니다.") - String ext, - @Schema(description = "사용자 ID", example = "1") - String userId, - @Schema(description = "채팅방 ID", example = "12345678-1234-5678-1234-567812345678") - String chatroomId - ) { - } + @Schema(title = "S3 이미지 저장을 위한 Presigned URL 발급 요청 DTO", description = "S3에 이미지를 저장하기 위한 Presigned URL을 발급 요청을 위한 DTO") + public record Req( + @Schema(description = "이미지 종류", example = "PROFILE/FEED/CHATROOM_PROFILE/CHAT/CHAT_PROFILE") + @NotBlank(message = "이미지 종류는 필수입니다.") + String type, + @Schema(description = "파일 확장자", example = "jpg/png/jpeg") + @NotBlank(message = "파일 확장자는 필수입니다.") + String ext, + @Schema(description = "사용자 ID", example = "1") + String userId, + @Schema(description = "채팅방 ID", example = "12345678-1234-5678-1234-567812345678") + String chatroomId + ) { + } - @Schema(title = "S3 이미지 저장을 위한 Presigned URL 발급 응답 DTO") - public record Res( - @Schema(description = "Presigned URL") - URI presignedUrl - ) { - /** - * Presigned URL 발급 응답 객체 생성 - * - * @param presignedUrl String : Presigned URL - */ - public static Res of(URI presignedUrl) { - return new Res(presignedUrl); - } - } + @Schema(title = "S3 이미지 저장을 위한 Presigned URL 발급 응답 DTO") + public record Res( + @Schema(description = "Presigned URL") + URI presignedUrl + ) { + /** + * Presigned URL 발급 응답 객체 생성 + * + * @param presignedUrl String : Presigned URL + */ + public static Res of(URI presignedUrl) { + return new Res(presignedUrl); + } + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/service/StorageService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/service/StorageService.java new file mode 100644 index 000000000..979ca9174 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/service/StorageService.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.api.apis.storage.service; + +import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.net.URI; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StorageService { + private final AwsS3Provider awsS3Provider; + + public URI getPresignedUrl(String type, String ext, String userId, String chatroomId) { + return awsS3Provider.generatedPresignedUrl(type, ext, userId, chatroomId); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java index 9ed41b47d..790daab67 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java @@ -88,7 +88,8 @@ public interface UserAccountApi { """) })) }) - ResponseEntity postPasswordVerification(@RequestBody @Validated UserProfileUpdateDto.PasswordVerificationReq request, @AuthenticationPrincipal SecurityUserDetails user); + ResponseEntity postPasswordVerification(@RequestBody @Validated UserProfileUpdateDto.PasswordVerificationReq request, + @AuthenticationPrincipal SecurityUserDetails user); @Operation(summary = "사용자 비밀번호 변경") @ApiResponses({ @@ -199,4 +200,25 @@ public interface UserAccountApi { @Operation(summary = "사용자 계정 삭제", description = "사용자 본인의 계정을 삭제합니다. 채팅방 방장이면 삭제가 안 되는 시나리오는 고려하지 않고 있습니다.") ResponseEntity deleteAccount(@AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "사용자 프로필 사진 등록", description = "사용자의 프로필 사진을 수정합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "프로필 사진 URL이 유효하지 않은 경우", value = """ + { + "code": "4000", + "message": "프로필 이미지 URL이 유효하지 않습니다." + } + """) + })), + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "프로필 사진 URL이 존재하지 않는 경우", value = """ + { + "code": "4040", + "message": "프로필 이미지 URL이 존재하지 않습니다." + } + """) + })) + }) + ResponseEntity postProfileImage(@RequestBody @Validated UserProfileUpdateDto.ProfileImageReq request, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java index c2729be69..cb482ecea 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java @@ -98,4 +98,12 @@ public ResponseEntity deleteAccount(@AuthenticationPrincipal SecurityUserDeta userAccountUseCase.deleteAccount(user.getUserId()); return ResponseEntity.ok(SuccessResponse.noContent()); } + + @Override + @PutMapping("/profile-image") + @PreAuthorize("isAuthenticated()") + public ResponseEntity postProfileImage(@Validated UserProfileUpdateDto.ProfileImageReq request, SecurityUserDetails user) { + userAccountUseCase.updateProfileImage(user.getUserId(), request); + return ResponseEntity.ok(SuccessResponse.noContent()); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java index 141116cfb..a511a8b1a 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java @@ -66,4 +66,13 @@ public static NotifySettingUpdateReq of(NotifySetting.NotifyType type, Boolean f }; } } + + @Schema(title = "프로필 이미지 등록 요청 DTO") + public record ProfileImageReq( + @Schema(description = "프로필 이미지 URL", example = "delete/profile/1/154aa3bd-da02-4311-a735-3bf7e4bb68d2_1717446100295.jpeg") + @Pattern(regexp = "^delete/.*$", message = "URL은 'delete/'로 시작해야 합니다.") + @NotBlank(message = "프로필 이미지 URL을 입력해주세요") + String profileImageUrl + ) { + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java index 03126ba93..37f29ada7 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java @@ -5,6 +5,10 @@ import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; +import kr.co.pennyway.infra.common.exception.StorageErrorCode; +import kr.co.pennyway.infra.common.exception.StorageException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -15,6 +19,7 @@ @RequiredArgsConstructor public class UserProfileUpdateService { private final PasswordEncoderHelper passwordEncoderHelper; + private final AwsS3Provider awsS3Provider; @Transactional public void updateName(User user, String newName) { @@ -36,6 +41,22 @@ public void updatePassword(User user, String oldPassword, String newPassword) { user.updatePassword(passwordEncoderHelper.encodePassword(newPassword)); } + @Transactional + public void updateProfileImage(User user, String profileImageUrl) { + // Profile Image 존재 여부 확인 + if (!awsS3Provider.isObjectExist(profileImageUrl)) { + log.info("프로필 이미지 URL이 유효하지 않습니다."); + throw new StorageException(StorageErrorCode.NOT_FOUND); + } + + // Profile Image 원본 저장 + awsS3Provider.copyObject(ObjectKeyType.PROFILE, profileImageUrl); + + // Profile Image URL 업데이트 + String originKey = ObjectKeyType.PROFILE.convertDeleteKeyToOriginKey(profileImageUrl); + user.updateProfileImageUrl(awsS3Provider.getObjectPrefix() + originKey); + } + @Transactional public void updateNotifySetting(User user, NotifySetting.NotifyType type, Boolean flag) { user.getNotifySetting().updateNotifySetting(type, flag); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java index 78c158267..14da19b17 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java @@ -101,6 +101,13 @@ public void updatePassword(Long userId, String oldPassword, String newPassword) userProfileUpdateService.updatePassword(user, oldPassword, newPassword); } + @Transactional + public void updateProfileImage(Long userId, UserProfileUpdateDto.ProfileImageReq request) { + User user = readUserOrThrow(userId); + + userProfileUpdateService.updateProfileImage(user, request.profileImageUrl()); + } + @Transactional public UserProfileUpdateDto.NotifySettingUpdateReq activateNotification(Long userId, NotifySetting.NotifyType type) { User user = readUserOrThrow(userId); @@ -119,7 +126,8 @@ public UserProfileUpdateDto.NotifySettingUpdateReq deactivateNotification(Long u @Transactional public void deleteAccount(Long userId) { - if (!userService.isExistUser(userId)) throw new UserErrorException(UserErrorCode.NOT_FOUND); + if (!userService.isExistUser(userId)) + throw new UserErrorException(UserErrorCode.NOT_FOUND); // TODO: [2024-05-03] 하나라도 채팅방의 방장으로 참여하는 경우 삭제 불가능 처리 diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java index 13ce6af13..37ae99a47 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java @@ -9,6 +9,8 @@ import kr.co.pennyway.common.exception.StatusCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.infra.common.exception.StorageErrorCode; +import kr.co.pennyway.infra.common.exception.StorageException; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -365,7 +367,10 @@ void updatePasswordValidationFail() throws Exception { String newPasswordWithOnlySpecialCharacterAndWhiteSpace = "!@#$%^&*() "; String newPasswordWithOnlySpecialCharacterAndEmoji = "!@#$%^&*()😊"; String newPasswordWithOnlySpecialCharacterAndEmojiAndWhiteSpace = "!@#$%^&*() 😊"; - List newPasswords = List.of(newPasswordWithBlank, newPasswordWithUnderLength, newPasswordWithOverLength, newPasswordWithOnlyAlphabet, newPasswordWithOnlyNumber, newPasswordWithOnlySpecialCharacter, newPasswordWithOnlyUpperCase, newPasswordWithOnlyLowerCase, newPasswordWithOnlyEmoji, newPasswordWithOnlyWhiteSpace, newPasswordWithOnlySpecialCharacterAndWhiteSpace, newPasswordWithOnlySpecialCharacterAndEmoji, newPasswordWithOnlySpecialCharacterAndEmojiAndWhiteSpace); + List newPasswords = List.of(newPasswordWithBlank, newPasswordWithUnderLength, newPasswordWithOverLength, newPasswordWithOnlyAlphabet, + newPasswordWithOnlyNumber, newPasswordWithOnlySpecialCharacter, newPasswordWithOnlyUpperCase, newPasswordWithOnlyLowerCase, + newPasswordWithOnlyEmoji, newPasswordWithOnlyWhiteSpace, newPasswordWithOnlySpecialCharacterAndWhiteSpace, + newPasswordWithOnlySpecialCharacterAndEmoji, newPasswordWithOnlySpecialCharacterAndEmojiAndWhiteSpace); String expectedErrorCode = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY.getCode()); @@ -506,4 +511,48 @@ private ResultActions performDeleteAccountRequest() throws Exception { .contentType("application/json")); } } + + @Nested + @Order(7) + @DisplayName("[7] 사용자 프로필 이미지 등록 테스트") + class RegisterProfileImageTest { + @DisplayName("사용자 프로필 이미지 등록 요청 시, 존재하지 않는 이미지 경로인 경우 404 에러를 반환한다.") + @Test + @WithSecurityMockUser + void registerProfileImageNotFound() throws Exception { + // given + String profileImageUrl = "delete/profile/1/154aa3bd-da02-4311-a735-3bf7e4bb68d2_1717446100295.jpeg"; + willThrow(new StorageException(StorageErrorCode.NOT_FOUND)).given(userAccountUseCase) + .updateProfileImage(1L, new UserProfileUpdateDto.ProfileImageReq(profileImageUrl)); + + // when + ResultActions result = performRegisterProfileImageRequest(profileImageUrl); + + // then + result.andExpect(status().isNotFound()) + .andDo(print()); + } + + @DisplayName("사용자 프로필 이미지 정상 요청 시, 200 코드를 반환한다.") + @Test + @WithSecurityMockUser + void registerProfileImageSuccess() throws Exception { + // given + String profileImageUrl = "delete/profile/1/154aa3bd-da02-4311-a735-3bf7e4bb68d2_1717446100295.jpeg"; + + // when + ResultActions result = performRegisterProfileImageRequest(profileImageUrl); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andDo(print()); + } + + private ResultActions performRegisterProfileImageRequest(String profileImageUrl) throws Exception { + return mockMvc.perform(put("/v2/users/me/profile-image") + .contentType("application/json") + .content(objectMapper.writeValueAsString(new UserProfileUpdateDto.ProfileImageReq(profileImageUrl)))); + } + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java index 567939fc7..b8f8ac886 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java @@ -1,6 +1,33 @@ package kr.co.pennyway.api.apis.users.usecase; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertNull; +import static org.springframework.test.util.AssertionErrors.assertTrue; +import static org.springframework.test.util.AssertionErrors.fail; + +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + import com.querydsl.jpa.impl.JPAQueryFactory; + import kr.co.pennyway.api.apis.users.dto.DeviceDto; import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; @@ -23,494 +50,488 @@ import kr.co.pennyway.domain.domains.user.service.UserService; import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; import kr.co.pennyway.domain.domains.user.type.Role; -import org.junit.jupiter.api.*; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.util.AssertionErrors.*; +import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; @ExtendWith(MockitoExtension.class) @DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") @ContextConfiguration(classes = { - JpaConfig.class, UserAccountUseCase.class, DeviceRegisterService.class, UserProfileUpdateService.class, UserDeleteService.class, - UserService.class, DeviceService.class, OauthService.class}) + JpaConfig.class, UserAccountUseCase.class, DeviceRegisterService.class, UserProfileUpdateService.class, UserDeleteService.class, + UserService.class, DeviceService.class, OauthService.class}) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @TestClassOrder(ClassOrderer.OrderAnnotation.class) class UserAccountUseCaseTest extends ExternalApiDBTestConfig { - @Autowired - private UserService userService; - - @Autowired - private DeviceService deviceService; - - @Autowired - private OauthService oauthService; - - @Autowired - private UserAccountUseCase userAccountUseCase; - - @MockBean - private PasswordEncoderHelper passwordEncoderHelper; - - @MockBean - private JPAQueryFactory queryFactory; - - @Order(1) - @Nested - @DisplayName("[1] 디바이스 등록 테스트") - class DeviceRegisterTest { - private User requestUser; - - @BeforeEach - void setUp() { - User user = User.builder().role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).build(); - requestUser = userService.createUser(user); - } - - @Test - @Transactional - @DisplayName("[1] originToken과 newToken이 같은 경우, 신규 디바이스를 등록한다.") - void registerNewDevice() { - // given - DeviceDto.RegisterReq request = DeviceFixture.INIT.toRegisterReq(); - - // when - DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - System.out.println("device = " + device); - }, - () -> fail("신규 디바이스가 등록되어 있어야 한다.") - ); - } - - @Test - @Transactional - @DisplayName("[1-1] 저장 요청에서 originToken에 대한 디바이스가 이미 존재하는 경우, 디바이스 정보 변경 사항만 업데이트하고 기존 디바이스 정보를 반환한다.") - void registerNewDeviceWhenDeviceIsAlreadyExists() { - // given - Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(originDevice); - DeviceDto.RegisterReq request = DeviceFixture.ONLY_MODEL_AND_OS_CHANGED.toRegisterReq(); - - // when - DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertEquals("디바이스 ID가 일치해야 한다.", originDevice.getId(), device.getId()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); - System.out.println("device = " + device); - }, - () -> fail("신규 디바이스가 등록되어 있어야 한다.") - ); - } - - @Test - @Transactional - @DisplayName("[2] originToken과 일치하는 활성화 디바이스 토큰이 존재한다면, 디바이스 토큰을 갱신한다.") - void updateActivateDeviceToken() { - // given - Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(originDevice); - - System.out.println("originDevice = " + originDevice); - DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); - - // when - DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); - System.out.println("device = " + device); - }, - () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") - ); - } - - @Test - @Transactional - @DisplayName("[2-1] 기존에 등록된 비활성화 디바이스 토큰이 있고 디바이스 정보가 일치한다면, 디바이스 토큰을 갱신하고 활성화로 변경한다.") - void updateDeactivateDeviceToken() { - // given - Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - originDevice.deactivate(); - deviceService.createDevice(originDevice); - - System.out.println("originDevice = " + originDevice); - DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); - - // when - DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); - System.out.println("device = " + device); - }, - () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") - ); - } - - @Test - @Transactional - @DisplayName("[2-2] 사용자가 유효한 토큰을 가지고 있지만 모델명이나 OS가 다른 경우, 디바이스 정보를 업데이트한다.") - void notMatchDevice() { - // given - Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(originDevice); - System.out.println("originDevice = " + originDevice); - DeviceDto.RegisterReq request = DeviceFixture.ALL_CHANGED.toRegisterReq(); - - // when - userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", request.newToken(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); - System.out.println("device = " + device); - }, - () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") - ); - } - - @Test - @Transactional - @DisplayName("[3] 토큰 수정 요청에서 oldToken에 대한 디바이스가 존재하지 않는 경우, NOT_FOUND 에러를 반환한다.") - void registerNewDeviceWhenOldDeviceTokenIsNotExists() { - // given - DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); - - // when - then - DeviceErrorException ex = assertThrows(DeviceErrorException.class, () -> userAccountUseCase.registerDevice(requestUser.getId(), request)); - assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode()); - } - } - - @Order(2) - @Nested - @DisplayName("[2] 디바이스 삭제 테스트") - class DeviceUnregisterTest { - private User requestUser; - - @BeforeEach - void setUp() { - User user = User.builder().role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).build(); - requestUser = userService.createUser(user); - } - - @Test - @Transactional - @DisplayName("사용자 ID와 origin token에 매칭되는 활성 디바이스가 존재하는 경우 디바이스를 삭제한다.") - void unregisterDevice() { - // given - Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(device); - - // when - userAccountUseCase.unregisterDevice(requestUser.getId(), device.getToken()); - - // then - Optional deletedDevice = deviceService.readDeviceByUserIdAndToken(requestUser.getId(), device.getToken()); - assertNull("디바이스가 삭제되어 있어야 한다.", deletedDevice.orElse(null)); - } - - @Test - @Transactional - @DisplayName("사용자 ID와 token에 매칭되는 디바이스가 존재하지 않는 경우 NOT_FOUND_DEVICE 에러를 반환한다.") - void unregisterDeviceWhenDeviceIsNotExists() { - // given - Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(device); - - // when - then - DeviceErrorException ex = assertThrows(DeviceErrorException.class, () -> userAccountUseCase.unregisterDevice(requestUser.getId(), "notExistsToken")); - assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode()); - } - } - - @Order(3) - @Nested - @DisplayName("[3] 사용자 이름 수정 테스트") - class UpdateNameTest { - @Test - @Transactional - @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") - void updateNameWhenUserIsDeleted() { - // given - String newName = "양재서"; - User originUser = UserFixture.GENERAL_USER.toUser(); - userService.createUser(originUser); - userService.deleteUser(originUser); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.updateName(originUser.getId(), newName)); - assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("사용자의 이름이 성공적으로 변경된다.") - void updateName() { - // given - User originUser = UserFixture.GENERAL_USER.toUser(); - userService.createUser(originUser); - String newName = "양재서"; - - // when - userAccountUseCase.updateName(originUser.getId(), newName); - - // then - User updatedUser = userService.readUser(originUser.getId()).orElseThrow(); - assertEquals("사용자 이름이 변경되어 있어야 한다.", newName, updatedUser.getName()); - } - } - - @Order(4) - @Nested - @DisplayName("[4] 사용자 비밀번호 검증 테스트") - class VerificationPasswordTest { - private User originUser; - - @BeforeEach - void setUp() { - originUser = UserFixture.GENERAL_USER.toUser(); - userService.createUser(originUser); - } - - @Test - @Transactional - @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") - void verifyPasswordWhenUserIsDeleted() { - // given - userService.deleteUser(originUser); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.verifyPassword(originUser.getId(), originUser.getPassword())); - assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("사용자가 일반 회원가입 이력이 없는 소셜 계정인 경우, DO_NOT_GENERAL_SIGNED_UP 에러를 반환한다.") - void verifyPasswordWhenUserIsNotGeneralSignedUp() { - // given - User originUser = UserFixture.OAUTH_USER.toUser(); - userService.createUser(originUser); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.verifyPassword(originUser.getId(), originUser.getPassword())); - assertEquals("일반 회원가입 이력이 없는 경우 Do Not General Signed Up을 반환한다.", UserErrorCode.DO_NOT_GENERAL_SIGNED_UP, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("비밀번호가 다른 경우 NOT_MATCHED_PASSWORD 에러를 반환한다.") - void verifyPasswordWhenPasswordIsNotMatched() { - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.verifyPassword(originUser.getId(), "notMatchedPassword")); - assertEquals("비밀번호가 다른 경우 Not Matched Password를 반환한다.", UserErrorCode.NOT_MATCHED_PASSWORD, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("비밀번호가 일치하는 경우 정상적으로 처리된다.") - void verifyPassword() { - // given - given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(true); - - // when - then - assertDoesNotThrow(() -> userAccountUseCase.verifyPassword(originUser.getId(), originUser.getPassword())); - } - } - - @Order(5) - @Nested - @DisplayName("[5] 사용자 비밀번호 변경 테스트") - class UpdatePasswordTest { - private User originUser; - - @BeforeEach - void setUp() { - originUser = UserFixture.GENERAL_USER.toUser(); - userService.createUser(originUser); - } - - @Test - @Transactional - @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") - void updatePasswordWhenUserIsDeleted() { - // given - userService.deleteUser(originUser); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), "newPassword")); - assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("oldPassword와 newPassword가 일치하는 경우와 현재 비밀번호와 동일한 비밀번호로 변경을 시도하는 경우, CLIENT_ERROR 에러를 반환한다.") - void updatePasswordWhenSamePassword() { - // given - given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(true); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), originUser.getPassword())); - assertEquals("현재 비밀번호와 동일한 비밀번호로 변경할 수 없는 경우 Client Error를 반환한다.", UserErrorCode.PASSWORD_NOT_CHANGED, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("비밀번호가 다른 경우 NOT_MATCHED_PASSWORD 에러를 반환한다.") - void updatePasswordWhenPasswordIsNotMatched() { - // given - given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(false); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.updatePassword(originUser.getId(), "notMatchedPassword", "newPassword")); - assertEquals("비밀번호가 다른 경우 Not Matched Password를 반환한다.", UserErrorCode.NOT_MATCHED_PASSWORD, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("사용자가 일반 회원가입 이력이 없는 소셜 계정인 경우, DO_NOT_GENERAL_SIGNED_UP 에러를 반환한다.") - void updatePasswordWhenUserIsNotGeneralSignedUp() { - // given - User originUser = UserFixture.OAUTH_USER.toUser(); - userService.createUser(originUser); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), "newPassword")); - assertEquals("일반 회원가입 이력이 없는 경우 Do Not General Signed Up을 반환한다.", UserErrorCode.DO_NOT_GENERAL_SIGNED_UP, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("정상적인 요청인 경우 비밀번호가 정상적으로 변경된다.") - void updatePassword() { - // given - given(passwordEncoderHelper.isSamePassword(originUser.getPassword(), originUser.getPassword())).willReturn(true); - given(passwordEncoderHelper.isSamePassword(originUser.getPassword(), "newPassword")).willReturn(false); - given(passwordEncoderHelper.encodePassword(any())).willReturn("encodedPassword"); - - // when - then - assertDoesNotThrow(() -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), "newPassword")); - assertEquals("비밀번호가 정상적으로 변경되어 있어야 한다.", "encodedPassword", userService.readUser(originUser.getId()).orElseThrow().getPassword()); - } - } - - @Order(6) - @Nested - @DisplayName("[6] 사용자 계정 삭제") - class DeleteAccountTest { - @Test - @Transactional - @DisplayName("사용자가 삭제된 유저를 조회하려는 경우 NOT_FOUND 에러를 반환한다.") - void deleteAccountWhenUserIsDeleted() { - // given - User user = UserFixture.GENERAL_USER.toUser(); - userService.createUser(user); - userService.deleteUser(user); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.deleteAccount(user.getId())); - assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("일반 회원가입 이력만 있는 사용자의 경우, 정상적으로 계정이 삭제된다.") - void deleteAccount() { - // given - User user = UserFixture.GENERAL_USER.toUser(); - userService.createUser(user); - - // when - then - assertDoesNotThrow(() -> userAccountUseCase.deleteAccount(user.getId())); - assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); - } - - @Test - @Transactional - @DisplayName("사용자 계정 삭제 시, 연동된 모든 소셜 계정은 soft delete 처리되어야 한다.") - void deleteAccountWithSocialAccounts() { - // given - User user = UserFixture.OAUTH_USER.toUser(); - userService.createUser(user); - - Oauth kakao = createOauth(Provider.KAKAO, "kakaoId", user); - Oauth google = createOauth(Provider.GOOGLE, "googleId", user); - - // when - then - assertDoesNotThrow(() -> userAccountUseCase.deleteAccount(user.getId())); - assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); - assertTrue("카카오 계정이 삭제되어 있어야 한다.", oauthService.readOauth(kakao.getId()).get().isDeleted()); - assertTrue("구글 계정이 삭제되어 있어야 한다.", oauthService.readOauth(google.getId()).get().isDeleted()); - } - - @Test - @Transactional - @DisplayName("사용자 삭제 시, 디바이스 정보는 CASCADE로 삭제되어야 한다.") - void deleteAccountWithDevices() { - // given - User user = UserFixture.GENERAL_USER.toUser(); - userService.createUser(user); - - Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(user); - deviceService.createDevice(device); - - // when - then - assertDoesNotThrow(() -> userAccountUseCase.deleteAccount(user.getId())); - assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); - assertTrue("디바이스가 삭제되어 있어야 한다.", deviceService.readDeviceByUserIdAndToken(user.getId(), device.getToken()).isEmpty()); - } - - private Oauth createOauth(Provider provider, String providerId, User user) { - Oauth oauth = Oauth.of(provider, providerId, user); - return oauthService.createOauth(oauth); - } - } + @Autowired + private UserService userService; + + @Autowired + private DeviceService deviceService; + + @Autowired + private OauthService oauthService; + + @Autowired + private UserAccountUseCase userAccountUseCase; + + @MockBean + private PasswordEncoderHelper passwordEncoderHelper; + + @MockBean + private AwsS3Provider awsS3Provider; + + @MockBean + private JPAQueryFactory queryFactory; + + @Order(1) + @Nested + @DisplayName("[1] 디바이스 등록 테스트") + class DeviceRegisterTest { + private User requestUser; + + @BeforeEach + void setUp() { + User user = User.builder().role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).build(); + requestUser = userService.createUser(user); + } + + @Test + @Transactional + @DisplayName("[1] originToken과 newToken이 같은 경우, 신규 디바이스를 등록한다.") + void registerNewDevice() { + // given + DeviceDto.RegisterReq request = DeviceFixture.INIT.toRegisterReq(); + + // when + DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + System.out.println("device = " + device); + }, + () -> fail("신규 디바이스가 등록되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[1-1] 저장 요청에서 originToken에 대한 디바이스가 이미 존재하는 경우, 디바이스 정보 변경 사항만 업데이트하고 기존 디바이스 정보를 반환한다.") + void registerNewDeviceWhenDeviceIsAlreadyExists() { + // given + Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(originDevice); + DeviceDto.RegisterReq request = DeviceFixture.ONLY_MODEL_AND_OS_CHANGED.toRegisterReq(); + + // when + DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertEquals("디바이스 ID가 일치해야 한다.", originDevice.getId(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); + System.out.println("device = " + device); + }, + () -> fail("신규 디바이스가 등록되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[2] originToken과 일치하는 활성화 디바이스 토큰이 존재한다면, 디바이스 토큰을 갱신한다.") + void updateActivateDeviceToken() { + // given + Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(originDevice); + + System.out.println("originDevice = " + originDevice); + DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); + + // when + DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); + System.out.println("device = " + device); + }, + () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[2-1] 기존에 등록된 비활성화 디바이스 토큰이 있고 디바이스 정보가 일치한다면, 디바이스 토큰을 갱신하고 활성화로 변경한다.") + void updateDeactivateDeviceToken() { + // given + Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + originDevice.deactivate(); + deviceService.createDevice(originDevice); + + System.out.println("originDevice = " + originDevice); + DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); + + // when + DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); + System.out.println("device = " + device); + }, + () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[2-2] 사용자가 유효한 토큰을 가지고 있지만 모델명이나 OS가 다른 경우, 디바이스 정보를 업데이트한다.") + void notMatchDevice() { + // given + Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(originDevice); + System.out.println("originDevice = " + originDevice); + DeviceDto.RegisterReq request = DeviceFixture.ALL_CHANGED.toRegisterReq(); + + // when + userAccountUseCase.registerDevice(requestUser.getId(), request); + + // then + deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", request.newToken(), device.getToken()); + assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); + assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); + System.out.println("device = " + device); + }, + () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[3] 토큰 수정 요청에서 oldToken에 대한 디바이스가 존재하지 않는 경우, NOT_FOUND 에러를 반환한다.") + void registerNewDeviceWhenOldDeviceTokenIsNotExists() { + // given + DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); + + // when - then + DeviceErrorException ex = assertThrows(DeviceErrorException.class, () -> userAccountUseCase.registerDevice(requestUser.getId(), request)); + assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode()); + } + } + + @Order(2) + @Nested + @DisplayName("[2] 디바이스 삭제 테스트") + class DeviceUnregisterTest { + private User requestUser; + + @BeforeEach + void setUp() { + User user = User.builder().role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).build(); + requestUser = userService.createUser(user); + } + + @Test + @Transactional + @DisplayName("사용자 ID와 origin token에 매칭되는 활성 디바이스가 존재하는 경우 디바이스를 삭제한다.") + void unregisterDevice() { + // given + Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(device); + + // when + userAccountUseCase.unregisterDevice(requestUser.getId(), device.getToken()); + + // then + Optional deletedDevice = deviceService.readDeviceByUserIdAndToken(requestUser.getId(), device.getToken()); + assertNull("디바이스가 삭제되어 있어야 한다.", deletedDevice.orElse(null)); + } + + @Test + @Transactional + @DisplayName("사용자 ID와 token에 매칭되는 디바이스가 존재하지 않는 경우 NOT_FOUND_DEVICE 에러를 반환한다.") + void unregisterDeviceWhenDeviceIsNotExists() { + // given + Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); + deviceService.createDevice(device); + + // when - then + DeviceErrorException ex = assertThrows(DeviceErrorException.class, + () -> userAccountUseCase.unregisterDevice(requestUser.getId(), "notExistsToken")); + assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode()); + } + } + + @Order(3) + @Nested + @DisplayName("[3] 사용자 이름 수정 테스트") + class UpdateNameTest { + @Test + @Transactional + @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") + void updateNameWhenUserIsDeleted() { + // given + String newName = "양재서"; + User originUser = UserFixture.GENERAL_USER.toUser(); + userService.createUser(originUser); + userService.deleteUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.updateName(originUser.getId(), newName)); + assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("사용자의 이름이 성공적으로 변경된다.") + void updateName() { + // given + User originUser = UserFixture.GENERAL_USER.toUser(); + userService.createUser(originUser); + String newName = "양재서"; + + // when + userAccountUseCase.updateName(originUser.getId(), newName); + + // then + User updatedUser = userService.readUser(originUser.getId()).orElseThrow(); + assertEquals("사용자 이름이 변경되어 있어야 한다.", newName, updatedUser.getName()); + } + } + + @Order(4) + @Nested + @DisplayName("[4] 사용자 비밀번호 검증 테스트") + class VerificationPasswordTest { + private User originUser; + + @BeforeEach + void setUp() { + originUser = UserFixture.GENERAL_USER.toUser(); + userService.createUser(originUser); + } + + @Test + @Transactional + @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") + void verifyPasswordWhenUserIsDeleted() { + // given + userService.deleteUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, + () -> userAccountUseCase.verifyPassword(originUser.getId(), originUser.getPassword())); + assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("사용자가 일반 회원가입 이력이 없는 소셜 계정인 경우, DO_NOT_GENERAL_SIGNED_UP 에러를 반환한다.") + void verifyPasswordWhenUserIsNotGeneralSignedUp() { + // given + User originUser = UserFixture.OAUTH_USER.toUser(); + userService.createUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, + () -> userAccountUseCase.verifyPassword(originUser.getId(), originUser.getPassword())); + assertEquals("일반 회원가입 이력이 없는 경우 Do Not General Signed Up을 반환한다.", UserErrorCode.DO_NOT_GENERAL_SIGNED_UP, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("비밀번호가 다른 경우 NOT_MATCHED_PASSWORD 에러를 반환한다.") + void verifyPasswordWhenPasswordIsNotMatched() { + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.verifyPassword(originUser.getId(), "notMatchedPassword")); + assertEquals("비밀번호가 다른 경우 Not Matched Password를 반환한다.", UserErrorCode.NOT_MATCHED_PASSWORD, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("비밀번호가 일치하는 경우 정상적으로 처리된다.") + void verifyPassword() { + // given + given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(true); + + // when - then + assertDoesNotThrow(() -> userAccountUseCase.verifyPassword(originUser.getId(), originUser.getPassword())); + } + } + + @Order(5) + @Nested + @DisplayName("[5] 사용자 비밀번호 변경 테스트") + class UpdatePasswordTest { + private User originUser; + + @BeforeEach + void setUp() { + originUser = UserFixture.GENERAL_USER.toUser(); + userService.createUser(originUser); + } + + @Test + @Transactional + @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") + void updatePasswordWhenUserIsDeleted() { + // given + userService.deleteUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, + () -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), "newPassword")); + assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("oldPassword와 newPassword가 일치하는 경우와 현재 비밀번호와 동일한 비밀번호로 변경을 시도하는 경우, CLIENT_ERROR 에러를 반환한다.") + void updatePasswordWhenSamePassword() { + // given + given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(true); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, + () -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), originUser.getPassword())); + assertEquals("현재 비밀번호와 동일한 비밀번호로 변경할 수 없는 경우 Client Error를 반환한다.", UserErrorCode.PASSWORD_NOT_CHANGED, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("비밀번호가 다른 경우 NOT_MATCHED_PASSWORD 에러를 반환한다.") + void updatePasswordWhenPasswordIsNotMatched() { + // given + given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(false); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, + () -> userAccountUseCase.updatePassword(originUser.getId(), "notMatchedPassword", "newPassword")); + assertEquals("비밀번호가 다른 경우 Not Matched Password를 반환한다.", UserErrorCode.NOT_MATCHED_PASSWORD, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("사용자가 일반 회원가입 이력이 없는 소셜 계정인 경우, DO_NOT_GENERAL_SIGNED_UP 에러를 반환한다.") + void updatePasswordWhenUserIsNotGeneralSignedUp() { + // given + User originUser = UserFixture.OAUTH_USER.toUser(); + userService.createUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, + () -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), "newPassword")); + assertEquals("일반 회원가입 이력이 없는 경우 Do Not General Signed Up을 반환한다.", UserErrorCode.DO_NOT_GENERAL_SIGNED_UP, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("정상적인 요청인 경우 비밀번호가 정상적으로 변경된다.") + void updatePassword() { + // given + given(passwordEncoderHelper.isSamePassword(originUser.getPassword(), originUser.getPassword())).willReturn(true); + given(passwordEncoderHelper.isSamePassword(originUser.getPassword(), "newPassword")).willReturn(false); + given(passwordEncoderHelper.encodePassword(any())).willReturn("encodedPassword"); + + // when - then + assertDoesNotThrow(() -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), "newPassword")); + assertEquals("비밀번호가 정상적으로 변경되어 있어야 한다.", "encodedPassword", userService.readUser(originUser.getId()).orElseThrow().getPassword()); + } + } + + @Order(6) + @Nested + @DisplayName("[6] 사용자 계정 삭제") + class DeleteAccountTest { + @Test + @Transactional + @DisplayName("사용자가 삭제된 유저를 조회하려는 경우 NOT_FOUND 에러를 반환한다.") + void deleteAccountWhenUserIsDeleted() { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + userService.deleteUser(user); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.deleteAccount(user.getId())); + assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("일반 회원가입 이력만 있는 사용자의 경우, 정상적으로 계정이 삭제된다.") + void deleteAccount() { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + + // when - then + assertDoesNotThrow(() -> userAccountUseCase.deleteAccount(user.getId())); + assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); + } + + @Test + @Transactional + @DisplayName("사용자 계정 삭제 시, 연동된 모든 소셜 계정은 soft delete 처리되어야 한다.") + void deleteAccountWithSocialAccounts() { + // given + User user = UserFixture.OAUTH_USER.toUser(); + userService.createUser(user); + + Oauth kakao = createOauth(Provider.KAKAO, "kakaoId", user); + Oauth google = createOauth(Provider.GOOGLE, "googleId", user); + + // when - then + assertDoesNotThrow(() -> userAccountUseCase.deleteAccount(user.getId())); + assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); + assertTrue("카카오 계정이 삭제되어 있어야 한다.", oauthService.readOauth(kakao.getId()).get().isDeleted()); + assertTrue("구글 계정이 삭제되어 있어야 한다.", oauthService.readOauth(google.getId()).get().isDeleted()); + } + + @Test + @Transactional + @DisplayName("사용자 삭제 시, 디바이스 정보는 CASCADE로 삭제되어야 한다.") + void deleteAccountWithDevices() { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + + Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(user); + deviceService.createDevice(device); + + // when - then + assertDoesNotThrow(() -> userAccountUseCase.deleteAccount(user.getId())); + assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); + assertTrue("디바이스가 삭제되어 있어야 한다.", deviceService.readDeviceByUserIdAndToken(user.getId(), device.getToken()).isEmpty()); + } + + private Oauth createOauth(Provider provider, String providerId, User user) { + Oauth oauth = Oauth.of(provider, providerId, user); + return oauthService.createOauth(oauth); + } + } } \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java index a838d84f1..112c147c6 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java @@ -56,7 +56,8 @@ public class User extends DateAuditable { private List devices = new ArrayList<>(); @Builder - private User(String username, String name, String password, LocalDateTime passwordUpdatedAt, String profileImageUrl, String phone, Role role, ProfileVisibility profileVisibility, NotifySetting notifySetting, Boolean locked, LocalDateTime deletedAt) { + private User(String username, String name, String password, LocalDateTime passwordUpdatedAt, String profileImageUrl, String phone, Role role, + ProfileVisibility profileVisibility, NotifySetting notifySetting, Boolean locked, LocalDateTime deletedAt) { this.username = username; this.name = name; this.password = password; @@ -83,6 +84,10 @@ public void updateUsername(String username) { this.username = username; } + public void updateProfileImageUrl(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + } + public boolean isGeneralSignedUpUser() { return password != null; } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java index 031eca226..9a67a7020 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java @@ -1,93 +1,148 @@ package kr.co.pennyway.infra.client.aws.s3; -import java.net.URI; -import java.time.Duration; -import java.util.Map; -import java.util.Set; - -import org.springframework.stereotype.Component; - import kr.co.pennyway.infra.common.exception.StorageErrorCode; import kr.co.pennyway.infra.common.exception.StorageException; import kr.co.pennyway.infra.config.AwsS3Config; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import java.net.URI; +import java.time.Duration; +import java.util.Map; +import java.util.Set; + @Slf4j @Component @RequiredArgsConstructor public class AwsS3Provider { - private static final Set extensionSet = Set.of("jpg", "png", "jpeg"); - - private final AwsS3Config awsS3Config; - private final S3Presigner s3Presigner; - - /** - * type에 해당하는 확장자를 가진 파일을 S3에 저장하기 위한 Presigned URL을 생성한다. - * @param type : ObjectKeyType (PROFILE, FEED, CHATROOM_PROFILE, CHAT, CHAT_PROFILE) - * @param ext : 파일 확장자 (jpg, png, jpeg) - * @param userId : 사용자 ID (PK) - PROFILE, CHAT_PROFILE - * @param chatroomId : 채팅방 ID (PK) - CHATROOM_PROFILE, CHAT, CHAT_PROFILE - * @return Presigned URL - * @throws Exception - */ - public URI generatedPresignedUrl(String type, String ext, String userId, String chatroomId) { - try { - if (!extensionSet.contains(ext)) { - throw new StorageException(StorageErrorCode.INVALID_EXTENSION); - } - - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(awsS3Config.getBucketName()) - .key(generateObjectKey(type, ext, userId, chatroomId)) - .build(); - - PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(r -> r.putObjectRequest(putObjectRequest) - .signatureDuration(Duration.ofMinutes(10))); - - return presignedRequest.url().toURI(); - } catch (Exception e) { - log.error("Presigned URL 생성 중 오류 발생", e); - throw new StorageException(StorageErrorCode.MISSING_REQUIRED_PARAMETER); - } - } - - /** - * type에 해당하는 ObjectKeyTemplate을 적용하여 ObjectKey(S3에 저장하기 위한 정적 파일의 경로 및 이름)를 생성한다. - * @param type : ObjectKeyType (PROFILE, FEED, CHATROOM_PROFILE, CHAT, CHAT_PROFILE) - * @param ext : 파일 확장자 (jpg, png, jpeg) - * @param userId : 사용자 ID (PK) - PROFILE, CHAT_PROFILE - * @param chatroomId : 채팅방 ID (PK) - CHATROOM_PROFILE, CHAT, CHAT_PROFILE - * @return ObjectKey - */ - private String generateObjectKey(String type, String ext, String userId, String chatroomId) { - ObjectKeyTemplate objectKeyTemplate = new ObjectKeyTemplate(ObjectKeyType.valueOf(type).getTemplate()); - Map variables = generateObjectKeyVariables(type, ext, userId, chatroomId); - String objectKey = objectKeyTemplate.apply(variables); - return objectKey; - - } - - /** - * ObjectKey에 사용될 변수들을 Template에 적용하기 위한 Map에 담아 반환한다. - * @param type : ObjectKeyType (PROFILE, FEED, CHATROOM_PROFILE, CHAT, CHAT_PROFILE) - * @param ext : 파일 확장자 (jpg, png, jpeg) - * @param userId : 사용자 ID (PK) - PROFILE, CHAT_PROFILE - * @param chatroomId : 채팅방 ID (PK) - CHATROOM_PROFILE, CHAT, CHAT_PROFILE - * @return - */ - private Map generateObjectKeyVariables(String type, String ext, String userId, String chatroomId) { - ObjectKeyType objectType; - try { - objectType = ObjectKeyType.valueOf(type); - } catch (IllegalArgumentException e) { - throw new StorageException(StorageErrorCode.INVALID_TYPE); - } - - UrlGenerator urlGenerator = UrlGeneratorFactory.getUrlGenerator(objectType); - return urlGenerator.generate(type, ext, userId, chatroomId); - } + private static final Set extensionSet = Set.of("jpg", "png", "jpeg"); + + private final AwsS3Config awsS3Config; + private final S3Presigner s3Presigner; + private final S3Client s3Client; + + /** + * type에 해당하는 확장자를 가진 파일을 S3에 저장하기 위한 Presigned URL을 생성한다. + * + * @param type : ObjectKeyType (PROFILE, FEED, CHATROOM_PROFILE, CHAT, CHAT_PROFILE) + * @param ext : 파일 확장자 (jpg, png, jpeg) + * @param userId : 사용자 ID (PK) - PROFILE, CHAT_PROFILE + * @param chatroomId : 채팅방 ID (PK) - CHATROOM_PROFILE, CHAT, CHAT_PROFILE + * @return Presigned URL + * @throws Exception + */ + public URI generatedPresignedUrl(String type, String ext, String userId, String chatroomId) { + try { + if (!extensionSet.contains(ext)) { + throw new StorageException(StorageErrorCode.INVALID_EXTENSION); + } + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(awsS3Config.getBucketName()) + .key(generateObjectKey(type, ext, userId, chatroomId)) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(r -> r.putObjectRequest(putObjectRequest) + .signatureDuration(Duration.ofMinutes(10))); + + return presignedRequest.url().toURI(); + } catch (Exception e) { + log.error("Presigned URL 생성 중 오류 발생", e); + throw new StorageException(StorageErrorCode.MISSING_REQUIRED_PARAMETER); + } + } + + /** + * type에 해당하는 ObjectKeyTemplate을 적용하여 ObjectKey(S3에 저장하기 위한 정적 파일의 경로 및 이름)를 생성한다. + * + * @param type : ObjectKeyType (PROFILE, FEED, CHATROOM_PROFILE, CHAT, CHAT_PROFILE) + * @param ext : 파일 확장자 (jpg, png, jpeg) + * @param userId : 사용자 ID (PK) - PROFILE, CHAT_PROFILE + * @param chatroomId : 채팅방 ID (PK) - CHATROOM_PROFILE, CHAT, CHAT_PROFILE + * @return ObjectKey + */ + private String generateObjectKey(String type, String ext, String userId, String chatroomId) { + ObjectKeyTemplate objectKeyTemplate = new ObjectKeyTemplate(ObjectKeyType.valueOf(type).getDeleteTemplate()); + Map variables = generateObjectKeyVariables(type, ext, userId, chatroomId); + String objectKey = objectKeyTemplate.apply(variables); + return objectKey; + + } + + /** + * ObjectKey에 사용될 변수들을 Template에 적용하기 위한 Map에 담아 반환한다. + * + * @param type : ObjectKeyType (PROFILE, FEED, CHATROOM_PROFILE, CHAT, CHAT_PROFILE) + * @param ext : 파일 확장자 (jpg, png, jpeg) + * @param userId : 사용자 ID (PK) - PROFILE, CHAT_PROFILE + * @param chatroomId : 채팅방 ID (PK) - CHATROOM_PROFILE, CHAT, CHAT_PROFILE + * @return + */ + private Map generateObjectKeyVariables(String type, String ext, String userId, String chatroomId) { + ObjectKeyType objectType; + try { + objectType = ObjectKeyType.valueOf(type); + } catch (IllegalArgumentException e) { + throw new StorageException(StorageErrorCode.INVALID_TYPE); + } + + UrlGenerator urlGenerator = UrlGeneratorFactory.getUrlGenerator(objectType); + return urlGenerator.generate(type, ext, userId, chatroomId); + } + + /** + * S3에 파일이 존재하는지 확인한다. + * + * @param key : S3 버킷 내의 파일 키 + * @return 파일이 존재하면 true, 존재하지 않으면 false + */ + public boolean isObjectExist(String key) { + try { + HeadObjectRequest headObjectRequest = HeadObjectRequest.builder() + .bucket(awsS3Config.getBucketName()) + .key(key) + .build(); + + HeadObjectResponse headObjectResponse = s3Client.headObject(headObjectRequest); + return true; + } catch (NoSuchKeyException e) { + return false; + } catch (Exception e) { + log.error("파일 존재 여부 확인 중 오류 발생", e); + throw new StorageException(StorageErrorCode.NOT_FOUND); + } + } + + /** + * S3에 저장된 파일을 복사한다. + * + * @param type : ObjectKeyType (PROFILE, FEED, CHATROOM_PROFILE, CHAT, CHAT_PROFILE) + * @param sourceKey : 복사할 파일의 키 + * @return 복사된 파일의 키 + */ + public void copyObject(ObjectKeyType type, String sourceKey) { + try { + CopyObjectRequest copyObjRequest = CopyObjectRequest.builder() + .sourceBucket(awsS3Config.getBucketName()) + .sourceKey(sourceKey) + .destinationBucket(awsS3Config.getBucketName()) + .destinationKey(type.convertDeleteKeyToOriginKey(sourceKey)) + .storageClass(StorageClass.ONEZONE_IA) + .build(); + + s3Client.copyObject(copyObjRequest); + } catch (Exception e) { + log.error("파일 복사 중 오류 발생", e); + throw new StorageException(StorageErrorCode.INVALID_FILE); + } + } + + public String getObjectPrefix() { + return awsS3Config.getObjectPrefix(); + } } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyPattern.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyPattern.java new file mode 100644 index 000000000..57e756997 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyPattern.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.regex.Pattern; + +public class ObjectKeyPattern { + public static final String USER_ID_PATTERN = "([^/]+)"; + public static final String UUID_PATTERN = "([^_]+)"; + public static final String TIMESTAMP_PATTERN = "([^\\.]+)"; + public static final String EXT_PATTERN = "([^/]+)"; + public static final String FEED_ID_PATTERN = "([^/]+)"; + public static final String CHATROOM_ID_PATTERN = "([^/]+)"; + public static final String CHAT_ID_PATTERN = "([^/]+)"; + + public static final Pattern PROFILE_PATTERN = Pattern.compile( + createRegex("delete/profile/{userId}/{uuid}_{timestamp}.{ext}")); + public static final Pattern FEED_PATTERN = Pattern.compile( + createRegex("delete/feed/{feed_id}/{uuid}_{timestamp}.{ext}")); + public static final Pattern CHATROOM_PROFILE_PATTERN = Pattern.compile( + createRegex("delete/chatroom/{chatroom_id}/{uuid}_{timestamp}.{ext}")); + public static final Pattern CHAT_PATTERN = Pattern.compile( + createRegex("delete/chatroom/{chatroom_id}/chat/{chat_id}/{uuid}_{timestamp}.{ext}")); + public static final Pattern CHAT_PROFILE_PATTERN = Pattern.compile( + createRegex("delete/chatroom/{chatroom_id}/chat_profile/{userId}/{uuid}_{timestamp}.{ext}")); + + private static String createRegex(String template) { + return template + .replace("{userId}", USER_ID_PATTERN) + .replace("{uuid}", UUID_PATTERN) + .replace("{timestamp}", TIMESTAMP_PATTERN) + .replace("{ext}", EXT_PATTERN) + .replace("{feed_id}", FEED_ID_PATTERN) + .replace("{chatroom_id}", CHATROOM_ID_PATTERN) + .replace("{chat_id}", CHAT_ID_PATTERN); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java index 57b5c028a..8a698fb5d 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyType.java @@ -2,27 +2,64 @@ import lombok.RequiredArgsConstructor; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + @RequiredArgsConstructor public enum ObjectKeyType { - PROFILE("1", "PROFILE", "delete/profile/{userId}/{uuid}_{timestamp}.{ext}"), - FEED("2", "FEED", "delete/feed/{feed_id}/{uuid}_{timestamp}.{ext}"), - CHATROOM_PROFILE("3", "CHATROOM_PROFILE", "delete/chatroom/{chatroom_id}/{uuid}_{timestamp}.{ext}"), - CHAT("4", "CHAT", "delete/chatroom/{chatroom_id}/chat/{chat_id}/{uuid}_{timestamp}.{ext}"), - CHAT_PROFILE("5", "CHAT_PROFILE", "delete/chatroom/{chatroom_id}/chat_profile//{uuid}_{timestamp}.{ext}"); - - private final String code; - private final String type; - private final String template; - - public String getCode() { - return code; - } - - public String getType() { - return type; - } - - public String getTemplate() { - return template; - } + PROFILE("1", "PROFILE", "delete/profile/{userId}/{uuid}_{timestamp}.{ext}", "profile/{userId}/origin/{uuid}_{timestamp}.{ext}"), + FEED("2", "FEED", "delete/feed/{feed_id}/{uuid}_{timestamp}.{ext}", "feed/{feed_id}/origin/{uuid}_{timestamp}.{ext}"), + CHATROOM_PROFILE("3", "CHATROOM_PROFILE", "delete/chatroom/{chatroom_id}/{uuid}_{timestamp}.{ext}", + "chatroom/{chatroom_id}/origin/{uuid}_{timestamp}.{ext}"), + CHAT("4", "CHAT", "delete/chatroom/{chatroom_id}/chat/{chat_id}/{uuid}_{timestamp}.{ext}", + "chatroom/{chatroom_id}/chat/{chat_id}/origin/{uuid}_{timestamp}.{ext}"), + CHAT_PROFILE("5", "CHAT_PROFILE", "delete/chatroom/{chatroom_id}/chat_profile/{userId}/{uuid}_{timestamp}.{ext}", + "chatroom/{chatroom_id}/chat_profile/{userId}/origin/{uuid}_{timestamp}.{ext}"); + + private final String code; + private final String type; + private final String deleteTemplate; + private final String originTemplate; + + public static String convertDeleteKeyToOriginKey(String deleteKey, Pattern pattern, String originTemplate) { + Matcher matcher = pattern.matcher(deleteKey); + + if (matcher.matches()) { + String originKey = originTemplate; + for (int i = 1; i <= matcher.groupCount(); i++) { + originKey = originKey.replaceFirst("\\{[^}]+\\}", matcher.group(i)); + } + return originKey; + } + + throw new IllegalArgumentException("No matching ObjectKeyType for deleteKey: " + deleteKey); + } + + public String getCode() { + return code; + } + + public String getType() { + return type; + } + + public String getDeleteTemplate() { + return deleteTemplate; + } + + public String getOriginTemplate() { + return originTemplate; + } + + public String convertDeleteKeyToOriginKey(String deleteKey) { + Pattern pattern = switch (this) { + case PROFILE -> ObjectKeyPattern.PROFILE_PATTERN; + case FEED -> ObjectKeyPattern.FEED_PATTERN; + case CHATROOM_PROFILE -> ObjectKeyPattern.CHATROOM_PROFILE_PATTERN; + case CHAT -> ObjectKeyPattern.CHAT_PATTERN; + case CHAT_PROFILE -> ObjectKeyPattern.CHAT_PROFILE_PATTERN; + default -> throw new IllegalArgumentException("Unknown ObjectKeyType: " + this); + }; + return convertDeleteKeyToOriginKey(deleteKey, pattern, this.originTemplate); + } } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageErrorCode.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageErrorCode.java index 6a511aab2..5d5879cc5 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageErrorCode.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/exception/StorageErrorCode.java @@ -13,7 +13,11 @@ public enum StorageErrorCode implements BaseErrorCode { // 400 Bad Request MISSING_REQUIRED_PARAMETER(StatusCode.BAD_REQUEST, ReasonCode.MISSING_REQUIRED_PARAMETER, "필수 파라미터가 누락되었습니다."), INVALID_EXTENSION(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "지원하지 않는 확장자입니다."), - INVALID_TYPE(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "지원하지 않는 타입입니다."); + INVALID_TYPE(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "지원하지 않는 타입입니다."), + INVALID_FILE(StatusCode.BAD_REQUEST, ReasonCode.MALFORMED_PARAMETER, "올바르지 않은 파일입니다."), + + // 404 Not Found + NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."); private final StatusCode statusCode; private final ReasonCode reasonCode; diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AwsS3Config.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AwsS3Config.java index 3478c5f72..f7362bd4f 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AwsS3Config.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AwsS3Config.java @@ -1,50 +1,61 @@ package kr.co.pennyway.infra.config; +import lombok.Getter; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; - -import lombok.Getter; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Getter @Configuration public class AwsS3Config { - private final String accessKey; - private final String secretKey; - private final String region; - private final String bucketName; + private final String accessKey; + private final String secretKey; + private final String region; + private final String bucketName; + private final String objectPrefix; + + public AwsS3Config( + @Value("${spring.cloud.aws.s3.credentials.access-key}") String accessKey, + @Value("${spring.cloud.aws.s3.credentials.secret-key}") String secretKey, + @Value("${spring.cloud.aws.s3.region.static}") String region, + @Value("${spring.cloud.aws.s3.bucket.name}") String bucketName, + @Value("${spring.cloud.aws.cloudfront.domain}") String objectPrefix + ) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.region = region; + this.bucketName = bucketName; + this.objectPrefix = objectPrefix; + } - public AwsS3Config( - @Value("${spring.cloud.aws.s3.credentials.access-key}") String accessKey, - @Value("${spring.cloud.aws.s3.credentials.secret-key}") String secretKey, - @Value("${spring.cloud.aws.s3.region.static}") String region, - @Value("${spring.cloud.aws.s3.bucket.name}") String bucketName - ) { - this.accessKey = accessKey; - this.secretKey = secretKey; - this.region = region; - this.bucketName = bucketName; - } + public String getBucketName() { + return bucketName; + } - public String getBucketName() { - return bucketName; - } + @Bean + public AwsCredentials awsS3Credentials() { + return AwsBasicCredentials.create(accessKey, secretKey); + } - @Bean - public AwsCredentials awsS3Credentials() { - return AwsBasicCredentials.create(accessKey, secretKey); - } + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsS3Credentials())) + .build(); + } - @Bean - public S3Presigner s3Presigner() { - return S3Presigner.builder() - .region(Region.of(region)) - .credentialsProvider(StaticCredentialsProvider.create(awsS3Credentials())) - .build(); - } + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsS3Credentials())) + .build(); + } } diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index ce8f4bdbd..ae7690dc9 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -25,6 +25,8 @@ spring: static: ${AWS_S3_REGION:ap-northeast-2} bucket: name: ${AWS_S3_BUCKET_NAME:pennyway} + cloudfront: + domain: ${AWS_CLOUDFRONT_DOMAIN:https://cdn.cloudfront.net} app: question-address: ${ADMIN_ADDRESS:team.collabu@gmail.com} From 772b23f654b4b32c6f77c7cd8cc1a59310f2d1ef Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 5 Jun 2024 18:15:51 +0900 Subject: [PATCH 103/152] =?UTF-8?q?=20fix:=20=E2=9C=8F=EF=B8=8F=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=A1=9C=EA=B7=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=95=EC=B1=85=20=EB=B3=80=EA=B2=BD=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20Device=20API=20=EC=88=98=EC=A0=95=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * fix: device entity os & model 필드 제거 * fix: device token dto model, os 필드 제거 * fix: device_dto to_entity() 메서드 복구 * fix: device update 메서드 호출 시, activate 상태로 변경 * fix: device 등록 비지니스 로직 수정 * test: device controller unit test에서 model, os 제거 * fix: device_fixture model, os 필드 제거 * rename: fixture token 변경 상수명 수정 * fix: device entity 조회 시 활성화 조건 추가 * fix: device usecase 테스트 시나리오 수정 * fix: device 쿼리 자동 조건문 수정 * test: origin_token에 대한 정보가 없는 경우 new_token 등록 * fix: @sql_restriction 제거 * test: 수정 요청 & 기존 토큰 없을 시, 수정 토큰으로 신규 등록 테스트 * fix: device_register_service 로직 수정 * fix: device_dto new_token 필드 제거 * test: device controller unit test 수정 * fix: device 응답 객체 생성 시 요청 토큰이 아닌 서버 토큰을 응답으로 사용 * fix: device_register_service 제거 && 로직 단순화 * fix: 사용자가 보낸 토큰이 서버에서 비활성화 상태일 때 예외처리 * test: test case 요청 수정 * rename: device -> device_token으로 수정 * rename: device_token entity to_string 수정 * rename: device token put 요청 응답 key 수정 * docs: device put api 스웨거 수정 * fix: device controller 경로 수정 * test: device_token controller unit test 변경된 url 및 응답 포맷 반영 * test: device_fixture -> device_token_fixture * test: 단일 token에 대한 시나리오로 테스트 수정 및 비활성화 토큰 요청 에러 케이스 추가 * fix: register_device_token 파사드 패턴 적용 * fix: device_token_unregister 파사드 패턴 적용 * test: device token 단위 테스트 분리 * test: device_token_unregister_service_test 분리 * fix: user_account_use_case 파사드 패턴 적용 * test: user_account_usecase unit test 분리 * fix: device entity os & model 필드 제거 * fix: device token dto model, os 필드 제거 * fix: device_dto to_entity() 메서드 복구 * fix: device update 메서드 호출 시, activate 상태로 변경 * fix: device 등록 비지니스 로직 수정 * test: device controller unit test에서 model, os 제거 * fix: device_fixture model, os 필드 제거 * rename: fixture token 변경 상수명 수정 * fix: device entity 조회 시 활성화 조건 추가 * fix: device usecase 테스트 시나리오 수정 * fix: device 쿼리 자동 조건문 수정 * test: origin_token에 대한 정보가 없는 경우 new_token 등록 * fix: @sql_restriction 제거 * test: 수정 요청 & 기존 토큰 없을 시, 수정 토큰으로 신규 등록 테스트 * fix: device_register_service 로직 수정 * fix: device_dto new_token 필드 제거 * test: device controller unit test 수정 * fix: device 응답 객체 생성 시 요청 토큰이 아닌 서버 토큰을 응답으로 사용 * fix: device_register_service 제거 && 로직 단순화 * fix: 사용자가 보낸 토큰이 서버에서 비활성화 상태일 때 예외처리 * test: test case 요청 수정 * rename: device -> device_token으로 수정 * rename: device_token entity to_string 수정 * rename: device token put 요청 응답 key 수정 * docs: device put api 스웨거 수정 * fix: device controller 경로 수정 * test: device_token controller unit test 변경된 url 및 응답 포맷 반영 * test: device_fixture -> device_token_fixture * test: 단일 token에 대한 시나리오로 테스트 수정 및 비활성화 토큰 요청 에러 케이스 추가 * fix: register_device_token 파사드 패턴 적용 * fix: device_token_unregister 파사드 패턴 적용 * test: device token 단위 테스트 분리 * test: device_token_unregister_service_test 분리 * fix: user_account_use_case 파사드 패턴 적용 * test: user_account_usecase unit test 분리 * fix: user_profile_update_dto merge conflict --- .../api/apis/users/api/UserAccountApi.java | 17 +- .../controller/UserAccountController.java | 12 +- .../api/apis/users/dto/DeviceDto.java | 48 -- .../api/apis/users/dto/DeviceTokenDto.java | 31 + .../apis/users/dto/UserProfileUpdateDto.java | 10 +- .../apis/users/mapper/DeviceTokenMapper.java | 12 + .../apis/users/mapper/UserProfileMapper.java | 10 + .../users/service/DeviceRegisterService.java | 84 --- .../service/DeviceTokenRegisterService.java | 39 ++ .../service/DeviceTokenUnregisterService.java | 33 ++ .../users/service/PasswordUpdateService.java | 69 +++ .../apis/users/service/UserDeleteService.java | 8 +- .../service/UserProfileSearchService.java | 33 ++ .../service/UserProfileUpdateService.java | 37 +- .../users/usecase/UserAccountUseCase.java | 135 +---- .../UserAccountControllerUnitTest.java | 17 +- .../DeviceTokenRegisterServiceTest.java | 118 ++++ .../DeviceTokenUnregisterServiceTest.java | 87 +++ .../usecase/PasswordUpdateServiceTest.java | 182 ++++++ .../users/usecase/UserAccountUseCaseTest.java | 537 ------------------ .../users/usecase/UserDeleteServiceTest.java | 119 ++++ .../usecase/UserProfileUpdateServiceTest.java | 75 +++ .../api/config/fixture/DeviceFixture.java | 33 -- .../config/fixture/DeviceTokenFixture.java | 24 + .../domain/{Device.java => DeviceToken.java} | 27 +- .../exception/DeviceErrorException.java | 20 - ...rorCode.java => DeviceTokenErrorCode.java} | 5 +- .../exception/DeviceTokenErrorException.java | 20 + .../device/repository/DeviceRepository.java | 13 - .../repository/DeviceTokenRepository.java | 13 + .../domains/device/service/DeviceService.java | 43 -- .../device/service/DeviceTokenService.java | 43 ++ .../domain/domains/user/domain/User.java | 4 +- 33 files changed, 1003 insertions(+), 955 deletions(-) delete mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceDto.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceTokenDto.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/DeviceTokenMapper.java delete mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceRegisterService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenRegisterService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/PasswordUpdateService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileSearchService.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/PasswordUpdateServiceTest.java delete mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserDeleteServiceTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserProfileUpdateServiceTest.java delete mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceFixture.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceTokenFixture.java rename pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/{Device.java => DeviceToken.java} (66%) delete mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorException.java rename pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/{DeviceErrorCode.java => DeviceTokenErrorCode.java} (78%) create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorException.java delete mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java delete mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceService.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java index 790daab67..b47b1949d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java @@ -11,7 +11,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.NotBlank; -import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.api.apis.users.dto.DeviceTokenDto; import kr.co.pennyway.api.apis.users.dto.UserProfileDto; import kr.co.pennyway.api.apis.users.dto.UserProfileUpdateDto; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; @@ -24,15 +24,14 @@ @Tag(name = "사용자 계정 관리 API", description = "사용자 본인의 계정 관리를 위한 Usecase를 제공합니다.") public interface UserAccountApi { - @Operation(summary = "디바이스 등록", description = "사용자의 디바이스 정보를 등록(originToken == newToken)하거나 갱신(originToken != newToken)합니다.") + @Operation(summary = "디바이스 등록", description = "사용자의 디바이스 정보를 등록합니다.") @ApiResponses({ - @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "디바이스 등록 성공", value = """ + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "deviceToken", schema = @Schema(implementation = DeviceTokenDto.RegisterRes.class)))), + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "잘못된 디바이스 토큰 저장 요청", description = "서버에 동일한 이름의 토큰이 사용자에게 등록되어 있고, 해당 토큰이 만료처리되어 있을 경우에 해당한다. (애초에 발생해선 안 되는 에러)", value = """ { - "device": { - "id": 1, - "token": "newToken" - } + "code": "4005", + "message": "활성화되지 않은 디바이스 토큰 정보입니다." } """) })), @@ -45,7 +44,7 @@ public interface UserAccountApi { """) })) }) - ResponseEntity putDevice(@RequestBody @Validated DeviceDto.RegisterReq request, @AuthenticationPrincipal SecurityUserDetails user); + ResponseEntity putDevice(@RequestBody @Validated DeviceTokenDto.RegisterReq request, @AuthenticationPrincipal SecurityUserDetails user); @Operation(summary = "디바이스 토큰 제거", description = "사용자의 디바이스 정보와 토큰을 제거합니다.") @Parameter(name = "token", description = "삭제할 디바이스 토큰", required = true, in = ParameterIn.QUERY) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java index cb482ecea..c389426b0 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java @@ -2,7 +2,7 @@ import jakarta.validation.constraints.NotBlank; import kr.co.pennyway.api.apis.users.api.UserAccountApi; -import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.api.apis.users.dto.DeviceTokenDto; import kr.co.pennyway.api.apis.users.dto.UserProfileUpdateDto; import kr.co.pennyway.api.apis.users.usecase.UserAccountUseCase; import kr.co.pennyway.api.common.response.SuccessResponse; @@ -24,17 +24,17 @@ public class UserAccountController implements UserAccountApi { private final UserAccountUseCase userAccountUseCase; @Override - @PutMapping("/devices") + @PutMapping("/device-tokens") @PreAuthorize("isAuthenticated()") - public ResponseEntity putDevice(@RequestBody @Validated DeviceDto.RegisterReq request, @AuthenticationPrincipal SecurityUserDetails user) { - return ResponseEntity.ok(SuccessResponse.from("device", userAccountUseCase.registerDevice(user.getUserId(), request))); + public ResponseEntity putDevice(@RequestBody @Validated DeviceTokenDto.RegisterReq request, @AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from("deviceToken", userAccountUseCase.registerDeviceToken(user.getUserId(), request))); } @Override - @DeleteMapping("/devices") + @DeleteMapping("/device-tokens") @PreAuthorize("isAuthenticated()") public ResponseEntity deleteDevice(@RequestParam("token") @Validated @NotBlank String token, @AuthenticationPrincipal SecurityUserDetails user) { - userAccountUseCase.unregisterDevice(user.getUserId(), token); + userAccountUseCase.unregisterDeviceToken(user.getUserId(), token); return ResponseEntity.ok(SuccessResponse.noContent()); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceDto.java deleted file mode 100644 index ea8f91278..000000000 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceDto.java +++ /dev/null @@ -1,48 +0,0 @@ -package kr.co.pennyway.api.apis.users.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import kr.co.pennyway.domain.domains.device.domain.Device; -import kr.co.pennyway.domain.domains.user.domain.User; - -public class DeviceDto { - @Schema(title = "디바이스 등록 요청") - public record RegisterReq( - @Schema(description = "기존 디바이스 토큰", requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank(message = "originToken은 필수입니다.") - String originToken, - @Schema(description = "새로운 디바이스 토큰", requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank(message = "newToken은 필수입니다.") - String newToken, - @Schema(description = "디바이스 모델명", requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank(message = "model은 필수입니다.") - String model, - @Schema(description = "디바이스 OS", requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank(message = "os는 필수입니다.") - String os - ) { - /** - * oldToken과 newToken이 같은 경우, 신규 등록 요청으로 판단 - */ - @Schema(hidden = true) - public boolean isInitRequest() { - return originToken.equals(newToken); - } - - public Device toEntity(User user) { - return Device.of(newToken, model, os, user); - } - } - - @Schema(title = "디바이스 등록 응답") - public record RegisterRes( - @Schema(title = "디바이스 ID") - Long id, - @Schema(title = "디바이스 토큰") - String token - ) { - public static RegisterRes of(Long id, String token) { - return new RegisterRes(id, token); - } - } -} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceTokenDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceTokenDto.java new file mode 100644 index 000000000..38b716046 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/DeviceTokenDto.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.api.apis.users.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.user.domain.User; + +public class DeviceTokenDto { + @Schema(title = "디바이스 등록 요청") + public record RegisterReq( + @Schema(description = "디바이스 FCM 토큰", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "token은 필수입니다.") + String token + ) { + public DeviceToken toEntity(User user) { + return DeviceToken.of(token, user); + } + } + + @Schema(title = "디바이스 등록 응답") + public record RegisterRes( + @Schema(title = "디바이스 ID") + Long id, + @Schema(title = "디바이스 토큰") + String token + ) { + public static RegisterRes of(Long id, String token) { + return new RegisterRes(id, token); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java index a511a8b1a..b9f8b6db6 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java @@ -5,7 +5,6 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import kr.co.pennyway.api.common.validator.Password; -import kr.co.pennyway.domain.domains.user.domain.NotifySetting; public class UserProfileUpdateDto { @Schema(title = "이름 변경 요청 DTO") @@ -47,7 +46,7 @@ public record PasswordReq( } @Schema(title = "사용자 알림 설정 응답 DTO") - public record NotifySettingUpdateReq( + public record NotifySettingUpdateRes( @Schema(description = "계좌 알림 설정", example = "true", requiredMode = Schema.RequiredMode.NOT_REQUIRED) @JsonInclude(JsonInclude.Include.NON_NULL) Boolean accountBookNotify, @@ -58,13 +57,6 @@ public record NotifySettingUpdateReq( @JsonInclude(JsonInclude.Include.NON_NULL) Boolean chatNotify ) { - public static NotifySettingUpdateReq of(NotifySetting.NotifyType type, Boolean flag) { - return switch (type) { - case ACCOUNT_BOOK -> new NotifySettingUpdateReq(flag, null, null); - case FEED -> new NotifySettingUpdateReq(null, flag, null); - case CHAT -> new NotifySettingUpdateReq(null, null, flag); - }; - } } @Schema(title = "프로필 이미지 등록 요청 DTO") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/DeviceTokenMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/DeviceTokenMapper.java new file mode 100644 index 000000000..81316075a --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/DeviceTokenMapper.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.api.apis.users.mapper; + +import kr.co.pennyway.api.apis.users.dto.DeviceTokenDto; +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; + +@Mapper +public class DeviceTokenMapper { + public static DeviceTokenDto.RegisterRes toRegisterRes(DeviceToken deviceToken) { + return DeviceTokenDto.RegisterRes.of(deviceToken.getId(), deviceToken.getToken()); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/UserProfileMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/UserProfileMapper.java index 885875675..96b24e225 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/UserProfileMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/UserProfileMapper.java @@ -2,8 +2,10 @@ import kr.co.pennyway.api.apis.users.dto.OauthAccountDto; import kr.co.pennyway.api.apis.users.dto.UserProfileDto; +import kr.co.pennyway.api.apis.users.dto.UserProfileUpdateDto; import kr.co.pennyway.common.annotation.Mapper; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; import java.util.Set; @@ -24,4 +26,12 @@ public static UserProfileDto toUserProfileDto(User user, Set oauths) { return UserProfileDto.from(user, OauthAccountDto.of(kakao, google, apple)); } + + public static UserProfileUpdateDto.NotifySettingUpdateRes toNotifySettingUpdateRes(NotifySetting.NotifyType type, Boolean flag) { + return switch (type) { + case ACCOUNT_BOOK -> new UserProfileUpdateDto.NotifySettingUpdateRes(flag, null, null); + case FEED -> new UserProfileUpdateDto.NotifySettingUpdateRes(null, flag, null); + case CHAT -> new UserProfileUpdateDto.NotifySettingUpdateRes(null, null, flag); + }; + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceRegisterService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceRegisterService.java deleted file mode 100644 index f03bb435a..000000000 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceRegisterService.java +++ /dev/null @@ -1,84 +0,0 @@ -package kr.co.pennyway.api.apis.users.service; - -import kr.co.pennyway.api.apis.users.dto.DeviceDto; -import kr.co.pennyway.domain.domains.device.domain.Device; -import kr.co.pennyway.domain.domains.device.exception.DeviceErrorCode; -import kr.co.pennyway.domain.domains.device.exception.DeviceErrorException; -import kr.co.pennyway.domain.domains.device.service.DeviceService; -import kr.co.pennyway.domain.domains.user.domain.User; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -@Slf4j -@Service -@RequiredArgsConstructor -public class DeviceRegisterService { - private final DeviceService deviceService; - - @Transactional - public Device createOrUpdateDevice(User user, DeviceDto.RegisterReq request) { - Optional device = deviceService.readDeviceByUserIdAndToken(user.getId(), request.originToken()); - - if (request.isInitRequest() && device.isEmpty()) { - return createDevice(user, request); - } - - Device originDevice = getDeviceOrThrow(device); - - log.info("디바이스 토큰 갱신: 사용자 {} - model {} - os {}", user, request.model(), request.os()); - return updateExistingDevice(originDevice, request); - } - - private Device createDevice(User user, DeviceDto.RegisterReq request) { - log.info("신규 디바이스 등록: 사용자 {} - model {} - os {}", user, request.model(), request.os()); - Device newDevice = request.toEntity(user); - return deviceService.createDevice(newDevice); - } - - /** - * 사용자 ID와 토큰으로 디바이스 정보를 조회한다. - * - * @throws DeviceErrorException 사용자 id와 originToken과 매칭되는 디바이스 정보가 없는 경우 - */ - private Device getDeviceOrThrow(Optional device) { - return device.orElseThrow(() -> new DeviceErrorException(DeviceErrorCode.NOT_FOUND_DEVICE)); - } - - /** - * 기존에 등록된 사용자의 디바이스 토큰을 갱신한다. - */ - private Device updateExistingDevice(Device device, DeviceDto.RegisterReq request) { - if (!isMatchOriginDeviceInfo(device, request)) { - log.warn("사용자 디바이스 정보 변경됨 : model {} - os {}", request.model(), request.os()); - device.updateDeviceInfo(request.model(), request.os()); - } - - return updateDeviceToken(device, request.newToken()); - } - - /** - * 요청한 디바이스 정보가 기존 디바이스 정보와 일치하는지 확인한다. - */ - private boolean isMatchOriginDeviceInfo(Device device, DeviceDto.RegisterReq request) { - return device.getOs().equals(request.os()) && device.getModel().equals(request.model()); - } - - /** - * 디바이스 토큰을 newToken으로 갱신하고, 만약 비활성화 토큰이라면 활성화 상태로 되돌린다. - */ - private Device updateDeviceToken(Device device, String newToken) { - log.debug("디바이스 토큰 갱신: {} -> {}", device.getToken(), newToken); - - device.updateToken(newToken); - - if (!device.isActivated()) { - device.activate(); - } - - return device; - } -} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenRegisterService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenRegisterService.java new file mode 100644 index 000000000..3f36421ae --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenRegisterService.java @@ -0,0 +1,39 @@ +package kr.co.pennyway.api.apis.users.service; + +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException; +import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DeviceTokenRegisterService { + private final UserService userService; + private final DeviceTokenService deviceTokenService; + + @Transactional + public DeviceToken execute(Long userId, String token) { + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + DeviceToken deviceToken = getOrCreateDevice(user, token); + + if (!deviceToken.isActivated()) { + throw new DeviceTokenErrorException(DeviceTokenErrorCode.NOT_ACTIVATED_DEVICE); + } + + return deviceToken; + } + + private DeviceToken getOrCreateDevice(User user, String token) { + return deviceTokenService.readDeviceByUserIdAndToken(user.getId(), token) + .orElseGet(() -> deviceTokenService.createDevice(DeviceToken.of(token, user))); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java new file mode 100644 index 000000000..557352566 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.api.apis.users.service; + +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException; +import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DeviceTokenUnregisterService { + private final UserService userService; + private final DeviceTokenService deviceTokenService; + + @Transactional + public void execute(Long userId, String token) { + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + + DeviceToken deviceToken = deviceTokenService.readDeviceByUserIdAndToken(user.getId(), token).orElseThrow( + () -> new DeviceTokenErrorException(DeviceTokenErrorCode.NOT_FOUND_DEVICE) + ); + + deviceTokenService.deleteDevice(deviceToken); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/PasswordUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/PasswordUpdateService.java new file mode 100644 index 000000000..94ac73fba --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/PasswordUpdateService.java @@ -0,0 +1,69 @@ +package kr.co.pennyway.api.apis.users.service; + +import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PasswordUpdateService { + private final UserService userService; + private final PasswordEncoderHelper passwordEncoderHelper; + + @Transactional(readOnly = true) + public void verify(Long userId, String expectedPassword) { + User user = readUserOrThrow(userId); + + validateGeneralSignedUpUser(user); + validatePasswordMatch(expectedPassword, user.getPassword()); + } + + @Transactional + public void execute(Long userId, String oldPassword, String newPassword) { + User user = readUserOrThrow(userId); + + validateGeneralSignedUpUser(user); + validatePasswordMatch(oldPassword, user.getPassword()); + + updatePassword(user, newPassword); + } + + private User readUserOrThrow(Long userId) { + return userService.readUser(userId).orElseThrow( + () -> { + log.info("사용자를 찾을 수 없습니다."); + return new UserErrorException(UserErrorCode.NOT_FOUND); + } + ); + } + + private void validateGeneralSignedUpUser(User user) { + if (!user.isGeneralSignedUpUser()) { + log.info("일반 회원가입 이력이 없습니다."); + throw new UserErrorException(UserErrorCode.DO_NOT_GENERAL_SIGNED_UP); + } + } + + private void validatePasswordMatch(String password, String storedPassword) { + if (!passwordEncoderHelper.isSamePassword(password, storedPassword)) { + log.info("기존 비밀번호와 일치하지 않습니다."); + throw new UserErrorException(UserErrorCode.NOT_MATCHED_PASSWORD); + } + } + + private void updatePassword(User user, String newPassword) { + if (passwordEncoderHelper.isSamePassword(user.getPassword(), newPassword)) { + log.info("기존과 동일한 비밀번호로는 변경할 수 없습니다."); + throw new UserErrorException(UserErrorCode.PASSWORD_NOT_CHANGED); + } + + user.updatePassword(passwordEncoderHelper.encodePassword(newPassword)); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java index 1af59641f..b347c53d3 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java @@ -1,6 +1,8 @@ package kr.co.pennyway.api.apis.users.service; import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,7 +24,11 @@ public class UserDeleteService { private final OauthService oauthService; @Transactional - public void deleteUser(Long userId) { + public void execute(Long userId) { + if (!userService.isExistUser(userId)) throw new UserErrorException(UserErrorCode.NOT_FOUND); + + // TODO: [2024-05-03] 하나라도 채팅방의 방장으로 참여하는 경우 삭제 불가능 처리 + oauthService.deleteOauthsByUserId(userId); userService.deleteUser(userId); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileSearchService.java new file mode 100644 index 000000000..0b2392aa8 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileSearchService.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.api.apis.users.service; + +import kr.co.pennyway.api.apis.users.dto.UserProfileDto; +import kr.co.pennyway.api.apis.users.mapper.UserProfileMapper; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserProfileSearchService { + private final UserService userService; + private final OauthService oauthService; + + @Transactional(readOnly = true) + public UserProfileDto readMyAccount(Long userId) { + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + Set oauths = oauthService.readOauthsByUserId(userId).stream().filter(oauth -> !oauth.isDeleted()).collect(Collectors.toUnmodifiableSet()); + + return UserProfileMapper.toUserProfileDto(user, oauths); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java index 37f29ada7..3f8719466 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java @@ -1,10 +1,10 @@ package kr.co.pennyway.api.apis.users.service; -import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; import kr.co.pennyway.infra.common.exception.StorageErrorCode; @@ -18,31 +18,27 @@ @Service @RequiredArgsConstructor public class UserProfileUpdateService { - private final PasswordEncoderHelper passwordEncoderHelper; + private final UserService userService; private final AwsS3Provider awsS3Provider; @Transactional - public void updateName(User user, String newName) { + public void updateName(Long userId, String newName) { + User user = readUserOrThrow(userId); + user.updateName(newName); } @Transactional - public void updateUsername(User user, String newUsername) { + public void updateUsername(Long userId, String newUsername) { + User user = readUserOrThrow(userId); + user.updateUsername(newUsername); } @Transactional - public void updatePassword(User user, String oldPassword, String newPassword) { - if (passwordEncoderHelper.isSamePassword(user.getPassword(), newPassword)) { - log.info("기존과 동일한 비밀번호로는 변경할 수 없습니다."); - throw new UserErrorException(UserErrorCode.PASSWORD_NOT_CHANGED); - } - - user.updatePassword(passwordEncoderHelper.encodePassword(newPassword)); - } + public void updateProfileImage(Long userId, String profileImageUrl) { + User user = readUserOrThrow(userId); - @Transactional - public void updateProfileImage(User user, String profileImageUrl) { // Profile Image 존재 여부 확인 if (!awsS3Provider.isObjectExist(profileImageUrl)) { log.info("프로필 이미지 URL이 유효하지 않습니다."); @@ -58,7 +54,18 @@ public void updateProfileImage(User user, String profileImageUrl) { } @Transactional - public void updateNotifySetting(User user, NotifySetting.NotifyType type, Boolean flag) { + public void updateNotifySetting(Long userId, NotifySetting.NotifyType type, Boolean flag) { + User user = readUserOrThrow(userId); + user.getNotifySetting().updateNotifySetting(type, flag); } + + private User readUserOrThrow(Long userId) { + return userService.readUser(userId).orElseThrow( + () -> { + log.info("사용자를 찾을 수 없습니다."); + return new UserErrorException(UserErrorCode.NOT_FOUND); + } + ); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java index 14da19b17..03486ff17 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java @@ -1,159 +1,76 @@ package kr.co.pennyway.api.apis.users.usecase; -import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.api.apis.users.dto.DeviceTokenDto; import kr.co.pennyway.api.apis.users.dto.UserProfileDto; import kr.co.pennyway.api.apis.users.dto.UserProfileUpdateDto; -import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; +import kr.co.pennyway.api.apis.users.mapper.DeviceTokenMapper; import kr.co.pennyway.api.apis.users.mapper.UserProfileMapper; -import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; -import kr.co.pennyway.api.apis.users.service.UserDeleteService; -import kr.co.pennyway.api.apis.users.service.UserProfileUpdateService; +import kr.co.pennyway.api.apis.users.service.*; import kr.co.pennyway.common.annotation.UseCase; -import kr.co.pennyway.domain.domains.device.domain.Device; -import kr.co.pennyway.domain.domains.device.exception.DeviceErrorCode; -import kr.co.pennyway.domain.domains.device.exception.DeviceErrorException; -import kr.co.pennyway.domain.domains.device.service.DeviceService; -import kr.co.pennyway.domain.domains.oauth.domain.Oauth; -import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; import kr.co.pennyway.domain.domains.user.domain.NotifySetting; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; -import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; -import java.util.Set; -import java.util.stream.Collectors; - @Slf4j @UseCase @RequiredArgsConstructor public class UserAccountUseCase { - private final UserService userService; - private final OauthService oauthService; - private final DeviceService deviceService; + private final DeviceTokenRegisterService deviceTokenRegisterService; + private final DeviceTokenUnregisterService deviceTokenUnregisterService; + private final UserProfileSearchService userProfileSearchService; private final UserProfileUpdateService userProfileUpdateService; private final UserDeleteService userDeleteService; - private final DeviceRegisterService deviceRegisterService; - private final PasswordEncoderHelper passwordEncoderHelper; + private final PasswordUpdateService passwordUpdateService; @Transactional - public DeviceDto.RegisterRes registerDevice(Long userId, DeviceDto.RegisterReq request) { - User user = readUserOrThrow(userId); - - Device device = deviceRegisterService.createOrUpdateDevice(user, request); - - return DeviceDto.RegisterRes.of(device.getId(), request.newToken()); + public DeviceTokenDto.RegisterRes registerDeviceToken(Long userId, DeviceTokenDto.RegisterReq request) { + DeviceToken deviceToken = deviceTokenRegisterService.execute(userId, request.token()); + return DeviceTokenMapper.toRegisterRes(deviceToken); } - @Transactional - public void unregisterDevice(Long userId, String token) { - User user = readUserOrThrow(userId); - - Device device = deviceService.readDeviceByUserIdAndToken(user.getId(), token).orElseThrow( - () -> new DeviceErrorException(DeviceErrorCode.NOT_FOUND_DEVICE) - ); - - deviceService.deleteDevice(device); + public void unregisterDeviceToken(Long userId, String token) { + deviceTokenUnregisterService.execute(userId, token); } - @Transactional(readOnly = true) public UserProfileDto getMyAccount(Long userId) { - User user = readUserOrThrow(userId); - Set oauths = oauthService.readOauthsByUserId(userId).stream().filter(oauth -> !oauth.isDeleted()).collect(Collectors.toUnmodifiableSet()); - - return UserProfileMapper.toUserProfileDto(user, oauths); + return userProfileSearchService.readMyAccount(userId); } - @Transactional public void updateName(Long userId, String newName) { - User user = readUserOrThrow(userId); - - userProfileUpdateService.updateName(user, newName); + userProfileUpdateService.updateName(userId, newName); } - @Transactional public void updateUsername(Long userId, String newUsername) { - User user = readUserOrThrow(userId); - - userProfileUpdateService.updateUsername(user, newUsername); + userProfileUpdateService.updateUsername(userId, newUsername); } - @Transactional(readOnly = true) public void verifyPassword(Long userId, String expectedPassword) { - User user = readUserOrThrow(userId); - - validateGeneralSignedUpUser(user); - validatePasswordMatch(expectedPassword, user.getPassword()); + passwordUpdateService.verify(userId, expectedPassword); } - @Transactional public void updatePassword(Long userId, String oldPassword, String newPassword) { - User user = readUserOrThrow(userId); - - validateGeneralSignedUpUser(user); - validatePasswordMatch(oldPassword, user.getPassword()); - - userProfileUpdateService.updatePassword(user, oldPassword, newPassword); + passwordUpdateService.execute(userId, oldPassword, newPassword); } - @Transactional public void updateProfileImage(Long userId, UserProfileUpdateDto.ProfileImageReq request) { - User user = readUserOrThrow(userId); - - userProfileUpdateService.updateProfileImage(user, request.profileImageUrl()); + userProfileUpdateService.updateProfileImage(userId, request.profileImageUrl()); } - @Transactional - public UserProfileUpdateDto.NotifySettingUpdateReq activateNotification(Long userId, NotifySetting.NotifyType type) { - User user = readUserOrThrow(userId); - - userProfileUpdateService.updateNotifySetting(user, type, Boolean.TRUE); - return UserProfileUpdateDto.NotifySettingUpdateReq.of(type, Boolean.TRUE); + public UserProfileUpdateDto.NotifySettingUpdateRes activateNotification(Long userId, NotifySetting.NotifyType type) { + userProfileUpdateService.updateNotifySetting(userId, type, Boolean.TRUE); + return UserProfileMapper.toNotifySettingUpdateRes(type, Boolean.TRUE); } - @Transactional - public UserProfileUpdateDto.NotifySettingUpdateReq deactivateNotification(Long userId, NotifySetting.NotifyType type) { - User user = readUserOrThrow(userId); - - userProfileUpdateService.updateNotifySetting(user, type, Boolean.FALSE); - return UserProfileUpdateDto.NotifySettingUpdateReq.of(type, Boolean.FALSE); + public UserProfileUpdateDto.NotifySettingUpdateRes deactivateNotification(Long userId, NotifySetting.NotifyType type) { + userProfileUpdateService.updateNotifySetting(userId, type, Boolean.FALSE); + return UserProfileMapper.toNotifySettingUpdateRes(type, Boolean.FALSE); } - @Transactional public void deleteAccount(Long userId) { - if (!userService.isExistUser(userId)) - throw new UserErrorException(UserErrorCode.NOT_FOUND); - - // TODO: [2024-05-03] 하나라도 채팅방의 방장으로 참여하는 경우 삭제 불가능 처리 - - userDeleteService.deleteUser(userId); - } - - private User readUserOrThrow(Long userId) { - return userService.readUser(userId).orElseThrow( - () -> { - log.info("사용자를 찾을 수 없습니다."); - return new UserErrorException(UserErrorCode.NOT_FOUND); - } - ); - } - - private void validateGeneralSignedUpUser(User user) { - if (!user.isGeneralSignedUpUser()) { - log.info("일반 회원가입 이력이 없습니다."); - throw new UserErrorException(UserErrorCode.DO_NOT_GENERAL_SIGNED_UP); - } - } - - private void validatePasswordMatch(String password, String storedPassword) { - if (!passwordEncoderHelper.isSamePassword(password, storedPassword)) { - log.info("기존 비밀번호와 일치하지 않습니다."); - throw new UserErrorException(UserErrorCode.NOT_MATCHED_PASSWORD); - } + userDeleteService.execute(userId); } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java index 37ae99a47..3b7f3e46d 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java @@ -1,10 +1,11 @@ package kr.co.pennyway.api.apis.users.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import kr.co.pennyway.api.apis.users.dto.DeviceDto; +import kr.co.pennyway.api.apis.users.dto.DeviceTokenDto; import kr.co.pennyway.api.apis.users.dto.UserProfileUpdateDto; import kr.co.pennyway.api.apis.users.usecase.UserAccountUseCase; import kr.co.pennyway.api.config.WebConfig; +import kr.co.pennyway.api.config.fixture.DeviceTokenFixture; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; import kr.co.pennyway.common.exception.StatusCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; @@ -61,26 +62,26 @@ void setUp(WebApplicationContext webApplicationContext) { @Nested @Order(1) @DisplayName("[1] 디바이스 요청 테스트") - class DeviceRequestTest { + class DeviceTokenRequestTest { @DisplayName("디바이스가 정상적으로 저장되었을 때, 디바이스 pk와 등록된 토큰을 반환한다.") @Test @WithSecurityMockUser void putDevice() throws Exception { // given - DeviceDto.RegisterReq request = new DeviceDto.RegisterReq("newToken", "newToken", "modelA", "Windows"); - DeviceDto.RegisterRes expectedResponse = new DeviceDto.RegisterRes(2L, "newToken"); - given(userAccountUseCase.registerDevice(1L, request)).willReturn(expectedResponse); + DeviceTokenDto.RegisterReq request = DeviceTokenFixture.INIT.toRegisterReq(); + DeviceTokenDto.RegisterRes expectedResponse = new DeviceTokenDto.RegisterRes(2L, "originToken"); + given(userAccountUseCase.registerDeviceToken(1L, request)).willReturn(expectedResponse); // when - ResultActions result = mockMvc.perform(put("/v2/users/me/devices") + ResultActions result = mockMvc.perform(put("/v2/users/me/device-tokens") .contentType("application/json") .content(objectMapper.writeValueAsString(request))); // then result.andExpect(status().isOk()) .andExpect(jsonPath("$.code").value("2000")) - .andExpect(jsonPath("$.data.device.id").value(expectedResponse.id())) - .andExpect(jsonPath("$.data.device.token").value(expectedResponse.token())) + .andExpect(jsonPath("$.data.deviceToken.id").value(expectedResponse.id())) + .andExpect(jsonPath("$.data.deviceToken.token").value(expectedResponse.token())) .andDo(print()); } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java new file mode 100644 index 000000000..7e10250a0 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java @@ -0,0 +1,118 @@ +package kr.co.pennyway.api.apis.users.usecase; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.api.apis.users.dto.DeviceTokenDto; +import kr.co.pennyway.api.apis.users.service.DeviceTokenRegisterService; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.fixture.DeviceTokenFixture; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException; +import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.util.AssertionErrors.*; + +@ExtendWith(MockitoExtension.class) +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") +@ContextConfiguration(classes = {JpaConfig.class, DeviceTokenRegisterService.class, UserService.class, DeviceTokenService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class DeviceTokenRegisterServiceTest extends ExternalApiDBTestConfig { + @Autowired + private UserService userService; + + @Autowired + private DeviceTokenService deviceTokenService; + + @Autowired + private DeviceTokenRegisterService deviceTokenRegisterService; + + @MockBean + private JPAQueryFactory queryFactory; + + private User requestUser; + + @BeforeEach + void setUp() { + User user = User.builder().role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).build(); + requestUser = userService.createUser(user); + } + + @Test + @Transactional + @DisplayName("[1] token 등록 요청이 들어왔을 때, 새로운 디바이스 토큰을 등록한다.") + void registerNewDevice() { + // given + DeviceTokenDto.RegisterReq request = DeviceTokenFixture.INIT.toRegisterReq(); + + // when + DeviceToken response = deviceTokenRegisterService.execute(requestUser.getId(), request.token()); + + // then + deviceTokenService.readDeviceByUserIdAndToken(requestUser.getId(), request.token()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.getToken(), device.getToken()); + assertEquals("디바이스 ID가 일치해야 한다.", response.getId(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + System.out.println("device = " + device); + }, + () -> fail("신규 디바이스가 등록되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[2] token에 대한 활성화 디바이스 토큰이 이미 존재하는 경우 기존 데이터를 반환한다.") + void registerNewDeviceWhenDeviceIsAlreadyExists() { + // given + DeviceToken originDeviceToken = DeviceTokenFixture.INIT.toDevice(requestUser); + deviceTokenService.createDevice(originDeviceToken); + DeviceTokenDto.RegisterReq request = DeviceTokenFixture.INIT.toRegisterReq(); + + // when + DeviceToken response = deviceTokenRegisterService.execute(requestUser.getId(), request.token()); + + // then + deviceTokenService.readDeviceByUserIdAndToken(requestUser.getId(), request.token()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.getToken(), device.getToken()); + assertEquals("디바이스 ID가 일치해야 한다.", originDeviceToken.getId(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); + System.out.println("device = " + device); + }, + () -> fail("신규 디바이스가 등록되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[3] token 등록 요청이 들어왔을 때, 활성화되지 않은 디바이스 토큰이 존재하는 경우 NOT_ACTIVATED_DEVICE 에러를 반환한다.") + void registerNewDeviceWhenDeviceIsNotActivated() { + // given + DeviceToken originDeviceToken = DeviceTokenFixture.INIT.toDevice(requestUser); + originDeviceToken.deactivate(); + deviceTokenService.createDevice(originDeviceToken); + DeviceTokenDto.RegisterReq request = DeviceTokenFixture.INIT.toRegisterReq(); + + // when - then + DeviceTokenErrorException ex = assertThrows(DeviceTokenErrorException.class, () -> deviceTokenRegisterService.execute(requestUser.getId(), request.token())); + assertEquals("활성화되지 않은 디바이스 토큰이 존재하는 경우 Not Activated Device를 반환한다.", DeviceTokenErrorCode.NOT_ACTIVATED_DEVICE, ex.getBaseErrorCode()); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java new file mode 100644 index 000000000..e6ada1d2a --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java @@ -0,0 +1,87 @@ +package kr.co.pennyway.api.apis.users.usecase; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.api.apis.users.service.DeviceTokenUnregisterService; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.fixture.DeviceTokenFixture; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException; +import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertNull; + +@ExtendWith(MockitoExtension.class) +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") +@ContextConfiguration(classes = {JpaConfig.class, DeviceTokenUnregisterService.class, UserService.class, DeviceTokenService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class DeviceTokenUnregisterServiceTest extends ExternalApiDBTestConfig { + @Autowired + private UserService userService; + + @Autowired + private DeviceTokenService deviceTokenService; + + @Autowired + private DeviceTokenUnregisterService deviceTokenUnregisterService; + + @MockBean + private JPAQueryFactory queryFactory; + + private User requestUser; + + @BeforeEach + void setUp() { + User user = User.builder().role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).build(); + requestUser = userService.createUser(user); + } + + @Test + @Transactional + @DisplayName("사용자 ID와 origin token에 매칭되는 활성 디바이스가 존재하는 경우 디바이스를 삭제한다.") + void unregisterDevice() { + // given + DeviceToken deviceToken = DeviceTokenFixture.INIT.toDevice(requestUser); + deviceTokenService.createDevice(deviceToken); + + // when + deviceTokenUnregisterService.execute(requestUser.getId(), deviceToken.getToken()); + + // then + Optional deletedDevice = deviceTokenService.readDeviceByUserIdAndToken(requestUser.getId(), deviceToken.getToken()); + assertNull("디바이스가 삭제되어 있어야 한다.", deletedDevice.orElse(null)); + } + + @Test + @Transactional + @DisplayName("사용자 ID와 token에 매칭되는 디바이스가 존재하지 않는 경우 NOT_FOUND_DEVICE 에러를 반환한다.") + void unregisterDeviceWhenDeviceIsNotExists() { + // given + DeviceToken deviceToken = DeviceTokenFixture.INIT.toDevice(requestUser); + deviceTokenService.createDevice(deviceToken); + + // when - then + DeviceTokenErrorException ex = assertThrows(DeviceTokenErrorException.class, () -> deviceTokenUnregisterService.execute(requestUser.getId(), "notExistsToken")); + assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceTokenErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode()); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/PasswordUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/PasswordUpdateServiceTest.java new file mode 100644 index 000000000..e63c1a094 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/PasswordUpdateServiceTest.java @@ -0,0 +1,182 @@ +package kr.co.pennyway.api.apis.users.usecase; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; +import kr.co.pennyway.api.apis.users.service.PasswordUpdateService; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.util.AssertionErrors.assertEquals; + +@ExtendWith(MockitoExtension.class) +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") +@ContextConfiguration(classes = {JpaConfig.class, PasswordUpdateService.class, UserService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class PasswordUpdateServiceTest extends ExternalApiDBTestConfig { + @Autowired + private UserService userService; + + @Autowired + private PasswordUpdateService passwordUpdateService; + + @MockBean + private PasswordEncoderHelper passwordEncoderHelper; + + @MockBean + private JPAQueryFactory queryFactory; + + @Nested + @DisplayName("사용자 비밀번호 검증 테스트") + class VerificationPasswordTest { + private User originUser; + + @BeforeEach + void setUp() { + originUser = UserFixture.GENERAL_USER.toUser(); + userService.createUser(originUser); + } + + @Test + @Transactional + @DisplayName("[1] 사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") + void verifyPasswordWhenUserIsDeleted() { + // given + userService.deleteUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> passwordUpdateService.verify(originUser.getId(), originUser.getPassword())); + assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("[2] 사용자가 일반 회원가입 이력이 없는 소셜 계정인 경우, DO_NOT_GENERAL_SIGNED_UP 에러를 반환한다.") + void verifyPasswordWhenUserIsNotGeneralSignedUp() { + // given + User originUser = UserFixture.OAUTH_USER.toUser(); + userService.createUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> passwordUpdateService.verify(originUser.getId(), originUser.getPassword())); + assertEquals("일반 회원가입 이력이 없는 경우 Do Not General Signed Up을 반환한다.", UserErrorCode.DO_NOT_GENERAL_SIGNED_UP, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("[3] 비밀번호가 다른 경우 NOT_MATCHED_PASSWORD 에러를 반환한다.") + void verifyPasswordWhenPasswordIsNotMatched() { + // given + given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(false); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> passwordUpdateService.verify(originUser.getId(), "notMatchedPassword")); + assertEquals("비밀번호가 다른 경우 Not Matched Password를 반환한다.", UserErrorCode.NOT_MATCHED_PASSWORD, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("[4] 비밀번호가 일치하는 경우 정상적으로 처리된다.") + void verifyPassword() { + // given + given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(true); + + // when - then + assertDoesNotThrow(() -> passwordUpdateService.verify(originUser.getId(), originUser.getPassword())); + } + } + + @Nested + @DisplayName("사용자 비밀번호 변경 테스트") + class UpdatePasswordTest { + private User originUser; + + @BeforeEach + void setUp() { + originUser = UserFixture.GENERAL_USER.toUser(); + userService.createUser(originUser); + } + + @Test + @Transactional + @DisplayName("[1] 사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") + void updatePasswordWhenUserIsDeleted() { + // given + userService.deleteUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> passwordUpdateService.execute(originUser.getId(), originUser.getPassword(), "newPassword")); + assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("[2] oldPassword와 newPassword가 일치하는 경우와 현재 비밀번호와 동일한 비밀번호로 변경을 시도하는 경우, CLIENT_ERROR 에러를 반환한다.") + void updatePasswordWhenSamePassword() { + // given + given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(true); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> passwordUpdateService.execute(originUser.getId(), originUser.getPassword(), originUser.getPassword())); + assertEquals("현재 비밀번호와 동일한 비밀번호로 변경할 수 없는 경우 Client Error를 반환한다.", UserErrorCode.PASSWORD_NOT_CHANGED, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("[3] 비밀번호가 다른 경우 NOT_MATCHED_PASSWORD 에러를 반환한다.") + void updatePasswordWhenPasswordIsNotMatched() { + // given + given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(false); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> passwordUpdateService.execute(originUser.getId(), "notMatchedPassword", "newPassword")); + assertEquals("비밀번호가 다른 경우 Not Matched Password를 반환한다.", UserErrorCode.NOT_MATCHED_PASSWORD, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("[4] 사용자가 일반 회원가입 이력이 없는 소셜 계정인 경우, DO_NOT_GENERAL_SIGNED_UP 에러를 반환한다.") + void updatePasswordWhenUserIsNotGeneralSignedUp() { + // given + User originUser = userService.createUser(UserFixture.OAUTH_USER.toUser()); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> passwordUpdateService.execute(originUser.getId(), originUser.getPassword(), "newPassword")); + assertEquals("일반 회원가입 이력이 없는 경우 Do Not General Signed Up을 반환한다.", UserErrorCode.DO_NOT_GENERAL_SIGNED_UP, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("[5] 정상적인 요청인 경우 비밀번호가 정상적으로 변경된다.") + void updatePassword() { + // given + given(passwordEncoderHelper.isSamePassword(originUser.getPassword(), originUser.getPassword())).willReturn(true); + given(passwordEncoderHelper.isSamePassword(originUser.getPassword(), "newPassword")).willReturn(false); + given(passwordEncoderHelper.encodePassword(any())).willReturn("encodedPassword"); + + // when - then + assertDoesNotThrow(() -> passwordUpdateService.execute(originUser.getId(), originUser.getPassword(), "newPassword")); + assertEquals("비밀번호가 정상적으로 변경되어 있어야 한다.", "encodedPassword", userService.readUser(originUser.getId()).orElseThrow().getPassword()); + } + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java deleted file mode 100644 index b8f8ac886..000000000 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCaseTest.java +++ /dev/null @@ -1,537 +0,0 @@ -package kr.co.pennyway.api.apis.users.usecase; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.*; -import static org.springframework.test.util.AssertionErrors.assertEquals; -import static org.springframework.test.util.AssertionErrors.assertNull; -import static org.springframework.test.util.AssertionErrors.assertTrue; -import static org.springframework.test.util.AssertionErrors.fail; - -import java.util.Optional; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.ClassOrderer; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestClassOrder; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.transaction.annotation.Transactional; - -import com.querydsl.jpa.impl.JPAQueryFactory; - -import kr.co.pennyway.api.apis.users.dto.DeviceDto; -import kr.co.pennyway.api.apis.users.helper.PasswordEncoderHelper; -import kr.co.pennyway.api.apis.users.service.DeviceRegisterService; -import kr.co.pennyway.api.apis.users.service.UserDeleteService; -import kr.co.pennyway.api.apis.users.service.UserProfileUpdateService; -import kr.co.pennyway.api.config.ExternalApiDBTestConfig; -import kr.co.pennyway.api.config.fixture.DeviceFixture; -import kr.co.pennyway.api.config.fixture.UserFixture; -import kr.co.pennyway.domain.config.JpaConfig; -import kr.co.pennyway.domain.domains.device.domain.Device; -import kr.co.pennyway.domain.domains.device.exception.DeviceErrorCode; -import kr.co.pennyway.domain.domains.device.exception.DeviceErrorException; -import kr.co.pennyway.domain.domains.device.service.DeviceService; -import kr.co.pennyway.domain.domains.oauth.domain.Oauth; -import kr.co.pennyway.domain.domains.oauth.service.OauthService; -import kr.co.pennyway.domain.domains.oauth.type.Provider; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; -import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; -import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; -import kr.co.pennyway.domain.domains.user.type.Role; -import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; - -@ExtendWith(MockitoExtension.class) -@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") -@ContextConfiguration(classes = { - JpaConfig.class, UserAccountUseCase.class, DeviceRegisterService.class, UserProfileUpdateService.class, UserDeleteService.class, - UserService.class, DeviceService.class, OauthService.class}) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@TestClassOrder(ClassOrderer.OrderAnnotation.class) -class UserAccountUseCaseTest extends ExternalApiDBTestConfig { - @Autowired - private UserService userService; - - @Autowired - private DeviceService deviceService; - - @Autowired - private OauthService oauthService; - - @Autowired - private UserAccountUseCase userAccountUseCase; - - @MockBean - private PasswordEncoderHelper passwordEncoderHelper; - - @MockBean - private AwsS3Provider awsS3Provider; - - @MockBean - private JPAQueryFactory queryFactory; - - @Order(1) - @Nested - @DisplayName("[1] 디바이스 등록 테스트") - class DeviceRegisterTest { - private User requestUser; - - @BeforeEach - void setUp() { - User user = User.builder().role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).build(); - requestUser = userService.createUser(user); - } - - @Test - @Transactional - @DisplayName("[1] originToken과 newToken이 같은 경우, 신규 디바이스를 등록한다.") - void registerNewDevice() { - // given - DeviceDto.RegisterReq request = DeviceFixture.INIT.toRegisterReq(); - - // when - DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - System.out.println("device = " + device); - }, - () -> fail("신규 디바이스가 등록되어 있어야 한다.") - ); - } - - @Test - @Transactional - @DisplayName("[1-1] 저장 요청에서 originToken에 대한 디바이스가 이미 존재하는 경우, 디바이스 정보 변경 사항만 업데이트하고 기존 디바이스 정보를 반환한다.") - void registerNewDeviceWhenDeviceIsAlreadyExists() { - // given - Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(originDevice); - DeviceDto.RegisterReq request = DeviceFixture.ONLY_MODEL_AND_OS_CHANGED.toRegisterReq(); - - // when - DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertEquals("디바이스 ID가 일치해야 한다.", originDevice.getId(), device.getId()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); - System.out.println("device = " + device); - }, - () -> fail("신규 디바이스가 등록되어 있어야 한다.") - ); - } - - @Test - @Transactional - @DisplayName("[2] originToken과 일치하는 활성화 디바이스 토큰이 존재한다면, 디바이스 토큰을 갱신한다.") - void updateActivateDeviceToken() { - // given - Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(originDevice); - - System.out.println("originDevice = " + originDevice); - DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); - - // when - DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); - System.out.println("device = " + device); - }, - () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") - ); - } - - @Test - @Transactional - @DisplayName("[2-1] 기존에 등록된 비활성화 디바이스 토큰이 있고 디바이스 정보가 일치한다면, 디바이스 토큰을 갱신하고 활성화로 변경한다.") - void updateDeactivateDeviceToken() { - // given - Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - originDevice.deactivate(); - deviceService.createDevice(originDevice); - - System.out.println("originDevice = " + originDevice); - DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); - - // when - DeviceDto.RegisterRes response = userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.token(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertEquals("디바이스 ID가 일치해야 한다.", response.id(), device.getId()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); - System.out.println("device = " + device); - }, - () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") - ); - } - - @Test - @Transactional - @DisplayName("[2-2] 사용자가 유효한 토큰을 가지고 있지만 모델명이나 OS가 다른 경우, 디바이스 정보를 업데이트한다.") - void notMatchDevice() { - // given - Device originDevice = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(originDevice); - System.out.println("originDevice = " + originDevice); - DeviceDto.RegisterReq request = DeviceFixture.ALL_CHANGED.toRegisterReq(); - - // when - userAccountUseCase.registerDevice(requestUser.getId(), request); - - // then - deviceService.readDeviceByUserIdAndToken(requestUser.getId(), request.newToken()).ifPresentOrElse( - device -> { - assertEquals("요청한 디바이스 토큰과 동일해야 한다.", request.newToken(), device.getToken()); - assertEquals("요청한 디바이스 모델과 동일해야 한다.", request.model(), device.getModel()); - assertEquals("요청한 디바이스 OS와 동일해야 한다.", request.os(), device.getOs()); - assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); - assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); - System.out.println("device = " + device); - }, - () -> fail("디바이스 토큰이 갱신되어 있어야 한다.") - ); - } - - @Test - @Transactional - @DisplayName("[3] 토큰 수정 요청에서 oldToken에 대한 디바이스가 존재하지 않는 경우, NOT_FOUND 에러를 반환한다.") - void registerNewDeviceWhenOldDeviceTokenIsNotExists() { - // given - DeviceDto.RegisterReq request = DeviceFixture.ONLY_TOKEN_CHANGED.toRegisterReq(); - - // when - then - DeviceErrorException ex = assertThrows(DeviceErrorException.class, () -> userAccountUseCase.registerDevice(requestUser.getId(), request)); - assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode()); - } - } - - @Order(2) - @Nested - @DisplayName("[2] 디바이스 삭제 테스트") - class DeviceUnregisterTest { - private User requestUser; - - @BeforeEach - void setUp() { - User user = User.builder().role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).build(); - requestUser = userService.createUser(user); - } - - @Test - @Transactional - @DisplayName("사용자 ID와 origin token에 매칭되는 활성 디바이스가 존재하는 경우 디바이스를 삭제한다.") - void unregisterDevice() { - // given - Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(device); - - // when - userAccountUseCase.unregisterDevice(requestUser.getId(), device.getToken()); - - // then - Optional deletedDevice = deviceService.readDeviceByUserIdAndToken(requestUser.getId(), device.getToken()); - assertNull("디바이스가 삭제되어 있어야 한다.", deletedDevice.orElse(null)); - } - - @Test - @Transactional - @DisplayName("사용자 ID와 token에 매칭되는 디바이스가 존재하지 않는 경우 NOT_FOUND_DEVICE 에러를 반환한다.") - void unregisterDeviceWhenDeviceIsNotExists() { - // given - Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(requestUser); - deviceService.createDevice(device); - - // when - then - DeviceErrorException ex = assertThrows(DeviceErrorException.class, - () -> userAccountUseCase.unregisterDevice(requestUser.getId(), "notExistsToken")); - assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode()); - } - } - - @Order(3) - @Nested - @DisplayName("[3] 사용자 이름 수정 테스트") - class UpdateNameTest { - @Test - @Transactional - @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") - void updateNameWhenUserIsDeleted() { - // given - String newName = "양재서"; - User originUser = UserFixture.GENERAL_USER.toUser(); - userService.createUser(originUser); - userService.deleteUser(originUser); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.updateName(originUser.getId(), newName)); - assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("사용자의 이름이 성공적으로 변경된다.") - void updateName() { - // given - User originUser = UserFixture.GENERAL_USER.toUser(); - userService.createUser(originUser); - String newName = "양재서"; - - // when - userAccountUseCase.updateName(originUser.getId(), newName); - - // then - User updatedUser = userService.readUser(originUser.getId()).orElseThrow(); - assertEquals("사용자 이름이 변경되어 있어야 한다.", newName, updatedUser.getName()); - } - } - - @Order(4) - @Nested - @DisplayName("[4] 사용자 비밀번호 검증 테스트") - class VerificationPasswordTest { - private User originUser; - - @BeforeEach - void setUp() { - originUser = UserFixture.GENERAL_USER.toUser(); - userService.createUser(originUser); - } - - @Test - @Transactional - @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") - void verifyPasswordWhenUserIsDeleted() { - // given - userService.deleteUser(originUser); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, - () -> userAccountUseCase.verifyPassword(originUser.getId(), originUser.getPassword())); - assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("사용자가 일반 회원가입 이력이 없는 소셜 계정인 경우, DO_NOT_GENERAL_SIGNED_UP 에러를 반환한다.") - void verifyPasswordWhenUserIsNotGeneralSignedUp() { - // given - User originUser = UserFixture.OAUTH_USER.toUser(); - userService.createUser(originUser); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, - () -> userAccountUseCase.verifyPassword(originUser.getId(), originUser.getPassword())); - assertEquals("일반 회원가입 이력이 없는 경우 Do Not General Signed Up을 반환한다.", UserErrorCode.DO_NOT_GENERAL_SIGNED_UP, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("비밀번호가 다른 경우 NOT_MATCHED_PASSWORD 에러를 반환한다.") - void verifyPasswordWhenPasswordIsNotMatched() { - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.verifyPassword(originUser.getId(), "notMatchedPassword")); - assertEquals("비밀번호가 다른 경우 Not Matched Password를 반환한다.", UserErrorCode.NOT_MATCHED_PASSWORD, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("비밀번호가 일치하는 경우 정상적으로 처리된다.") - void verifyPassword() { - // given - given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(true); - - // when - then - assertDoesNotThrow(() -> userAccountUseCase.verifyPassword(originUser.getId(), originUser.getPassword())); - } - } - - @Order(5) - @Nested - @DisplayName("[5] 사용자 비밀번호 변경 테스트") - class UpdatePasswordTest { - private User originUser; - - @BeforeEach - void setUp() { - originUser = UserFixture.GENERAL_USER.toUser(); - userService.createUser(originUser); - } - - @Test - @Transactional - @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") - void updatePasswordWhenUserIsDeleted() { - // given - userService.deleteUser(originUser); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, - () -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), "newPassword")); - assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("oldPassword와 newPassword가 일치하는 경우와 현재 비밀번호와 동일한 비밀번호로 변경을 시도하는 경우, CLIENT_ERROR 에러를 반환한다.") - void updatePasswordWhenSamePassword() { - // given - given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(true); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, - () -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), originUser.getPassword())); - assertEquals("현재 비밀번호와 동일한 비밀번호로 변경할 수 없는 경우 Client Error를 반환한다.", UserErrorCode.PASSWORD_NOT_CHANGED, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("비밀번호가 다른 경우 NOT_MATCHED_PASSWORD 에러를 반환한다.") - void updatePasswordWhenPasswordIsNotMatched() { - // given - given(passwordEncoderHelper.isSamePassword(any(), any())).willReturn(false); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, - () -> userAccountUseCase.updatePassword(originUser.getId(), "notMatchedPassword", "newPassword")); - assertEquals("비밀번호가 다른 경우 Not Matched Password를 반환한다.", UserErrorCode.NOT_MATCHED_PASSWORD, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("사용자가 일반 회원가입 이력이 없는 소셜 계정인 경우, DO_NOT_GENERAL_SIGNED_UP 에러를 반환한다.") - void updatePasswordWhenUserIsNotGeneralSignedUp() { - // given - User originUser = UserFixture.OAUTH_USER.toUser(); - userService.createUser(originUser); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, - () -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), "newPassword")); - assertEquals("일반 회원가입 이력이 없는 경우 Do Not General Signed Up을 반환한다.", UserErrorCode.DO_NOT_GENERAL_SIGNED_UP, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("정상적인 요청인 경우 비밀번호가 정상적으로 변경된다.") - void updatePassword() { - // given - given(passwordEncoderHelper.isSamePassword(originUser.getPassword(), originUser.getPassword())).willReturn(true); - given(passwordEncoderHelper.isSamePassword(originUser.getPassword(), "newPassword")).willReturn(false); - given(passwordEncoderHelper.encodePassword(any())).willReturn("encodedPassword"); - - // when - then - assertDoesNotThrow(() -> userAccountUseCase.updatePassword(originUser.getId(), originUser.getPassword(), "newPassword")); - assertEquals("비밀번호가 정상적으로 변경되어 있어야 한다.", "encodedPassword", userService.readUser(originUser.getId()).orElseThrow().getPassword()); - } - } - - @Order(6) - @Nested - @DisplayName("[6] 사용자 계정 삭제") - class DeleteAccountTest { - @Test - @Transactional - @DisplayName("사용자가 삭제된 유저를 조회하려는 경우 NOT_FOUND 에러를 반환한다.") - void deleteAccountWhenUserIsDeleted() { - // given - User user = UserFixture.GENERAL_USER.toUser(); - userService.createUser(user); - userService.deleteUser(user); - - // when - then - UserErrorException ex = assertThrows(UserErrorException.class, () -> userAccountUseCase.deleteAccount(user.getId())); - assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); - } - - @Test - @Transactional - @DisplayName("일반 회원가입 이력만 있는 사용자의 경우, 정상적으로 계정이 삭제된다.") - void deleteAccount() { - // given - User user = UserFixture.GENERAL_USER.toUser(); - userService.createUser(user); - - // when - then - assertDoesNotThrow(() -> userAccountUseCase.deleteAccount(user.getId())); - assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); - } - - @Test - @Transactional - @DisplayName("사용자 계정 삭제 시, 연동된 모든 소셜 계정은 soft delete 처리되어야 한다.") - void deleteAccountWithSocialAccounts() { - // given - User user = UserFixture.OAUTH_USER.toUser(); - userService.createUser(user); - - Oauth kakao = createOauth(Provider.KAKAO, "kakaoId", user); - Oauth google = createOauth(Provider.GOOGLE, "googleId", user); - - // when - then - assertDoesNotThrow(() -> userAccountUseCase.deleteAccount(user.getId())); - assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); - assertTrue("카카오 계정이 삭제되어 있어야 한다.", oauthService.readOauth(kakao.getId()).get().isDeleted()); - assertTrue("구글 계정이 삭제되어 있어야 한다.", oauthService.readOauth(google.getId()).get().isDeleted()); - } - - @Test - @Transactional - @DisplayName("사용자 삭제 시, 디바이스 정보는 CASCADE로 삭제되어야 한다.") - void deleteAccountWithDevices() { - // given - User user = UserFixture.GENERAL_USER.toUser(); - userService.createUser(user); - - Device device = DeviceFixture.ORIGIN_DEVICE.toDevice(user); - deviceService.createDevice(device); - - // when - then - assertDoesNotThrow(() -> userAccountUseCase.deleteAccount(user.getId())); - assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); - assertTrue("디바이스가 삭제되어 있어야 한다.", deviceService.readDeviceByUserIdAndToken(user.getId(), device.getToken()).isEmpty()); - } - - private Oauth createOauth(Provider provider, String providerId, User user) { - Oauth oauth = Oauth.of(provider, providerId, user); - return oauthService.createOauth(oauth); - } - } -} \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserDeleteServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserDeleteServiceTest.java new file mode 100644 index 000000000..b3fc3ea50 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserDeleteServiceTest.java @@ -0,0 +1,119 @@ +package kr.co.pennyway.api.apis.users.usecase; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.api.apis.users.service.UserDeleteService; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.fixture.DeviceTokenFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertTrue; + +@ExtendWith(MockitoExtension.class) +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") +@ContextConfiguration(classes = {JpaConfig.class, UserDeleteService.class, UserService.class, OauthService.class, DeviceTokenService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class UserDeleteServiceTest extends ExternalApiDBTestConfig { + @Autowired + private UserService userService; + + @Autowired + private OauthService oauthService; + + @Autowired + private DeviceTokenService deviceTokenService; + + @Autowired + private UserDeleteService userDeleteService; + + @MockBean + private JPAQueryFactory queryFactory; + + @Test + @Transactional + @DisplayName("사용자가 삭제된 유저를 조회하려는 경우 NOT_FOUND 에러를 반환한다.") + void deleteAccountWhenUserIsDeleted() { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + userService.deleteUser(user); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userDeleteService.execute(user.getId())); + assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("일반 회원가입 이력만 있는 사용자의 경우, 정상적으로 계정이 삭제된다.") + void deleteAccount() { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + + // when - then + assertDoesNotThrow(() -> userDeleteService.execute(user.getId())); + assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); + } + + @Test + @Transactional + @DisplayName("사용자 계정 삭제 시, 연동된 모든 소셜 계정은 soft delete 처리되어야 한다.") + void deleteAccountWithSocialAccounts() { + // given + User user = UserFixture.OAUTH_USER.toUser(); + userService.createUser(user); + + Oauth kakao = createOauth(Provider.KAKAO, "kakaoId", user); + Oauth google = createOauth(Provider.GOOGLE, "googleId", user); + + // when - then + assertDoesNotThrow(() -> userDeleteService.execute(user.getId())); + assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); + assertTrue("카카오 계정이 삭제되어 있어야 한다.", oauthService.readOauth(kakao.getId()).get().isDeleted()); + assertTrue("구글 계정이 삭제되어 있어야 한다.", oauthService.readOauth(google.getId()).get().isDeleted()); + } + + @Test + @Transactional + @DisplayName("사용자 삭제 시, 디바이스 정보는 CASCADE로 삭제되어야 한다.") + void deleteAccountWithDevices() { + // given + User user = UserFixture.GENERAL_USER.toUser(); + userService.createUser(user); + + DeviceToken deviceToken = DeviceTokenFixture.INIT.toDevice(user); + deviceTokenService.createDevice(deviceToken); + + // when - then + assertDoesNotThrow(() -> userDeleteService.execute(user.getId())); + assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); + assertTrue("디바이스가 삭제되어 있어야 한다.", deviceTokenService.readDeviceByUserIdAndToken(user.getId(), deviceToken.getToken()).isEmpty()); + } + + private Oauth createOauth(Provider provider, String providerId, User user) { + Oauth oauth = Oauth.of(provider, providerId, user); + return oauthService.createOauth(oauth); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserProfileUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserProfileUpdateServiceTest.java new file mode 100644 index 000000000..d203aeedc --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserProfileUpdateServiceTest.java @@ -0,0 +1,75 @@ +package kr.co.pennyway.api.apis.users.usecase; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.api.apis.users.service.UserProfileUpdateService; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.util.AssertionErrors.assertEquals; + +@ExtendWith(MockitoExtension.class) +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") +@ContextConfiguration(classes = {JpaConfig.class, UserProfileUpdateService.class, UserService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class UserProfileUpdateServiceTest extends ExternalApiDBTestConfig { + @Autowired + private UserService userService; + + @Autowired + private UserProfileUpdateService userProfileUpdateService; + + @MockBean + private AwsS3Provider awsS3Provider; + + @MockBean + private JPAQueryFactory queryFactory; + + @Test + @Transactional + @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") + void updateNameWhenUserIsDeleted() { + // given + String newName = "양재서"; + User originUser = UserFixture.GENERAL_USER.toUser(); + userService.createUser(originUser); + userService.deleteUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userProfileUpdateService.updateName(originUser.getId(), newName)); + assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("사용자의 이름이 성공적으로 변경된다.") + void updateName() { + // given + User originUser = UserFixture.GENERAL_USER.toUser(); + userService.createUser(originUser); + String newName = "양재서"; + + // when + userProfileUpdateService.updateName(originUser.getId(), newName); + + // then + User updatedUser = userService.readUser(originUser.getId()).orElseThrow(); + assertEquals("사용자 이름이 변경되어 있어야 한다.", newName, updatedUser.getName()); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceFixture.java deleted file mode 100644 index e28c537a9..000000000 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceFixture.java +++ /dev/null @@ -1,33 +0,0 @@ -package kr.co.pennyway.api.config.fixture; - -import kr.co.pennyway.api.apis.users.dto.DeviceDto; -import kr.co.pennyway.domain.domains.device.domain.Device; -import kr.co.pennyway.domain.domains.user.domain.User; - -public enum DeviceFixture { - INIT("originToken", "originToken", "modelA", "Windows 11"), - ORIGIN_DEVICE("originToken", "originToken", "modelA", "Windows 11"), - ONLY_TOKEN_CHANGED("originToken", "newToken", "modelA", "Windows 11"), - ONLY_MODEL_AND_OS_CHANGED("originToken", "originToken", "modelB", "Windows 11"), - ALL_CHANGED("originToken", "newToken", "modelB", "Mac OS X"); - - private final String originToken; - private final String newToken; - private final String model; - private final String os; - - DeviceFixture(String originToken, String newToken, String model, String os) { - this.originToken = originToken; - this.newToken = newToken; - this.model = model; - this.os = os; - } - - public Device toDevice(User user) { - return Device.of(originToken, model, os, user); - } - - public DeviceDto.RegisterReq toRegisterReq() { - return new DeviceDto.RegisterReq(originToken, newToken, model, os); - } -} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceTokenFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceTokenFixture.java new file mode 100644 index 000000000..6618fc5fd --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/DeviceTokenFixture.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.api.config.fixture; + +import kr.co.pennyway.api.apis.users.dto.DeviceTokenDto; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.user.domain.User; + +public enum DeviceTokenFixture { + INIT("originToken"), + CHANGED_TOKEN("newToken"); + + private final String token; + + DeviceTokenFixture(String token) { + this.token = token; + } + + public DeviceToken toDevice(User user) { + return DeviceToken.of(token, user); + } + + public DeviceTokenDto.RegisterReq toRegisterReq() { + return new DeviceTokenDto.RegisterReq(token); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java similarity index 66% rename from pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java index e1045f56c..ae6a76e85 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/Device.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java @@ -10,16 +10,14 @@ @Entity @Getter -@Table(name = "device") +@Table(name = "device_token") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Device extends DateAuditable { +public class DeviceToken extends DateAuditable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String token; - private String model; - private String os; @ColumnDefault("true") private Boolean activated; @@ -27,16 +25,14 @@ public class Device extends DateAuditable { @JoinColumn(name = "user_id") private User user; - private Device(String token, String model, String os, Boolean activated, User user) { + private DeviceToken(String token, Boolean activated, User user) { this.token = token; - this.model = model; - this.os = os; this.activated = activated; this.user = user; } - public static Device of(String token, String model, String os, User user) { - return new Device(token, model, os, Boolean.TRUE, user); + public static DeviceToken of(String token, User user) { + return new DeviceToken(token, Boolean.TRUE, user); } public Boolean isActivated() { @@ -51,22 +47,19 @@ public void deactivate() { this.activated = Boolean.FALSE; } + /** + * 디바이스 토큰을 갱신하고 활성화 상태로 변경한다. + */ public void updateToken(String token) { + this.activated = Boolean.TRUE; this.token = token; } - public void updateDeviceInfo(String model, String os) { - this.model = model; - this.os = os; - } - @Override public String toString() { - return "Device{" + + return "DeviceToken {" + "id=" + id + ", token='" + token + '\'' + - ", model='" + model + '\'' + - ", os='" + os + '\'' + ", activated=" + activated + '}'; } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorException.java deleted file mode 100644 index 05455f94e..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorException.java +++ /dev/null @@ -1,20 +0,0 @@ -package kr.co.pennyway.domain.domains.device.exception; - -import kr.co.pennyway.common.exception.GlobalErrorException; - -public class DeviceErrorException extends GlobalErrorException { - private final DeviceErrorCode deviceErrorCode; - - public DeviceErrorException(DeviceErrorCode deviceErrorCode) { - super(deviceErrorCode); - this.deviceErrorCode = deviceErrorCode; - } - - public String getExplainError() { - return deviceErrorCode.getExplainError(); - } - - public String getErrorCode() { - return deviceErrorCode.name(); - } -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java similarity index 78% rename from pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorCode.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java index e3eecec29..ec0ab16bc 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java @@ -7,7 +7,10 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor -public enum DeviceErrorCode implements BaseErrorCode { +public enum DeviceTokenErrorCode implements BaseErrorCode { + /* 400 BAD_REQUEST */ + NOT_ACTIVATED_DEVICE(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "활성화되지 않은 디바이스 토큰 정보입니다."), + /* 404 NOT_FOUND */ NOT_FOUND_DEVICE(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "디바이스를 찾을 수 없습니다."); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorException.java new file mode 100644 index 000000000..2a83c00c1 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorException.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.domains.device.exception; + +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class DeviceTokenErrorException extends GlobalErrorException { + private final DeviceTokenErrorCode deviceTokenErrorCode; + + public DeviceTokenErrorException(DeviceTokenErrorCode deviceTokenErrorCode) { + super(deviceTokenErrorCode); + this.deviceTokenErrorCode = deviceTokenErrorCode; + } + + public String getExplainError() { + return deviceTokenErrorCode.getExplainError(); + } + + public String getErrorCode() { + return deviceTokenErrorCode.name(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceRepository.java deleted file mode 100644 index f85818449..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package kr.co.pennyway.domain.domains.device.repository; - -import kr.co.pennyway.domain.domains.device.domain.Device; -import org.springframework.data.repository.ListCrudRepository; - -import java.util.List; -import java.util.Optional; - -public interface DeviceRepository extends ListCrudRepository { - Optional findByUser_IdAndToken(Long userId, String token); - - List findAllByUser_Id(Long userId); -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java new file mode 100644 index 000000000..da2f4135d --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.domains.device.repository; + +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import org.springframework.data.repository.ListCrudRepository; + +import java.util.List; +import java.util.Optional; + +public interface DeviceTokenRepository extends ListCrudRepository { + Optional findByUser_IdAndToken(Long userId, String token); + + List findAllByUser_Id(Long userId); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceService.java deleted file mode 100644 index 6e3f46c69..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceService.java +++ /dev/null @@ -1,43 +0,0 @@ -package kr.co.pennyway.domain.domains.device.service; - -import kr.co.pennyway.common.annotation.DomainService; -import kr.co.pennyway.domain.domains.device.domain.Device; -import kr.co.pennyway.domain.domains.device.repository.DeviceRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.Optional; - -@Slf4j -@DomainService -@RequiredArgsConstructor -public class DeviceService { - private final DeviceRepository deviceRepository; - - @Transactional - public Device createDevice(Device device) { - return deviceRepository.save(device); - } - - @Transactional - public Optional readDeviceByUserIdAndToken(Long userId, String token) { - return deviceRepository.findByUser_IdAndToken(userId, token); - } - - @Transactional(readOnly = true) - public List readDevicesByUserId(Long userId) { - return deviceRepository.findAllByUser_Id(userId); - } - - @Transactional - public void deleteDevice(Long deviceId) { - deviceRepository.deleteById(deviceId); - } - - @Transactional - public void deleteDevice(Device device) { - deviceRepository.delete(device); - } -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java new file mode 100644 index 000000000..fde0c0573 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java @@ -0,0 +1,43 @@ +package kr.co.pennyway.domain.domains.device.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.repository.DeviceTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class DeviceTokenService { + private final DeviceTokenRepository deviceTokenRepository; + + @Transactional + public DeviceToken createDevice(DeviceToken deviceToken) { + return deviceTokenRepository.save(deviceToken); + } + + @Transactional + public Optional readDeviceByUserIdAndToken(Long userId, String token) { + return deviceTokenRepository.findByUser_IdAndToken(userId, token); + } + + @Transactional(readOnly = true) + public List readDevicesByUserId(Long userId) { + return deviceTokenRepository.findAllByUser_Id(userId); + } + + @Transactional + public void deleteDevice(Long deviceId) { + deviceTokenRepository.deleteById(deviceId); + } + + @Transactional + public void deleteDevice(DeviceToken deviceToken) { + deviceTokenRepository.delete(deviceToken); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java index 112c147c6..3740f1a7c 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java @@ -4,7 +4,7 @@ import kr.co.pennyway.domain.common.converter.ProfileVisibilityConverter; import kr.co.pennyway.domain.common.converter.RoleConverter; import kr.co.pennyway.domain.common.model.DateAuditable; -import kr.co.pennyway.domain.domains.device.domain.Device; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; import kr.co.pennyway.domain.domains.user.type.Role; import lombok.AccessLevel; @@ -53,7 +53,7 @@ public class User extends DateAuditable { private LocalDateTime deletedAt; @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) - private List devices = new ArrayList<>(); + private List deviceTokens = new ArrayList<>(); @Builder private User(String username, String name, String password, LocalDateTime passwordUpdatedAt, String profileImageUrl, String phone, Role role, From 07c6057fc6894e12acf7801c3d9a9d951c6c7445 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 5 Jun 2024 18:19:23 +0900 Subject: [PATCH 104/152] =?UTF-8?q?fix:=20=E2=9C=8F=EF=B8=8F=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=A0=95=EC=9D=98=20=EC=A7=80=EC=B6=9C=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20`=E2=8B=AF`=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EB=B0=98=EC=98=81=20(#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * rename: 커스텀 카테고리 상수 0->custom, 12->other 수정 * style: spending key 상수화 * docs: spending category api 예시 요청 파라미터 other 추가 * fix: spending_controller ...아이콘 validation 체크 * fix: spending_category_dto validation 추가 * fix: spending entity 생성자 validation 추가 * fix: spending_category_dto validation 추가 * test: spending category 등록 시 other -> custom 거부 테스트로 수정 * test: 지출 내역 등록 시 카테고리 validation 체크 수정 * fix: 지출 등록 시 category validation 체크 조건식 수정 * docs: spending api 스웨거 문서 수정 --- .../api/apis/ledger/api/SpendingApi.java | 8 ++++---- .../apis/ledger/api/SpendingCategoryApi.java | 2 +- .../SpendingCategoryController.java | 2 +- .../ledger/controller/SpendingController.java | 16 ++++++++------- .../apis/ledger/dto/SpendingCategoryDto.java | 8 ++++++-- .../SpendingCategoryControllerUnitTest.java | 4 ++-- .../SpendingControllerUnitTest.java | 20 ++++++++++++++++++- .../SpendingControllerIntegrationTest.java | 4 ++-- .../service/SpendingUpdateServiceTest.java | 2 +- .../domains/spending/domain/Spending.java | 18 ++++++++--------- .../domain/SpendingCustomCategory.java | 2 +- .../domains/spending/dto/CategoryInfo.java | 2 +- .../spending/type/SpendingCategory.java | 5 +++-- 13 files changed, 59 insertions(+), 34 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java index b61289d3e..1bd7ee38b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java @@ -25,13 +25,13 @@ public interface SpendingApi { @Operation(summary = "지출 내역 추가", method = "POST", description = """ 사용자의 지출 내역을 추가하고 추가된 지출 내역을 반환합니다.
- 서비스에서 제공하는 지출 카테고리를 사용하는 경우 categoryId는 -1이어야 하며, icon은 OTHER가 될 수 없습니다.
- 사용자가 정의한 지출 카테고리를 사용하는 경우 categoryId는 -1이 아니어야 하며, icon은 OTHER여야 합니다. + 서비스에서 제공하는 지출 카테고리를 사용하는 경우 categoryId는 -1이어야 하며, icon은 CUSTOM 혹은 OTHER이 될 수 없습니다.
+ 사용자가 정의한 지출 카테고리를 사용하는 경우 categoryId는 -1이 아니어야 하며, icon은 CUSTOM이여야 합니다. """) @ApiResponses({ @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "spending", schema = @Schema(implementation = SpendingSearchRes.Individual.class)))), @ApiResponse(responseCode = "400", description = "지출 카테고리 ID와 아이콘의 조합이 올바르지 않습니다.", content = @Content(examples = { - @ExampleObject(name = "카테고리 id, 아이콘 조합 오류", description = "categoryId가 -1인데 icon이 OTHER이거나, categoryId가 -1이 아닌데 icon이 OTHER가 아닙니다.", + @ExampleObject(name = "카테고리 id, 아이콘 조합 오류", description = "categoryId가 -1인데 icon이 CUSTOM/OTHER이거나, categoryId가 -1이 아닌데 icon이 CUSTOM이 아닙니다.", value = """ { "code": "4005", @@ -85,7 +85,7 @@ public interface SpendingApi { """) @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "spending", schema = @Schema(implementation = SpendingSearchRes.Individual.class)))) ResponseEntity updateSpending(@PathVariable Long spendingId, @RequestBody @Validated SpendingReq request, @AuthenticationPrincipal SecurityUserDetails user); - + @Operation(summary = "지출 내역 삭제", method = "DELETE", description = "지출 내역의 ID값으로 해당 지출 내역을 삭제 합니다.") @Parameter(name = "spendingId", description = "지출 내역 ID", example = "1", required = true, in = ParameterIn.PATH) @ApiResponse(responseCode = "403", description = "지출 카테고리에 대한 권한이 없습니다.", content = @Content(examples = { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java index 015bd1fd7..d84f53220 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java @@ -22,7 +22,7 @@ public interface SpendingCategoryApi { @ExampleObject(name = "식사", value = "FOOD"), @ExampleObject(name = "교통", value = "TRANSPORTATION"), @ExampleObject(name = "뷰티/패션", value = "BEAUTY_OR_FASHION"), @ExampleObject(name = "편의점/마트", value = "CONVENIENCE_STORE"), @ExampleObject(name = "교육", value = "EDUCATION"), @ExampleObject(name = "생활", value = "LIVING"), @ExampleObject(name = "건강", value = "HEALTH"), @ExampleObject(name = "취미/여가", value = "HOBBY"), @ExampleObject(name = "여행/숙박", value = "TRAVEL"), - @ExampleObject(name = "술/유흥", value = "ALCOHOL_OR_ENTERTAINMENT"), @ExampleObject(name = "회비/경조사", value = "MEMBERSHIP_OR_FAMILY_EVENT") + @ExampleObject(name = "술/유흥", value = "ALCOHOL_OR_ENTERTAINMENT"), @ExampleObject(name = "회비/경조사", value = "MEMBERSHIP_OR_FAMILY_EVENT"), @ExampleObject(name = "기타", value = "OTHER") }), @Parameter(name = "param", hidden = true) }) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java index f629b965d..076acbf5a 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java @@ -30,7 +30,7 @@ public class SpendingCategoryController implements SpendingCategoryApi { @PostMapping("") @PreAuthorize("isAuthenticated()") public ResponseEntity postSpendingCategory(@Validated SpendingCategoryDto.CreateParamReq param, @AuthenticationPrincipal SecurityUserDetails user) { - if (param.icon().equals(SpendingCategory.OTHER)) { + if (param.icon().equals(SpendingCategory.CUSTOM)) { throw new SpendingErrorException(SpendingErrorCode.INVALID_ICON); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java index 7648ecddd..0b2c7a9f5 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java @@ -21,6 +21,8 @@ @RequiredArgsConstructor @RequestMapping("/v2/spendings") public class SpendingController implements SpendingApi { + private static final String SPENDING = "spending"; + private final SpendingUseCase spendingUseCase; @Override @@ -31,21 +33,21 @@ public ResponseEntity postSpending(@RequestBody @Validated SpendingReq reques throw new SpendingErrorException(SpendingErrorCode.INVALID_ICON_WITH_CATEGORY_ID); } - return ResponseEntity.ok(SuccessResponse.from("spending", spendingUseCase.createSpending(user.getUserId(), request))); + return ResponseEntity.ok(SuccessResponse.from(SPENDING, spendingUseCase.createSpending(user.getUserId(), request))); } @Override @GetMapping("") @PreAuthorize("isAuthenticated()") public ResponseEntity getSpendingListAtYearAndMonth(@RequestParam("year") int year, @RequestParam("month") int month, @AuthenticationPrincipal SecurityUserDetails user) { - return ResponseEntity.ok(SuccessResponse.from("spending", spendingUseCase.getSpendingsAtYearAndMonth(user.getUserId(), year, month))); + return ResponseEntity.ok(SuccessResponse.from(SPENDING, spendingUseCase.getSpendingsAtYearAndMonth(user.getUserId(), year, month))); } @Override @GetMapping("/{spendingId}") @PreAuthorize("isAuthenticated() and @spendingManager.hasPermission(#user.getUserId(), #spendingId)") public ResponseEntity getSpendingDetail(@PathVariable Long spendingId, @AuthenticationPrincipal SecurityUserDetails user) { - return ResponseEntity.ok(SuccessResponse.from("spending", spendingUseCase.getSpedingDetail(spendingId))); + return ResponseEntity.ok(SuccessResponse.from(SPENDING, spendingUseCase.getSpedingDetail(spendingId))); } @Override @@ -56,7 +58,7 @@ public ResponseEntity updateSpending(@PathVariable Long spendingId, @RequestB throw new SpendingErrorException(SpendingErrorCode.INVALID_ICON_WITH_CATEGORY_ID); } - return ResponseEntity.ok(SuccessResponse.from("spending", spendingUseCase.updateSpending(spendingId, request))); + return ResponseEntity.ok(SuccessResponse.from(SPENDING, spendingUseCase.updateSpending(spendingId, request))); } @Override @@ -69,13 +71,13 @@ public ResponseEntity deleteSpending(@PathVariable Long spendingId, @Authenti } /** - * categoryId가 -1이면 서비스에서 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 OTHER가 될 수 없고,
- * categoryId가 -1이 아니면 사용자가 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 OTHER임을 확인한다. + * categoryId가 -1이면 서비스에서 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 CUSTOM이나 OTHER이 될 수 없고,
+ * categoryId가 -1이 아니면 사용자가 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 CUSTOM임을 확인한다. * * @param categoryId : 사용자가 정의한 카테고리 ID * @param icon : 지출 내역으로 저장하려는 카테고리의 아이콘 */ private boolean isValidCategoryIdAndIcon(Long categoryId, SpendingCategory icon) { - return (categoryId.equals(-1L) && !icon.equals(SpendingCategory.OTHER) || categoryId > 0 && icon.equals(SpendingCategory.OTHER)); + return (categoryId.equals(-1L) && (!icon.equals(SpendingCategory.CUSTOM) && !icon.equals(SpendingCategory.OTHER))) || (categoryId > 0 && icon.equals(SpendingCategory.CUSTOM)); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingCategoryDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingCategoryDto.java index dec3d08cf..6ffe4825a 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingCategoryDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingCategoryDto.java @@ -39,8 +39,12 @@ public record Res( throw new IllegalArgumentException("isCustom과 id 정보가 일치하지 않습니다."); } - if (isCustom && icon.equals(SpendingCategory.OTHER)) { - throw new IllegalArgumentException("사용자 정의 카테고리는 OTHER가 될 수 없습니다."); + if (isCustom && icon.equals(SpendingCategory.CUSTOM)) { + throw new IllegalArgumentException("사용자 정의 카테고리는 CUSTOM이 될 수 없습니다."); + } + + if (!isCustom && (icon.equals(SpendingCategory.CUSTOM) || icon.equals(SpendingCategory.OTHER))) { + throw new IllegalArgumentException("서비스에서 제공하는 카테고리는 CUSTOM 혹은 OTHER이 될 수 없습니다."); } if (!StringUtils.hasText(name)) { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java index 9a37c3c2a..a8537f396 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java @@ -87,12 +87,12 @@ void postSpendingCategoryWithInvalidIcon() throws Exception { } @Test - @DisplayName("OTHER 아이콘을 입력하면 400 BAD_REQUEST 에러 응답을 반환한다.") + @DisplayName("CUSTOM 아이콘을 입력하면 400 BAD_REQUEST 에러 응답을 반환한다.") @WithSecurityMockUser void postSpendingCategoryWithOtherIcon() throws Exception { // given String name = "식비"; - String icon = "OTHER"; + String icon = SpendingCategory.CUSTOM.name(); // when ResultActions result = performPostSpendingCategory(name, icon); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java index 84a63a0a7..e41ba5714 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java @@ -72,9 +72,26 @@ void whenAmountIsZeroOrNegative() throws Exception { } @Test - @DisplayName("아이콘이 OTHER이면서 categoryId가 -1인 경우 400 Bad Request를 반환한다.") + @DisplayName("아이콘이 CUSTOM이면서 categoryId가 -1인 경우 400 Bad Request를 반환한다.") @WithSecurityMockUser void whenCategoryIsNotDefined() throws Exception { + // given + Long categoryId = -1L; + SpendingCategory icon = SpendingCategory.CUSTOM; + SpendingReq request = new SpendingReq(10000, categoryId, icon, LocalDate.now(), "소비처", "메모"); + given(spendingUseCase.createSpending(1L, request)).willReturn(SpendingSearchRes.Individual.builder().build()); + + // when + ResultActions result = performPostSpending(request); + + // then + result.andDo(print()).andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("아이콘이 OTHER이면서 categoryId가 -1인 경우 400 Bad Request를 반환한다.") + @WithSecurityMockUser + void whenCategoryIsInvalidIcon() throws Exception { // given Long categoryId = -1L; SpendingCategory icon = SpendingCategory.OTHER; @@ -88,6 +105,7 @@ void whenCategoryIsNotDefined() throws Exception { result.andDo(print()).andExpect(status().isBadRequest()); } + @Test @DisplayName("지출일이 현재보다 미래인 경우 422 Unprocessable Entity를 반환한다.") @WithSecurityMockUser diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java index baa9eed9e..99cc3de78 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java @@ -83,7 +83,7 @@ void createSpendingWithCustomCategorySuccess() throws Exception { // given User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); SpendingCustomCategory category = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategory.of("잉여비", SpendingCategory.LIVING, user)); - SpendingReq request = new SpendingReq(10000, category.getId(), SpendingCategory.OTHER, LocalDate.now(), "소비처", "메모"); + SpendingReq request = new SpendingReq(10000, category.getId(), SpendingCategory.CUSTOM, LocalDate.now(), "소비처", "메모"); // when ResultActions result = performCreateSpendingSuccess(request, user); @@ -103,7 +103,7 @@ void createSpendingWithCustomCategorySuccess() throws Exception { void createSpendingWithInvalidCustomCategory() throws Exception { // given User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); - SpendingReq request = new SpendingReq(10000, 1000L, SpendingCategory.OTHER, LocalDate.now(), "소비처", "메모"); + SpendingReq request = new SpendingReq(10000, 1000L, SpendingCategory.CUSTOM, LocalDate.now(), "소비처", "메모"); // when ResultActions result = performCreateSpendingSuccess(request, user); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java index 9c6e0c764..325615b53 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java @@ -42,7 +42,7 @@ void setUp() { spendingUpdateService = new SpendingUpdateService(spendingCustomCategoryService); request = new SpendingReq(10000, -1L, SpendingCategory.FOOD, LocalDate.now(), "소비처", "메모"); - requestWithCustomCategory = new SpendingReq(10000, 1L, SpendingCategory.OTHER, LocalDate.now(), "소비처", "메모"); + requestWithCustomCategory = new SpendingReq(10000, 1L, SpendingCategory.CUSTOM, LocalDate.now(), "소비처", "메모"); user = UserFixture.GENERAL_USER.toUser(); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java index a3d92bd01..0db485d2c 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java @@ -45,10 +45,10 @@ public class Spending extends DateAuditable { @Builder private Spending(Integer amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo, User user, SpendingCustomCategory spendingCustomCategory) { - if (category.equals(SpendingCategory.OTHER) && spendingCustomCategory == null) { - throw new IllegalArgumentException("OTHER 아이콘의 경우 SpendingCustomCategory는 null일 수 없습니다."); - } else if (!category.equals(SpendingCategory.OTHER) && spendingCustomCategory != null) { - throw new IllegalArgumentException("OTHER 아이콘이 아닌 경우 SpendingCustomCategory는 null이어야 합니다."); + if (spendingCustomCategory == null && (category.equals(SpendingCategory.CUSTOM) || category.equals(SpendingCategory.OTHER))) { + throw new IllegalArgumentException("서비스 제공 아이콘을 등록할 때는 CUSTOM, OHTER 아이콘을 사용할 수 없습니다."); + } else if (spendingCustomCategory != null && !category.equals(SpendingCategory.CUSTOM)) { + throw new IllegalArgumentException("사용자 정의 아이콘을 등록할 때는 CUSTOM 아이콘이어야 합니다."); } this.amount = amount; @@ -71,7 +71,7 @@ public int getDay() { * @return {@link CategoryInfo} */ public CategoryInfo getCategory() { - if (this.category.equals(SpendingCategory.OTHER)) { + if (this.category.equals(SpendingCategory.CUSTOM)) { SpendingCustomCategory category = getSpendingCustomCategory(); return CategoryInfo.of(category.getId(), category.getName(), category.getIcon()); } @@ -80,10 +80,10 @@ public CategoryInfo getCategory() { } public void updateSpendingCustomCategory(SpendingCustomCategory spendingCustomCategory) { - if (this.category.equals(SpendingCategory.OTHER) && spendingCustomCategory == null) { - throw new IllegalArgumentException("OTHER 아이콘의 경우 SpendingCustomCategory는 null일 수 없습니다."); - } else if (!this.category.equals(SpendingCategory.OTHER) && spendingCustomCategory != null) { - throw new IllegalArgumentException("OTHER 아이콘이 아닌 경우 SpendingCustomCategory는 null이어야 합니다."); + if (spendingCustomCategory == null && (category.equals(SpendingCategory.CUSTOM) || category.equals(SpendingCategory.OTHER))) { + throw new IllegalArgumentException("서비스 제공 아이콘을 등록할 때는 CUSTOM, OHTER 아이콘을 사용할 수 없습니다."); + } else if (spendingCustomCategory != null && !category.equals(SpendingCategory.CUSTOM)) { + throw new IllegalArgumentException("사용자 정의 아이콘을 등록할 때는 CUSTOM 아이콘이어야 합니다."); } this.spendingCustomCategory = spendingCustomCategory; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java index 74ae6085f..f42f02214 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java @@ -34,7 +34,7 @@ public class SpendingCustomCategory extends DateAuditable { private User user; private SpendingCustomCategory(String name, SpendingCategory icon, User user) { - if (icon.equals(SpendingCategory.OTHER)) { + if (icon.equals(SpendingCategory.CUSTOM)) { throw new IllegalArgumentException("OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java index 9369127ff..9934bb592 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java @@ -27,7 +27,7 @@ public record CategoryInfo( throw new IllegalArgumentException("isCustom이 " + isCustom + "일 때 id는 " + (isCustom ? "0 이상" : "-1") + "이어야 합니다."); } - if (isCustom && icon.equals(SpendingCategory.OTHER)) { + if (isCustom && icon.equals(SpendingCategory.CUSTOM)) { throw new IllegalArgumentException("사용자 정의 카테고리는 OTHER가 될 수 없습니다."); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java index 5a847d339..3c0bdbce4 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java @@ -7,7 +7,7 @@ @Getter @RequiredArgsConstructor public enum SpendingCategory implements LegacyCommonType { - OTHER("0", "사용자 정의"), + CUSTOM("0", "사용자 정의"), FOOD("1", "식비"), TRANSPORTATION("2", "교통비"), BEAUTY_OR_FASHION("3", "뷰티/패션"), @@ -18,7 +18,8 @@ public enum SpendingCategory implements LegacyCommonType { HOBBY("8", "취미/여가"), TRAVEL("9", "여행/숙박"), ALCOHOL_OR_ENTERTAINMENT("10", "술/유흥"), - MEMBERSHIP_OR_FAMILY_EVENT("11", "회비/경조사"); + MEMBERSHIP_OR_FAMILY_EVENT("11", "회비/경조사"), + OTHER("12", "기타"); private final String code; private final String type; From 079a61a06a3b7457c9907c03cf0c7170c60d5679 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 6 Jun 2024 20:05:06 +0900 Subject: [PATCH 105/152] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=B5=9C=EA=B7=BC?= =?UTF-8?q?=20=EB=AA=A9=ED=91=9C=20=EA=B8=88=EC=95=A1=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20API=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 최근 설정 목표 금액 api 스웨거 문서 작성 * feat: 최근 목표 금액 조회 dto 작성 * feat: 최근 목표 금액 조회 controller 정의 * test: jpa named rule 최근 목표 금액 조회 메서드 단위 테스트 * test: jpa 메서드명 규칙 -> query dsl 메서드로 수정 * feat: user_id 기반 최근 목표 금액 데이터 조회 메서드 추가 * test: jpa query factory 의존성 주입 * test: 최근 목표 금액 없는 경우 테스트 * feat: 최근 목표 금액 조회 서비스 구현 * feat: 최근 목표 금액 조회 dto 변환 mapper 메서드 추가 * feat: 최근 목표 금액 조회 use case 작성 --- .../api/apis/ledger/api/TargetAmountApi.java | 5 + .../controller/TargetAmountController.java | 7 ++ .../api/apis/ledger/dto/TargetAmountDto.java | 20 ++++ .../ledger/mapper/TargetAmountMapper.java | 9 ++ .../RecentTargetAmountSearchService.java | 20 ++++ .../ledger/usecase/TargetAmountUseCase.java | 7 ++ .../TargetAmountCustomRepository.java | 5 + .../TargetAmountCustomRepositoryImpl.java | 21 ++++ .../target/service/TargetAmountService.java | 5 + .../RecentTargetAmountSearchTest.java | 110 ++++++++++++++++++ 10 files changed, 209 insertions(+) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/RecentTargetAmountSearchService.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java index 333d41f01..eb4c081d0 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java @@ -68,6 +68,11 @@ public interface TargetAmountApi { schemaProperties = @SchemaProperty(name = "targetAmounts", array = @ArraySchema(schema = @Schema(implementation = TargetAmountDto.WithTotalSpendingRes.class))))) ResponseEntity getTargetAmountsAndTotalSpendings(@Validated TargetAmountDto.DateParam param, @AuthenticationPrincipal SecurityUserDetails user); + @Operation(summary = "당월 이전 사용자가 입력한 목표 금액 중 최신 데이터 단일 조회", method = "GET", + description = "당월에 목표 금액이 존재한다면 당월 목표 금액이 반환되겠지만, 일반적으로 해당 API는 당월 목표 금액 조회 시 isRead가 false인 경우이므로 amount도 -1이라는 전제를 두어 별도의 예외처리를 수행하지는 않는다. isPresent 필드를 통해 데이터 존재 여부를 확인할 수 있다.") + @ApiResponse(responseCode = "200", description = "목표 금액 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "targetAmount", schema = @Schema(implementation = TargetAmountDto.RecentTargetAmountRes.class)))) + ResponseEntity getRecentTargetAmount(@AuthenticationPrincipal SecurityUserDetails user); + @Operation(summary = "당월 목표 금액 수정", method = "PATCH") @Parameters({ @Parameter(name = "targetAmountId", description = "수정하려는 목표 금액 ID", required = true, example = "1", in = ParameterIn.PATH), diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java index 867ed2976..5c450474f 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountController.java @@ -52,6 +52,13 @@ public ResponseEntity getTargetAmountsAndTotalSpendings(@Validated TargetAmou return ResponseEntity.ok(SuccessResponse.from(TARGET_AMOUNTS, targetAmountUseCase.getTargetAmountsAndTotalSpendings(user.getUserId(), param.date()))); } + @Override + @GetMapping("/recent") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getRecentTargetAmount(@AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from(TARGET_AMOUNT, targetAmountUseCase.getRecentTargetAmount(user.getUserId()))); + } + @Override @PatchMapping("/{target_amount_id}") @PreAuthorize("isAuthenticated() and @targetAmountManager.hasPermission(principal.userId, #targetAmountId)") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java index 29cb580bf..5ece8e2e1 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java @@ -1,6 +1,7 @@ package kr.co.pennyway.api.apis.ledger.dto; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; import io.swagger.v3.oas.annotations.media.Schema; @@ -88,4 +89,23 @@ public static TargetAmountInfo from(TargetAmount targetAmount) { return new TargetAmountInfo(targetAmount.getId(), targetAmount.getAmount(), targetAmount.isRead()); } } + + @Schema(title = "가장 최근에 입력한 목표 금액 정보") + public record RecentTargetAmountRes( + @Schema(description = "최근 목표 금액 존재 여부로써 데이터가 존재하지 않으면 false, 존재하면 true", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + boolean isPresent, + @Schema(description = "최근 목표 금액 정보. isPresent가 false인 경우 필드가 존재하지 않는다.", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonInclude(JsonInclude.Include.NON_NULL) + Integer amount + ) { + public RecentTargetAmountRes { + if (!isPresent) { + amount = null; + } + } + + public static RecentTargetAmountRes valueOf(Integer amount) { + return (amount.equals(-1)) ? new RecentTargetAmountRes(false, null) : new RecentTargetAmountRes(true, amount); + } + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java index f14342adc..62b9379d3 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java @@ -49,6 +49,15 @@ public static List toWithTotalSpendingResp .toList(); } + /** + * 최근 목표 금액을 응답 형태로 변환한다. + * + * @return TargetAmountDto.RecentTargetAmountRes + */ + public static TargetAmountDto.RecentTargetAmountRes toRecentTargetAmountResponse(Integer amount) { + return TargetAmountDto.RecentTargetAmountRes.valueOf(amount); + } + private static List createWithTotalSpendingResponses(Map targetAmounts, Map totalSpendings, LocalDate startAt, int monthLength) { List withTotalSpendingResponses = new ArrayList<>(monthLength + 1); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/RecentTargetAmountSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/RecentTargetAmountSearchService.java new file mode 100644 index 000000000..50174528a --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/RecentTargetAmountSearchService.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.target.service.TargetAmountService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RecentTargetAmountSearchService { + private final TargetAmountService targetAmountService; + + @Transactional(readOnly = true) + public Integer readRecentTargetAmount(Long userId) { + return targetAmountService.readRecentTargetAmount(userId) + .map(TargetAmount::getAmount) + .orElse(-1); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java index 7d1e27b07..803b5801a 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java @@ -2,6 +2,7 @@ import kr.co.pennyway.api.apis.ledger.dto.TargetAmountDto; import kr.co.pennyway.api.apis.ledger.mapper.TargetAmountMapper; +import kr.co.pennyway.api.apis.ledger.service.RecentTargetAmountSearchService; import kr.co.pennyway.api.apis.ledger.service.TargetAmountSaveService; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.common.redisson.DistributedLockPrefix; @@ -32,6 +33,7 @@ public class TargetAmountUseCase { private final SpendingService spendingService; private final TargetAmountSaveService targetAmountSaveService; + private final RecentTargetAmountSearchService recentTargetAmountSearchService; @Transactional public TargetAmountDto.TargetAmountInfo createTargetAmount(Long userId, int year, int month) { @@ -60,6 +62,11 @@ public List getTargetAmountsAndTotalSpendi return TargetAmountMapper.toWithTotalSpendingResponses(targetAmounts, totalSpendings, user.getCreatedAt().toLocalDate(), date); } + @Transactional(readOnly = true) + public TargetAmountDto.RecentTargetAmountRes getRecentTargetAmount(Long userId) { + return TargetAmountMapper.toRecentTargetAmountResponse(recentTargetAmountSearchService.readRecentTargetAmount(userId)); + } + @Transactional public TargetAmountDto.TargetAmountInfo updateTargetAmount(Long targetAmountId, Integer amount) { TargetAmount targetAmount = targetAmountService.readTargetAmount(targetAmountId) diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java index 4b117acc2..b97857b6f 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java @@ -1,7 +1,12 @@ package kr.co.pennyway.domain.domains.target.repository; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; + import java.time.LocalDate; +import java.util.Optional; public interface TargetAmountCustomRepository { + Optional findRecentOneByUserId(Long userId); + boolean existsByUserIdThatMonth(Long userId, LocalDate date); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java index a5e0a2daf..f3803491a 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java @@ -2,12 +2,16 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import kr.co.pennyway.domain.domains.target.domain.QTargetAmount; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; import kr.co.pennyway.domain.domains.user.domain.QUser; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Repository; import java.time.LocalDate; +import java.util.Optional; +@Slf4j @Repository @RequiredArgsConstructor public class TargetAmountCustomRepositoryImpl implements TargetAmountCustomRepository { @@ -16,6 +20,23 @@ public class TargetAmountCustomRepositoryImpl implements TargetAmountCustomRepos private final QUser user = QUser.user; private final QTargetAmount targetAmount = QTargetAmount.targetAmount; + /** + * 사용자의 가장 최근 목표 금액을 조회한다. + * + * @return 최근 목표 금액이 존재하지 않을 경우 Optional.empty()를 반환하며, 당월 목표 금액 정보일 수도 있다. + */ + @Override + public Optional findRecentOneByUserId(Long userId) { + TargetAmount result = queryFactory.selectFrom(targetAmount) + .innerJoin(user).on(targetAmount.user.id.eq(user.id)) + .where(user.id.eq(userId) + .and(targetAmount.amount.gt(-1))) + .orderBy(targetAmount.createdAt.desc()) + .fetchFirst(); + + return Optional.ofNullable(result); + } + @Override public boolean existsByUserIdThatMonth(Long userId, LocalDate date) { return queryFactory.selectOne().from(targetAmount) diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java index 96ac0cb43..115dd2f18 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java @@ -37,6 +37,11 @@ public List readTargetAmountsByUserId(Long userId) { return targetAmountRepository.findByUser_Id(userId); } + @Transactional(readOnly = true) + public Optional readRecentTargetAmount(Long userId) { + return targetAmountRepository.findRecentOneByUserId(userId); + } + @Transactional(readOnly = true) public boolean isExistsTargetAmountThatMonth(Long userId, LocalDate date) { return targetAmountRepository.existsByUserIdThatMonth(userId, date); diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java new file mode 100644 index 000000000..f1a6e21bf --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java @@ -0,0 +1,110 @@ +package kr.co.pennyway.domain.domains.target.repository; + +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.TestJpaConfig; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = JpaConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Import(TestJpaConfig.class) +public class RecentTargetAmountSearchTest extends ContainerMySqlTestConfig { + private final Collection mockTargetAmounts = List.of( + MockTargetAmount.of(10000, true, LocalDateTime.of(2021, 1, 1, 0, 0, 0), LocalDateTime.of(2021, 1, 1, 0, 0, 0)), + MockTargetAmount.of(-1, false, LocalDateTime.of(2022, 3, 1, 0, 0, 0), LocalDateTime.of(2022, 3, 1, 0, 0, 0)), + MockTargetAmount.of(20000, true, LocalDateTime.of(2022, 5, 1, 0, 0, 0), LocalDateTime.of(2022, 5, 1, 0, 0, 0)), + MockTargetAmount.of(30000, true, LocalDateTime.of(2023, 7, 1, 0, 0, 0), LocalDateTime.of(2023, 7, 1, 0, 0, 0)), + MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)), + MockTargetAmount.of(-1, true, LocalDateTime.of(2024, 2, 1, 0, 0, 0), LocalDateTime.of(2024, 2, 1, 0, 0, 0)) + ); + private final Collection mockTargetAmountsMinus = List.of( + MockTargetAmount.of(-1, true, LocalDateTime.of(2022, 3, 1, 0, 0, 0), LocalDateTime.of(2022, 3, 1, 0, 0, 0)), + MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)), + MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)), + MockTargetAmount.of(-1, true, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)) + ); + @Autowired + private UserRepository userRepository; + @Autowired + private TargetAmountRepository targetAmountRepository; + ; + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + @Test + @DisplayName("사용자의 가장 최근 목표 금액을 조회할 수 있다.") + @Transactional + public void 가장_최근_사용자_목표_금액_조회() { + // given + User user = userRepository.save(User.builder().username("jayang").name("Yang").phone("010-0000-0000").build()); + bulkInsertTargetAmount(user, mockTargetAmounts); + + // when - then + targetAmountRepository.findRecentOneByUserId(user.getId()) + .ifPresentOrElse( + targetAmount -> assertEquals(targetAmount.getAmount(), 30000), + () -> Assertions.fail("최근 목표 금액이 존재하지 않습니다.") + ); + } + + @Test + @DisplayName("사용자의 가장 최근 목표 금액이 존재하지 않으면 Optional.empty()를 반환한다.") + @Transactional + public void 가장_최근_사용자_목표_금액_미존재() { + // given + User user = userRepository.save(User.builder().username("jayang").name("Yang").phone("010-0000-0000").build()); + bulkInsertTargetAmount(user, mockTargetAmountsMinus); + + // when - then + targetAmountRepository.findRecentOneByUserId(user.getId()) + .ifPresentOrElse( + targetAmount -> Assertions.fail("최근 목표 금액이 존재합니다."), + () -> log.info("최근 목표 금액이 존재하지 않습니다.") + ); + } + + private void bulkInsertTargetAmount(User user, Collection targetAmounts) { + String sql = String.format(""" + INSERT INTO `%s` (amount, is_read, user_id, created_at, updated_at) + VALUES (:amount, true, :userId, :createdAt, :updatedAt) + """, "target_amount"); + SqlParameterSource[] params = targetAmounts.stream() + .map(mockTargetAmount -> new MapSqlParameterSource() + .addValue("amount", mockTargetAmount.amount) + .addValue("userId", user.getId()) + .addValue("createdAt", mockTargetAmount.createdAt) + .addValue("updatedAt", mockTargetAmount.updatedAt)) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, params); + } + + private record MockTargetAmount(int amount, boolean isRead, LocalDateTime createdAt, LocalDateTime updatedAt) { + public static MockTargetAmount of(int amount, boolean isRead, LocalDateTime createdAt, LocalDateTime updatedAt) { + return new MockTargetAmount(amount, isRead, createdAt, updatedAt); + } + } +} From 3453d88f7bb652857ecdf6cf65ba10274725885e Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 7 Jun 2024 13:25:48 +0900 Subject: [PATCH 106/152] =?UTF-8?q?fix:=20=E2=9C=8F=EF=B8=8F=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=83=9D=EC=84=B1=EC=9E=90=20=EB=B0=8F=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * fix: device entity 생성자 유효성 검사 * fix: oauth entity 유효성 검사 * fix: oauth entity 생성자 deleted_at 제거 * fix: question entity 유효성 검사 * fix: querstion entity @sql_delete 옵션 추가 * fix: spending entity 유효성 검사 * fix: spending 카테고리 업데이트 메서드 제거 * fix: target_amount update 파라미터 타입 변환 래퍼 -> 원시타입 * fix: user entity 유효성 검사 * fix: user locked 타입 기본타입으로 수정 && get_locked() -> is_locked() 메서드로 수정 * fix: user update 메서드 유효성 검사 추가 * fix: notify_setting 필드 타입 기본타입으로 수정 * test: user_fixture notify_setting 필드 추가 * test: user 유효성 체크 실패로 인한 테스트 수정 * fix: spending 업데이트 시 entity 사용 -> 인자 받아서 처리 * test: spending user validation 체크로 인한 문제로 인한 테스트 수정 * test: 계정 연동 pre-condition 수정 * test: 로그인 비밀번호 불일치 시나리오 any() 로 given 수정 * test: domain 모듈 테스트에서 mock user 생성 시 유효성 만족하도록 수정 * fix: device entity 생성자 유효성 검사 * fix: oauth entity 유효성 검사 * fix: oauth entity 생성자 deleted_at 제거 * fix: question entity 유효성 검사 * fix: querstion entity @sql_delete 옵션 추가 * fix: spending entity 유효성 검사 * fix: spending 카테고리 업데이트 메서드 제거 * fix: target_amount update 파라미터 타입 변환 래퍼 -> 원시타입 * fix: user entity 유효성 검사 * fix: user locked 타입 기본타입으로 수정 && get_locked() -> is_locked() 메서드로 수정 * fix: user update 메서드 유효성 검사 추가 * fix: notify_setting 필드 타입 기본타입으로 수정 * test: user_fixture notify_setting 필드 추가 * test: user 유효성 체크 실패로 인한 테스트 수정 * fix: spending 업데이트 시 entity 사용 -> 인자 받아서 처리 * test: spending user validation 체크로 인한 문제로 인한 테스트 수정 * test: 계정 연동 pre-condition 수정 * test: 로그인 비밀번호 불일치 시나리오 any() 로 given 수정 * test: domain 모듈 테스트에서 mock user 생성 시 유효성 만족하도록 수정 * test: 최근 목표 금액 테스트 user 유효성 검사 반영 --- .../pennyway/api/apis/auth/dto/SignUpReq.java | 3 + .../api/apis/ledger/dto/SpendingReq.java | 27 --------- .../ledger/service/SpendingUpdateService.java | 12 ++-- .../api/apis/users/dto/UserProfileDto.java | 2 +- .../authentication/SecurityUserDetails.java | 2 +- .../AuthControllerIntegrationTest.java | 60 ++++--------------- .../OAuthControllerIntegrationTest.java | 3 + .../UserAuthControllerIntegrationTest.java | 9 +-- .../service/UserGeneralSignServiceTest.java | 26 ++++---- .../SpendingControllerIntegrationTest.java | 7 +-- .../DeviceTokenRegisterServiceTest.java | 6 +- .../DeviceTokenUnregisterServiceTest.java | 6 +- .../usecase/PasswordUpdateServiceTest.java | 3 +- .../api/config/fixture/UserFixture.java | 12 +++- .../domains/device/domain/DeviceToken.java | 8 ++- .../domain/domains/oauth/domain/Oauth.java | 18 ++++-- .../domains/question/domain/Question.java | 17 ++++-- .../domains/spending/domain/Spending.java | 25 ++++---- .../domains/target/domain/TargetAmount.java | 13 +++- .../domains/user/domain/NotifySetting.java | 30 +++++++--- .../domain/domains/user/domain/User.java | 37 +++++++++--- .../oauth/repository/OauthRepositoryTest.java | 21 ++++++- .../RecentTargetAmountSearchTest.java | 20 ++++++- .../UserExtendedRepositoryTest.java | 3 +- .../user/repository/UserSoftDeleteTest.java | 2 + 25 files changed, 197 insertions(+), 175 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java index 3bf20ba3c..3e3aba6ac 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import kr.co.pennyway.api.common.validator.Password; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; import kr.co.pennyway.domain.domains.user.type.Role; @@ -31,6 +32,7 @@ public User toEntity(PasswordEncoder bCryptPasswordEncoder) { .phone(phone) .role(Role.USER) .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) .build(); } @@ -49,6 +51,7 @@ public User toUser() { .phone(phone) .role(Role.USER) .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) .build(); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java index a6e475679..23093cb08 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingReq.java @@ -69,33 +69,6 @@ public Spending toEntity(User user, SpendingCustomCategory spendingCustomCategor .build(); } - /** - * 지출 내역 수정시 사용되는 user필드가 null인 지출 내역으로 변환 - */ - public Spending toEntity() { - return Spending.builder() - .amount(amount) - .category(icon) - .spendAt(spendAt.atStartOfDay()) - .accountName(accountName) - .memo(memo) - .build(); - } - - /** - * 지출 내역 수정시 사용되는 user필드가 null이며, 사용자 정의 지출 카테고리를 사용하는 지출 내역으로 변환 - */ - public Spending toEntity(SpendingCustomCategory spendingCustomCategory) { - return Spending.builder() - .amount(amount) - .category(icon) - .spendAt(spendAt.atStartOfDay()) - .accountName(accountName) - .memo(memo) - .spendingCustomCategory(spendingCustomCategory) - .build(); - } - @Schema(hidden = true) public boolean isCustomCategory() { return !categoryId.equals(-1L); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java index 08638093e..88915729e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java @@ -19,13 +19,11 @@ public class SpendingUpdateService { @Transactional public Spending updateSpending(Spending spending, SpendingReq request) { - if (!request.isCustomCategory()) { - spending.update(request.toEntity()); - } else { - SpendingCustomCategory customCategory = spendingCustomCategoryService.readSpendingCustomCategory(request.categoryId()) - .orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_CUSTOM_CATEGORY)); - spending.update(request.toEntity(customCategory)); - } + SpendingCustomCategory customCategory = (request.isCustomCategory()) + ? spendingCustomCategoryService.readSpendingCustomCategory(request.categoryId()).orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_CUSTOM_CATEGORY)) + : null; + + spending.update(request.amount(), request.icon(), request.spendAt().atStartOfDay(), request.accountName(), request.memo(), customCategory); return spending; } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java index 22ff3b3a7..72fce5281 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java @@ -68,7 +68,7 @@ public static UserProfileDto from(User user, OauthAccountDto oauthAccount) { .profileImageUrl(Objects.toString(user.getProfileImageUrl(), "")) .phone(user.getPhone()) .profileVisibility(user.getProfileVisibility()) - .locked(user.getLocked()) + .locked(user.isLocked()) .notifySetting(user.getNotifySetting()) .isGeneralSignUp(user.isGeneralSignedUpUser()) .createdAt(user.getCreatedAt()) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java index 57240ffc1..23278d930 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authentication/SecurityUserDetails.java @@ -46,7 +46,7 @@ public static UserDetails from(User user) { .userId(user.getId()) .username(user.getUsername()) .authorities(List.of(new CustomGrantedAuthority(user.getRole().getType()))) - .accountNonLocked(user.getLocked()) + .accountNonLocked(user.isLocked()) .build(); } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java index d54bd115c..30c2f5a32 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/AuthControllerIntegrationTest.java @@ -6,6 +6,7 @@ import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.UserFixture; import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; @@ -14,8 +15,6 @@ import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.service.UserService; -import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; -import kr.co.pennyway.domain.domains.user.type.Role; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -42,10 +41,8 @@ @AutoConfigureMockMvc @TestClassOrder(ClassOrderer.OrderAnnotation.class) public class AuthControllerIntegrationTest extends ExternalApiDBTestConfig { - private final String expectedUsername = "jayang"; private final String expectedPhone = "010-1234-5678"; private final String expectedCode = "123456"; - private final String expectedOauthId = "oauthId"; @Autowired private MockMvc mockMvc; @@ -66,40 +63,6 @@ void setUp(WebApplicationContext webApplicationContext) { .build(); } - /** - * 일반 회원가입 유저 생성 - */ - private User createGeneralSignedUser() { - return User.builder() - .name("페니웨이") - .username(expectedUsername) - .password("dkssudgktpdy1") - .phone("010-1234-5678") - .role(Role.USER) - .profileVisibility(ProfileVisibility.PUBLIC) - .build(); - } - - /** - * OAuth로 가입한 유저 생성 (password가 NULL) - */ - private User createOauthSignedUser() { - return User.builder() - .name("페니웨이") - .username(expectedUsername) - .phone("010-1234-5678") - .role(Role.USER) - .profileVisibility(ProfileVisibility.PUBLIC) - .build(); - } - - /** - * User에 연결된 Oauth 생성 - */ - private Oauth createOauthAccount(User user) { - return Oauth.of(Provider.KAKAO, expectedOauthId, user); - } - @Nested @Order(1) @DisplayName("[2] 전화번호 검증 테스트") @@ -110,7 +73,7 @@ class GeneralSignUpPhoneVerifyTest { void generalSignUpFailBecauseAlreadyGeneralSignUp() throws Exception { // given phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.SIGN_UP); - given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.of(createGeneralSignedUser())); + given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.of(UserFixture.GENERAL_USER.toUser())); // when ResultActions resultActions = performPhoneVerificationRequest(expectedCode); @@ -187,7 +150,7 @@ void generalSignUpSuccess() throws Exception { void generalSignUpSuccessWithOauth() throws Exception { // given phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.SIGN_UP); - given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.of(createOauthSignedUser())); + given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.of(UserFixture.OAUTH_USER.toUser())); // when ResultActions resultActions = performPhoneVerificationRequest(expectedCode); @@ -197,7 +160,7 @@ void generalSignUpSuccessWithOauth() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.sms.code").value(true)) .andExpect(jsonPath("$.data.sms.oauth").value(true)) - .andExpect(jsonPath("$.data.sms.username").value(expectedUsername)) + .andExpect(jsonPath("$.data.sms.username").value(UserFixture.OAUTH_USER.getUsername())) .andDo(print()); } @@ -261,7 +224,7 @@ void generalSignUpSuccess() throws Exception { } private ResultActions performGeneralSignUpRequest(String code) throws Exception { - SignUpReq.General request = new SignUpReq.General(expectedUsername, "pennyway", "dkssudgktpdy1", expectedPhone, code); + SignUpReq.General request = new SignUpReq.General(UserFixture.GENERAL_USER.getUsername(), "pennyway", "dkssudgktpdy1", expectedPhone, code); return mockMvc.perform( post("/v1/auth/sign-up") .contentType(MediaType.APPLICATION_JSON) @@ -288,7 +251,7 @@ void syncWithOauthSignUpFailBecauseInvalidCode() throws Exception { String invalidCode = "111111"; // when - ResultActions resultActions = performSyncWithOauthSignUpRequest(invalidCode); + ResultActions resultActions = performSyncWithOauthSignUpRequest(expectedPhone, invalidCode); // then resultActions @@ -304,13 +267,12 @@ void syncWithOauthSignUpFailBecauseInvalidCode() throws Exception { @DisplayName("인증번호가 일치하는 경우 200 OK를 반환하고, 기존의 소셜 계정과 연동된 회원가입이 완료된다.") void syncWithOauthSignUpSuccess() throws Exception { // given - phoneCodeService.create(expectedPhone, expectedCode, PhoneCodeKeyType.SIGN_UP); - User user = createOauthSignedUser(); - userService.createUser(user); - oauthService.createOauth(createOauthAccount(user)); + User user = userService.createUser(UserFixture.OAUTH_USER.toUser()); + oauthService.createOauth(Oauth.of(Provider.KAKAO, "oauthId", user)); + phoneCodeService.create(user.getPhone(), expectedCode, PhoneCodeKeyType.SIGN_UP); // when - ResultActions resultActions = performSyncWithOauthSignUpRequest(expectedCode); + ResultActions resultActions = performSyncWithOauthSignUpRequest(user.getPhone(), expectedCode); // then resultActions @@ -323,7 +285,7 @@ void syncWithOauthSignUpSuccess() throws Exception { assertNotNull(user.getPassword()); } - private ResultActions performSyncWithOauthSignUpRequest(String code) throws Exception { + private ResultActions performSyncWithOauthSignUpRequest(String expectedPhone, String code) throws Exception { SignUpReq.SyncWithOauth request = new SignUpReq.SyncWithOauth("dkssudgktpdy1", expectedPhone, code); return mockMvc.perform( post("/v1/auth/link-oauth") diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java index 344406cb5..903ae61f3 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java @@ -14,6 +14,7 @@ import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; @@ -87,6 +88,7 @@ private User createGeneralSignedUser() { .phone("010-1234-5678") .role(Role.USER) .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) .build(); } @@ -100,6 +102,7 @@ private User createOauthSignedUser() { .phone("010-1234-5678") .role(Role.USER) .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) .build(); } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java index d14cf4125..95ee754bd 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java @@ -21,7 +21,6 @@ import kr.co.pennyway.domain.domains.oauth.type.Provider; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; -import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; import kr.co.pennyway.domain.domains.user.type.Role; import kr.co.pennyway.infra.common.exception.JwtErrorCode; import kr.co.pennyway.infra.common.oidc.OidcDecodePayload; @@ -88,13 +87,7 @@ class SignOut { @BeforeEach void setUp() { - User user = User.builder() - .username("pennyway") - .password("password") - .profileVisibility(ProfileVisibility.PUBLIC) - .role(Role.USER) - .locked(Boolean.FALSE) - .build(); + User user = UserFixture.GENERAL_USER.toUser(); userService.createUser(user); userId = user.getId(); expectedAccessToken = accessTokenProvider.generateToken(AccessTokenClaim.of(user.getId(), Role.USER.getType())); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignServiceTest.java index 2c6dbffa7..d1ee07421 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignServiceTest.java @@ -1,6 +1,7 @@ package kr.co.pennyway.api.apis.auth.service; import kr.co.pennyway.api.apis.auth.dto.UserSyncDto; +import kr.co.pennyway.api.config.fixture.UserFixture; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; @@ -16,6 +17,7 @@ import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @ExtendWith(MockitoExtension.class) @@ -51,7 +53,7 @@ void isSignedUserWhenGeneralReturnFalse() { @Test void isSignedUserWhenGeneralReturnTrue() { // given - given(userService.readUserByPhone(phone)).willReturn(Optional.of(User.builder().username("pennyway").password(null).build())); + given(userService.readUserByPhone(phone)).willReturn(Optional.of(UserFixture.OAUTH_USER.toUser())); // when UserSyncDto userSync = userGeneralSignService.isSignUpAllowed(phone); @@ -59,15 +61,14 @@ void isSignedUserWhenGeneralReturnTrue() { // then assertTrue(userSync.isSignUpAllowed()); assertTrue(userSync.isExistAccount()); - assertEquals("pennyway", userSync.username()); + assertEquals(UserFixture.OAUTH_USER.getUsername(), userSync.username()); } @DisplayName("일반 회원가입 시, 이미 일반회원 가입된 회원인 경우 계정 생성 불가 응답을 반환한다.") @Test void isSignedUserWhenGeneralThrowUserErrorException() { // given - given(userService.readUserByPhone(phone)).willReturn( - Optional.of(User.builder().username("pennyway").password("password").build())); + given(userService.readUserByPhone(phone)).willReturn(Optional.of(UserFixture.GENERAL_USER.toUser())); // when UserSyncDto userSync = userGeneralSignService.isSignUpAllowed(phone); @@ -75,19 +76,19 @@ void isSignedUserWhenGeneralThrowUserErrorException() { // then assertFalse(userSync.isSignUpAllowed()); assertTrue(userSync.isExistAccount()); - assertEquals("pennyway", userSync.username()); + assertEquals(UserFixture.GENERAL_USER.getUsername(), userSync.username()); } @DisplayName("로그인 시, 유저가 존재하고 비밀번호가 일치하면 User를 반환한다.") @Test void readUserIfValidReturnUser() { // given - User user = User.builder().username("pennyway").password("password").build(); - given(userService.readUserByUsername("pennyway")).willReturn(Optional.of(user)); - given(passwordEncoder.matches("password", user.getPassword())).willReturn(true); + User user = UserFixture.GENERAL_USER.toUser(); + given(userService.readUserByUsername(user.getUsername())).willReturn(Optional.of(user)); + given(passwordEncoder.matches(user.getPassword(), user.getPassword())).willReturn(true); // when - User result = userGeneralSignService.readUserIfValid("pennyway", "password"); + User result = userGeneralSignService.readUserIfValid(user.getUsername(), user.getPassword()); // then assertEquals(result, user); @@ -97,8 +98,7 @@ void readUserIfValidReturnUser() { @Test void readUserIfNotFound() { // given - given(userService.readUserByUsername("pennyway")).willThrow( - new UserErrorException(UserErrorCode.NOT_FOUND)); + given(userService.readUserByUsername("pennyway")).willThrow(new UserErrorException(UserErrorCode.NOT_FOUND)); // when - then UserErrorException exception = assertThrows(UserErrorException.class, () -> userGeneralSignService.readUserIfValid("pennyway", "password")); @@ -109,8 +109,8 @@ void readUserIfNotFound() { @Test void readUserIfNotMatchedPassword() { // given - User user = User.builder().username("pennyway").password("password").build(); - given(userService.readUserByUsername("pennyway")).willReturn(Optional.of(user)); + User user = UserFixture.GENERAL_USER.toUser(); + given(userService.readUserByUsername(any())).willReturn(Optional.of(user)); given(passwordEncoder.matches("password", user.getPassword())).willReturn(false); // when - then diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java index 99cc3de78..f07bb14dc 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java @@ -219,18 +219,15 @@ class UpdateSpending { void updateSpendingSuccess() throws Exception { // given User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); - Spending spending = SpendingFixture.GENERAL_SPENDING.toSpending(user); + Spending spending = spendingService.createSpending(SpendingFixture.GENERAL_SPENDING.toSpending(user)); SpendingReq request = new SpendingReq(20000, -1L, SpendingCategory.LIVING, LocalDate.now(), "수정된 소비처", "수정된 메모"); - spendingService.createSpending(spending); // when ResultActions resultActions = performUpdateSpending(request, user, spending.getId()); // then - resultActions - .andDo(print()) - .andExpect(status().isOk()); + resultActions.andDo(print()).andExpect(status().isOk()); Spending updatedSpending = spendingService.readSpending(spending.getId()).get(); Assertions.assertEquals(request.memo(), updatedSpending.getMemo()); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java index 7e10250a0..c2f3230b0 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java @@ -5,6 +5,7 @@ import kr.co.pennyway.api.apis.users.service.DeviceTokenRegisterService; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.fixture.DeviceTokenFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; import kr.co.pennyway.domain.config.JpaConfig; import kr.co.pennyway.domain.domains.device.domain.DeviceToken; import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; @@ -12,8 +13,6 @@ import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; -import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; -import kr.co.pennyway.domain.domains.user.type.Role; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -50,8 +49,7 @@ public class DeviceTokenRegisterServiceTest extends ExternalApiDBTestConfig { @BeforeEach void setUp() { - User user = User.builder().role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).build(); - requestUser = userService.createUser(user); + requestUser = userService.createUser(UserFixture.GENERAL_USER.toUser()); } @Test diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java index e6ada1d2a..11aa0bdf1 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java @@ -4,6 +4,7 @@ import kr.co.pennyway.api.apis.users.service.DeviceTokenUnregisterService; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.fixture.DeviceTokenFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; import kr.co.pennyway.domain.config.JpaConfig; import kr.co.pennyway.domain.domains.device.domain.DeviceToken; import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; @@ -11,8 +12,6 @@ import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; -import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; -import kr.co.pennyway.domain.domains.user.type.Role; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -52,8 +51,7 @@ public class DeviceTokenUnregisterServiceTest extends ExternalApiDBTestConfig { @BeforeEach void setUp() { - User user = User.builder().role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).build(); - requestUser = userService.createUser(user); + requestUser = userService.createUser(UserFixture.GENERAL_USER.toUser()); } @Test diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/PasswordUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/PasswordUpdateServiceTest.java index e63c1a094..f371bb804 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/PasswordUpdateServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/PasswordUpdateServiceTest.java @@ -53,8 +53,7 @@ class VerificationPasswordTest { @BeforeEach void setUp() { - originUser = UserFixture.GENERAL_USER.toUser(); - userService.createUser(originUser); + originUser = userService.createUser(UserFixture.GENERAL_USER.toUser()); } @Test diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java index 53e7201fa..2bd8451b3 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/UserFixture.java @@ -2,9 +2,11 @@ import kr.co.pennyway.api.common.security.authentication.CustomGrantedAuthority; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.Getter; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.SqlParameterSource; @@ -12,9 +14,10 @@ import java.time.LocalDateTime; import java.util.List; +@Getter public enum UserFixture { - GENERAL_USER(1L, "jayang", "dkssudgktpdy1", "Yang", "010-1111-1111", Role.USER, ProfileVisibility.PUBLIC, false), - OAUTH_USER(2L, "only._.o", null, "Only", "0101-2222-2222", Role.USER, ProfileVisibility.PUBLIC, false), + GENERAL_USER(1L, "jayang", "dkssudgktpdy1", "Yang", "010-1111-1111", Role.USER, ProfileVisibility.PUBLIC, NotifySetting.of(true, true, true), false), + OAUTH_USER(2L, "only._.o", null, "Only", "010-2222-2222", Role.USER, ProfileVisibility.PUBLIC, NotifySetting.of(true, true, true), false), ; private final Long id; @@ -24,9 +27,10 @@ public enum UserFixture { private final String phone; private final Role role; private final ProfileVisibility profileVisibility; + private final NotifySetting notifySetting; private final Boolean locked; - UserFixture(Long id, String username, String password, String name, String phone, Role role, ProfileVisibility profileVisibility, Boolean locked) { + UserFixture(Long id, String username, String password, String name, String phone, Role role, ProfileVisibility profileVisibility, NotifySetting notifySetting, Boolean locked) { this.id = id; this.username = username; this.password = password; @@ -34,6 +38,7 @@ public enum UserFixture { this.phone = phone; this.role = role; this.profileVisibility = profileVisibility; + this.notifySetting = notifySetting; this.locked = locked; } @@ -68,6 +73,7 @@ public User toUser() { .phone(phone) .role(role) .profileVisibility(profileVisibility) + .notifySetting(notifySetting) .locked(locked) .build(); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java index ae6a76e85..6fa11123b 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java @@ -8,6 +8,8 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.ColumnDefault; +import java.util.Objects; + @Entity @Getter @Table(name = "device_token") @@ -26,9 +28,9 @@ public class DeviceToken extends DateAuditable { private User user; private DeviceToken(String token, Boolean activated, User user) { - this.token = token; - this.activated = activated; - this.user = user; + this.token = Objects.requireNonNull(token, "token은 null이 될 수 없습니다."); + this.activated = Objects.requireNonNull(activated, "activated는 null이 될 수 없습니다."); + this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다."); } public static DeviceToken of(String token, User user) { diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java index 8bc836c57..add81268b 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java @@ -13,8 +13,10 @@ import org.hibernate.annotations.SQLDelete; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.util.StringUtils; import java.time.LocalDateTime; +import java.util.Objects; @Entity @Getter @@ -44,12 +46,14 @@ public class Oauth { private User user; @Builder(access = AccessLevel.PRIVATE) - private Oauth(Provider provider, String oauthId, LocalDateTime createdAt, LocalDateTime deletedAt, User user) { - this.provider = provider; + private Oauth(Provider provider, String oauthId, User user) { + if (!StringUtils.hasText(oauthId)) { + throw new IllegalArgumentException("oauthId는 null이거나 빈 문자열이 될 수 없습니다."); + } + + this.provider = Objects.requireNonNull(provider, "provider는 null이 될 수 없습니다."); this.oauthId = oauthId; - this.createdAt = createdAt; - this.deletedAt = deletedAt; - this.user = user; + this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다."); } public static Oauth of(Provider provider, String oauthId, User user) { @@ -68,6 +72,10 @@ public void revertDelete(String oauthId) { if (deletedAt == null) { throw new IllegalStateException("삭제되지 않은 oauth 정보 갱신 요청입니다. oauthId: " + oauthId); } + if (!StringUtils.hasText(oauthId)) { + throw new IllegalArgumentException("oauthId는 null이거나 빈 문자열이 될 수 없습니다."); + } + this.oauthId = oauthId; this.deletedAt = null; } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java index f9b57ad2b..6f369708d 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java @@ -6,14 +6,18 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.util.StringUtils; import java.time.LocalDateTime; +import java.util.Objects; @Entity @Getter -@Table(name = "Question") +@Table(name = "question") +@SQLDelete(sql = "UPDATE question SET deleted_at = NOW() WHERE id = ?") @NoArgsConstructor(access = AccessLevel.PROTECTED) @EntityListeners(AuditingEntityListener.class) public class Question { @@ -32,10 +36,15 @@ public class Question { private LocalDateTime deletedAt; @Builder - private Question(String email, QuestionCategory category, String content, LocalDateTime createdAt, LocalDateTime deletedAt) { + private Question(String email, QuestionCategory category, String content) { + if (!StringUtils.hasText(email)) { + throw new IllegalArgumentException("email은 null이거나 빈 문자열이 될 수 없습니다."); + } else if (!StringUtils.hasText(content)) { + throw new IllegalArgumentException("content는 null이거나 빈 문자열이 될 수 없습니다."); + } + this.email = email; - this.category = category; + this.category = Objects.requireNonNull(category, "category는 null이 될 수 없습니다."); this.content = content; - this.createdAt = createdAt; } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java index 0db485d2c..410d3a136 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java @@ -14,6 +14,7 @@ import org.hibernate.annotations.SQLRestriction; import java.time.LocalDateTime; +import java.util.Objects; @Entity @Getter @@ -51,12 +52,12 @@ private Spending(Integer amount, SpendingCategory category, LocalDateTime spendA throw new IllegalArgumentException("사용자 정의 아이콘을 등록할 때는 CUSTOM 아이콘이어야 합니다."); } - this.amount = amount; - this.category = category; - this.spendAt = spendAt; + this.amount = Objects.requireNonNull(amount, "amount는 null이 될 수 없습니다."); + this.category = Objects.requireNonNull(category, "category는 null이 될 수 없습니다."); + this.spendAt = Objects.requireNonNull(spendAt, "spendAt는 null이 될 수 없습니다."); this.accountName = accountName; this.memo = memo; - this.user = user; + this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다."); this.spendingCustomCategory = spendingCustomCategory; } @@ -79,22 +80,18 @@ public CategoryInfo getCategory() { return CategoryInfo.of(-1L, this.category.getType(), this.category); } - public void updateSpendingCustomCategory(SpendingCustomCategory spendingCustomCategory) { + public void update(Integer amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo, SpendingCustomCategory spendingCustomCategory) { if (spendingCustomCategory == null && (category.equals(SpendingCategory.CUSTOM) || category.equals(SpendingCategory.OTHER))) { throw new IllegalArgumentException("서비스 제공 아이콘을 등록할 때는 CUSTOM, OHTER 아이콘을 사용할 수 없습니다."); } else if (spendingCustomCategory != null && !category.equals(SpendingCategory.CUSTOM)) { throw new IllegalArgumentException("사용자 정의 아이콘을 등록할 때는 CUSTOM 아이콘이어야 합니다."); } + this.amount = Objects.requireNonNull(amount, "amount는 null이 될 수 없습니다."); + this.category = Objects.requireNonNull(category, "category는 null이 될 수 없습니다."); + this.spendAt = Objects.requireNonNull(spendAt, "spendAt는 null이 될 수 없습니다."); + this.accountName = accountName; + this.memo = memo; this.spendingCustomCategory = spendingCustomCategory; } - - public void update(Spending spending) { - this.amount = spending.amount; - this.category = spending.category; - this.spendAt = spending.spendAt; - this.accountName = spending.accountName; - this.memo = spending.memo; - this.spendingCustomCategory = spending.spendingCustomCategory; - } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java index aa4f21367..8576bdf77 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java @@ -9,6 +9,7 @@ import org.hibernate.annotations.SQLDelete; import java.time.YearMonth; +import java.util.Objects; @Entity @Getter @@ -29,15 +30,23 @@ public class TargetAmount extends DateAuditable { private TargetAmount(int amount, User user) { this.amount = amount; - this.user = user; + this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다."); this.isRead = false; } + /** + * @param amount 목표 금액은 null을 허용하지 않는다. + * @param user 사용자는 null을 허용하지 않는다. + * @throws NullPointerException amount가 null이거나 user가 null일 때 + */ public static TargetAmount of(int amount, User user) { return new TargetAmount(amount, user); } - public void updateAmount(Integer amount) { + /** + * @param amount 변경할 목표 금액은 null을 허용하지 않는다. + */ + public void updateAmount(int amount) { this.amount = amount; this.isRead = true; } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java index 87a0f89ba..373a4fe78 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java @@ -1,31 +1,33 @@ package kr.co.pennyway.domain.domains.user.domain; import jakarta.persistence.Embeddable; -import lombok.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.ToString; import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.DynamicInsert; -@Getter @Embeddable @NoArgsConstructor(access = AccessLevel.PROTECTED) @DynamicInsert @ToString(of = {"accountBookNotify", "feedNotify", "chatNotify"}) public class NotifySetting { @ColumnDefault("true") - private Boolean accountBookNotify; + private boolean accountBookNotify; @ColumnDefault("true") - private Boolean feedNotify; + private boolean feedNotify; @ColumnDefault("true") - private Boolean chatNotify; + private boolean chatNotify; @Builder - private NotifySetting(Boolean accountBookNotify, Boolean feedNotify, Boolean chatNotify) { + private NotifySetting(boolean accountBookNotify, boolean feedNotify, boolean chatNotify) { this.accountBookNotify = accountBookNotify; this.feedNotify = feedNotify; this.chatNotify = chatNotify; } - public static NotifySetting of(Boolean accountBookNotify, Boolean feedNotify, Boolean chatNotify) { + public static NotifySetting of(boolean accountBookNotify, boolean feedNotify, boolean chatNotify) { return NotifySetting.builder() .accountBookNotify(accountBookNotify) .feedNotify(feedNotify) @@ -33,7 +35,7 @@ public static NotifySetting of(Boolean accountBookNotify, Boolean feedNotify, Bo .build(); } - public void updateNotifySetting(NotifyType notifyType, Boolean flag) { + public void updateNotifySetting(NotifyType notifyType, boolean flag) { switch (notifyType) { case ACCOUNT_BOOK -> this.accountBookNotify = flag; case FEED -> this.feedNotify = flag; @@ -41,6 +43,18 @@ public void updateNotifySetting(NotifyType notifyType, Boolean flag) { } } + public boolean isAccountBookNotify() { + return accountBookNotify; + } + + public boolean isFeedNotify() { + return feedNotify; + } + + public boolean isChatNotify() { + return chatNotify; + } + public enum NotifyType { ACCOUNT_BOOK, FEED, CHAT } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java index 3740f1a7c..222209f4c 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java @@ -15,10 +15,12 @@ import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLRestriction; +import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Objects; @Entity @Getter @@ -46,7 +48,7 @@ public class User extends DateAuditable { @Convert(converter = ProfileVisibilityConverter.class) private ProfileVisibility profileVisibility; @ColumnDefault("false") - private Boolean locked; + private boolean locked; @Embedded private NotifySetting notifySetting; @ColumnDefault("NULL") @@ -57,30 +59,47 @@ public class User extends DateAuditable { @Builder private User(String username, String name, String password, LocalDateTime passwordUpdatedAt, String profileImageUrl, String phone, Role role, - ProfileVisibility profileVisibility, NotifySetting notifySetting, Boolean locked, LocalDateTime deletedAt) { + ProfileVisibility profileVisibility, NotifySetting notifySetting, boolean locked) { + if (!StringUtils.hasText(username)) { + throw new IllegalArgumentException("username은 null이거나 빈 문자열이 될 수 없습니다."); + } else if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name은 null이거나 빈 문자열이 될 수 없습니다."); + } + this.username = username; this.name = name; this.password = password; this.passwordUpdatedAt = passwordUpdatedAt; this.profileImageUrl = profileImageUrl; - this.phone = phone; - this.role = role; - this.profileVisibility = profileVisibility; - this.notifySetting = notifySetting; + this.phone = Objects.requireNonNull(phone, "phone은 null이 될 수 없습니다."); + this.role = Objects.requireNonNull(role, "role은 null이 될 수 없습니다."); + this.profileVisibility = Objects.requireNonNull(profileVisibility, "profileVisibility는 null이 될 수 없습니다."); + this.notifySetting = Objects.requireNonNull(notifySetting, "notifySetting은 null이 될 수 없습니다."); this.locked = locked; - this.deletedAt = deletedAt; } public void updatePassword(String password) { + if (!StringUtils.hasText(password)) { + throw new IllegalArgumentException("password는 null이거나 빈 문자열이 될 수 없습니다."); + } + this.password = password; this.passwordUpdatedAt = LocalDateTime.now(); } public void updateName(String name) { + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name은 null이거나 빈 문자열이 될 수 없습니다."); + } + this.name = name; } public void updateUsername(String username) { + if (!StringUtils.hasText(username)) { + throw new IllegalArgumentException("username은 null이거나 빈 문자열이 될 수 없습니다."); + } + this.username = username; } @@ -92,6 +111,10 @@ public boolean isGeneralSignedUpUser() { return password != null; } + public boolean isLocked() { + return locked; + } + @Override public String toString() { return "User{" + diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java index e60a46286..946dc36c4 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java @@ -5,6 +5,7 @@ import kr.co.pennyway.domain.config.JpaConfig; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.repository.UserRepository; import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; @@ -37,16 +38,18 @@ public class OauthRepositoryTest extends ContainerMySqlTestConfig { @MockBean private JPAQueryFactory jpaQueryFactory; + private User user; + @Test @DisplayName("soft delete된 다른 user_id를 가지면서, 같은 oauth_id, provider를 갖는 정보가 존재해도, 하나의 결과만을 반환한다.") @Transactional public void test() { // given - User user = User.builder().username("jayang").name("Yang").phone("010-0000-0000").role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).locked(Boolean.FALSE).build(); + User user = createUser(); Oauth oauth = Oauth.of(Provider.KAKAO, "oauth_id", user); - User newUser = User.builder().username("jayang").name("Yang").phone("010-0000-0000").role(Role.USER).profileVisibility(ProfileVisibility.PUBLIC).locked(Boolean.FALSE).build(); - Oauth newOauth = Oauth.of(Provider.KAKAO, "oauth_id", user); + User newUser = createUser(); + Oauth newOauth = Oauth.of(Provider.KAKAO, "oauth_id", newUser); // when (소셜 회원가입 ⇾ 회원 탈퇴 ⇾ 동일 정보 소셜 회원가입 ⇾ 조회 성공) userRepository.save(user); @@ -63,4 +66,16 @@ public void test() { // then assertDoesNotThrow(() -> oauthRepository.findByOauthIdAndProviderAndDeletedAtIsNull(newOauth.getOauthId(), newOauth.getProvider())); } + + private User createUser() { + return User.builder() + .username("test") + .name("pannyway") + .password("test") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } } diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java index f1a6e21bf..326f78775 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java @@ -3,8 +3,11 @@ import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; import kr.co.pennyway.domain.config.JpaConfig; import kr.co.pennyway.domain.config.TestJpaConfig; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -51,7 +54,6 @@ public class RecentTargetAmountSearchTest extends ContainerMySqlTestConfig { private UserRepository userRepository; @Autowired private TargetAmountRepository targetAmountRepository; - ; @Autowired private NamedParameterJdbcTemplate jdbcTemplate; @@ -60,7 +62,7 @@ public class RecentTargetAmountSearchTest extends ContainerMySqlTestConfig { @Transactional public void 가장_최근_사용자_목표_금액_조회() { // given - User user = userRepository.save(User.builder().username("jayang").name("Yang").phone("010-0000-0000").build()); + User user = userRepository.save(createUser()); bulkInsertTargetAmount(user, mockTargetAmounts); // when - then @@ -76,7 +78,7 @@ public class RecentTargetAmountSearchTest extends ContainerMySqlTestConfig { @Transactional public void 가장_최근_사용자_목표_금액_미존재() { // given - User user = userRepository.save(User.builder().username("jayang").name("Yang").phone("010-0000-0000").build()); + User user = userRepository.save(createUser()); bulkInsertTargetAmount(user, mockTargetAmountsMinus); // when - then @@ -102,6 +104,18 @@ private void bulkInsertTargetAmount(User user, Collection targ jdbcTemplate.batchUpdate(sql, params); } + private User createUser() { + return User.builder() + .username("test") + .name("pannyway") + .password("test") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } + private record MockTargetAmount(int amount, boolean isRead, LocalDateTime createdAt, LocalDateTime updatedAt) { public static MockTargetAmount of(int amount, boolean isRead, LocalDateTime createdAt, LocalDateTime updatedAt) { return new MockTargetAmount(amount, isRead, createdAt, updatedAt); diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java index e9ee1ccf8..6742972dc 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java @@ -95,7 +95,7 @@ public void findList() { assertTrue("id는 내림차순 정렬되어야 한다.", user.getId() <= maxValue); assertTrue("일반 회원가입 이력이 존재해야 한다.", user.isGeneralSignedUpUser()); - assertFalse("lock이 걸려있지 않아야 한다.", user.getLocked()); + assertFalse("lock이 걸려있지 않아야 한다.", user.isLocked()); maxValue = user.getId(); } @@ -213,7 +213,6 @@ private List getRandomUsers() { .role(Role.USER) .locked((i % 10 == 0)) .notifySetting(NotifySetting.of(true, true, true)) - .deletedAt(null) .build(); users.add(user); diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java index f07317cba..f708f45c8 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java @@ -4,6 +4,7 @@ import jakarta.persistence.EntityManager; import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; @@ -46,6 +47,7 @@ public void setUp() { .phone("01012345678") .role(Role.USER) .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) .build(); } From 64587988826d2b71fdb96a5002168124008a8fba Mon Sep 17 00:00:00 2001 From: DinoDeveloper <79460319+asn6878@users.noreply.github.com> Date: Mon, 24 Jun 2024 07:01:12 +0900 Subject: [PATCH 107/152] =?UTF-8?q?Api:=20=E2=9C=8F=EF=B8=8F=20=EC=9B=94?= =?UTF-8?q?=EB=B3=84=20=EC=A7=80=EC=B6=9C=EB=82=B4=EC=97=AD=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=8B=9C=20=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20N+1?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C=20=EA=B0=9C=EC=84=A0=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 커스텀 카테고리를 가지는 spendingfixture를 만들기위한 customcategoryfixture 작성 * test: 커스텀 카테고리를 가지는 spending 조회시 lazy loading 확인용 테스트 작성 * test: fetch 테스트를 위한 spendingfixture 추가작성 * test: 테스트용 customcategory 벌크 삽입연산 메소드 작성 및 customcategory spending 생성 메소드 변경 * test: searchspendings customcategory fetch 테스트 작성 * fix: 월별 지출내역 조회시 N+1 문제 개선을 위한 fetchjoin 사용 * test: 테스트 assertion 수정 * test: 랜덤한 커스텀 카테고리가 아닌 동일한 커스텀 카테고리 사용 * refactor: searchspendingservice 제거를 위한 spendingcustomrepository 연월별 조회 메서드 작성 * test: desc sorted 검증 추가 --- .../ledger/service/SpendingSearchService.java | 41 --------- .../apis/ledger/usecase/SpendingUseCase.java | 6 +- .../SpendingControllerIntegrationTest.java | 2 +- .../TargetAmountIntegrationTest.java | 4 +- .../service/SpendingSearchServiceTest.java | 86 +++++++++++++++++++ .../SpendingCustomCategoryFixture.java | 50 +++++++++++ .../api/config/fixture/SpendingFixture.java | 40 +++++++-- .../repository/SpendingCustomRepository.java | 4 + .../SpendingCustomRepositoryImpl.java | 24 ++++++ .../spending/service/SpendingService.java | 4 +- .../src/main/resources/application-domain.yml | 4 +- 11 files changed, 205 insertions(+), 60 deletions(-) delete mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java deleted file mode 100644 index 19b7cd9be..000000000 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java +++ /dev/null @@ -1,41 +0,0 @@ -package kr.co.pennyway.api.apis.ledger.service; - -import com.querydsl.core.types.Predicate; -import kr.co.pennyway.domain.common.repository.QueryHandler; -import kr.co.pennyway.domain.domains.spending.domain.QSpending; -import kr.co.pennyway.domain.domains.spending.domain.Spending; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; -import kr.co.pennyway.domain.domains.user.domain.QUser; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Slf4j -@Service -@RequiredArgsConstructor -public class SpendingSearchService { - private final SpendingService spendingService; - - private final QUser user = QUser.user; - private final QSpending spending = QSpending.spending; - - /** - * 사용자의 해당 년/월 지출 내역을 조회하는 메서드 - */ - @Transactional(readOnly = true) - public List readSpendings(Long userId, int year, int month) { - Predicate predicate = spending.user.id.eq(userId) - .and(spending.spendAt.year().eq(year)) - .and(spending.spendAt.month().eq(month)); - - QueryHandler queryHandler = query -> query.leftJoin(user).on(spending.user.eq(user)); - - Sort sort = Sort.by(Sort.Order.desc("spendAt")); - - return spendingService.readSpendings(predicate, queryHandler, sort); - } -} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java index 14ef1995b..315c10db8 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java @@ -4,7 +4,6 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; import kr.co.pennyway.api.apis.ledger.mapper.SpendingMapper; import kr.co.pennyway.api.apis.ledger.service.SpendingSaveService; -import kr.co.pennyway.api.apis.ledger.service.SpendingSearchService; import kr.co.pennyway.api.apis.ledger.service.SpendingUpdateService; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.domains.spending.domain.Spending; @@ -26,7 +25,6 @@ @RequiredArgsConstructor public class SpendingUseCase { private final SpendingSaveService spendingSaveService; - private final SpendingSearchService spendingSearchService; private final SpendingUpdateService spendingUpdateService; private final SpendingService spendingService; @@ -45,7 +43,9 @@ public SpendingSearchRes.Individual createSpending(Long userId, SpendingReq requ @Transactional(readOnly = true) public SpendingSearchRes.Month getSpendingsAtYearAndMonth(Long userId, int year, int month) { - List spendings = spendingSearchService.readSpendings(userId, year, month); + List spendings = spendingService.readSpendings(userId, year, month).orElseThrow( + () -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_SPENDING) + ); return SpendingMapper.toSpendingSearchResMonth(spendings, year, month); } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java index f07bb14dc..6e95243bd 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java @@ -133,7 +133,7 @@ class GetSpendingListAtYearAndMonth { void getSpendingListAtYearAndMonthSuccess() throws Exception { // given User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); - SpendingFixture.bulkInsertSpending(user, 150, jdbcTemplate); + SpendingFixture.bulkInsertSpending(user, 150, false, jdbcTemplate); // when long before = System.currentTimeMillis(); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java index bdc2cf446..b3c987598 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java @@ -114,7 +114,7 @@ class GetTargetAmountAndTotalSpending { void getTargetAmountAndTotalSpending() throws Exception { // given User user = createUserWithCreatedAt(LocalDateTime.now().minusYears(2), jdbcTemplate); - SpendingFixture.bulkInsertSpending(user, 300, jdbcTemplate); + SpendingFixture.bulkInsertSpending(user, 300, false, jdbcTemplate); TargetAmountFixture.bulkInsertTargetAmount(user, jdbcTemplate); // when @@ -156,7 +156,7 @@ class GetTargetAmountsAndTotalSpendings { void getTargetAmountsAndTotalSpendings() throws Exception { // given User user = createUserWithCreatedAt(LocalDateTime.now().minusYears(2).plusMonths(2), jdbcTemplate); - SpendingFixture.bulkInsertSpending(user, 300, jdbcTemplate); + SpendingFixture.bulkInsertSpending(user, 300, false, jdbcTemplate); TargetAmountFixture.bulkInsertTargetAmount(user, jdbcTemplate); // when diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java new file mode 100644 index 000000000..53269ea84 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java @@ -0,0 +1,86 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.SpendingFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.engine.spi.SessionImplementor; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.IntStream; + +@Slf4j +@ExtendWith(MockitoExtension.class) +@ExternalApiIntegrationTest +class SpendingSearchServiceTest extends ExternalApiDBTestConfig { + @Autowired + private UserService userService; + @Autowired + private SpendingService spendingService; + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + @PersistenceContext + private EntityManager entityManager; + private Statistics statistics; + + + @BeforeEach + public void setUp() { + SessionFactoryImplementor sessionFactory = (SessionFactoryImplementor) ((SessionImplementor) entityManager.getDelegate()).getSessionFactory(); + statistics = sessionFactory.getStatistics(); + statistics.setStatisticsEnabled(true); + } + + @AfterEach + public void tearDown() { + statistics.clear(); + } + + @Test + @Transactional + @DisplayName("커스텀 카테고리 지출 내역을 기간별 조회시 카테고리를 바로 fetch 한다.") + void testReadSpendingsLazyLoading() { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + SpendingFixture.bulkInsertSpending(user, 100, true, jdbcTemplate); + + // when + List spendings = spendingService.readSpendings(user.getId(), LocalDate.now().getYear(), LocalDate.now().getMonthValue()).orElseThrow(); + + int size = spendings.size(); + for (Spending spending : spendings) { + log.info("지출내역 id : {} 커스텀 카테고리 id : {} 커스텀 카테고리 name : {}", + spending.getId(), + spending.getSpendingCustomCategory().getId(), + spending.getSpendingCustomCategory().getName() + ); + } + + // then + log.info("쿼리문 실행 횟수: {}", statistics.getPrepareStatementCount()); + log.info("readSpendings로 조회해온 지출 내역 개수: {}", size); + + Assertions.assertEquals(2, statistics.getPrepareStatementCount()); + + boolean isSortedDescending = IntStream.range(0, spendings.size() - 1) + .allMatch(i -> !spendings.get(i).getSpendAt().isBefore(spendings.get(i + 1).getSpendAt())); + Assertions.assertTrue(isSortedDescending); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java new file mode 100644 index 000000000..24eadb0a3 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java @@ -0,0 +1,50 @@ +package kr.co.pennyway.api.config.fixture; + +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public enum SpendingCustomCategoryFixture { + GENERAL_SPENDING_CUSTOM_CATEGORY("커스텀 지출 내역 카테고리", SpendingCategory.FOOD); + + private final String name; + private final SpendingCategory icon; + + SpendingCustomCategoryFixture(String name, SpendingCategory icon) { + this.name = name; + this.icon = icon; + } + + public static void bulkInsertCustomCategory(User user, int capacity, NamedParameterJdbcTemplate jdbcTemplate) { + Collection customCategories = getCustomCategories(user, capacity); + + String sql = String.format(""" + INSERT INTO `%s` (name, icon, user_id, created_at, updated_at, deleted_at) + VALUES (:name, 1, :user.id, NOW(), NOW(), null) + """, "spending_custom_category"); + SqlParameterSource[] params = customCategories.stream() + .map(BeanPropertySqlParameterSource::new) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, params); + } + + private static List getCustomCategories(User user, int capacity) { + List customCategories = new ArrayList<>(capacity); + + for (int i = 0; i < capacity; i++) { + customCategories.add(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user)); + } + return customCategories; + } + + public SpendingCustomCategory toCustomSpendingCategory(User user) { + return SpendingCustomCategory.of(name, icon, user); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java index 185084a1f..2a2a2266b 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java @@ -2,6 +2,7 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; @@ -16,35 +17,44 @@ import java.util.concurrent.ThreadLocalRandom; public enum SpendingFixture { - GENERAL_SPENDING(10000, SpendingCategory.FOOD, LocalDateTime.now(), "카페인 수혈", "아메리카노 1잔", UserFixture.GENERAL_USER.toUser()); + GENERAL_SPENDING(10000, SpendingCategory.FOOD, LocalDateTime.now(), "카페인 수혈", "아메리카노 1잔"), + CUSTOM_CATEGORY_SPENDING(10000, SpendingCategory.CUSTOM, LocalDateTime.now(), "커스텀 카페인 수혈", "아메리카노 1잔"); private final int amount; private final SpendingCategory category; private final LocalDateTime spendAt; private final String accountName; private final String memo; - private final User user; - SpendingFixture(int amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo, User user) { + + SpendingFixture(int amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo) { this.amount = amount; this.category = category; this.spendAt = spendAt; this.accountName = accountName; this.memo = memo; - this.user = user; } public static SpendingReq toSpendingReq(User user) { return new SpendingReq(10000, -1L, SpendingCategory.FOOD, LocalDate.now(), "카페인 수혈", "아메리카노 1잔"); } - public static void bulkInsertSpending(User user, int capacity, NamedParameterJdbcTemplate jdbcTemplate) { + public static void bulkInsertSpending(User user, int capacity, boolean isCustom, NamedParameterJdbcTemplate jdbcTemplate) { Collection spendings = getRandomSpendings(user, capacity); + String sql; + if (isCustom) { + SpendingCustomCategoryFixture.bulkInsertCustomCategory(user, capacity, jdbcTemplate); + sql = String.format(""" + INSERT INTO `%s` (amount, category, spend_at, account_name, memo, user_id, spending_custom_category_id, created_at, updated_at, deleted_at) + VALUES (:amount, 1+FLOOR(RAND()*11), :spendAt, :accountName, :memo, :user.id, 1 + FLOOR(RAND() * %d), NOW(), NOW(), null) + """, "spending", capacity); + } else { + sql = String.format(""" + INSERT INTO `%s` (amount, category, spend_at, account_name, memo, user_id, spending_custom_category_id, created_at, updated_at, deleted_at) + VALUES (:amount, 1+FLOOR(RAND()*11), :spendAt, :accountName, :memo, :user.id, null, NOW(), NOW(), null) + """, "spending"); + } - String sql = String.format(""" - INSERT INTO `%s` (amount, category, spend_at, account_name, memo, user_id, spending_custom_category_id, created_at, updated_at, deleted_at) - VALUES (:amount, 1+FLOOR(RAND()*11), :spendAt, :accountName, :memo, :user.id, null, NOW(), NOW(), null) - """, "spending"); SqlParameterSource[] params = spendings.stream() .map(BeanPropertySqlParameterSource::new) .toArray(SqlParameterSource[]::new); @@ -96,4 +106,16 @@ public Spending toSpending(User user) { .user(user) .build(); } + + public Spending toCustomCategorySpending(User user, SpendingCustomCategory customCategory) { + return Spending.builder() + .amount(amount) + .category(category) + .spendAt(spendAt) + .accountName(accountName) + .memo(memo) + .user(user) + .spendingCustomCategory(customCategory) + .build(); + } } \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java index 8687e8589..bc11d7bec 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java @@ -1,9 +1,13 @@ package kr.co.pennyway.domain.domains.spending.repository; +import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; +import java.util.List; import java.util.Optional; public interface SpendingCustomRepository { Optional findTotalSpendingAmountByUserId(Long userId, int year, int month); + + Optional> findByYearAndMonth(Long userId, int year, int month); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java index 7b6ed3ade..004752e0a 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java @@ -1,13 +1,19 @@ package kr.co.pennyway.domain.domains.spending.repository; +import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.domain.common.util.QueryDslUtil; import kr.co.pennyway.domain.domains.spending.domain.QSpending; +import kr.co.pennyway.domain.domains.spending.domain.QSpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; import kr.co.pennyway.domain.domains.user.domain.QUser; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -17,6 +23,7 @@ public class SpendingCustomRepositoryImpl implements SpendingCustomRepository { private final QUser user = QUser.user; private final QSpending spending = QSpending.spending; + private final QSpendingCustomCategory spendingCustomCategory = QSpendingCustomCategory.spendingCustomCategory; @Override public Optional findTotalSpendingAmountByUserId(Long userId, int year, int month) { @@ -37,4 +44,21 @@ public Optional findTotalSpendingAmountByUserId(Long userId return Optional.ofNullable(result); } + + @Override + public Optional> findByYearAndMonth(Long userId, int year, int month) { + Sort sort = Sort.by(Sort.Order.desc("spendAt")); + List> orderSpecifiers = QueryDslUtil.getOrderSpecifier(sort); + + List result = queryFactory.selectFrom(spending) + .leftJoin(spending.user, user) + .leftJoin(spending.spendingCustomCategory, spendingCustomCategory).fetchJoin() + .where(spending.spendAt.year().eq(year) + .and(spending.spendAt.month().eq(month))) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .fetch(); + + return Optional.ofNullable(result); + } + } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java index 8e7d83044..76f490766 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -45,8 +45,8 @@ public Optional readTotalSpendingAmountByUserId(Long userId } @Transactional(readOnly = true) - public List readSpendings(Predicate predicate, QueryHandler queryHandler, Sort sort) { - return spendingRepository.findList(predicate, queryHandler, sort); + public Optional> readSpendings(Long userId, int year, int month) { + return spendingRepository.findByYearAndMonth(userId, year, month); } @Transactional(readOnly = true) diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml index 462eec76f..d18e61916 100644 --- a/pennyway-domain/src/main/resources/application-domain.yml +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -26,7 +26,7 @@ spring: open-in-view: false generate-ddl: false hibernate: - ddl-auto: none + ddl-auto: update show-sql: true properties: hibernate: @@ -57,7 +57,7 @@ spring: generate-ddl: false hibernate: ddl-auto: none - show-sql: false + show-sql: true properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect From a69fc70585dd409af8af57e10abc773bb61b3b52 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 25 Jun 2024 16:54:06 +0900 Subject: [PATCH 108/152] =?UTF-8?q?=F0=9F=94=A7=20=EB=AA=A9=ED=91=9C=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=20=EC=9C=A0=EC=A6=88=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * refactor: create_target_amount() 리팩토링 * rename: recent_target_amount_search_service -> target_amount_search_service * refactor: get_target_amount_and_total_spending() * refactor: get_target_amounts_and_total_spendings() * refactor: update_target_amount 리팩토링 * refactor: target_amount_delete_service() 리팩토링 * style: 불필요한 transactional 어노테이션 제거 * refactor: 월별 총 지출 내역 조회 메서드 분리 * refactor: 지출 조회, 지출 목표 금액 조회 서비스 로직 분리 * refactor: 목표금액&월별 지출 내역 리스트 조회 메서드 분리 * fix: target_amount_mapper start_at 사용자 회원가입 일자 -> 가장 오래된 목표 금액 데이터 기반으로 수정 * docs: 목표금액 리스트 조회 스웨거 문서 요약 수정 --- .../api/apis/ledger/api/TargetAmountApi.java | 2 +- .../ledger/mapper/TargetAmountMapper.java | 22 +++++-- .../RecentTargetAmountSearchService.java | 20 ------ .../ledger/service/SpendingSearchService.java | 29 +++++++++ .../service/TargetAmountDeleteService.java | 28 +++++++++ .../service/TargetAmountSaveService.java | 25 +++++++- .../service/TargetAmountSearchService.java | 35 +++++++++++ .../ledger/usecase/TargetAmountUseCase.java | 61 +++++-------------- 8 files changed, 150 insertions(+), 72 deletions(-) delete mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/RecentTargetAmountSearchService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountDeleteService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSearchService.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java index eb4c081d0..3dd1e18ed 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/TargetAmountApi.java @@ -59,7 +59,7 @@ public interface TargetAmountApi { }) ResponseEntity getTargetAmountAndTotalSpending(@PathVariable LocalDate date, @AuthenticationPrincipal SecurityUserDetails user); - @Operation(summary = "사용자 가입 이후 현재까지의 목표 금액 및 총 사용 금액 리스트 조회", method = "GET", description = "일수는 무시하고 년/월 정보만 사용한다. 데이터가 존재하지 않을 때 더미 값을 사용하며, 최신 데이터 순으로 정렬된 응답을 반환한다.") + @Operation(summary = "가장 오래된 목표 금액 이후부터 현재까지의 목표 금액 및 총 사용 금액 리스트 조회", method = "GET", description = "일수는 무시하고 년/월 정보만 사용한다. 데이터가 존재하지 않을 때 더미 값을 사용하며, 최신 데이터 순으로 정렬된 응답을 반환한다.") @Parameters({ @Parameter(name = "date", description = "현재 날짜(yyyy-MM-dd)", required = true, in = ParameterIn.QUERY), @Parameter(name = "param", hidden = true) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java index 62b9379d3..7e407a53c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java @@ -32,12 +32,13 @@ public static TargetAmountDto.WithTotalSpendingRes toWithTotalSpendingResponse(T /** * TargetAmount와 TotalSpendingAmount를 이용하여 WithTotalSpendingRes 리스트를 생성한다.
- * startAt부터 endAt까지의 날짜에 대한 WithTotalSpendingRes를 생성하며, 임의의 날짜에 대한 정보가 없을 경우 더미 데이터를 생성한다. + * startAt부터 endAt까지의 날짜에 대한 WithTotalSpendingRes를 생성하며, 임의의 날짜에 대한 정보가 없을 경우 더미 데이터를 생성한다.
+ * startAt은 목표 금액 데이터 중 가장 오래된 날짜를 기준으로 잡는다. * - * @param startAt : 조회 시작 날짜. 이유가 없다면 사용자 생성 날짜를 사용한다. - * @param endAt : 조회 종료 날짜. 이유가 없다면 현재 날짜이며, 클라이언트로 부터 받은 날짜를 사용한다. + * @param endAt : 조회 종료 날짜. 이유가 없다면 현재 날짜이며, 클라이언트로 부터 받은 날짜를 사용한다. */ - public static List toWithTotalSpendingResponses(List targetAmounts, List totalSpendings, LocalDate startAt, LocalDate endAt) { + public static List toWithTotalSpendingResponses(List targetAmounts, List totalSpendings, LocalDate endAt) { + LocalDate startAt = getOldestDate(targetAmounts); int monthLength = (endAt.getYear() - startAt.getYear()) * 12 + (endAt.getMonthValue() - startAt.getMonthValue()); Map targetAmountsByDates = toYearMonthMap(targetAmounts, targetAmount -> YearMonth.of(targetAmount.getCreatedAt().getYear(), targetAmount.getCreatedAt().getMonthValue()), Function.identity()); @@ -86,6 +87,19 @@ private static TargetAmountDto.WithTotalSpendingRes createWithTotalSpendingRes(T .build(); } + private static LocalDate getOldestDate(List targetAmounts) { + LocalDate minDate = LocalDate.now(); + + for (TargetAmount targetAmount : targetAmounts) { + LocalDate date = targetAmount.getCreatedAt().toLocalDate(); + if (date.isBefore(minDate)) { + minDate = date; + } + } + + return minDate; + } + /** * List를 YearMonth를 key로 하는 Map으로 변환한다. * diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/RecentTargetAmountSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/RecentTargetAmountSearchService.java deleted file mode 100644 index 50174528a..000000000 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/RecentTargetAmountSearchService.java +++ /dev/null @@ -1,20 +0,0 @@ -package kr.co.pennyway.api.apis.ledger.service; - -import kr.co.pennyway.domain.domains.target.domain.TargetAmount; -import kr.co.pennyway.domain.domains.target.service.TargetAmountService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class RecentTargetAmountSearchService { - private final TargetAmountService targetAmountService; - - @Transactional(readOnly = true) - public Integer readRecentTargetAmount(Long userId) { - return targetAmountService.readRecentTargetAmount(userId) - .map(TargetAmount::getAmount) - .orElse(-1); - } -} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java new file mode 100644 index 000000000..f717b3a14 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java @@ -0,0 +1,29 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SpendingSearchService { + private final SpendingService spendingService; + + @Transactional(readOnly = true) + public Optional readTotalSpendingAmountByUserIdThatMonth(Long userId, LocalDate date) { + return spendingService.readTotalSpendingAmountByUserId(userId, date); + } + + @Transactional(readOnly = true) + public List readTotalSpendingsAmountByUserId(Long userId) { + return spendingService.readTotalSpendingsAmountByUserId(userId); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountDeleteService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountDeleteService.java new file mode 100644 index 000000000..57ba14fa4 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountDeleteService.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; +import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorException; +import kr.co.pennyway.domain.domains.target.service.TargetAmountService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TargetAmountDeleteService { + private final TargetAmountService targetAmountService; + + @Transactional + public void execute(Long targetAmountId) { + TargetAmount targetAmount = targetAmountService.readTargetAmount(targetAmountId) + .filter(TargetAmount::isAllocatedAmount) + .orElseThrow(() -> new TargetAmountErrorException(TargetAmountErrorCode.NOT_FOUND_TARGET_AMOUNT)); + + if (!targetAmount.isThatMonth()) { + throw new TargetAmountErrorException(TargetAmountErrorCode.INVALID_TARGET_AMOUNT_DATE); + } + + targetAmountService.deleteTargetAmount(targetAmount); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java index 5f0a1c33f..abaaf64f3 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java @@ -6,9 +6,13 @@ import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorException; import kr.co.pennyway.domain.domains.target.service.TargetAmountService; import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; @@ -16,10 +20,13 @@ @Service @RequiredArgsConstructor public class TargetAmountSaveService { + private final UserService userService; private final TargetAmountService targetAmountService; - @DistributedLock(key = "#key.concat(#user.getId()).concat('_').concat(#date.getYear()).concat('-').concat(#date.getMonthValue())") - public TargetAmount createTargetAmount(String key, User user, LocalDate date) { + @DistributedLock(key = "#key.concat(#userId).concat('_').concat(#date.getYear()).concat('-').concat(#date.getMonthValue())") + public TargetAmount createTargetAmount(String key, Long userId, LocalDate date) { + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + if (targetAmountService.isExistsTargetAmountThatMonth(user.getId(), date)) { log.info("{}에 대한 날짜의 목표 금액이 이미 존재합니다.", date); throw new TargetAmountErrorException(TargetAmountErrorCode.ALREADY_EXIST_TARGET_AMOUNT); @@ -27,4 +34,18 @@ public TargetAmount createTargetAmount(String key, User user, LocalDate date) { return targetAmountService.createTargetAmount(TargetAmount.of(-1, user)); } + + @Transactional + public TargetAmount updateTargetAmount(Long targetAmountId, Integer amount) { + TargetAmount targetAmount = targetAmountService.readTargetAmount(targetAmountId) + .orElseThrow(() -> new TargetAmountErrorException(TargetAmountErrorCode.NOT_FOUND_TARGET_AMOUNT)); + + if (!targetAmount.isThatMonth()) { + throw new TargetAmountErrorException(TargetAmountErrorCode.INVALID_TARGET_AMOUNT_DATE); + } + + targetAmount.updateAmount(amount); + + return targetAmount; + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSearchService.java new file mode 100644 index 000000000..711db65df --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSearchService.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; +import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorException; +import kr.co.pennyway.domain.domains.target.service.TargetAmountService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TargetAmountSearchService { + private final TargetAmountService targetAmountService; + + @Transactional(readOnly = true) + public List readTargetAmountsByUserId(Long userId) { + return targetAmountService.readTargetAmountsByUserId(userId); + } + + @Transactional(readOnly = true) + public TargetAmount readTargetAmountThatMonth(Long userId, LocalDate date) { + return targetAmountService.readTargetAmountThatMonth(userId, date).orElseThrow(() -> new TargetAmountErrorException(TargetAmountErrorCode.NOT_FOUND_TARGET_AMOUNT)); + } + + @Transactional(readOnly = true) + public Integer readRecentTargetAmount(Long userId) { + return targetAmountService.readRecentTargetAmount(userId) + .map(TargetAmount::getAmount) + .orElse(-1); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java index 803b5801a..593fc1f19 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java @@ -2,20 +2,14 @@ import kr.co.pennyway.api.apis.ledger.dto.TargetAmountDto; import kr.co.pennyway.api.apis.ledger.mapper.TargetAmountMapper; -import kr.co.pennyway.api.apis.ledger.service.RecentTargetAmountSearchService; +import kr.co.pennyway.api.apis.ledger.service.SpendingSearchService; +import kr.co.pennyway.api.apis.ledger.service.TargetAmountDeleteService; import kr.co.pennyway.api.apis.ledger.service.TargetAmountSaveService; +import kr.co.pennyway.api.apis.ledger.service.TargetAmountSearchService; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.common.redisson.DistributedLockPrefix; import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.target.domain.TargetAmount; -import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; -import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorException; -import kr.co.pennyway.domain.domains.target.service.TargetAmountService; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; -import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; @@ -28,69 +22,46 @@ @UseCase @RequiredArgsConstructor public class TargetAmountUseCase { - private final UserService userService; - private final TargetAmountService targetAmountService; - private final SpendingService spendingService; - private final TargetAmountSaveService targetAmountSaveService; - private final RecentTargetAmountSearchService recentTargetAmountSearchService; + private final TargetAmountSearchService targetAmountSearchService; + private final TargetAmountDeleteService targetAmountDeleteService; + + private final SpendingSearchService spendingSearchService; @Transactional public TargetAmountDto.TargetAmountInfo createTargetAmount(Long userId, int year, int month) { - User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); - - TargetAmount targetAmount = targetAmountSaveService.createTargetAmount(DistributedLockPrefix.TARGET_AMOUNT_USER, user, LocalDate.of(year, month, 1)); + TargetAmount targetAmount = targetAmountSaveService.createTargetAmount(DistributedLockPrefix.TARGET_AMOUNT_USER, userId, LocalDate.of(year, month, 1)); return TargetAmountDto.TargetAmountInfo.from(targetAmount); } @Transactional(readOnly = true) public TargetAmountDto.WithTotalSpendingRes getTargetAmountAndTotalSpending(Long userId, LocalDate date) { - TargetAmount targetAmount = targetAmountService.readTargetAmountThatMonth(userId, date).orElseThrow(() -> new TargetAmountErrorException(TargetAmountErrorCode.NOT_FOUND_TARGET_AMOUNT)); - Optional totalSpending = spendingService.readTotalSpendingAmountByUserId(userId, date); - + TargetAmount targetAmount = targetAmountSearchService.readTargetAmountThatMonth(userId, date); + Optional totalSpending = spendingSearchService.readTotalSpendingAmountByUserIdThatMonth(userId, date); return TargetAmountMapper.toWithTotalSpendingResponse(targetAmount, totalSpending.orElse(null), date); } @Transactional(readOnly = true) public List getTargetAmountsAndTotalSpendings(Long userId, LocalDate date) { - User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); - - List targetAmounts = targetAmountService.readTargetAmountsByUserId(userId); - List totalSpendings = spendingService.readTotalSpendingsAmountByUserId(userId); + List targetAmounts = targetAmountSearchService.readTargetAmountsByUserId(userId); + List totalSpendings = spendingSearchService.readTotalSpendingsAmountByUserId(userId); - return TargetAmountMapper.toWithTotalSpendingResponses(targetAmounts, totalSpendings, user.getCreatedAt().toLocalDate(), date); + return TargetAmountMapper.toWithTotalSpendingResponses(targetAmounts, totalSpendings, date); } - @Transactional(readOnly = true) public TargetAmountDto.RecentTargetAmountRes getRecentTargetAmount(Long userId) { - return TargetAmountMapper.toRecentTargetAmountResponse(recentTargetAmountSearchService.readRecentTargetAmount(userId)); + return TargetAmountMapper.toRecentTargetAmountResponse(targetAmountSearchService.readRecentTargetAmount(userId)); } @Transactional public TargetAmountDto.TargetAmountInfo updateTargetAmount(Long targetAmountId, Integer amount) { - TargetAmount targetAmount = targetAmountService.readTargetAmount(targetAmountId) - .orElseThrow(() -> new TargetAmountErrorException(TargetAmountErrorCode.NOT_FOUND_TARGET_AMOUNT)); - - if (!targetAmount.isThatMonth()) { - throw new TargetAmountErrorException(TargetAmountErrorCode.INVALID_TARGET_AMOUNT_DATE); - } - - targetAmount.updateAmount(amount); + TargetAmount targetAmount = targetAmountSaveService.updateTargetAmount(targetAmountId, amount); return TargetAmountDto.TargetAmountInfo.from(targetAmount); } - @Transactional public void deleteTargetAmount(Long targetAmountId) { - TargetAmount targetAmount = targetAmountService.readTargetAmount(targetAmountId) - .filter(TargetAmount::isAllocatedAmount) - .orElseThrow(() -> new TargetAmountErrorException(TargetAmountErrorCode.NOT_FOUND_TARGET_AMOUNT)); - - if (!targetAmount.isThatMonth()) { - throw new TargetAmountErrorException(TargetAmountErrorCode.INVALID_TARGET_AMOUNT_DATE); - } - - targetAmountService.deleteTargetAmount(targetAmount); + targetAmountDeleteService.execute(targetAmountId); } } From 9b69bd43e886fc39f5bb25894202c62ff7b9af45 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 25 Jun 2024 16:58:17 +0900 Subject: [PATCH 109/152] =?UTF-8?q?refactor:=20=F0=9F=94=A7=20=EC=A7=80?= =?UTF-8?q?=EC=B6=9C=20=EB=82=B4=EC=97=AD=20=EC=9C=A0=EC=A6=88=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#1?= =?UTF-8?q?13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * refactor: create_target_amount() 리팩토링 * rename: recent_target_amount_search_service -> target_amount_search_service * refactor: get_target_amount_and_total_spending() * refactor: get_target_amounts_and_total_spendings() * refactor: update_target_amount 리팩토링 * refactor: target_amount_delete_service() 리팩토링 * style: 불필요한 transactional 어노테이션 제거 * refactor: 월별 총 지출 내역 조회 메서드 분리 * refactor: 지출 조회, 지출 목표 금액 조회 서비스 로직 분리 * refactor: 목표금액&월별 지출 내역 리스트 조회 메서드 분리 * fix: target_amount_mapper start_at 사용자 회원가입 일자 -> 가장 오래된 목표 금액 데이터 기반으로 수정 * docs: 목표금액 리스트 조회 스웨거 문서 요약 수정 * refactor: create_spending 리팩토링 * fix: spending_service read_spendings 반환 타입 list에서 optional 제거 * refactor: spending_search_service 관련 메서드 분리 * fix: spending_repository find_by_year_and_month() 반환값 optional 제거 * refactor: spending_update_service 리팩토링 * refactor: spending update & delete 분리 * test: spending_update_service_test 수정 --- .../ledger/service/SpendingDeleteService.java | 25 +++++++++ .../ledger/service/SpendingSaveService.java | 9 +++- .../ledger/service/SpendingSearchService.java | 13 +++++ .../ledger/service/SpendingUpdateService.java | 6 ++- .../apis/ledger/usecase/SpendingUseCase.java | 52 ++++--------------- .../service/SpendingSearchServiceTest.java | 2 +- .../service/SpendingUpdateServiceTest.java | 19 +++++-- .../repository/SpendingCustomRepository.java | 2 +- .../SpendingCustomRepositoryImpl.java | 6 +-- .../spending/service/SpendingService.java | 2 +- 10 files changed, 79 insertions(+), 57 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingDeleteService.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingDeleteService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingDeleteService.java new file mode 100644 index 000000000..62ffb52ec --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingDeleteService.java @@ -0,0 +1,25 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SpendingDeleteService { + private final SpendingService spendingService; + + @Transactional + public void deleteSpending(Long spendingId) { + Spending spending = spendingService.readSpending(spendingId) + .orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_SPENDING)); + + spendingService.deleteSpending(spending); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSaveService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSaveService.java index 351c4cbd6..4b35a70ec 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSaveService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSaveService.java @@ -8,6 +8,9 @@ import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -17,13 +20,15 @@ @Service @RequiredArgsConstructor public class SpendingSaveService { + private final UserService userService; private final SpendingService spendingService; private final SpendingCustomCategoryService spendingCustomCategoryService; @Transactional - public Spending createSpending(User user, SpendingReq request) { - Spending spending; + public Spending createSpending(Long userId, SpendingReq request) { + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + Spending spending; if (!request.isCustomCategory()) { spending = spendingService.createSpending(request.toEntity(user)); } else { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java index f717b3a14..7d1890e49 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java @@ -1,6 +1,9 @@ package kr.co.pennyway.api.apis.ledger.service; +import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; import kr.co.pennyway.domain.domains.spending.service.SpendingService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,6 +20,16 @@ public class SpendingSearchService { private final SpendingService spendingService; + @Transactional(readOnly = true) + public Spending readSpending(Long spendingId) { + return spendingService.readSpending(spendingId).orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_SPENDING)); + } + + @Transactional(readOnly = true) + public List readSpendingsAtYearAndMonth(Long userId, int year, int month) { + return spendingService.readSpendings(userId, year, month); + } + @Transactional(readOnly = true) public Optional readTotalSpendingAmountByUserIdThatMonth(Long userId, LocalDate date) { return spendingService.readTotalSpendingAmountByUserId(userId, date); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java index 88915729e..1c9a3b93c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java @@ -6,6 +6,7 @@ import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -15,10 +16,13 @@ @Service @RequiredArgsConstructor public class SpendingUpdateService { + private final SpendingService spendingService; private final SpendingCustomCategoryService spendingCustomCategoryService; @Transactional - public Spending updateSpending(Spending spending, SpendingReq request) { + public Spending updateSpending(Long spendingId, SpendingReq request) { + Spending spending = spendingService.readSpending(spendingId).orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_SPENDING)); + SpendingCustomCategory customCategory = (request.isCustomCategory()) ? spendingCustomCategoryService.readSpendingCustomCategory(request.categoryId()).orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_CUSTOM_CATEGORY)) : null; diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java index 315c10db8..a881c10c0 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java @@ -3,17 +3,12 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; import kr.co.pennyway.api.apis.ledger.mapper.SpendingMapper; +import kr.co.pennyway.api.apis.ledger.service.SpendingDeleteService; import kr.co.pennyway.api.apis.ledger.service.SpendingSaveService; +import kr.co.pennyway.api.apis.ledger.service.SpendingSearchService; import kr.co.pennyway.api.apis.ledger.service.SpendingUpdateService; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.domains.spending.domain.Spending; -import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; -import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; -import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; @@ -25,68 +20,39 @@ @RequiredArgsConstructor public class SpendingUseCase { private final SpendingSaveService spendingSaveService; + private final SpendingSearchService spendingSearchService; private final SpendingUpdateService spendingUpdateService; - private final SpendingService spendingService; - - - private final UserService userService; - + private final SpendingDeleteService spendingDeleteService; @Transactional public SpendingSearchRes.Individual createSpending(Long userId, SpendingReq request) { - User user = readUserOrThrow(userId); - - Spending spending = spendingSaveService.createSpending(user, request); + Spending spending = spendingSaveService.createSpending(userId, request); return SpendingMapper.toSpendingSearchResIndividual(spending); } @Transactional(readOnly = true) public SpendingSearchRes.Month getSpendingsAtYearAndMonth(Long userId, int year, int month) { - List spendings = spendingService.readSpendings(userId, year, month).orElseThrow( - () -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_SPENDING) - ); + List spendings = spendingSearchService.readSpendingsAtYearAndMonth(userId, year, month); return SpendingMapper.toSpendingSearchResMonth(spendings, year, month); } @Transactional(readOnly = true) public SpendingSearchRes.Individual getSpedingDetail(Long spendingId) { - Spending spending = readSpendingOrThrow(spendingId); + Spending spending = spendingSearchService.readSpending(spendingId); return SpendingMapper.toSpendingSearchResIndividual(spending); } @Transactional public SpendingSearchRes.Individual updateSpending(Long spendingId, SpendingReq request) { - Spending spending = readSpendingOrThrow(spendingId); - - Spending updatedSpending = spendingUpdateService.updateSpending(spending, request); + Spending updatedSpending = spendingUpdateService.updateSpending(spendingId, request); return SpendingMapper.toSpendingSearchResIndividual(updatedSpending); } - @Transactional public void deleteSpending(Long spendingId) { - Spending spending = spendingService.readSpending(spendingId) - .orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_SPENDING)); - - spendingService.deleteSpending(spending); - } - - private User readUserOrThrow(Long userId) { - return userService.readUser(userId).orElseThrow( - () -> { - throw new UserErrorException(UserErrorCode.NOT_FOUND); - } - ); - } - - private Spending readSpendingOrThrow(Long spendingId) { - return spendingService.readSpending(spendingId).orElseThrow( - () -> { - throw new SpendingErrorException(SpendingErrorCode.NOT_FOUND_SPENDING); - } - ); + spendingDeleteService.deleteSpending(spendingId); } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java index 53269ea84..0b038481a 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java @@ -62,7 +62,7 @@ void testReadSpendingsLazyLoading() { SpendingFixture.bulkInsertSpending(user, 100, true, jdbcTemplate); // when - List spendings = spendingService.readSpendings(user.getId(), LocalDate.now().getYear(), LocalDate.now().getMonthValue()).orElseThrow(); + List spendings = spendingService.readSpendings(user.getId(), LocalDate.now().getYear(), LocalDate.now().getMonthValue()); int size = spendings.size(); for (Spending spending : spendings) { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java index 325615b53..773065940 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java @@ -6,6 +6,7 @@ import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; import lombok.extern.slf4j.Slf4j; @@ -28,6 +29,8 @@ public class SpendingUpdateServiceTest { private SpendingUpdateService spendingUpdateService; @Mock private SpendingCustomCategoryService spendingCustomCategoryService; + @Mock + private SpendingService spendingService; private Spending spending; private Spending spendingWithCustomCategory; @@ -39,7 +42,7 @@ public class SpendingUpdateServiceTest { @BeforeEach void setUp() { - spendingUpdateService = new SpendingUpdateService(spendingCustomCategoryService); + spendingUpdateService = new SpendingUpdateService(spendingService, spendingCustomCategoryService); request = new SpendingReq(10000, -1L, SpendingCategory.FOOD, LocalDate.now(), "소비처", "메모"); requestWithCustomCategory = new SpendingReq(10000, 1L, SpendingCategory.CUSTOM, LocalDate.now(), "소비처", "메모"); @@ -56,11 +59,13 @@ void setUp() { @Test void testUpdateSpendingWithCustomCategoryNotFound() { // given + Long spendingId = 1L; + given(spendingService.readSpending(spendingId)).willReturn(Optional.of(spending)); given(spendingCustomCategoryService.readSpendingCustomCategory(1L)).willReturn(Optional.empty()); // when - then SpendingErrorException exception = assertThrows(SpendingErrorException.class, () -> { - spendingUpdateService.updateSpending(spending, requestWithCustomCategory); + spendingUpdateService.updateSpending(spendingId, requestWithCustomCategory); }); log.debug(exception.getExplainError()); } @@ -69,18 +74,24 @@ void testUpdateSpendingWithCustomCategoryNotFound() { @Test void testUpdateSpendingWithCustomCategory() { // given + Long spendingId = 1L; + given(spendingService.readSpending(spendingId)).willReturn(Optional.of(spending)); given(spendingCustomCategoryService.readSpendingCustomCategory(1L)).willReturn(Optional.of(customCategory)); // when - then - assertDoesNotThrow(() -> spendingUpdateService.updateSpending(spending, requestWithCustomCategory)); + assertDoesNotThrow(() -> spendingUpdateService.updateSpending(spendingId, requestWithCustomCategory)); assertNotNull(spending.getSpendingCustomCategory()); } @DisplayName("시스템 카테고리를 사용한 지출내역으로 수정할 시, Spending 객체가 수정된다.") @Test void testUpdateSpendingWithNonCustomCategory() { + // given + Long spendingId = 1L; + given(spendingService.readSpending(spendingId)).willReturn(Optional.of(spending)); + // when - then - assertDoesNotThrow(() -> spendingUpdateService.updateSpending(spending, request)); + assertDoesNotThrow(() -> spendingUpdateService.updateSpending(spendingId, request)); assertNull(spending.getSpendingCustomCategory()); } } \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java index bc11d7bec..3c94a3481 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java @@ -9,5 +9,5 @@ public interface SpendingCustomRepository { Optional findTotalSpendingAmountByUserId(Long userId, int year, int month); - Optional> findByYearAndMonth(Long userId, int year, int month); + List findByYearAndMonth(Long userId, int year, int month); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java index 004752e0a..359ad2f02 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java @@ -46,19 +46,17 @@ public Optional findTotalSpendingAmountByUserId(Long userId } @Override - public Optional> findByYearAndMonth(Long userId, int year, int month) { + public List findByYearAndMonth(Long userId, int year, int month) { Sort sort = Sort.by(Sort.Order.desc("spendAt")); List> orderSpecifiers = QueryDslUtil.getOrderSpecifier(sort); - List result = queryFactory.selectFrom(spending) + return queryFactory.selectFrom(spending) .leftJoin(spending.user, user) .leftJoin(spending.spendingCustomCategory, spendingCustomCategory).fetchJoin() .where(spending.spendAt.year().eq(year) .and(spending.spendAt.month().eq(month))) .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) .fetch(); - - return Optional.ofNullable(result); } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java index 76f490766..233c60e6b 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -45,7 +45,7 @@ public Optional readTotalSpendingAmountByUserId(Long userId } @Transactional(readOnly = true) - public Optional> readSpendings(Long userId, int year, int month) { + public List readSpendings(Long userId, int year, int month) { return spendingRepository.findByYearAndMonth(userId, year, month); } From b976bcce95b181dd6437f05695c9a55556233f56 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 25 Jun 2024 17:01:45 +0900 Subject: [PATCH 110/152] =?UTF-8?q?refactor:=20=F0=9F=94=A7=20=EC=A7=80?= =?UTF-8?q?=EC=B6=9C=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=9C=A0?= =?UTF-8?q?=EC=A6=88=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * refactor: spending_category create 유즈케이스 리팩토링 * refactor: get_spending_categories usecase 리팩토링 --- .../ledger/mapper/SpendingCategoryMapper.java | 21 ++++++++++++++ .../service/SpendingCategorySaveService.java | 28 +++++++++++++++++++ .../SpendingCategorySearchService.java | 22 +++++++++++++++ .../usecase/SpendingCategoryUseCase.java | 25 ++++++----------- 4 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingCategoryMapper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySaveService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySearchService.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingCategoryMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingCategoryMapper.java new file mode 100644 index 000000000..7f27d3f99 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingCategoryMapper.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.api.apis.ledger.mapper; + +import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; + +import java.util.List; + +@Mapper +public class SpendingCategoryMapper { + public static SpendingCategoryDto.Res toResponse(SpendingCustomCategory category) { + return SpendingCategoryDto.Res.from(CategoryInfo.of(category.getId(), category.getName(), category.getIcon())); + } + + public static List toResponses(List categories) { + return categories.stream() + .map(SpendingCategoryMapper::toResponse) + .toList(); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySaveService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySaveService.java new file mode 100644 index 000000000..d916dea0c --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySaveService.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SpendingCategorySaveService { + private final UserService userService; + private final SpendingCustomCategoryService spendingCustomCategoryService; + + @Transactional + public SpendingCustomCategory execute(Long userId, String categoryName, SpendingCategory icon) { + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + + return spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategory.of(categoryName, icon, user)); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySearchService.java new file mode 100644 index 000000000..a27b1c2a8 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySearchService.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SpendingCategorySearchService { + private final SpendingCustomCategoryService spendingCustomCategoryService; + + @Transactional(readOnly = true) + public List readSpendingCustomCategories(Long userId) { + return spendingCustomCategoryService.readSpendingCustomCategories(userId); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java index f8cdfb824..2ecb83c85 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java @@ -1,15 +1,12 @@ package kr.co.pennyway.api.apis.ledger.usecase; import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; +import kr.co.pennyway.api.apis.ledger.mapper.SpendingCategoryMapper; +import kr.co.pennyway.api.apis.ledger.service.SpendingCategorySaveService; +import kr.co.pennyway.api.apis.ledger.service.SpendingCategorySearchService; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; -import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; -import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; -import kr.co.pennyway.domain.domains.user.exception.UserErrorException; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; @@ -20,24 +17,20 @@ @UseCase @RequiredArgsConstructor public class SpendingCategoryUseCase { - private final UserService userService; - private final SpendingCustomCategoryService spendingCustomCategoryService; + private final SpendingCategorySaveService spendingCategorySaveService; + private final SpendingCategorySearchService spendingCategorySearchService; @Transactional public SpendingCategoryDto.Res createSpendingCategory(Long userId, String categoryName, SpendingCategory icon) { - User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + SpendingCustomCategory category = spendingCategorySaveService.execute(userId, categoryName, icon); - SpendingCustomCategory category = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategory.of(categoryName, icon, user)); - - return SpendingCategoryDto.Res.from(CategoryInfo.of(category.getId(), category.getName(), category.getIcon())); + return SpendingCategoryMapper.toResponse(category); } @Transactional(readOnly = true) public List getSpendingCategories(Long userId) { - List categories = spendingCustomCategoryService.readSpendingCustomCategories(userId); + List categories = spendingCategorySearchService.readSpendingCustomCategories(userId); - return categories.stream() - .map(category -> SpendingCategoryDto.Res.from(CategoryInfo.of(category.getId(), category.getName(), category.getIcon()))) - .toList(); + return SpendingCategoryMapper.toResponses(categories); } } From 3cbc53b82da0ce08549171663df7ee5c92988a2b Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 26 Jun 2024 19:02:45 +0900 Subject: [PATCH 111/152] =?UTF-8?q?fix:=20=E2=9C=8F=EF=B8=8F=20=EA=B0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=B5=9C=EA=B7=BC=EC=97=90=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=ED=95=9C=20=EB=AA=A9=ED=91=9C=20=EA=B8=88=EC=95=A1=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=9D=91=EB=8B=B5=20=EC=8B=9C=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * fix: 최근 목표 금액 조회 서비스 optinal 객체 반환하도록 수정 * fix: 최근 목표 금액 응답 dto에 year, month 정보 추가 * fix: 최근 목표 금액 없을 때 정적 팩토리 메서드 추가 * fix: mapper to_recent_target_amount_response 매핑 정보 수정 * fix: use_case 영속화 상태 유지를 위해 transactional 선언 --- .../api/apis/ledger/dto/TargetAmountDto.java | 18 +++++++++++++++--- .../ledger/mapper/TargetAmountMapper.java | 19 ++++++++++++------- .../service/TargetAmountSearchService.java | 7 +++---- .../ledger/usecase/TargetAmountUseCase.java | 1 + 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java index 5ece8e2e1..bc8179f4d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java @@ -94,18 +94,30 @@ public static TargetAmountInfo from(TargetAmount targetAmount) { public record RecentTargetAmountRes( @Schema(description = "최근 목표 금액 존재 여부로써 데이터가 존재하지 않으면 false, 존재하면 true", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) boolean isPresent, + @Schema(description = "최근 목표 금액 년도 정보. isPresent가 false인 경우 필드가 존재하지 않는다.", example = "2024", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonInclude(JsonInclude.Include.NON_NULL) + Integer year, + @Schema(description = "최근 목표 금액 월 정보. isPresent가 false인 경우 필드가 존재하지 않는다.", example = "6", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonInclude(JsonInclude.Include.NON_NULL) + Integer month, @Schema(description = "최근 목표 금액 정보. isPresent가 false인 경우 필드가 존재하지 않는다.", requiredMode = Schema.RequiredMode.REQUIRED) @JsonInclude(JsonInclude.Include.NON_NULL) Integer amount ) { public RecentTargetAmountRes { if (!isPresent) { - amount = null; + assert year == null; + assert month == null; + assert amount == null; } } - public static RecentTargetAmountRes valueOf(Integer amount) { - return (amount.equals(-1)) ? new RecentTargetAmountRes(false, null) : new RecentTargetAmountRes(true, amount); + public static RecentTargetAmountRes notPresent() { + return new RecentTargetAmountRes(false, null, null, null); + } + + public static RecentTargetAmountRes of(Integer year, Integer month, Integer amount) { + return (amount.equals(-1)) ? new RecentTargetAmountRes(false, null, null, null) : new RecentTargetAmountRes(true, year, month, amount); } } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java index 7e407a53c..289bfe198 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java @@ -8,10 +8,7 @@ import java.time.LocalDate; import java.time.YearMonth; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -53,10 +50,18 @@ public static List toWithTotalSpendingResp /** * 최근 목표 금액을 응답 형태로 변환한다. * - * @return TargetAmountDto.RecentTargetAmountRes + * @return {@link TargetAmountDto.RecentTargetAmountRes} */ - public static TargetAmountDto.RecentTargetAmountRes toRecentTargetAmountResponse(Integer amount) { - return TargetAmountDto.RecentTargetAmountRes.valueOf(amount); + public static TargetAmountDto.RecentTargetAmountRes toRecentTargetAmountResponse(Optional targetAmount) { + if (targetAmount.isEmpty()) { + return TargetAmountDto.RecentTargetAmountRes.notPresent(); + } + + Integer year = targetAmount.get().getCreatedAt().getYear(); + Integer month = targetAmount.get().getCreatedAt().getMonthValue(); + Integer amount = targetAmount.get().getAmount(); + + return TargetAmountDto.RecentTargetAmountRes.of(year, month, amount); } private static List createWithTotalSpendingResponses(Map targetAmounts, Map totalSpendings, LocalDate startAt, int monthLength) { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSearchService.java index 711db65df..1f665a7c2 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSearchService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSearchService.java @@ -10,6 +10,7 @@ import java.time.LocalDate; import java.util.List; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -27,9 +28,7 @@ public TargetAmount readTargetAmountThatMonth(Long userId, LocalDate date) { } @Transactional(readOnly = true) - public Integer readRecentTargetAmount(Long userId) { - return targetAmountService.readRecentTargetAmount(userId) - .map(TargetAmount::getAmount) - .orElse(-1); + public Optional readRecentTargetAmount(Long userId) { + return targetAmountService.readRecentTargetAmount(userId); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java index 593fc1f19..ad1571d5e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java @@ -50,6 +50,7 @@ public List getTargetAmountsAndTotalSpendi return TargetAmountMapper.toWithTotalSpendingResponses(targetAmounts, totalSpendings, date); } + @Transactional(readOnly = true) public TargetAmountDto.RecentTargetAmountRes getRecentTargetAmount(Long userId) { return TargetAmountMapper.toRecentTargetAmountResponse(targetAmountSearchService.readRecentTargetAmount(userId)); } From 8ed7c8c0c90db7cbb6e2701383c152de3a5b1c7a Mon Sep 17 00:00:00 2001 From: DinoDeveloper <79460319+asn6878@users.noreply.github.com> Date: Wed, 26 Jun 2024 22:46:06 +0900 Subject: [PATCH 112/152] =?UTF-8?q?fix:=20=E2=9C=8F=EF=B8=8F=20=EB=AC=B8?= =?UTF-8?q?=EC=9D=98=ED=95=98=EA=B8=B0=20API=20content=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=20=EC=88=98=20=EC=A0=9C=ED=95=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9D=B4=EB=A6=84=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=EC=8B=9D=20=EC=88=98=EC=A0=95=20(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 문의하기 api content dto 글자수 제한 * test: content 글자수 제한 테스트 작성 * fix: username 정규표현식 수정 * test: 수정된 username 조건에 맞추어 test 수정 --- .../pennyway/api/apis/auth/dto/SignUpReq.java | 2 +- .../api/apis/question/dto/QuestionReq.java | 3 ++- .../apis/users/dto/UserProfileUpdateDto.java | 2 +- .../AuthControllerValidationTest.java | 8 +++---- .../controller/QuestionControllerTest.java | 24 ++++++++++++++++++- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java index 3e3aba6ac..dad332063 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java @@ -64,7 +64,7 @@ public record General( String username, @Schema(description = "이름", example = "페니웨이") @NotBlank(message = "이름을 입력해주세요") - @Pattern(regexp = "^[가-힣a-z]{2,8}$", message = "2~8자의 한글, 영문 소문자만 사용 가능합니다.") + @Pattern(regexp = "^[가-힣a-z0-9]{2,8}$", message = "2~8자의 한글, 영문 소문자, 숫자만 사용 가능합니다.") String name, @Schema(description = "비밀번호", example = "pennyway1234") @NotBlank(message = "비밀번호를 입력해주세요") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/dto/QuestionReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/dto/QuestionReq.java index 78c0fd41b..c3c95fd34 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/dto/QuestionReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/question/dto/QuestionReq.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import kr.co.pennyway.domain.domains.question.domain.Question; import kr.co.pennyway.domain.domains.question.domain.QuestionCategory; @@ -13,7 +14,7 @@ public record QuestionReq( @Email(message = "이메일 형식이 올바르지 않습니다.") String email, @Schema(description = "문의 내용", example = "문의 내용입니다.") - @NotBlank(message = "문의 내용을 입력해주세요") + @Size(min = 1, max = 5000, message = "문의 내용은 1자 이상 5000자 이하로 입력해주세요") String content, @Schema(description = "문의 카테고리", example = "UTILIZATION") @NotNull(message = "문의 카테고리를 입력해주세요") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java index b9f8b6db6..a940e0045 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java @@ -11,7 +11,7 @@ public class UserProfileUpdateDto { public record NameReq( @Schema(description = "이름", example = "페니웨이") @NotBlank(message = "이름을 입력해주세요") - @Pattern(regexp = "^[가-힣a-z]{2,8}$", message = "2~8자의 한글, 영문 소문자만 사용 가능합니다.") + @Pattern(regexp = "^[가-힣a-z0-9]{2,8}$", message = "2~8자의 한글, 영문 소문자, 숫자만 사용 가능합니다.") String name ) { } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java index 9c0716649..c012c7c6e 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java @@ -101,11 +101,11 @@ void idValidError() throws Exception { .andDo(print()); } - @DisplayName("[3] 이름은 2~8자의 한글, 영문 소문자만 사용 가능합니다.") + @DisplayName("[3] 2~8자의 한글, 영문 소문자, 숫자만 사용 가능합니다.") @Test void nameValidError() throws Exception { // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이1", "pennyway1234", + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이12345", "pennyway1234", "010-1234-5678", "123456"); // when @@ -118,7 +118,7 @@ void nameValidError() throws Exception { // then resultActions .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.name").value("2~8자의 한글, 영문 소문자만 사용 가능합니다.")) + .andExpect(jsonPath("$.fieldErrors.name").value("2~8자의 한글, 영문 소문자, 숫자만 사용 가능합니다.")) .andDo(print()); } @@ -213,7 +213,7 @@ void someFieldMissingError() throws Exception { @Test void signUp() throws Exception { // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", + SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이123", "pennyway1234", "010-1234-5678", "123456"); ResponseCookie expectedCookie = ResponseCookie.from("refreshToken", "refreshToken") .maxAge(Duration.ofDays(7).toSeconds()).httpOnly(true).path("/").build(); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/question/controller/QuestionControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/question/controller/QuestionControllerTest.java index a72785c70..f0daf6a79 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/question/controller/QuestionControllerTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/question/controller/QuestionControllerTest.java @@ -65,7 +65,7 @@ void requiredInputError() throws Exception { resultActions .andExpect(status().isUnprocessableEntity()) .andExpect(jsonPath("$.fieldErrors.email").value("이메일을 입력해주세요")) - .andExpect(jsonPath("$.fieldErrors.content").value("문의 내용을 입력해주세요")) + .andExpect(jsonPath("$.fieldErrors.content").value("문의 내용은 1자 이상 5000자 이하로 입력해주세요")) .andDo(print()); } @@ -127,4 +127,26 @@ void sendQuestion() throws Exception { .andExpect(status().isOk()) .andDo(print()); } + + @Test + @DisplayName("[5] 이메일, 내용을 필수로 입력해야 합니다.") + void sendLargeQuestion() throws Exception { + + // given + String content = "a".repeat(5001); + QuestionReq request = new QuestionReq("team.collabu@gmail.com", content, QuestionCategory.ETC); + + // when + ResultActions resultActions = mockMvc.perform( + post("/v1/questions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.fieldErrors.content").value("문의 내용은 1자 이상 5000자 이하로 입력해주세요")) + .andDo(print()); + } } From 66c690713b241edea28a436bbda1b72103c970c9 Mon Sep 17 00:00:00 2001 From: DinoDeveloper <79460319+asn6878@users.noreply.github.com> Date: Thu, 27 Jun 2024 16:08:59 +0900 Subject: [PATCH 113/152] =?UTF-8?q?fix:=20=F0=9F=90=9B=20=EC=9B=94?= =?UTF-8?q?=EB=B3=84=20=EC=A7=80=EC=B6=9C=EB=82=B4=EC=97=AD=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spending/repository/SpendingCustomRepositoryImpl.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java index 359ad2f02..1c56e65cf 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java @@ -51,10 +51,11 @@ public List findByYearAndMonth(Long userId, int year, int month) { List> orderSpecifiers = QueryDslUtil.getOrderSpecifier(sort); return queryFactory.selectFrom(spending) - .leftJoin(spending.user, user) .leftJoin(spending.spendingCustomCategory, spendingCustomCategory).fetchJoin() .where(spending.spendAt.year().eq(year) - .and(spending.spendAt.month().eq(month))) + .and(spending.spendAt.month().eq(month)) + .and(spending.user.id.eq(userId)) + ) .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) .fetch(); } From e9d2cc500b2ad003a5c35388258e4e196a2d3dea Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sun, 30 Jun 2024 18:04:35 +0900 Subject: [PATCH 114/152] =?UTF-8?q?Api:=20=E2=9C=A8=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?API=20(#118)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * feat: 사용자 인증 코드 발급 & 전화번호 변경 dto 정의 * fix: 사용자 계정 수정 dto 변경 * rename: username-and-phone -> profile 수정 * rename: username-and-profile 수정 * docs: 아이디 & 전화번호 수정 스웨거 작성 * feat: 아이디 & 전화번호 수정 컨트롤러 추가 * feat: 아이디 & 전화번호 수정 usecase 추가 * fix: 인증코드 cache key타입 추가 * fix: 인증코드 요청 유형 phone 타입 추가 * feat: 인증 코드 요청 dto 정적 팩토리 메서드 of 추가 * feat: user entity phone 수정 메서드 추가 * feat: 인증 코드 검증 dto 정적 팩토리 메서드 of 추가 * fix: 사용자 아이디 & 전화번호 변경 dto 불필요한 메서드 제거 (인증코드 검증 dto 치환 메서드) * feat: 사용자 아이디 & 전화번호 수정 서비스 구현 * docs: 사용자 프로필 수정 dto의 code 필드 문서 수정 * docs: 사용자 프로필 수정 api 스웨거 인증번호 에러 응답 추가 * test: user profile update service test mock bean add * feat: 전화번호, 아이디 중복 검사 메서드 추가 (본인 제외) * feat: 사용자 전화번호, 아이디 중복 409 에러코드 추가 * fix: 아이디 & 전화번호 수정 서비스 유효성 검사 추가 * rename: 사용자 수정 서비스 테스트 -> 이름 수정 서비스 테스트 * fix: 나를 제외한 아이디, 전화번호 중복 검사 제거 -> 전체 중복 검사 확인 메서드 추가 * fix: 유효성 검사 시 호출 메서드 수정 * test: 사용자 아이디 & 전화번호 같은 경우 테스트 * test: 아이디 수정 요청 테스트 * test: 전화번호 변경 요청 테스트 * test: 아이디 혹은 전화번호 수정 실패 시 예외 테스트 * rename: 전화번호 예외 검사 불가 항목 주석 추가 * docs: 409 에러 스웨거 문서 추가 --- .../apis/auth/dto/PhoneVerificationDto.java | 171 +++++++++--------- .../api/apis/users/api/UserAccountApi.java | 35 ++++ .../controller/UserAccountController.java | 8 + .../apis/users/dto/UserProfileUpdateDto.java | 17 ++ .../service/UserProfileUpdateService.java | 33 +++- .../users/usecase/UserAccountUseCase.java | 4 + .../api/common/query/VerificationType.java | 3 +- .../NameUpdateServiceTest.java} | 13 +- .../service/UserProfileUpdateServiceTest.java | 130 +++++++++++++ .../common/redis/phone/PhoneCodeKeyType.java | 3 +- .../domain/domains/user/domain/User.java | 4 + .../domains/user/exception/UserErrorCode.java | 2 + .../user/repository/UserRepository.java | 2 + .../domains/user/service/UserService.java | 5 + 14 files changed, 340 insertions(+), 90 deletions(-) rename pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/{usecase/UserProfileUpdateServiceTest.java => service/NameUpdateServiceTest.java} (88%) create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateServiceTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java index 3d51df645..b333527b3 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/PhoneVerificationDto.java @@ -1,101 +1,104 @@ package kr.co.pennyway.api.apis.auth.dto; -import java.time.LocalDateTime; - import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; - import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; +import java.time.LocalDateTime; + public class PhoneVerificationDto { - @Schema(title = "인증번호 요청 DTO", description = "전화번호로 인증번호 송신 요청을 위한 DTO") - public record PushCodeReq( - @Schema(description = "전화번호", example = "010-2629-4624") - @NotBlank(message = "전화번호는 필수입니다.") - @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") - String phone - ) { - } + @Schema(title = "인증번호 요청 DTO", description = "전화번호로 인증번호 송신 요청을 위한 DTO") + public record PushCodeReq( + @Schema(description = "전화번호", example = "010-2629-4624") + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phone + ) { + } + + @Schema(title = "인증번호 발송 응답 DTO", description = "전화번호로 인증번호 송신 응답을 위한 DTO") + public record PushCodeRes( + @Schema(description = "수신자 번호") + String to, + @Schema(description = "발송 시간") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime sendAt, + @Schema(description = "만료 시간 (default: 3분)", example = "2021-08-01T00:00:00") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime expiresAt + ) { + /** + * 인증번호 발송 응답 객체 생성 + * + * @param to String : 수신자 번호 + * @param sendAt LocalDateTime : 발송 시간 + * @param expiresAt LocalDateTime : 만료 시간 (default: 5분) + */ + public static PushCodeRes of(String to, LocalDateTime sendAt, LocalDateTime expiresAt) { + return new PushCodeRes(to, sendAt, expiresAt); + } + } - @Schema(title = "인증번호 발송 응답 DTO", description = "전화번호로 인증번호 송신 응답을 위한 DTO") - public record PushCodeRes( - @Schema(description = "수신자 번호") - String to, - @Schema(description = "발송 시간") - @JsonSerialize(using = LocalDateTimeSerializer.class) - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - LocalDateTime sendAt, - @Schema(description = "만료 시간 (default: 3분)", example = "2021-08-01T00:00:00") - @JsonSerialize(using = LocalDateTimeSerializer.class) - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - LocalDateTime expiresAt - ) { - /** - * 인증번호 발송 응답 객체 생성 - * - * @param to String : 수신자 번호 - * @param sendAt LocalDateTime : 발송 시간 - * @param expiresAt LocalDateTime : 만료 시간 (default: 5분) - */ - public static PushCodeRes of(String to, LocalDateTime sendAt, LocalDateTime expiresAt) { - return new PushCodeRes(to, sendAt, expiresAt); - } - } + @Schema(title = "인증번호 검증 DTO", description = "전화번호로 인증번호 검증 요청을 위한 DTO") + public record VerifyCodeReq( + @Schema(description = "전화번호", example = "010-2629-4624") + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phone, + @Schema(description = "6자리 정수 인증번호", example = "123456") + @NotBlank(message = "인증번호는 필수입니다.") + @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") + String code + ) { + public static VerifyCodeReq from(SignUpReq.Info request) { + return new VerifyCodeReq(request.phone(), request.code()); + } - @Schema(title = "인증번호 검증 DTO", description = "전화번호로 인증번호 검증 요청을 위한 DTO") - public record VerifyCodeReq( - @Schema(description = "전화번호", example = "010-2629-4624") - @NotBlank(message = "전화번호는 필수입니다.") - @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") - String phone, - @Schema(description = "6자리 정수 인증번호", example = "123456") - @NotBlank(message = "인증번호는 필수입니다.") - @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자여야 합니다.") - String code - ) { - public static VerifyCodeReq from(SignUpReq.Info request) { - return new VerifyCodeReq(request.phone(), request.code()); - } + public static VerifyCodeReq from(SignUpReq.OauthInfo request) { + return new VerifyCodeReq(request.phone(), request.code()); + } - public static VerifyCodeReq from(SignUpReq.OauthInfo request) { - return new VerifyCodeReq(request.phone(), request.code()); - } - } + public static VerifyCodeReq of(String phone, String code) { + return new VerifyCodeReq(phone, code); + } + } - @Schema(title = "인증번호 검증 응답 DTO") - public record VerifyCodeRes( - @Schema(description = "코드 일치 여부 : 일치하지 않으면 예외이므로 성공하면 언제나 true", example = "true") - Boolean code, - @Schema(description = "oauth 사용자 여부. true면 sync, false면 회원가입으로 진행 (일반 회원가입 시 필수값)", example = "true") - @JsonInclude(JsonInclude.Include.NON_NULL) - Boolean oauth, - @Schema(description = "기존 계정 존재 여부. true면 sync, false면 회원가입 (oauth 회원가입 시 필수값)", example = "true") - @JsonInclude(JsonInclude.Include.NON_NULL) - Boolean existsUser, - @Schema(description = "기존 사용자 아이디", example = "pennyway") - @JsonInclude(JsonInclude.Include.NON_NULL) - String username - ) { - /** - * 일반 회원가입 시 인증 코드 응답 객체 생성 - * - * @param isOauthUser Boolean : oauth 사용자 여부. true면 sync, false면 회원가입으로 진행 - */ - public static VerifyCodeRes valueOfGeneral(Boolean isValidCode, Boolean isOauthUser, String username) { - return new VerifyCodeRes(isValidCode, isOauthUser, null, username); - } + @Schema(title = "인증번호 검증 응답 DTO") + public record VerifyCodeRes( + @Schema(description = "코드 일치 여부 : 일치하지 않으면 예외이므로 성공하면 언제나 true", example = "true") + Boolean code, + @Schema(description = "oauth 사용자 여부. true면 sync, false면 회원가입으로 진행 (일반 회원가입 시 필수값)", example = "true") + @JsonInclude(JsonInclude.Include.NON_NULL) + Boolean oauth, + @Schema(description = "기존 계정 존재 여부. true면 sync, false면 회원가입 (oauth 회원가입 시 필수값)", example = "true") + @JsonInclude(JsonInclude.Include.NON_NULL) + Boolean existsUser, + @Schema(description = "기존 사용자 아이디", example = "pennyway") + @JsonInclude(JsonInclude.Include.NON_NULL) + String username + ) { + /** + * 일반 회원가입 시 인증 코드 응답 객체 생성 + * + * @param isOauthUser Boolean : oauth 사용자 여부. true면 sync, false면 회원가입으로 진행 + */ + public static VerifyCodeRes valueOfGeneral(Boolean isValidCode, Boolean isOauthUser, String username) { + return new VerifyCodeRes(isValidCode, isOauthUser, null, username); + } - /** - * oauth 회원가입 시 인증 코드 응답 객체 생성 - * - * @param existsUser Boolean : 기존 계정 존재 여부. true면 sync, false면 회원가입으로 진행 - */ - public static VerifyCodeRes valueOfOauth(Boolean isValidCode, Boolean existsUser, String username) { - return new VerifyCodeRes(isValidCode, null, existsUser, username); - } - } + /** + * oauth 회원가입 시 인증 코드 응답 객체 생성 + * + * @param existsUser Boolean : 기존 계정 존재 여부. true면 sync, false면 회원가입으로 진행 + */ + public static VerifyCodeRes valueOfOauth(Boolean isValidCode, Boolean existsUser, String username) { + return new VerifyCodeRes(isValidCode, null, existsUser, username); + } + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java index b47b1949d..edb3ae305 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java @@ -117,6 +117,41 @@ ResponseEntity postPasswordVerification(@RequestBody @Validated UserProfileUp }) ResponseEntity patchPassword(@RequestBody @Validated UserProfileUpdateDto.PasswordReq request, @AuthenticationPrincipal SecurityUserDetails user); + @Operation(summary = "사용자 프로필 수정") + @ApiResponses({ + @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "검증 실패", value = """ + { + "code": "4010", + "message": "인증번호가 일치하지 않습니다." + } + """) + })), + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "검증 실패 - 인증번호 만료", value = """ + { + "code": "4042", + "message": "만료되었거나 등록되지 않은 휴대폰 정보입니다." + } + """) + })), + @ApiResponse(responseCode = "409", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "검증 실패 - 이미 존재하는 아이디", value = """ + { + "code": "4091", + "message": "이미 존재하는 아이디입니다." + } + """), + @ExampleObject(name = "검증 실패 - 이미 존재하는 휴대폰 번호", value = """ + { + "code": "4091", + "message": "이미 존재하는 휴대폰 번호입니다." + } + """) + })) + }) + ResponseEntity patchProfile(@RequestBody @Validated UserProfileUpdateDto.UsernameAndPhoneReq request, @AuthenticationPrincipal SecurityUserDetails user); + @Operation(summary = "사용자 알림 활성화") @Parameter(name = "type", description = "알림 타입", examples = { @ExampleObject(name = "가계부", value = "account_book"), @ExampleObject(name = "피드", value = "feed"), @ExampleObject(name = "채팅", value = "chat") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java index c389426b0..01b78ecb9 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java @@ -77,6 +77,14 @@ public ResponseEntity patchPassword(UserProfileUpdateDto.PasswordReq request, return ResponseEntity.ok(SuccessResponse.noContent()); } + @Override + @PatchMapping("/profile") + @PreAuthorize("isAuthenticated()") + public ResponseEntity patchProfile(@RequestBody @Validated UserProfileUpdateDto.UsernameAndPhoneReq request, @AuthenticationPrincipal SecurityUserDetails user) { + userAccountUseCase.updateUsernameAndPhone(user.getUserId(), request); + return ResponseEntity.ok(SuccessResponse.noContent()); + } + @Override @PatchMapping("/notifications") @PreAuthorize("isAuthenticated()") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java index a940e0045..004f4c978 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java @@ -67,4 +67,21 @@ public record ProfileImageReq( String profileImageUrl ) { } + + @Schema(title = "사용자 아이디, 전화번호 변경 DTO") + public record UsernameAndPhoneReq( + @Schema(description = "변경할 아이디", example = "pennyway") + @NotBlank(message = "아이디를 입력해주세요") + @Pattern(regexp = "^[a-z-_.]{5,20}$", message = "5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.") + String username, + @Schema(description = "전화번호", example = "010-2629-4624") + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") + String phone, + @Schema(description = "6자리 정수 인증번호. 만약 전화번호가 변경되지 않는다면, 6자리 정수 더미값 삽입", example = "123456") + @NotBlank(message = "인증번호는 필수입니다.") + @Pattern(regexp = "^[0-9]{6}$", message = "인증 코드는 6자리 숫자여야 합니다.") + String code + ) { + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java index 3f8719466..17ba91a4b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java @@ -1,5 +1,9 @@ package kr.co.pennyway.api.apis.users.service; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; import kr.co.pennyway.domain.domains.user.domain.NotifySetting; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; @@ -21,6 +25,9 @@ public class UserProfileUpdateService { private final UserService userService; private final AwsS3Provider awsS3Provider; + private final PhoneVerificationService phoneVerificationService; + private final PhoneCodeService phoneCodeService; + @Transactional public void updateName(Long userId, String newName) { User user = readUserOrThrow(userId); @@ -53,10 +60,34 @@ public void updateProfileImage(Long userId, String profileImageUrl) { user.updateProfileImageUrl(awsS3Provider.getObjectPrefix() + originKey); } + @Transactional + public void updateUsernameAndPhone(Long userId, String username, String phone, String code) { + User user = readUserOrThrow(userId); + + if (!user.getUsername().equals(username)) { + if (userService.isExistUsername(username)) { + throw new UserErrorException(UserErrorCode.ALREADY_EXIST_USERNAME); + } + + user.updateUsername(username); + } + + if (!user.getPhone().equals(phone)) { + phoneVerificationService.isValidCode(PhoneVerificationDto.VerifyCodeReq.of(phone, code), PhoneCodeKeyType.PHONE); + phoneCodeService.delete(phone, PhoneCodeKeyType.PHONE); + + if (userService.isExistPhone(phone)) { + throw new UserErrorException(UserErrorCode.ALREADY_EXIST_PHONE); + } + + user.updatePhone(phone); + } + } + @Transactional public void updateNotifySetting(Long userId, NotifySetting.NotifyType type, Boolean flag) { User user = readUserOrThrow(userId); - + user.getNotifySetting().updateNotifySetting(type, flag); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java index 03486ff17..b3e931d9c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java @@ -60,6 +60,10 @@ public void updateProfileImage(Long userId, UserProfileUpdateDto.ProfileImageReq userProfileUpdateService.updateProfileImage(userId, request.profileImageUrl()); } + public void updateUsernameAndPhone(Long userId, UserProfileUpdateDto.UsernameAndPhoneReq request) { + userProfileUpdateService.updateUsernameAndPhone(userId, request.username(), request.phone(), request.code()); + } + public UserProfileUpdateDto.NotifySettingUpdateRes activateNotification(Long userId, NotifySetting.NotifyType type) { userProfileUpdateService.updateNotifySetting(userId, type, Boolean.TRUE); return UserProfileMapper.toNotifySettingUpdateRes(type, Boolean.TRUE); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java index c3de0bfbf..9c63c6d7d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java @@ -7,7 +7,8 @@ public enum VerificationType { GENERAL("general"), OAUTH("oauth"), USERNAME("username"), - PASSWORD("password"); + PASSWORD("password"), + PHONE("phone"); private final String type; diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserProfileUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/NameUpdateServiceTest.java similarity index 88% rename from pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserProfileUpdateServiceTest.java rename to pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/NameUpdateServiceTest.java index d203aeedc..32036b723 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserProfileUpdateServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/NameUpdateServiceTest.java @@ -1,9 +1,10 @@ -package kr.co.pennyway.api.apis.users.usecase; +package kr.co.pennyway.api.apis.users.service; import com.querydsl.jpa.impl.JPAQueryFactory; -import kr.co.pennyway.api.apis.users.service.UserProfileUpdateService; +import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; import kr.co.pennyway.domain.config.JpaConfig; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; @@ -28,7 +29,7 @@ @DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") @ContextConfiguration(classes = {JpaConfig.class, UserProfileUpdateService.class, UserService.class}) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -public class UserProfileUpdateServiceTest extends ExternalApiDBTestConfig { +public class NameUpdateServiceTest extends ExternalApiDBTestConfig { @Autowired private UserService userService; @@ -38,6 +39,12 @@ public class UserProfileUpdateServiceTest extends ExternalApiDBTestConfig { @MockBean private AwsS3Provider awsS3Provider; + @MockBean + private PhoneVerificationService phoneVerificationService; + + @MockBean + private PhoneCodeService phoneCodeService; + @MockBean private JPAQueryFactory queryFactory; diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateServiceTest.java new file mode 100644 index 000000000..dd3416e94 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateServiceTest.java @@ -0,0 +1,130 @@ +package kr.co.pennyway.api.apis.users.service; + +import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.verifyNoInteractions; + +@Slf4j +@ExtendWith(MockitoExtension.class) +public class UserProfileUpdateServiceTest { + private final Long userId = 1L; + private User user = UserFixture.GENERAL_USER.toUser(); + @InjectMocks + private UserProfileUpdateService userProfileUpdateService; + @Mock + private UserService userService; + @Mock + private AwsS3Provider awsS3Provider; + @Mock + private PhoneVerificationService phoneVerificationService; + @Mock + private PhoneCodeService phoneCodeService; + + @BeforeEach + void setUp() { + given(userService.readUser(userId)).willReturn(Optional.of(user)); + } + + @Test + @DisplayName("수정 요청한 아이디와 전화번호가 기존 정보와 일치할 경우, 변경이 발생하지 않는다.") + void updateSameUsermameAndPhone() { + // when + userProfileUpdateService.updateUsernameAndPhone(userId, user.getUsername(), user.getPhone(), "000000"); + + // then + verifyNoInteractions(awsS3Provider, phoneVerificationService, phoneCodeService); + } + + @Test + @DisplayName("수정 요청한 아이디만 기존 정보와 다를 경우, 아이디만 변경이 발생한다.") + void updateDifferentUsername() { + // given + String newUsername = "newUsername"; + String expectedPhone = user.getPhone(); + + // when + userProfileUpdateService.updateUsernameAndPhone(userId, newUsername, user.getPhone(), "000000"); + + // then + assertEquals(newUsername, user.getUsername()); + assertEquals(expectedPhone, user.getPhone()); + verifyNoInteractions(awsS3Provider, phoneVerificationService, phoneCodeService); + } + + @Test + @DisplayName("수정 요청한 전화번호만 기존 정보와 다를 경우, 전화번호만 변경이 발생한다.") + void updateDifferentPhone() { + // given + String expectedUsername = user.getUsername(); + String newPhone = "010-0000-0000"; + given(phoneVerificationService.isValidCode(any(), eq(PhoneCodeKeyType.PHONE))).willReturn(true); + willDoNothing().given(phoneCodeService).delete(newPhone, PhoneCodeKeyType.PHONE); + + // when + userProfileUpdateService.updateUsernameAndPhone(userId, user.getUsername(), newPhone, "000000"); + + // then + assertEquals(expectedUsername, user.getUsername()); + assertEquals(newPhone, user.getPhone()); + verifyNoInteractions(awsS3Provider); + } + + @Test + @DisplayName("수정 요청한 아이디가 이미 존재하면, ALREADY_EXIST_USERNAME 에러를 반환한다.") + void updateAlreadyExistUsername() { + // given + String newUsername = "newUsername"; + given(userService.isExistUsername(newUsername)).willReturn(true); + + // when + UserErrorException exception = assertThrows(UserErrorException.class, () -> userProfileUpdateService.updateUsernameAndPhone(userId, newUsername, user.getPhone(), "000000")); + + // then + assertEquals(UserErrorCode.ALREADY_EXIST_USERNAME, exception.getBaseErrorCode()); + verifyNoInteractions(awsS3Provider, phoneVerificationService, phoneCodeService); + } + + /** + * 트랜잭션이 활성화되지 않아서, username 변경이 되지 않음을 확인할 수는 없다. + */ + @Test + @DisplayName("수정 요청한 전화번호가 이미 존재하면, ALREADY_EXIST_PHONE 에러를 반환한다.") + void updateAlreadyExistPhone() { + // given + String newPhone = "010-0000-0000"; + given(userService.isExistPhone(newPhone)).willReturn(true); + given(phoneVerificationService.isValidCode(any(), eq(PhoneCodeKeyType.PHONE))).willReturn(true); + willDoNothing().given(phoneCodeService).delete(newPhone, PhoneCodeKeyType.PHONE); + + // when + UserErrorException exception = assertThrows(UserErrorException.class, () -> userProfileUpdateService.updateUsernameAndPhone(userId, user.getUsername(), newPhone, "000000")); + + // then + assertEquals(UserErrorCode.ALREADY_EXIST_PHONE, exception.getBaseErrorCode()); + verifyNoInteractions(awsS3Provider); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeKeyType.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeKeyType.java index 754b83ce9..6be3174cf 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeKeyType.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeKeyType.java @@ -12,7 +12,8 @@ public enum PhoneCodeKeyType { OAUTH_SIGN_UP_GOOGLE("oauthSignUp:google"), OAUTH_SIGN_UP_APPLE("oauthSignUp:apple"), FIND_USERNAME("username"), - FIND_PASSWORD("password"); + FIND_PASSWORD("password"), + PHONE("phone"); private final String prefix; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java index 222209f4c..dfbc2d179 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java @@ -107,6 +107,10 @@ public void updateProfileImageUrl(String profileImageUrl) { this.profileImageUrl = profileImageUrl; } + public void updatePhone(String phone) { + this.phone = phone; + } + public boolean isGeneralSignedUpUser() { return password != null; } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java index 17dbe5092..4fa7fda92 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java @@ -21,6 +21,8 @@ public enum UserErrorCode implements BaseErrorCode { /* 409 Conflict */ ALREADY_SIGNUP(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 회원가입한 유저입니다."), + ALREADY_EXIST_USERNAME(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 존재하는 아이디입니다."), + ALREADY_EXIST_PHONE(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 존재하는 휴대폰 번호입니다."), /* 404 NOT_FOUND */ NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "유저를 찾을 수 없습니다."), diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java index 6554c6167..8db6e9c33 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java @@ -15,6 +15,8 @@ public interface UserRepository extends ExtendedRepository { boolean existsByUsername(String username); + boolean existsByPhone(String phone); + @Transactional @Modifying(clearAutomatically = true) @Query("UPDATE User u SET u.deletedAt = NOW() WHERE u.id = :userId") diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java index 054518861..225ac2597 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java @@ -43,6 +43,11 @@ public boolean isExistUsername(String username) { return userRepository.existsByUsername(username); } + @Transactional(readOnly = true) + public boolean isExistPhone(String phone) { + return userRepository.existsByPhone(phone); + } + @Transactional public void deleteUser(User user) { userRepository.delete(user); From cb8888d47a585dbc6b89bcf0e45a85e8b858d570 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 3 Jul 2024 20:58:32 +0900 Subject: [PATCH 115/152] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=EC=97=90=20=EB=93=B1=EB=A1=9D=EB=90=9C=20?= =?UTF-8?q?=EC=86=8C=EB=B9=84=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=AC=B4?= =?UTF-8?q?=ED=95=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20(#120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * test: controller unit test 작성 * test: 카테고리별 지출 리스트 조회 api 경로 수정 * feat: 조회하려는 카테고리 타입 상수 정의 * feat: 지출 카테고리 타입 400 에러 추가 * feat: 지출 카테고리 타입 상수 web-config conveter 정의 후 등록 * test: spending-category-type 쿼리 파라미터 테스트 추가 * rename: 에러 상수 오타 수정 * test: 400 error -> 422 에러 수정 * feat: get-spendings-by-category controller 구현 * feat: 카테고리 별 지출 내역 조회 usecase 작성 * feat: 카테고리 타입에 따른 권한 검사 메서드 추가 * test: controller type, category_id 조합 검사 테스트 * fix: default type 카테고리에 대해 category-id 조건 검사 추가 * feat: 카테고리에 등록된 지출 내역 리스트 조회 메서드 추가 * feat: 사용자 정의 카테고리 아이디 & 시스템 제공 카테고리 code 기반 지출 내역 슬라이스 조회 메서드 추가 * feat: 타입, 카테고리 아이디 불일치 에러코드 추가 * fix: spending-search-service 내부에서 시스템 정의 카테고리 지출 내역 조회 시, 상수 타입 변환 * feat: 지출 카테고리 code 기반 상수 탐색 정적 팩토리 메서드 추가 * fix: 시스템 제공 카테고리의 지출 내역 조회 시, 도메인 서비스에서 타입 검사 조건문 추가 * feat: 커스텀 카테고리 아이디 & 카테고리 코드 별 지출 리스트 조회 repository 메서드 추가 * feat: 지출 entity to_string 재정의 * fix: 지출 기본 정렬 필드 created_at -> spend_at * rename: 디버깅용 로그 제거 * feat: mapper 내 카테고리별 지출 리스트 응답 dto 메서드 추가 && daily-list 정렬 메서드 추가 * feat: usecase 응답 시 mapper 호출 * style: usecase 내 주석 제거 * feat: 지출 월별 데이터 슬라이싱 응답 dto 정의 * fix: usecase & mapper 타입 수정 * rename: month-slice dto 필드명 months -> content * refactor: 도메인 서비스 내 custom-repository -> interface로 로직 수행 * test: usecase mock given절 처리 * refactor: spending-mapper map key 연산 시, year-month 객체를 사용하여 수정 * docs: 카테고리의 지출 리스트 조회 api 스웨거 문서 작성 * feat: 카테고리에 등록된 소비 내역 총 개수 조회 controller 추가 * docs: 카테고리 내 지출 총 개수 조회 api 스웨거 문서 작성 * feat: 카테고리 내 지출 총 개수 조회 usecase 추가 * feat: 카테고리 내 지출 리스트 총 개수 조회 도메인 서비스 & repository 메서드 추가 * feat: spending-search-service total count 확인 메서드 추가 * feat: 자원 인가 검사 추가 --- .../apis/ledger/api/SpendingCategoryApi.java | 67 ++++++++++++++ .../SpendingCategoryController.java | 41 ++++++++- .../apis/ledger/dto/SpendingSearchRes.java | 20 +++++ .../apis/ledger/mapper/SpendingMapper.java | 34 ++++++++ .../ledger/service/SpendingSearchService.java | 34 ++++++++ .../usecase/SpendingCategoryUseCase.java | 21 +++++ .../SpendingCategoryTypeConverter.java | 17 ++++ .../common/query/SpendingCategoryType.java | 12 +++ .../SpendingCategoryManager.java | 15 ++++ .../kr/co/pennyway/api/config/WebConfig.java | 3 +- .../GetSpendingsByCategoryControllerTest.java | 87 +++++++++++++++++++ .../domains/spending/domain/Spending.java | 11 +++ .../spending/exception/SpendingErrorCode.java | 3 +- .../SpendingCustomRepositoryImpl.java | 3 +- .../repository/SpendingRepository.java | 7 ++ .../spending/service/SpendingService.java | 57 ++++++++++++ .../spending/type/SpendingCategory.java | 9 ++ 17 files changed, 434 insertions(+), 7 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingCategoryTypeConverter.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingCategoryType.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/GetSpendingsByCategoryControllerTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java index d84f53220..add401638 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java @@ -6,12 +6,21 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.*; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; +import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.api.common.query.SpendingCategoryType; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.data.web.SortDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "지출 카테고리 API") public interface SpendingCategoryApi { @@ -32,4 +41,62 @@ public interface SpendingCategoryApi { @Operation(summary = "사용자 정의 지출 카테고리 조회", method = "GET", description = "사용자가 생성한 지출 카테고리 목록을 조회합니다.") @ApiResponse(responseCode = "200", description = "지출 카테고리 조회 성공", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "spendingCategories", array = @ArraySchema(schema = @Schema(implementation = SpendingCategoryDto.Res.class))))) ResponseEntity getSpendingCategories(@AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "지출 카테고리에 등록된 지출 내역 총 개수 조회", method = "GET") + @Parameters({ + @Parameter(name = "categoryId", description = "type이 default면 아이콘 코드(1~11), custom이면 카테고리 pk", required = true, in = ParameterIn.PATH), + @Parameter(name = "type", description = "지출 카테고리 타입", required = true, in = ParameterIn.QUERY, examples = { + @ExampleObject(name = "기본", value = "default"), @ExampleObject(name = "사용자 정의", value = "custom") + }) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "지출 내역 총 개수 조회 성공", content = @Content(mediaType = "application/json", examples = @ExampleObject(name = "지출 내역 총 개수 조회 성공", value = """ + { + "totalCount": 10 + } + """))), + @ApiResponse(responseCode = "400", description = "type과 categoryId 미스 매치", content = @Content(examples = + @ExampleObject(name = "type과 categoryId가 유효하지 않은 조합", description = "type이 default면서, categoryId가 CUSTOM(0) 혹은 OTHER(12)일 수는 없다.", value = """ + { + "code": "4005", + "message": "type의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다." + } + """ + ))) + }) + ResponseEntity getSpendingTotalCountByCategory( + @PathVariable(value = "categoryId") Long categoryId, + @RequestParam(value = "type") SpendingCategoryType type, + @AuthenticationPrincipal SecurityUserDetails user + ); + + @Operation(summary = "지출 카테고리에 등록된 지출 내역 조회", method = "GET", description = "지출 카테고리별 지출 내역을 조회하며, 무한 스크롤 응답이 반환됩니다.") + @Parameters({ + @Parameter(name = "categoryId", description = "type이 default면 아이콘 코드(1~11), custom이면 카테고리 pk", required = true, in = ParameterIn.PATH), + @Parameter(name = "type", description = "지출 카테고리 타입", required = true, in = ParameterIn.QUERY, examples = { + @ExampleObject(name = "기본", value = "default"), @ExampleObject(name = "사용자 정의", value = "custom") + }), + @Parameter(name = "size", description = "페이지 사이즈 (default: 30)", example = "30", in = ParameterIn.QUERY), + @Parameter(name = "page", description = "페이지 번호 (default: 0)", example = "0", in = ParameterIn.QUERY), + @Parameter(name = "sort", description = "정렬 기준 (default: sending.spendAt)", example = "spending.spendAt", in = ParameterIn.QUERY), + @Parameter(name = "direction", description = "정렬 방식 (default: DESC)", example = "DESC", in = ParameterIn.QUERY), + @Parameter(name = "pageable", hidden = true) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "지출 내역 조회 성공", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "spendings", schema = @Schema(implementation = SpendingSearchRes.MonthSlice.class)))), + @ApiResponse(responseCode = "400", description = "type과 categoryId 미스 매치", content = @Content(examples = + @ExampleObject(name = "type과 categoryId가 유효하지 않은 조합", description = "type이 default면서, categoryId가 CUSTOM(0) 혹은 OTHER(12)일 수는 없다.", value = """ + { + "code": "4005", + "message": "type의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다." + } + """ + ))) + }) + ResponseEntity getSpendingsByCategory( + @PathVariable(value = "categoryId") Long categoryId, + @RequestParam(value = "type") SpendingCategoryType type, + @PageableDefault(size = 30, page = 0) @SortDefault(sort = "spending.spendAt", direction = Sort.Direction.DESC) Pageable pageable, + @AuthenticationPrincipal SecurityUserDetails user + ); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java index 076acbf5a..e40d0f355 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java @@ -3,6 +3,7 @@ import kr.co.pennyway.api.apis.ledger.api.SpendingCategoryApi; import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; import kr.co.pennyway.api.apis.ledger.usecase.SpendingCategoryUseCase; +import kr.co.pennyway.api.common.query.SpendingCategoryType; import kr.co.pennyway.api.common.response.SuccessResponse; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; @@ -10,14 +11,15 @@ import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.data.web.SortDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @Slf4j @RestController @@ -44,4 +46,35 @@ public ResponseEntity postSpendingCategory(@Validated SpendingCategoryDto.Cre public ResponseEntity getSpendingCategories(@AuthenticationPrincipal SecurityUserDetails user) { return ResponseEntity.ok(SuccessResponse.from("spendingCategories", spendingCategoryUseCase.getSpendingCategories(user.getUserId()))); } + + @Override + @GetMapping("/{categoryId}/spendings/count") + @PreAuthorize("isAuthenticated() and @spendingCategoryManager.hasPermission(#user.getUserId(), #categoryId, #type)") + public ResponseEntity getSpendingTotalCountByCategory( + @PathVariable(value = "categoryId") Long categoryId, + @RequestParam(value = "type") SpendingCategoryType type, + @AuthenticationPrincipal SecurityUserDetails user + ) { + if (type.equals(SpendingCategoryType.DEFAULT) && (categoryId.equals(0L) || categoryId.equals(12L))) { + throw new SpendingErrorException(SpendingErrorCode.INVALID_TYPE_WITH_CATEGORY_ID); + } + + return ResponseEntity.ok(SuccessResponse.from("totalCount", spendingCategoryUseCase.getSpendingTotalCountByCategory(user.getUserId(), categoryId, type))); + } + + @Override + @GetMapping("/{categoryId}/spendings") + @PreAuthorize("isAuthenticated() and @spendingCategoryManager.hasPermission(#user.getUserId(), #categoryId, #type)") + public ResponseEntity getSpendingsByCategory( + @PathVariable(value = "categoryId") Long categoryId, + @RequestParam(value = "type") SpendingCategoryType type, + @PageableDefault(size = 30, page = 0) @SortDefault(sort = "spending.spendAt", direction = Sort.Direction.DESC) Pageable pageable, + @AuthenticationPrincipal SecurityUserDetails user + ) { + if (type.equals(SpendingCategoryType.DEFAULT) && (categoryId.equals(0L) || categoryId.equals(12L))) { + throw new SpendingErrorException(SpendingErrorCode.INVALID_TYPE_WITH_CATEGORY_ID); + } + + return ResponseEntity.ok(SuccessResponse.from("spendings", spendingCategoryUseCase.getSpendingsByCategory(user.getUserId(), categoryId, pageable, type))); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java index 6b85dbd6e..10e6a0cd9 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java @@ -7,12 +7,32 @@ import jakarta.validation.constraints.NotNull; import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; import lombok.Builder; +import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; import java.util.List; import java.util.Objects; public class SpendingSearchRes { + @Builder + @Schema(title = "월별 지출 내역 조회 슬라이스 응답") + public record MonthSlice( + @Schema(description = "년/월별 지출 내역") + List content, + @Schema(description = "현재 페이지 번호") + int currentPageNumber, + @Schema(description = "페이지 크기") + int pageSize, + @Schema(description = "전체 요소 개수") + int numberOfElements, + @Schema(description = "다음 페이지 존재 여부") + boolean hasNext + ) { + public static MonthSlice from(List months, Pageable pageable, int numberOfElements, boolean hasNext) { + return new MonthSlice(months, pageable.getPageNumber(), pageable.getPageSize(), numberOfElements, hasNext); + } + } + @Builder @Schema(title = "월별 지출 내역 조회 응답") public record Month( diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java index 6c57bdc73..62921b0f8 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java @@ -3,18 +3,47 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; import kr.co.pennyway.common.annotation.Mapper; import kr.co.pennyway.domain.domains.spending.domain.Spending; +import org.springframework.data.domain.Slice; +import java.time.YearMonth; +import java.util.Comparator; import java.util.List; import java.util.concurrent.ConcurrentMap; import java.util.stream.Collectors; @Mapper public class SpendingMapper { + /** + * Slice 객체를 받아 년/월/일 별로 지출 내역을 그룹화 및 정렬화 후 {@link SpendingSearchRes.MonthSlice}로 변환하는 메서드 + */ + public static SpendingSearchRes.MonthSlice toMonthSlice(Slice spendings) { + List spendingList = spendings.getContent(); + + // 연도와 월별로 그룹화 + ConcurrentMap> groupSpendingsByYearAndMonth = spendingList.stream() + .collect(Collectors.groupingByConcurrent(spending -> YearMonth.of(spending.getSpendAt().getYear(), spending.getSpendAt().getMonthValue()))); + + // 그룹화된 결과를 Month 객체로 변환하고, 년-월 순으로 역정렬 + List months = groupSpendingsByYearAndMonth.entrySet().stream() + .map(entry -> toSpendingSearchResMonth(entry.getValue(), entry.getKey().getYear(), entry.getKey().getMonthValue())) + .sorted(Comparator.comparing(SpendingSearchRes.Month::year) + .thenComparing(SpendingSearchRes.Month::month) + .reversed()) + .toList(); + + return SpendingSearchRes.MonthSlice.from(months, spendings.getPageable(), spendings.getNumberOfElements(), spendings.hasNext()); + } + + /** + * 년/월 별로 지출 내역을 그룹화 및 정렬화 후 {@link SpendingSearchRes.Month}로 변환하는 메서드 + */ public static SpendingSearchRes.Month toSpendingSearchResMonth(List spendings, int year, int month) { ConcurrentMap> groupSpendingsByDay = spendings.stream().collect(Collectors.groupingByConcurrent(Spending::getDay)); + // 그룹화된 결과를 Daily 객체로 변환하고, 일(day)을 기준으로 역정렬 List dailySpendings = groupSpendingsByDay.entrySet().stream() .map(entry -> toSpendingSearchResDaily(entry.getKey(), entry.getValue())) + .sorted(Comparator.comparing(SpendingSearchRes.Daily::day).reversed()) .toList(); return SpendingSearchRes.Month.builder() @@ -24,9 +53,14 @@ public static SpendingSearchRes.Month toSpendingSearchResMonth(List sp .build(); } + /** + * 일 별로 지출 내역을 정렬 후 {@link SpendingSearchRes.Daily}로 변환하는 메서드 + */ private static SpendingSearchRes.Daily toSpendingSearchResDaily(int day, List spendings) { + // 지출 내역을 id 순으로 정렬 List individuals = spendings.stream() .map(SpendingMapper::toSpendingSearchResIndividual) + .sorted(Comparator.comparing(SpendingSearchRes.Individual::id)) .toList(); return SpendingSearchRes.Daily.builder() diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java index 7d1890e49..f11843a0c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java @@ -1,12 +1,16 @@ package kr.co.pennyway.api.apis.ledger.service; +import kr.co.pennyway.api.common.query.SpendingCategoryType; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,6 +34,36 @@ public List readSpendingsAtYearAndMonth(Long userId, int year, int mon return spendingService.readSpendings(userId, year, month); } + /** + * 카테고리에 등록된 지출 내역 개수를 조회한다. + */ + @Transactional(readOnly = true) + public int readSpendingTotalCountByCategoryId(Long userId, Long categoryId, SpendingCategoryType type) { + if (type.equals(SpendingCategoryType.CUSTOM)) { + return spendingService.readSpendingTotalCountByCategoryId(userId, categoryId); + } + + SpendingCategory spendingCategory = SpendingCategory.fromCode(categoryId.toString()); + return spendingService.readSpendingTotalCountByCategory(userId, spendingCategory); + } + + /** + * 카테고리에 등록된 지출 내역 리스트를 조회한다. + * + * @param categoryId type이 {@link SpendingCategoryType#CUSTOM}이면 커스텀 카테고리 아이디, {@link SpendingCategoryType#DEFAULT}이면 시스템 제공 카테고리 코드로 사용한다. + * @param type {@link SpendingCategoryType#CUSTOM}이면 커스텀 카테고리, {@link SpendingCategoryType#DEFAULT}이면 시스템 제공 카테고리에 대한 쿼리를 호출한다. + * @return 지출 내역 리스트를 {@link Slice}에 담아서 반환한다. + */ + @Transactional(readOnly = true) + public Slice readSpendingsByCategoryId(Long userId, Long categoryId, Pageable pageable, SpendingCategoryType type) { + if (type.equals(SpendingCategoryType.CUSTOM)) { + return spendingService.readSpendingsSliceByCategoryId(userId, categoryId, pageable); + } + + SpendingCategory spendingCategory = SpendingCategory.fromCode(categoryId.toString()); + return spendingService.readSpendingsSliceByCategory(userId, spendingCategory, pageable); + } + @Transactional(readOnly = true) public Optional readTotalSpendingAmountByUserIdThatMonth(Long userId, LocalDate date) { return spendingService.readTotalSpendingAmountByUserId(userId, date); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java index 2ecb83c85..c1d94f460 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java @@ -1,14 +1,21 @@ package kr.co.pennyway.api.apis.ledger.usecase; import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; +import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; import kr.co.pennyway.api.apis.ledger.mapper.SpendingCategoryMapper; +import kr.co.pennyway.api.apis.ledger.mapper.SpendingMapper; import kr.co.pennyway.api.apis.ledger.service.SpendingCategorySaveService; import kr.co.pennyway.api.apis.ledger.service.SpendingCategorySearchService; +import kr.co.pennyway.api.apis.ledger.service.SpendingSearchService; +import kr.co.pennyway.api.common.query.SpendingCategoryType; import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -20,6 +27,8 @@ public class SpendingCategoryUseCase { private final SpendingCategorySaveService spendingCategorySaveService; private final SpendingCategorySearchService spendingCategorySearchService; + private final SpendingSearchService spendingSearchService; + @Transactional public SpendingCategoryDto.Res createSpendingCategory(Long userId, String categoryName, SpendingCategory icon) { SpendingCustomCategory category = spendingCategorySaveService.execute(userId, categoryName, icon); @@ -33,4 +42,16 @@ public List getSpendingCategories(Long userId) { return SpendingCategoryMapper.toResponses(categories); } + + @Transactional(readOnly = true) + public int getSpendingTotalCountByCategory(Long userId, Long categoryId, SpendingCategoryType type) { + return spendingSearchService.readSpendingTotalCountByCategoryId(userId, categoryId, type); + } + + @Transactional(readOnly = true) + public SpendingSearchRes.MonthSlice getSpendingsByCategory(Long userId, Long categoryId, Pageable pageable, SpendingCategoryType type) { + Slice spendings = spendingSearchService.readSpendingsByCategoryId(userId, categoryId, pageable, type); + + return SpendingMapper.toMonthSlice(spendings); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingCategoryTypeConverter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingCategoryTypeConverter.java new file mode 100644 index 000000000..a46b15e24 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/converter/SpendingCategoryTypeConverter.java @@ -0,0 +1,17 @@ +package kr.co.pennyway.api.common.converter; + +import kr.co.pennyway.api.common.query.SpendingCategoryType; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; +import org.springframework.core.convert.converter.Converter; + +public class SpendingCategoryTypeConverter implements Converter { + @Override + public SpendingCategoryType convert(String type) { + try { + return SpendingCategoryType.valueOf(type.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new SpendingErrorException(SpendingErrorCode.INVALID_CATEGORY_TYPE); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingCategoryType.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingCategoryType.java new file mode 100644 index 000000000..048164750 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingCategoryType.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.api.common.query; + +public enum SpendingCategoryType { + DEFAULT("default"), + CUSTOM("custom"); + + private final String type; + + SpendingCategoryType(String type) { + this.type = type; + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java index a8e9aa81e..f3d0ece74 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java @@ -1,5 +1,6 @@ package kr.co.pennyway.api.common.security.authorization; +import kr.co.pennyway.api.common.query.SpendingCategoryType; import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,4 +27,18 @@ public boolean hasPermission(Long userId, Long categoryId) { return spendingCustomCategoryService.isExistsSpendingCustomCategory(userId, categoryId); } + + /** + * 사용자가 지출 카테고리에 대한 권한이 있는지 확인한다. + * {@link SpendingCategoryType#CUSTOM}이면 {@link #hasPermission(Long, Long)}를 호출한다. + * {@link SpendingCategoryType#DEFAULT}면, 시스템 제공 카테고리이므로 권한 검사를 수행하지 않는다. + */ + @Transactional(readOnly = true) + public boolean hasPermission(Long userId, Long categoryId, SpendingCategoryType type) { + if (type.equals(SpendingCategoryType.CUSTOM)) { + return hasPermission(userId, categoryId); + } + + return true; + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java index 3040f7d8f..fbac3f61e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java @@ -2,6 +2,7 @@ import kr.co.pennyway.api.common.converter.NotifyTypeConverter; import kr.co.pennyway.api.common.converter.ProviderConverter; +import kr.co.pennyway.api.common.converter.SpendingCategoryTypeConverter; import kr.co.pennyway.api.common.converter.VerificationTypeConverter; import kr.co.pennyway.api.common.interceptor.SignEventLogInterceptor; import kr.co.pennyway.domain.common.redis.sign.SignEventLogService; @@ -20,10 +21,10 @@ public class WebConfig implements WebMvcConfigurer { @Override public void addFormatters(FormatterRegistry registrar) { - registrar.addConverter(new ProviderConverter()); registrar.addConverter(new VerificationTypeConverter()); registrar.addConverter(new NotifyTypeConverter()); + registrar.addConverter(new SpendingCategoryTypeConverter()); } @Override diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/GetSpendingsByCategoryControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/GetSpendingsByCategoryControllerTest.java new file mode 100644 index 000000000..d0f70445e --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/GetSpendingsByCategoryControllerTest.java @@ -0,0 +1,87 @@ +package kr.co.pennyway.api.apis.ledger.controller; + +import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.api.apis.ledger.usecase.SpendingCategoryUseCase; +import kr.co.pennyway.api.common.query.SpendingCategoryType; +import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import kr.co.pennyway.domain.common.redis.sign.SignEventLogService; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.util.ArrayList; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = SpendingCategoryController.class) +@ActiveProfiles("test") +public class GetSpendingsByCategoryControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + private SpendingCategoryUseCase spendingCategoryUseCase; + @MockBean + private SignEventLogService signEventLogService; + @MockBean + private JwtProvider accessTokenProvider; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(MockMvcRequestBuilders.get("/**").with(csrf())) + .build(); + } + + @Test + @DisplayName("default, custom 타입은 올바르게 조회된다.") + @WithSecurityMockUser + void getSpendingsByCategory() throws Exception { + given(spendingCategoryUseCase.getSpendingsByCategory(any(), any(), any(), any())).willReturn(new SpendingSearchRes.MonthSlice(new ArrayList<>(), 0, 0, 0, false)); + + performGetSpendingsByCategory(1L, SpendingCategoryType.DEFAULT.name()) + .andDo(print()) + .andExpect(status().isOk()); + performGetSpendingsByCategory(1L, SpendingCategoryType.CUSTOM.name().toLowerCase()) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("잘못된 타입을 조회하면 422 에러가 발생한다.") + void getSpendingsByCategory_InvalidType() throws Exception { + performGetSpendingsByCategory(1L, "invalid") + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("카테고리 타입이 default이면서 categoryId가 0이거나 12이면 400 에러가 발생한다.") + void getSpendingsByCategory_InvalidCategoryId() throws Exception { + performGetSpendingsByCategory(0L, SpendingCategoryType.DEFAULT.name()) + .andDo(print()) + .andExpect(status().isBadRequest()); + performGetSpendingsByCategory(12L, SpendingCategoryType.DEFAULT.name()) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + private ResultActions performGetSpendingsByCategory(Long categoryId, String type) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders.get("/v2/spending-categories/{categoryId}/spendings", categoryId) + .param("type", type)); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java index 410d3a136..bdad51667 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java @@ -94,4 +94,15 @@ public void update(Integer amount, SpendingCategory category, LocalDateTime spen this.memo = memo; this.spendingCustomCategory = spendingCustomCategory; } + + @Override + public String toString() { + return "Spending{" + + "id=" + id + + ", amount=" + amount + + ", category=" + category + + ", spendAt=" + spendAt + + ", accountName='" + accountName + '\'' + + ", memo='" + memo + "'}"; + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java index 992599488..889ac2db1 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java @@ -13,12 +13,13 @@ public enum SpendingErrorCode implements BaseErrorCode { /* 400 Bad Request */ INVALID_ICON(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."), INVALID_ICON_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "icon의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."), + INVALID_TYPE_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "type의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."), + INVALID_CATEGORY_TYPE(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "존재하지 않는 카테고리 타입입니다."), /* 404 Not Found */ NOT_FOUND_SPENDING(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "존재하지 않는 지출 내역입니다."), NOT_FOUND_CUSTOM_CATEGORY(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "존재하지 않는 커스텀 카테고리입니다."); - private final StatusCode statusCode; private final ReasonCode reasonCode; private final String message; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java index 1c56e65cf..12bacd057 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java @@ -10,12 +10,14 @@ import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; import kr.co.pennyway.domain.domains.user.domain.QUser; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +@Slf4j @Repository @RequiredArgsConstructor public class SpendingCustomRepositoryImpl implements SpendingCustomRepository { @@ -59,5 +61,4 @@ public List findByYearAndMonth(Long userId, int year, int month) { .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) .fetch(); } - } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java index 90fc7a6e3..2ba3bd04d 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java @@ -2,9 +2,16 @@ import kr.co.pennyway.domain.common.repository.ExtendedRepository; import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import org.springframework.transaction.annotation.Transactional; public interface SpendingRepository extends ExtendedRepository, SpendingCustomRepository { @Transactional(readOnly = true) boolean existsByIdAndUser_Id(Long id, Long userId); + + @Transactional(readOnly = true) + int countByUser_IdAndSpendingCustomCategory_Id(Long userId, Long categoryId); + + @Transactional(readOnly = true) + int countByUser_IdAndCategory(Long userId, SpendingCategory spendingCategory); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java index 233c60e6b..b3b7e7698 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -4,13 +4,18 @@ import com.querydsl.core.types.Predicate; import kr.co.pennyway.common.annotation.DomainService; import kr.co.pennyway.domain.common.repository.QueryHandler; +import kr.co.pennyway.domain.common.util.SliceUtil; import kr.co.pennyway.domain.domains.spending.domain.QSpending; +import kr.co.pennyway.domain.domains.spending.domain.QSpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; import kr.co.pennyway.domain.domains.spending.repository.SpendingRepository; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.QUser; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.transaction.annotation.Transactional; @@ -28,6 +33,7 @@ public class SpendingService { private final QUser user = QUser.user; private final QSpending spending = QSpending.spending; + private final QSpendingCustomCategory spendingCustomCategory = QSpendingCustomCategory.spendingCustomCategory; @Transactional public Spending createSpending(Spending spending) { @@ -49,6 +55,57 @@ public List readSpendings(Long userId, int year, int month) { return spendingRepository.findByYearAndMonth(userId, year, month); } + @Transactional(readOnly = true) + public int readSpendingTotalCountByCategoryId(Long userId, Long categoryId) { + return spendingRepository.countByUser_IdAndSpendingCustomCategory_Id(userId, categoryId); + } + + @Transactional(readOnly = true) + public int readSpendingTotalCountByCategory(Long userId, SpendingCategory spendingCategory) { + return spendingRepository.countByUser_IdAndCategory(userId, spendingCategory); + } + + /** + * 사용자 정의 카테고리 ID로 지출 내역 리스트를 조회한다. + * + * @return 지출 내역 리스트를 {@link Slice}에 담아서 반환한다. + */ + @Transactional(readOnly = true) + public Slice readSpendingsSliceByCategoryId(Long userId, Long categoryId, Pageable pageable) { + Predicate predicate = spending.user.id.eq(userId).and(spendingCustomCategory.id.eq(categoryId)); + + QueryHandler queryHandler = query -> query + .leftJoin(spending.spendingCustomCategory, spendingCustomCategory).fetchJoin() + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1); + + Sort sort = pageable.getSort(); + + return SliceUtil.toSlice(spendingRepository.findList(predicate, queryHandler, sort), pageable); + } + + /** + * 시스템 제공 카테고리 code로 지출 내역 리스트를 조회한다. + * + * @return 지출 내역 리스트를 {@link Slice}에 담아서 반환한다. + */ + @Transactional(readOnly = true) + public Slice readSpendingsSliceByCategory(Long userId, SpendingCategory spendingCategory, Pageable pageable) { + if (spendingCategory.equals(SpendingCategory.CUSTOM) || spendingCategory.equals(SpendingCategory.OTHER)) { + throw new IllegalArgumentException("지출 카테고리가 시스템 제공 카테고리가 아닙니다."); + } + + Predicate predicate = spending.user.id.eq(userId).and(spending.category.eq(spendingCategory)); + + QueryHandler queryHandler = query -> query + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1); + + Sort sort = pageable.getSort(); + + return SliceUtil.toSlice(spendingRepository.findList(predicate, queryHandler, sort), pageable); + } + @Transactional(readOnly = true) public List readTotalSpendingsAmountByUserId(Long userId) { Predicate predicate = user.id.eq(userId); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java index 3c0bdbce4..63734ef0f 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java @@ -4,6 +4,8 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; +import java.util.stream.Stream; + @Getter @RequiredArgsConstructor public enum SpendingCategory implements LegacyCommonType { @@ -23,4 +25,11 @@ public enum SpendingCategory implements LegacyCommonType { private final String code; private final String type; + + public static SpendingCategory fromCode(String code) { + return Stream.of(values()) + .filter(v -> v.getCode().equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 카테고리 코드입니다.")); + } } From a10e6e89e09fa05b796ab4cb392b4789b9d22dba Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 4 Jul 2024 18:27:47 +0900 Subject: [PATCH 116/152] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A0=95=EC=9D=98=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=88=98=EC=A0=95=20API=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * feat: 조회하려는 카테고리 타입 상수 정의 * feat: 지출 카테고리 타입 400 에러 추가 * feat: 지출 카테고리 타입 상수 web-config conveter 정의 후 등록 * rename: 에러 상수 오타 수정 * test: 지출 카테고리 업데이트 컨트롤러 테스트 클래스 작성 * test: 카테고리 업데이트 콘트롤러 3가지 테스트 작성 * feat: 카테고리 타입에 따른 권한 검사 메서드 추가 * feat: 타입, 카테고리 아이디 불일치 에러코드 추가 * feat: 지출 카테고리 code 기반 상수 탐색 정적 팩토리 메서드 추가 * feat: 카테고리 수정 컨트롤러 정의 * test: 컨트롤러 테스트 성공 케이스에서 usecase given절 처리 * test: given 처리 시, default, custom 요청 각각에 대해 정의 * feat: 카테고리 update 유즈 케이스 정의 * rename: 지출 카테고리 save service의 execute() -> create() 메서드명 수정 * feat: save-service 클래스 update 메서드 임시 작성 * fix: category-create-param name size 조건 15자 -> 8자 수정 * test: 카테고리 수정 컨트롤러 테스트 수정 (카테고리 수정은 언제나 사용자 정의 카테고리이다) * feat: spenindg-category-manager 자원 접근 검사 시, -1 허용/비허용 메서드 분리 * fix: invalid_icon 에러 메시지 수정 other -> custom * fix: 수정된 요구 사항에 맞게 컨트롤러 로직 수정 * test: 아이콘 문자열로 인자 전달하도록 수정 * feat: 지출 카테고리 수정 서비스 구현 * fix: user_id 인자 제거 (이미 자원 접근 검증을 했으므로 필요없음) * feat: 지출 카테고리 entity to_string 재정의 * feat: 지출 카테고리 entity 정보 수정 메서드 추가 * test: @spring-boot-test random port 옵션 설정 * test: controller unit test user_id 값 삭제 * test: 자원 검증 실패 통합 테스트 작성 * fix: has_permission spel 문법에 맞게 principal.user_id 속성 전달 * test: 사용자 정의 지출 카테고리 fixture name 값 변경(8글자 이하) * test: 쿼리 파라미터 한글 깨짐 수정 * test: 반환 타입 수정 * docs: 지출 카테고리 수정 api 스웨거 작성 --- .../apis/ledger/api/SpendingCategoryApi.java | 16 ++- .../SpendingCategoryController.java | 11 ++ .../ledger/controller/SpendingController.java | 2 +- .../apis/ledger/dto/SpendingCategoryDto.java | 2 +- .../service/SpendingCategorySaveService.java | 13 +- .../usecase/SpendingCategoryUseCase.java | 11 +- .../SpendingCategoryManager.java | 7 +- .../SpendingCategoryUpdateControllerTest.java | 113 +++++++++++++++++ ...SpendingCategoryUpdateIntegrationTest.java | 118 ++++++++++++++++++ .../config/ExternalApiIntegrationTest.java | 2 +- .../SpendingCustomCategoryFixture.java | 2 +- .../domain/SpendingCustomCategory.java | 18 +++ .../spending/exception/SpendingErrorCode.java | 2 +- 13 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryUpdateControllerTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryUpdateIntegrationTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java index add401638..e2d55cbdc 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java @@ -26,7 +26,7 @@ public interface SpendingCategoryApi { @Operation(summary = "지출 내역 카테고리 등록", method = "POST", description = "사용자 커스텀 지출 카테고리를 생성합니다.") @Parameters({ - @Parameter(name = "name", description = "카테고리 이름", required = true, in = ParameterIn.QUERY), + @Parameter(name = "name", description = "카테고리 이름(8자 이하)", required = true, in = ParameterIn.QUERY), @Parameter(name = "icon", description = "카테고리 아이콘. 대문자만 허용합니다.", required = true, in = ParameterIn.QUERY, examples = { @ExampleObject(name = "식사", value = "FOOD"), @ExampleObject(name = "교통", value = "TRANSPORTATION"), @ExampleObject(name = "뷰티/패션", value = "BEAUTY_OR_FASHION"), @ExampleObject(name = "편의점/마트", value = "CONVENIENCE_STORE"), @ExampleObject(name = "교육", value = "EDUCATION"), @ExampleObject(name = "생활", value = "LIVING"), @@ -99,4 +99,18 @@ ResponseEntity getSpendingsByCategory( @PageableDefault(size = 30, page = 0) @SortDefault(sort = "spending.spendAt", direction = Sort.Direction.DESC) Pageable pageable, @AuthenticationPrincipal SecurityUserDetails user ); + + @Operation(summary = "지출 내역 카테고리 수정", method = "PATCH", description = "사용자 커스텀 지출 카테고리를 수정합니다.") + @Parameters({ + @Parameter(name = "name", description = "카테고리 이름(8자 이하)", required = true, in = ParameterIn.QUERY), + @Parameter(name = "icon", description = "카테고리 아이콘. 대문자만 허용합니다.", required = true, in = ParameterIn.QUERY, examples = { + @ExampleObject(name = "식사", value = "FOOD"), @ExampleObject(name = "교통", value = "TRANSPORTATION"), @ExampleObject(name = "뷰티/패션", value = "BEAUTY_OR_FASHION"), + @ExampleObject(name = "편의점/마트", value = "CONVENIENCE_STORE"), @ExampleObject(name = "교육", value = "EDUCATION"), @ExampleObject(name = "생활", value = "LIVING"), + @ExampleObject(name = "건강", value = "HEALTH"), @ExampleObject(name = "취미/여가", value = "HOBBY"), @ExampleObject(name = "여행/숙박", value = "TRAVEL"), + @ExampleObject(name = "술/유흥", value = "ALCOHOL_OR_ENTERTAINMENT"), @ExampleObject(name = "회비/경조사", value = "MEMBERSHIP_OR_FAMILY_EVENT"), @ExampleObject(name = "기타", value = "OTHER") + }), + @Parameter(name = "param", hidden = true) + }) + @ApiResponse(responseCode = "200", description = "지출 카테고리 등록 성공", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "spendingCategory", schema = @Schema(implementation = SpendingCategoryDto.Res.class)))) + ResponseEntity patchSpendingCategory(@PathVariable Long categoryId, @Validated SpendingCategoryDto.CreateParamReq param); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java index e40d0f355..6ab0dc440 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java @@ -77,4 +77,15 @@ public ResponseEntity getSpendingsByCategory( return ResponseEntity.ok(SuccessResponse.from("spendings", spendingCategoryUseCase.getSpendingsByCategory(user.getUserId(), categoryId, pageable, type))); } + + @Override + @PatchMapping("/{categoryId}") + @PreAuthorize("isAuthenticated() and @spendingCategoryManager.hasPermission(principal.userId, #categoryId)") + public ResponseEntity patchSpendingCategory(@PathVariable Long categoryId, @Validated SpendingCategoryDto.CreateParamReq param) { + if (SpendingCategory.CUSTOM.equals(param.icon())) { + throw new SpendingErrorException(SpendingErrorCode.INVALID_ICON); + } + + return ResponseEntity.ok(SuccessResponse.from("spendingCategory", spendingCategoryUseCase.updateSpendingCategory(categoryId, param.name(), param.icon()))); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java index 0b2c7a9f5..475d18927 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java @@ -27,7 +27,7 @@ public class SpendingController implements SpendingApi { @Override @PostMapping("") - @PreAuthorize("isAuthenticated() and @spendingCategoryManager.hasPermission(#user.getUserId(), #request.categoryId())") + @PreAuthorize("isAuthenticated() and @spendingCategoryManager.hasPermissionExceptMinus(#user.getUserId(), #request.categoryId())") public ResponseEntity postSpending(@RequestBody @Validated SpendingReq request, @AuthenticationPrincipal SecurityUserDetails user) { if (!isValidCategoryIdAndIcon(request.categoryId(), request.icon())) { throw new SpendingErrorException(SpendingErrorCode.INVALID_ICON_WITH_CATEGORY_ID); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingCategoryDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingCategoryDto.java index 6ffe4825a..c65012e03 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingCategoryDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingCategoryDto.java @@ -13,7 +13,7 @@ public class SpendingCategoryDto { public record CreateParamReq( @NotBlank(message = "카테고리 이름은 필수입니다.") - @Size(max = 15, message = "카테고리 이름은 15자 이하로 입력해주세요.") + @Size(max = 8, message = "카테고리 이름은 8자 이하로 입력해주세요.") String name, @NotNull(message = "카테고리 아이콘은 필수입니다.") SpendingCategory icon diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySaveService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySaveService.java index d916dea0c..dbfb08081 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySaveService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategorySaveService.java @@ -1,6 +1,8 @@ package kr.co.pennyway.api.apis.ledger.service; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; @@ -20,9 +22,18 @@ public class SpendingCategorySaveService { private final SpendingCustomCategoryService spendingCustomCategoryService; @Transactional - public SpendingCustomCategory execute(Long userId, String categoryName, SpendingCategory icon) { + public SpendingCustomCategory create(Long userId, String categoryName, SpendingCategory icon) { User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); return spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategory.of(categoryName, icon, user)); } + + @Transactional + public SpendingCustomCategory update(Long categoryId, String name, SpendingCategory icon) { + SpendingCustomCategory category = spendingCustomCategoryService.readSpendingCustomCategory(categoryId) + .orElseThrow(() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_CUSTOM_CATEGORY)); + + category.update(name, icon); + return category; + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java index c1d94f460..a43e5a5f2 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java @@ -31,7 +31,7 @@ public class SpendingCategoryUseCase { @Transactional public SpendingCategoryDto.Res createSpendingCategory(Long userId, String categoryName, SpendingCategory icon) { - SpendingCustomCategory category = spendingCategorySaveService.execute(userId, categoryName, icon); + SpendingCustomCategory category = spendingCategorySaveService.create(userId, categoryName, icon); return SpendingCategoryMapper.toResponse(category); } @@ -42,7 +42,7 @@ public List getSpendingCategories(Long userId) { return SpendingCategoryMapper.toResponses(categories); } - + @Transactional(readOnly = true) public int getSpendingTotalCountByCategory(Long userId, Long categoryId, SpendingCategoryType type) { return spendingSearchService.readSpendingTotalCountByCategoryId(userId, categoryId, type); @@ -54,4 +54,11 @@ public SpendingSearchRes.MonthSlice getSpendingsByCategory(Long userId, Long cat return SpendingMapper.toMonthSlice(spendings); } + + @Transactional + public SpendingCategoryDto.Res updateSpendingCategory(Long categoryId, String name, SpendingCategory icon) { + SpendingCustomCategory category = spendingCategorySaveService.update(categoryId, name, icon); + + return SpendingCategoryMapper.toResponse(category); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java index f3d0ece74..53cf460cd 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingCategoryManager.java @@ -13,6 +13,11 @@ public class SpendingCategoryManager { private final SpendingCustomCategoryService spendingCustomCategoryService; + @Transactional(readOnly = true) + public boolean hasPermission(Long userId, Long categoryId) { + return spendingCustomCategoryService.isExistsSpendingCustomCategory(userId, categoryId); + } + /** * 사용자가 커스텀 지출 카테고리에 대한 권한이 있는지 확인한다.
* -1L이면 서비스에서 제공하는 기본 카테고리를 사용하는 것이므로 무시한다. @@ -20,7 +25,7 @@ public class SpendingCategoryManager { * @return 권한이 있으면 true, 없으면 false */ @Transactional(readOnly = true) - public boolean hasPermission(Long userId, Long categoryId) { + public boolean hasPermissionExceptMinus(Long userId, Long categoryId) { if (categoryId.equals(-1L)) { return true; } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryUpdateControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryUpdateControllerTest.java new file mode 100644 index 000000000..3b4d6bdde --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryUpdateControllerTest.java @@ -0,0 +1,113 @@ +package kr.co.pennyway.api.apis.ledger.controller; + +import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; +import kr.co.pennyway.api.apis.ledger.usecase.SpendingCategoryUseCase; +import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import kr.co.pennyway.domain.common.redis.sign.SignEventLogService; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(SpendingCategoryController.class) +@ActiveProfiles("test") +public class SpendingCategoryUpdateControllerTest { + @MockBean + private SignEventLogService signEventLogService; + @MockBean + private JwtProvider accessTokenProvider; + @MockBean + private SpendingCategoryUseCase spendingCategoryUseCase; + @Autowired + private MockMvc mockMvc; + + @BeforeEach + void setUp(WebApplicationContext context) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .defaultRequest(patch("/**").with(csrf())) + .build(); + } + + @Test + @DisplayName("icon이 8자 이하의 공백이 아닌 문자열이고, icon이 SpendingCategory에 있는 값이면 200 OK 응답을 반환한다.") + @WithSecurityMockUser + void patchSpendingCategorySuccess() throws Exception { + // given + Long spendingCategoryId = 1L; + String expectedName = "name"; + SpendingCategory icon = SpendingCategory.FOOD; + given(spendingCategoryUseCase.updateSpendingCategory(spendingCategoryId, expectedName, icon)).willReturn(new SpendingCategoryDto.Res(false, -1L, "name", icon)); + + // when + ResultActions resultDefault = performPatchSpendingCategory(spendingCategoryId, expectedName, icon.name()); + + // then + resultDefault.andExpect(status().isOk()); + } + + @Test + @DisplayName("name이 공백 문자거나, 8자 이상인 경우 422 Unprocessable Entity 에러 응답을 반환한다.") + void patchSpendingCategoryWithInvalidDefaultAndCategoryId() throws Exception { + // given + Long spendingCategoryId = 1L; + String whitespaceName = " ", longName = "123456789"; + SpendingCategory icon = SpendingCategory.FOOD; + + // when + ResultActions result1 = performPatchSpendingCategory(spendingCategoryId, whitespaceName, icon.name()); + ResultActions result2 = performPatchSpendingCategory(spendingCategoryId, longName, icon.name()); + + // then + result1.andExpect(status().isUnprocessableEntity()); + result2.andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("icon이 SpendingCategory에 없는 값이면 422 Unprocessable Entity 에러 응답을 반환한다.") + void patchSpendingCategoryWithInvalidIcon() throws Exception { + // given + Long spendingCategoryId = 1L; + String name = "name", invalidIcon = "INVALID"; + + // when + ResultActions result = performPatchSpendingCategory(spendingCategoryId, name, invalidIcon); + + // then + result.andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("icon이 CUSTOM이면 400 Bad Request 에러 응답을 반환한다.") + void patchSpendingCategoryWithCustomIcon() throws Exception { + // given + Long spendingCategoryId = 1L; + String name = "name", customIcon = SpendingCategory.CUSTOM.name(); + + // when + ResultActions result = performPatchSpendingCategory(spendingCategoryId, name, customIcon); + + // then + result.andExpect(status().isBadRequest()); + } + + private ResultActions performPatchSpendingCategory(Long spendingCategoryId, String name, String icon) throws Exception { + return mockMvc.perform(patch("/v2/spending-categories/{categoryId}", spendingCategoryId) + .param("name", name) + .param("icon", icon)); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryUpdateIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryUpdateIntegrationTest.java new file mode 100644 index 000000000..da51f0ae6 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryUpdateIntegrationTest.java @@ -0,0 +1,118 @@ +package kr.co.pennyway.api.apis.ledger.integration; + +import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; +import kr.co.pennyway.api.common.response.ErrorResponse; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.SpendingCustomCategoryFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Map; + +import static org.springframework.test.util.AssertionErrors.assertEquals; + +@Slf4j +@ExternalApiIntegrationTest +public class SpendingCategoryUpdateIntegrationTest extends ExternalApiDBTestConfig { + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private UserService userService; + + @Autowired + private SpendingCustomCategoryService spendingCustomCategoryService; + + @Autowired + private JwtProvider accessTokenProvider; + + @LocalServerPort + private int port; + + @Test + @DisplayName("지출 카테고리에 접근 권한이 없는 경우 403 Forbidden 응답을 받는다.") + void withOutPermission() { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + + // when + ResponseEntity response = failureRequest(user, user.getId(), "name", SpendingCategory.FOOD.name()); + + // then + assertEquals("403 Forbidden 예외가 발생해야 합니다.", response.getStatusCode(), HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("지출 카테고리가 수정되면, 수정된 정보를 응답 받는다.") + void success() { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + SpendingCustomCategory category = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user)); + String expectedName = "뉴 카테고리"; + + // when + ResponseEntity>> response = successRequest(user, category.getId(), expectedName, SpendingCategory.HEALTH.name()); + SpendingCategoryDto.Res data = response.getBody().getData().get("spendingCategory"); + + // then + assertEquals("200 OK 응답을 받아야 합니다.", response.getStatusCode(), HttpStatus.OK); + assertEquals("수정된 지출 카테고리 이름이 일치해야 합니다.", expectedName, data.name()); + assertEquals("수정된 지출 카테고리 아이콘이 일치해야 합니다.", SpendingCategory.HEALTH.name(), data.icon().name()); + } + + private ResponseEntity>> successRequest(User user, Long spendingCategoryId, String name, String icon) { + return restTemplate.exchange( + createUriComponentsBuilder(spendingCategoryId, name, icon), + HttpMethod.PATCH, + createHttpEntity(user), + new ParameterizedTypeReference>>() { + } + ); + } + + private ResponseEntity failureRequest(User user, Long spendingCategoryId, String name, String icon) { + return restTemplate.exchange( + createUriComponentsBuilder(spendingCategoryId, name, icon), + HttpMethod.PATCH, + createHttpEntity(user), + ErrorResponse.class + ); + } + + private String createUriComponentsBuilder(Long spendingCategoryId, String name, String icon) { + // 기본 URL 설정 + String baseUrl = "http://localhost:" + port + "/v2/spending-categories/" + spendingCategoryId.toString(); + + // 쿼리 파라미터 설정 + return UriComponentsBuilder.fromHttpUrl(baseUrl) + .queryParam("name", name) + .queryParam("icon", icon) + .build(false).toUriString(); // encoded false 옵션으로 한글 깨짐 방지 + } + + private HttpEntity createHttpEntity(User user) { + // HTTP 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessTokenProvider.generateToken(AccessTokenClaim.of(user.getId(), user.getRole().name()))); + + // 요청 Entity 생성 (empty body) + return new HttpEntity<>(headers); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTest.java index 294f35ea4..1166cbf1f 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiIntegrationTest.java @@ -7,7 +7,7 @@ @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) -@SpringBootTest(classes = ExternalApiIntegrationTestConfig.class) +@SpringBootTest(classes = ExternalApiIntegrationTestConfig.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles(profiles = {"test"}, resolver = ExternalApiIntegrationProfileResolver.class) @Documented public @interface ExternalApiIntegrationTest { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java index 24eadb0a3..44addf96d 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java @@ -12,7 +12,7 @@ import java.util.List; public enum SpendingCustomCategoryFixture { - GENERAL_SPENDING_CUSTOM_CATEGORY("커스텀 지출 내역 카테고리", SpendingCategory.FOOD); + GENERAL_SPENDING_CUSTOM_CATEGORY("일반카테고리", SpendingCategory.FOOD); private final String name; private final SpendingCategory icon; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java index f42f02214..6e040dce1 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java @@ -46,4 +46,22 @@ private SpendingCustomCategory(String name, SpendingCategory icon, User user) { public static SpendingCustomCategory of(String name, SpendingCategory icon, User user) { return new SpendingCustomCategory(name, icon, user); } + + public void update(String name, SpendingCategory icon) { + if (icon.equals(SpendingCategory.CUSTOM)) { + throw new IllegalArgumentException("OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."); + } + + this.name = name; + this.icon = icon; + } + + @Override + public String toString() { + return "SpendingCustomCategory{" + + "id=" + id + + ", name='" + name + '\'' + + ", icon=" + icon + + ", deletedAt=" + deletedAt + '}'; + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java index 889ac2db1..eb38dc5f4 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java @@ -11,7 +11,7 @@ @RequiredArgsConstructor public enum SpendingErrorCode implements BaseErrorCode { /* 400 Bad Request */ - INVALID_ICON(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."), + INVALID_ICON(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "CUSTOM 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."), INVALID_ICON_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "icon의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."), INVALID_TYPE_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "type의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."), INVALID_CATEGORY_TYPE(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "존재하지 않는 카테고리 타입입니다."), From adbaeed2afe12533ce7e1706d0f31b5e6f5827b6 Mon Sep 17 00:00:00 2001 From: DinoDeveloper <79460319+asn6878@users.noreply.github.com> Date: Thu, 4 Jul 2024 22:47:38 +0900 Subject: [PATCH 117/152] =?UTF-8?q?fix:=20=F0=9F=90=9B=20n+1=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EA=B0=9C=EC=84=A0=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9D=98=20=EA=B0=84=ED=97=90=EC=A0=81=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95=20(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpendingControllerIntegrationTest.java | 2 +- .../TargetAmountIntegrationTest.java | 4 +-- .../service/SpendingSearchServiceTest.java | 12 ++++++-- .../SpendingCustomCategoryFixture.java | 29 ------------------- .../api/config/fixture/SpendingFixture.java | 17 +++++++---- 5 files changed, 24 insertions(+), 40 deletions(-) diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java index 6e95243bd..fdd36db20 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java @@ -133,7 +133,7 @@ class GetSpendingListAtYearAndMonth { void getSpendingListAtYearAndMonthSuccess() throws Exception { // given User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); - SpendingFixture.bulkInsertSpending(user, 150, false, jdbcTemplate); + SpendingFixture.bulkInsertSpending(user, 150, 0L, jdbcTemplate); // when long before = System.currentTimeMillis(); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java index b3c987598..784b52da8 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java @@ -114,7 +114,7 @@ class GetTargetAmountAndTotalSpending { void getTargetAmountAndTotalSpending() throws Exception { // given User user = createUserWithCreatedAt(LocalDateTime.now().minusYears(2), jdbcTemplate); - SpendingFixture.bulkInsertSpending(user, 300, false, jdbcTemplate); + SpendingFixture.bulkInsertSpending(user, 300, 0L, jdbcTemplate); TargetAmountFixture.bulkInsertTargetAmount(user, jdbcTemplate); // when @@ -156,7 +156,7 @@ class GetTargetAmountsAndTotalSpendings { void getTargetAmountsAndTotalSpendings() throws Exception { // given User user = createUserWithCreatedAt(LocalDateTime.now().minusYears(2).plusMonths(2), jdbcTemplate); - SpendingFixture.bulkInsertSpending(user, 300, false, jdbcTemplate); + SpendingFixture.bulkInsertSpending(user, 300, 0L, jdbcTemplate); TargetAmountFixture.bulkInsertTargetAmount(user, jdbcTemplate); // when diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java index 0b038481a..b9cfbf32f 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java @@ -4,9 +4,12 @@ import jakarta.persistence.PersistenceContext; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.SpendingCustomCategoryFixture; import kr.co.pennyway.api.config.fixture.SpendingFixture; import kr.co.pennyway.api.config.fixture.UserFixture; import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; @@ -35,6 +38,8 @@ class SpendingSearchServiceTest extends ExternalApiDBTestConfig { private SpendingService spendingService; @Autowired private NamedParameterJdbcTemplate jdbcTemplate; + @Autowired + private SpendingCustomCategoryService spendingCustomCategoryService; @PersistenceContext private EntityManager entityManager; @@ -59,7 +64,9 @@ public void tearDown() { void testReadSpendingsLazyLoading() { // given User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); - SpendingFixture.bulkInsertSpending(user, 100, true, jdbcTemplate); + SpendingCustomCategory spendingCustomCategory = SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user); + spendingCustomCategoryService.createSpendingCustomCategory(spendingCustomCategory); + SpendingFixture.bulkInsertSpending(user, 100, spendingCustomCategory.getId(), jdbcTemplate); // when List spendings = spendingService.readSpendings(user.getId(), LocalDate.now().getYear(), LocalDate.now().getMonthValue()); @@ -76,8 +83,7 @@ void testReadSpendingsLazyLoading() { // then log.info("쿼리문 실행 횟수: {}", statistics.getPrepareStatementCount()); log.info("readSpendings로 조회해온 지출 내역 개수: {}", size); - - Assertions.assertEquals(2, statistics.getPrepareStatementCount()); + Assertions.assertEquals(3, statistics.getPrepareStatementCount()); boolean isSortedDescending = IntStream.range(0, spendings.size() - 1) .allMatch(i -> !spendings.get(i).getSpendAt().isBefore(spendings.get(i + 1).getSpendAt())); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java index 44addf96d..3e69ca9c1 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java @@ -3,13 +3,6 @@ import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; -import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.jdbc.core.namedparam.SqlParameterSource; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; public enum SpendingCustomCategoryFixture { GENERAL_SPENDING_CUSTOM_CATEGORY("일반카테고리", SpendingCategory.FOOD); @@ -22,28 +15,6 @@ public enum SpendingCustomCategoryFixture { this.icon = icon; } - public static void bulkInsertCustomCategory(User user, int capacity, NamedParameterJdbcTemplate jdbcTemplate) { - Collection customCategories = getCustomCategories(user, capacity); - - String sql = String.format(""" - INSERT INTO `%s` (name, icon, user_id, created_at, updated_at, deleted_at) - VALUES (:name, 1, :user.id, NOW(), NOW(), null) - """, "spending_custom_category"); - SqlParameterSource[] params = customCategories.stream() - .map(BeanPropertySqlParameterSource::new) - .toArray(SqlParameterSource[]::new); - jdbcTemplate.batchUpdate(sql, params); - } - - private static List getCustomCategories(User user, int capacity) { - List customCategories = new ArrayList<>(capacity); - - for (int i = 0; i < capacity; i++) { - customCategories.add(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user)); - } - return customCategories; - } - public SpendingCustomCategory toCustomSpendingCategory(User user) { return SpendingCustomCategory.of(name, icon, user); } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java index 2a2a2266b..520d93644 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java @@ -39,15 +39,22 @@ public static SpendingReq toSpendingReq(User user) { return new SpendingReq(10000, -1L, SpendingCategory.FOOD, LocalDate.now(), "카페인 수혈", "아메리카노 1잔"); } - public static void bulkInsertSpending(User user, int capacity, boolean isCustom, NamedParameterJdbcTemplate jdbcTemplate) { + /** + * Spending 객체들을 벌크연산으로 삽입한다. + * + * @param user {@link User} Spending 객체의 사용자 + * @param capacity {@link Integer} 생성할 Spending 객체의 개수 + * @param customCategoryId {@link Long} Spending 객체의 customCategoryId. 기본 카테고리를 가질시 0이 된다. + * @param jdbcTemplate {@link NamedParameterJdbcTemplate} Spending 객체를 생성할 때 사용할 jdbcTemplate + */ + public static void bulkInsertSpending(User user, int capacity, Long customCategoryId, NamedParameterJdbcTemplate jdbcTemplate) { Collection spendings = getRandomSpendings(user, capacity); String sql; - if (isCustom) { - SpendingCustomCategoryFixture.bulkInsertCustomCategory(user, capacity, jdbcTemplate); + if (!customCategoryId.equals(0L)) { sql = String.format(""" INSERT INTO `%s` (amount, category, spend_at, account_name, memo, user_id, spending_custom_category_id, created_at, updated_at, deleted_at) - VALUES (:amount, 1+FLOOR(RAND()*11), :spendAt, :accountName, :memo, :user.id, 1 + FLOOR(RAND() * %d), NOW(), NOW(), null) - """, "spending", capacity); + VALUES (:amount, 1+FLOOR(RAND()*11), :spendAt, :accountName, :memo, :user.id, %d, NOW(), NOW(), null) + """, "spending", customCategoryId); } else { sql = String.format(""" INSERT INTO `%s` (amount, category, spend_at, account_name, memo, user_id, spending_custom_category_id, created_at, updated_at, deleted_at) From 74e0081433304c005107faa91bf7eee95cd5659f Mon Sep 17 00:00:00 2001 From: DinoDeveloper <79460319+asn6878@users.noreply.github.com> Date: Fri, 5 Jul 2024 01:57:35 +0900 Subject: [PATCH 118/152] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=86=8C=EB=B9=84?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EB=B3=B5=EC=88=98=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?API=20(#119)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: controller 및 인가 처리 메서드 작성 * feat: usecase 작성 * feat: query dsl을 활용한 비즈니스 로직 작성 * feat: dto 작성 * fix: deleteallbyid 사용하게 변경 * fix: 권한 검사 구문 수정 * test: 소비내역 복수 삭제 통합 테스트 작성 * fix: dto 빈값 검증 수정 * docs: swagger 문서 작성 * fix: 권한 검사 로직 성능 개선을 위한 쿼리 수정 * fix: 삭제 연산 쿼리 횟수 개선을 위한 jpql 사용 * fix: 쿼리 연산을 구분하기 위한 메서드명 변경 * fix: 쿼리 연산을 구분하기 위한 domain 레벨 메서드 명 변경 * fix: repository 반환타입 long으로 통일 * fix; 암묵적 형변환을 지양하기 위한 명시적 형변환 추가 --- .../api/apis/ledger/api/SpendingApi.java | 19 ++++- .../ledger/controller/SpendingController.java | 9 +++ .../api/apis/ledger/dto/SpendingIdsDto.java | 13 ++++ .../ledger/service/SpendingDeleteService.java | 7 ++ .../apis/ledger/usecase/SpendingUseCase.java | 6 ++ .../authorization/SpendingManager.java | 11 +++ .../SpendingControllerIntegrationTest.java | 71 +++++++++++++++++++ .../repository/SpendingRepository.java | 14 +++- .../spending/service/SpendingService.java | 10 +++ 9 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingIdsDto.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java index 1bd7ee38b..ec81cb6e2 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java @@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.ledger.dto.SpendingIdsDto; import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; @@ -88,8 +89,8 @@ public interface SpendingApi { @Operation(summary = "지출 내역 삭제", method = "DELETE", description = "지출 내역의 ID값으로 해당 지출 내역을 삭제 합니다.") @Parameter(name = "spendingId", description = "지출 내역 ID", example = "1", required = true, in = ParameterIn.PATH) - @ApiResponse(responseCode = "403", description = "지출 카테고리에 대한 권한이 없습니다.", content = @Content(examples = { - @ExampleObject(name = "지출 카테고리 권한 오류", description = "지출 카테고리에 대한 권한이 없습니다.", + @ApiResponse(responseCode = "403", description = "지출 내역에 대한 권한이 없습니다.", content = @Content(examples = { + @ExampleObject(name = "지출 내역 권한 오류", description = "지출 내역에 대한 권한이 없습니다.", value = """ { "code": "4030", @@ -99,4 +100,18 @@ public interface SpendingApi { ) })) ResponseEntity deleteSpending(@PathVariable Long spendingId, @AuthenticationPrincipal SecurityUserDetails user); + + + @Operation(summary = "지출 내역 복수 삭제", method = "DELETE", description = "사용자의 지출 내역의 ID목록으로 해당 지출 내역들을 삭제 합니다.") + @ApiResponse(responseCode = "403", description = "지출 내역에 대한 권한이 없습니다.", content = @Content(examples = { + @ExampleObject(name = "지출 내역 권한 오류", description = "지출 내역에 대한 권한이 없습니다.", + value = """ + { + "code": "4030", + "message": "ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN" + } + """ + ) + })) + ResponseEntity deleteSpendings(@RequestBody SpendingIdsDto spendingIds, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java index 475d18927..720619b9f 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingController.java @@ -1,6 +1,7 @@ package kr.co.pennyway.api.apis.ledger.controller; import kr.co.pennyway.api.apis.ledger.api.SpendingApi; +import kr.co.pennyway.api.apis.ledger.dto.SpendingIdsDto; import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.apis.ledger.usecase.SpendingUseCase; import kr.co.pennyway.api.common.response.SuccessResponse; @@ -70,6 +71,14 @@ public ResponseEntity deleteSpending(@PathVariable Long spendingId, @Authenti return ResponseEntity.ok(SuccessResponse.noContent()); } + @Override + @DeleteMapping("") + @PreAuthorize("isAuthenticated() and @spendingManager.hasPermissions(#user.getUserId(), #spendingIds.spendingIds())") + public ResponseEntity deleteSpendings(@RequestBody SpendingIdsDto spendingIds, @AuthenticationPrincipal SecurityUserDetails user) { + spendingUseCase.deleteSpendings(spendingIds.spendingIds()); + return ResponseEntity.ok(SuccessResponse.noContent()); + } + /** * categoryId가 -1이면 서비스에서 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 CUSTOM이나 OTHER이 될 수 없고,
* categoryId가 -1이 아니면 사용자가 정의한 카테고리를 사용하므로 저장하려는 지출 내역의 icon은 CUSTOM임을 확인한다. diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingIdsDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingIdsDto.java new file mode 100644 index 000000000..c876324c9 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingIdsDto.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.api.apis.ledger.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +public record SpendingIdsDto( + @Schema(description = "지출 내역 ID 목록") + @NotEmpty(message = "지출 내역 ID 목록은 필수입니다.") + List spendingIds +) { +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingDeleteService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingDeleteService.java index 62ffb52ec..4e38ac18c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingDeleteService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingDeleteService.java @@ -9,6 +9,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Slf4j @Service @RequiredArgsConstructor @@ -22,4 +24,9 @@ public void deleteSpending(Long spendingId) { spendingService.deleteSpending(spending); } + + @Transactional + public void deleteSpendings(List spendingIds) { + spendingService.deleteSpendingsInQuery(spendingIds); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java index a881c10c0..6f13cdf1c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java @@ -52,7 +52,13 @@ public SpendingSearchRes.Individual updateSpending(Long spendingId, SpendingReq return SpendingMapper.toSpendingSearchResIndividual(updatedSpending); } + @Transactional public void deleteSpending(Long spendingId) { spendingDeleteService.deleteSpending(spendingId); } + + @Transactional + public void deleteSpendings(List spendingIds) { + spendingDeleteService.deleteSpendings(spendingIds); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingManager.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingManager.java index 05a41a712..67e1b4a16 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingManager.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/SpendingManager.java @@ -6,6 +6,8 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Slf4j @Component("spendingManager") @RequiredArgsConstructor @@ -21,5 +23,14 @@ public class SpendingManager { public boolean hasPermission(Long userId, Long spendingId) { return spendingService.isExistsSpending(userId, spendingId); } + + @Transactional(readOnly = true) + public boolean hasPermissions(Long userId, List spendingIds) { + if (spendingService.countByUserIdAndIdIn(userId, spendingIds) != (long) spendingIds.size()) { + return false; + } + + return true; + } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java index fdd36db20..e81d254c9 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java @@ -1,6 +1,7 @@ package kr.co.pennyway.api.apis.ledger.integration; import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.ledger.dto.SpendingIdsDto; import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; @@ -26,6 +27,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -315,4 +318,72 @@ private ResultActions performDeleteSpendingSuccess(User requestUser, Long spendi .with(user(userDetails))); } } + + @Order(6) + @Nested + @DisplayName("지출 내역 복수 삭제") + class DeleteSpendings { + + @Test + @DisplayName("지출 내역 복수 삭제 성공") + @Transactional + void deleteSpendingsSuccess() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + List spendingIds = new ArrayList<>(); + + for (int i = 0; i < 5; i++) { + Spending spending = SpendingFixture.GENERAL_SPENDING.toSpending(user); + spendingService.createSpending(spending); + spendingIds.add(spending.getId()); + } + SpendingIdsDto spendingIdsDto = new SpendingIdsDto(spendingIds); + + // when + ResultActions resultActions = performDeleteSpendings(user, spendingIdsDto); + + // then + resultActions + .andDo(print()) + .andExpect(status().isOk()); + + for (Long id : spendingIds) { + Assertions.assertTrue(spendingService.readSpending(id).isEmpty()); + } + } + + @Test + @DisplayName("spendingIds에 하나라도 소유하지 않은 지출 내역이 포함되어 있을 경우, 403 Forbidden을 반환한다.") + @Transactional + void deleteSpendingsForbidden() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + User user2 = userService.createUser(UserFixture.GENERAL_USER.toUser()); + List spendingIds = new ArrayList<>(); + + for (int i = 0; i < 5; i++) { + Spending spending = SpendingFixture.GENERAL_SPENDING.toSpending(user); + spendingService.createSpending(spending); + spendingIds.add(spending.getId()); + } + SpendingIdsDto spendingIdsDto = new SpendingIdsDto(spendingIds); + + // when + ResultActions resultActions = performDeleteSpendings(user2, spendingIdsDto); + + // then + resultActions + .andDo(print()) + .andExpect(status().isForbidden()); + } + + private ResultActions performDeleteSpendings(User requestUser, SpendingIdsDto req) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + + return mockMvc.perform(MockMvcRequestBuilders.delete("/v2/spendings", req) + .contentType("application/json") + .with(user(userDetails)) + .content(objectMapper.writeValueAsString(req))); + } + } } \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java index 2ba3bd04d..e366307e5 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java @@ -3,15 +3,27 @@ import kr.co.pennyway.domain.common.repository.ExtendedRepository; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + public interface SpendingRepository extends ExtendedRepository, SpendingCustomRepository { @Transactional(readOnly = true) boolean existsByIdAndUser_Id(Long id, Long userId); @Transactional(readOnly = true) int countByUser_IdAndSpendingCustomCategory_Id(Long userId, Long categoryId); - + @Transactional(readOnly = true) int countByUser_IdAndCategory(Long userId, SpendingCategory spendingCategory); + + @Transactional(readOnly = true) + long countByUserIdAndIdIn(Long userId, List spendingIds); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.deletedAt = NOW() where s.id IN :spendingIds AND s.deletedAt IS NULL") + void deleteAllByIdAndDeletedAtNullInQuery(List spendingIds); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java index b3b7e7698..0ed830483 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -132,4 +132,14 @@ public boolean isExistsSpending(Long userId, Long spendingId) { public void deleteSpending(Spending spending) { spendingRepository.delete(spending); } + + @Transactional + public void deleteSpendingsInQuery(List spendingIds) { + spendingRepository.deleteAllByIdAndDeletedAtNullInQuery(spendingIds); + } + + @Transactional(readOnly = true) + public long countByUserIdAndIdIn(Long userId, List spendingIds) { + return spendingRepository.countByUserIdAndIdIn(userId, spendingIds); + } } From e80cf8be29ba6892aa47b76e113b922ad708f361 Mon Sep 17 00:00:00 2001 From: DinoDeveloper <79460319+asn6878@users.noreply.github.com> Date: Tue, 9 Jul 2024 15:41:59 +0900 Subject: [PATCH 119/152] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A0=95=EC=9D=98=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=82=AD=EC=A0=9C=20API=20(#123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 커스텀 카테고리 삭제 api 구현 * test: 통합 테스트 작성 * docs: swagger 작성 * fix: 사용하지않는 securityuserdetails 제거 * fix: conflict 해결시 발생한 syntax error fix --- .../apis/ledger/api/SpendingCategoryApi.java | 17 +++++ .../SpendingCategoryController.java | 8 ++ .../SpendingCategoryDeleteService.java | 22 ++++++ .../usecase/SpendingCategoryUseCase.java | 9 ++- .../SpendingCategoryIntegrationTest.java | 74 +++++++++++++++++++ .../repository/SpendingRepository.java | 5 ++ .../SpendingCustomCategoryService.java | 5 ++ .../spending/service/SpendingService.java | 5 ++ 8 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategoryDeleteService.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryIntegrationTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java index e2d55cbdc..556915401 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java @@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; + @Tag(name = "지출 카테고리 API") public interface SpendingCategoryApi { @Operation(summary = "지출 내역 카테고리 등록", method = "POST", description = "사용자 커스텀 지출 카테고리를 생성합니다.") @@ -42,6 +43,20 @@ public interface SpendingCategoryApi { @ApiResponse(responseCode = "200", description = "지출 카테고리 조회 성공", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "spendingCategories", array = @ArraySchema(schema = @Schema(implementation = SpendingCategoryDto.Res.class))))) ResponseEntity getSpendingCategories(@AuthenticationPrincipal SecurityUserDetails user); + @Operation(summary = "사용자 정의 카테고리 삭제", method = "DELETE", description = "사용자가 생성한 지출 카테고리를 삭제합니다.") + @Parameter(name = "categoryId", description = "카테고리 ID", example = "1", required = true, in = ParameterIn.PATH) + @ApiResponse(responseCode = "403", description = "지출 카테고리에 대한 권한이 없습니다.", content = @Content(examples = { + @ExampleObject(name = "지출 카테고리 권한 오류", description = "지출 카테고리에 대한 권한이 없습니다.", + value = """ + { + "code": "4030", + "message": "ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN" + } + """ + ) + })) + ResponseEntity deleteSpendingCategory(@PathVariable Long categoryId); + @Operation(summary = "지출 카테고리에 등록된 지출 내역 총 개수 조회", method = "GET") @Parameters({ @Parameter(name = "categoryId", description = "type이 default면 아이콘 코드(1~11), custom이면 카테고리 pk", required = true, in = ParameterIn.PATH), @@ -114,3 +129,5 @@ ResponseEntity getSpendingsByCategory( @ApiResponse(responseCode = "200", description = "지출 카테고리 등록 성공", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "spendingCategory", schema = @Schema(implementation = SpendingCategoryDto.Res.class)))) ResponseEntity patchSpendingCategory(@PathVariable Long categoryId, @Validated SpendingCategoryDto.CreateParamReq param); } + + diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java index 6ab0dc440..07d4de34a 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java @@ -48,6 +48,14 @@ public ResponseEntity getSpendingCategories(@AuthenticationPrincipal Security } @Override + @DeleteMapping("/{categoryId}") + @PreAuthorize("isAuthenticated() and @spendingCategoryManager.hasPermission(principal.userId, #categoryId)") + public ResponseEntity deleteSpendingCategory(@PathVariable Long categoryId) { + spendingCategoryUseCase.deleteSpendingCategory(categoryId); + + return ResponseEntity.ok(SuccessResponse.noContent()); + } + @GetMapping("/{categoryId}/spendings/count") @PreAuthorize("isAuthenticated() and @spendingCategoryManager.hasPermission(#user.getUserId(), #categoryId, #type)") public ResponseEntity getSpendingTotalCountByCategory( diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategoryDeleteService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategoryDeleteService.java new file mode 100644 index 000000000..e81ad5d51 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingCategoryDeleteService.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SpendingCategoryDeleteService { + private final SpendingCustomCategoryService spendingCustomCategoryService; + private final SpendingService spendingService; + + @Transactional + public void execute(Long categoryId) { + spendingService.deleteSpendingsByCategoryIdInQuery(categoryId); + spendingCustomCategoryService.deleteSpendingCustomCategory(categoryId); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java index a43e5a5f2..6e6b40b60 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java @@ -4,6 +4,7 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; import kr.co.pennyway.api.apis.ledger.mapper.SpendingCategoryMapper; import kr.co.pennyway.api.apis.ledger.mapper.SpendingMapper; +import kr.co.pennyway.api.apis.ledger.service.SpendingCategoryDeleteService; import kr.co.pennyway.api.apis.ledger.service.SpendingCategorySaveService; import kr.co.pennyway.api.apis.ledger.service.SpendingCategorySearchService; import kr.co.pennyway.api.apis.ledger.service.SpendingSearchService; @@ -26,6 +27,7 @@ public class SpendingCategoryUseCase { private final SpendingCategorySaveService spendingCategorySaveService; private final SpendingCategorySearchService spendingCategorySearchService; + private final SpendingCategoryDeleteService spendingCategoryDeleteService; private final SpendingSearchService spendingSearchService; @@ -42,7 +44,12 @@ public List getSpendingCategories(Long userId) { return SpendingCategoryMapper.toResponses(categories); } - + + @Transactional + public void deleteSpendingCategory(Long categoryId) { + spendingCategoryDeleteService.execute(categoryId); + } + @Transactional(readOnly = true) public int getSpendingTotalCountByCategory(Long userId, Long categoryId, SpendingCategoryType type) { return spendingSearchService.readSpendingTotalCountByCategoryId(userId, categoryId, type); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryIntegrationTest.java new file mode 100644 index 000000000..c62785173 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryIntegrationTest.java @@ -0,0 +1,74 @@ +package kr.co.pennyway.api.apis.ledger.integration; + +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.SpendingCustomCategoryFixture; +import kr.co.pennyway.api.config.fixture.SpendingFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Slf4j +@ExternalApiIntegrationTest +@AutoConfigureMockMvc +public class SpendingCategoryIntegrationTest extends ExternalApiDBTestConfig { + + @Autowired + private MockMvc mockMvc; + @Autowired + private SpendingService spendingService; + @Autowired + private UserService userService; + @Autowired + private SpendingCustomCategoryService spendingCustomCategoryService; + + @Test + @DisplayName("사용자 정의 지출 카테고리를 삭제하고, 삭제된 카테고리를 가지는 지출 내역 또한 삭제된다.") + @Transactional + void deleteSpendingCustomCategory() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + SpendingCustomCategory spendingCustomCategory = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user)); + Spending spending = spendingService.createSpending(SpendingFixture.CUSTOM_CATEGORY_SPENDING.toCustomCategorySpending(user, spendingCustomCategory)); + + // when + ResultActions resultActions = performDeleteSpendingCategory(spendingCustomCategory.getId(), user); + + // then + resultActions + .andDo(print()) + .andExpect(status().isOk()); + + Assertions.assertTrue(spendingCustomCategoryService.readSpendingCustomCategory(spendingCustomCategory.getId()).isEmpty()); + Assertions.assertTrue(spendingService.readSpending(spending.getId()).isEmpty()); + } + + private ResultActions performDeleteSpendingCategory(Long categoryId, User requestUser) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + + return mockMvc.perform(MockMvcRequestBuilders.delete("/v2/spending-categories/{categoryId}", categoryId) + .with(user(userDetails))); + } + + +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java index e366307e5..b49c81773 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java @@ -13,6 +13,11 @@ public interface SpendingRepository extends ExtendedRepository, @Transactional(readOnly = true) boolean existsByIdAndUser_Id(Long id, Long userId); + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE Spending s SET s.deletedAt = NOW() WHERE s.spendingCustomCategory.id = :categoryId AND s.deletedAt IS NULL") + void deleteAllByCategoryIdAndDeletedAtNullInQuery(Long categoryId); + @Transactional(readOnly = true) int countByUser_IdAndSpendingCustomCategory_Id(Long userId, Long categoryId); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java index ebe526245..b71ea8436 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java @@ -35,4 +35,9 @@ public List readSpendingCustomCategories(Long userId) { public boolean isExistsSpendingCustomCategory(Long userId, Long categoryId) { return spendingCustomCategoryRepository.existsByIdAndUser_Id(categoryId, userId); } + + @Transactional + public void deleteSpendingCustomCategory(Long categoryId) { + spendingCustomCategoryRepository.deleteById(categoryId); + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java index 0ed830483..392a349a9 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -123,6 +123,11 @@ public List readTotalSpendingsAmountByUserId(Long userId) { return spendingRepository.selectList(predicate, TotalSpendingAmount.class, bindings, queryHandler, sort); } + @Transactional + public void deleteSpendingsByCategoryIdInQuery(Long categoryId) { + spendingRepository.deleteAllByCategoryIdAndDeletedAtNullInQuery(categoryId); + } + @Transactional(readOnly = true) public boolean isExistsSpending(Long userId, Long spendingId) { return spendingRepository.existsByIdAndUser_Id(spendingId, userId); From 6e4341d5556a58bab2e08a064298d8dfd68334ed Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 11 Jul 2024 12:03:58 +0900 Subject: [PATCH 120/152] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=A0=95=EA=B8=B0?= =?UTF-8?q?=20=EC=A7=80=EC=B6=9C=20=EB=93=B1=EB=A1=9D=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20&=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20(#124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * feat: domain 모듈 내 notify_type 정의 * fix: fcm_config @enable_async 추가 * feat: notification_event 객체 생성 * fix: 알림 이벤트 객체 image_url 필드 추가 * fix: 푸시 알림 이벤트 class -> record 타입 변경 및 편의성 메서드 추가 * feat: fcm 푸시 알림 전송을 위한 fcm_manager 작성 * fix: fcm_manager send_message() 반환 값 api_future로 수정 * feat: 푸시 알림 이벤트 핸들러 작성 * rename: 푸시 알림 핸들러 주석 수정 * chore: batch config 작성 * feat: 정치 지출 관리 알림 job 설정 * feat: 정기 지출 관리 알림 step 설정 * feat: 활성화된 디바이스 토큰 탐색 reader 정의 * feat: 푸시 알림 프로세서 정의 * feat: 푸시 알림 writer 임시 정의 * feat: job & step 빈 주입 * chore: @enable_scheduling 어노테이션 선언 * chore: yml 파일에 schedule 설정 추가 * chore: 정기 알람 cron 정보 .yml 등록 * fix: 푸시 알림 이벤트 @getter 제거 * feat: 정리 알림 batch scheduler 작성 * fix: device_token_repository list_crud_repository -> jpa_repository * fix: token 조회 시, 활성화된 토큰만 조회하도록 메서드 추가 * fix: fcm_manager getter 메서드 수정 * fix: active_device_token_reader generic 제거 * feat: device_info dto 정의 * rename: device_token dto 이름 수정 * fix: device entity 제네릭 -> device_token_owner dto로 변경 * feat: device_token_owner 활성화 토큰 페이지 조회 쿼리 추가 * feat: 공지사항 enum 타입 생성 * rename: 공지 사항 열거 타입 이름 수정 announce -> announcement * fix: notice_type legacy_common_type 구현 * feat: 알림 타입 컨버터 작성 * feat: 공지 알림 타입 컨버터 생성 * fix: job_config 실패 케이스 재실행 or 중지 플로우 추가 * feat: notification_repository 정의 * feat: device_token_owner dto 사용자 이름 필드 추가 * fix: device_token_owner dto 조회 query 수정 (user entity left join 추가) * style: 인자 순서 변경 * fix: group by u.id 조건 추가 * rename: find_activated_device_token_owners 메서드 인터페이스에 주석 추가 * feat: 알림 벌크 insert 메서드 추가 * feat: notification entity builder 클래스 생성 * feat: notification repository query dsl interface 작성 * feat: 정기 지출 등록 알림 벌크 삽입 연산 메서드 추가 * fix: 기존 푸시 알림 저장 메서드 제거" * feat: 정기 지출 푸시 알림 writer batch 구현 * fix: notification entity announcement 필드 추가 * fix: notification read batch 호출 메서드 수정 * rename: 정기 지줄 등록 알림 벌크 삽입 메서드 주석 추가 * fix: 푸시 알림 벌크 삽입 연산 조건절 수정 * rename: 푸시 알림 벌크 삽입 연산 메서드 쿼리 주석 추가 * feat: 공지 알림 타입 포매팅 메서드 추가 * fix: batch writer에서 publisher 등록 시 반복문 호출하도록 수정 * feat: 정기 지출 등록 푸시 알림 dto 정의 * fix: process 배치 반환 타입 수정 * fix: step chunk generic 값 수정 * fix: writer batch 매개변수 타입 수정 & 로직 수정 * fix: 정기 지출 등록 알림 dto 필드에서 published_at 제거 * fix: device_token_repository read_device_tokens_activate_is_true 메서드 제거 * chore: @enable_async config 파일 분리 * chore: batch_application 경로 수정 * fix: notification_event_handler @async 어노테이션 추가 * fix: fcm_manager 빈 주입 방식 수정 * feat: notification_handler interface 정의 * fix: notification event handler impl 클래스명 수정 * test: 알림 설정 허용 사용자의 활성화된 디바이스 토큰 조회 테스트 케이스 작성 * test: 사용자별 device_token 리스트 담기는 것을 테스트 * chore: mysql_container only_full_group_by 옵션 제거 * test: repository 메서드 호출 결과 map 컬렉션으로 매핑하여 결과 비교 * test: 디버깅용 log 제거 * fix: device_token_owner device_tokens -> device_token * fix: 푸시 알림 허용 사용자의 활성화된 디바이스 토큰 탐색 쿼리에서 group_by절 제거 * fix: daily_spending_notification dto device_tokens -> device_token 필드 수정 * fix: daily_spending_notification dto 정적 팩토리 메서드 수정 & 디바이스 토큰 추가 메서드 * fix: batch output 제네릭 타입 수정 * fix: 배치 writer 내부 로직 수정 -> daily_spending_notification 매핑 로직 추가 * test: notification bulk insert method 테스트 * fix: bulk insert 시, query_dsl에서 jdbc_template를 사용하는 것으로 변경 * fix: notification entity read_at nullable 허용 * test: notification 중복 저장 안 되는 것을 확인 * fix: reader batch bean 이름 수정 * chore: batch 모듈 fcm config 활성화 * chore: batch application 상위 경로로 이동 * fix: cron value application.yml에서 제거하고 직접 입력 * fix: count 쿼리에서 select 누락 수정 * rename: reader-processor-writer 주석 추가 * rename: fcm 메시지 전송 시 단일/다중 로그 추가 * fix: 푸시 알림 dto device_token 중복 제거를 위해 list -> set 타입으로 수정 * fix: 사용자 아이디 리스트 얻기 위한 로직 수정 (key_set으로 획득) * refactor: notification_event_handler interface 메서드 추가 * rename: notification_event_handler 주석에서 타입 인터페이스 -> 인터페이스로 수정 * rename: notification_event_handler 명세 수정 * rename: notification_event_handler_impl -> fcm_notification_event_handler * rename: find_activated_device_token_owners 주석에서 group by 절 제거 * rename: save_daily_spending_announce_in_bulk sql 예시 수정 * rename: reader 로그 제거 * fix: device_token 조회 시, count 쿼리를 위한 조건 추가 --- .../api/config/ExternalApiDBTestConfig.java | 1 + .../co/pennyway/PennywayBatchApplication.java | 2 + .../co/pennyway/batch/config/BatchConfig.java | 9 ++ .../co/pennyway/batch/config/InfraConfig.java | 12 ++ .../batch/dto/DailySpendingNotification.java | 54 +++++++ .../job/DailySpendingNotifyJobConfig.java | 29 ++++ .../processor/NotificationProcessor.java | 18 +++ .../batch/reader/ActiveDeviceTokenReader.java | 33 +++++ .../scheduler/SpendingNotifyScheduler.java | 36 +++++ .../step/SendSpendingNotifyStepConfig.java | 32 ++++ .../batch/writer/NotificationWriter.java | 50 +++++++ .../src/main/resources/application.yml | 12 ++ .../converter/AnnouncementConverter.java | 13 ++ .../common/converter/NoticeTypeConverter.java | 13 ++ .../domains/device/dto/DeviceTokenOwner.java | 11 ++ .../DeviceTokenCustomRepository.java | 31 ++++ .../DeviceTokenCustomRepositoryImpl.java | 52 +++++++ .../repository/DeviceTokenRepository.java | 4 +- .../notification/domain/Notification.java | 90 ++++++++++++ .../NotificationCustomRepository.java | 36 +++++ .../NotificationCustomRepositoryImpl.java | 76 ++++++++++ .../repository/NotificationRepository.java | 7 + .../notification/type/Announcement.java | 40 +++++ .../domains/notification/type/NoticeType.java | 32 ++++ .../domain/config/ContainerDBTestConfig.java | 1 + .../config/ContainerMySqlTestConfig.java | 1 + .../repository/ActivatedDeviceSearchTest.java | 139 ++++++++++++++++++ .../SaveDailySpendingAnnounceInBulkTest.java | 101 +++++++++++++ .../infra/client/google/fcm/FcmManager.java | 64 ++++++++ .../event/FcmNotificationEventHandler.java | 39 +++++ .../infra/common/event/NotificationEvent.java | 62 ++++++++ .../event/NotificationEventHandler.java | 13 ++ .../co/pennyway/infra/config/AsyncConfig.java | 7 + .../co/pennyway/infra/config/FcmConfig.java | 14 +- .../co/pennyway/infra/config/MailConfig.java | 2 - 35 files changed, 1131 insertions(+), 5 deletions(-) create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/config/BatchConfig.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/config/InfraConfig.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/dto/DailySpendingNotification.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyJobConfig.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/NotificationProcessor.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/scheduler/SpendingNotifyScheduler.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/step/SendSpendingNotifyStepConfig.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AnnouncementConverter.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/NoticeTypeConverter.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/dto/DeviceTokenOwner.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepositoryImpl.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/NoticeType.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/device/repository/ActivatedDeviceSearchTest.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/SaveDailySpendingAnnounceInBulkTest.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/fcm/FcmManager.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/FcmNotificationEventHandler.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/NotificationEvent.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/NotificationEventHandler.java create mode 100644 pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AsyncConfig.java diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java index 5632616f9..b42de3dce 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java @@ -28,6 +28,7 @@ public abstract class ExternalApiDBTestConfig { .withDatabaseName("pennyway") .withUsername("root") .withPassword("testpass") + .withCommand("--sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION") .withReuse(true); REDIS_CONTAINER.start(); diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/PennywayBatchApplication.java b/pennyway-batch/src/main/java/kr/co/pennyway/PennywayBatchApplication.java index b16070a57..149f6ae04 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/PennywayBatchApplication.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/PennywayBatchApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class PennywayBatchApplication { public static void main(String[] args) { SpringApplication.run(PennywayBatchApplication.class, args); diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/config/BatchConfig.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/config/BatchConfig.java new file mode 100644 index 000000000..ce403355d --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/config/BatchConfig.java @@ -0,0 +1,9 @@ +package kr.co.pennyway.batch.config; + +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableBatchProcessing +public class BatchConfig { +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/config/InfraConfig.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/config/InfraConfig.java new file mode 100644 index 000000000..9046350b4 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/config/InfraConfig.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.batch.config; + +import kr.co.pennyway.infra.common.importer.EnablePennywayInfraConfig; +import kr.co.pennyway.infra.common.importer.PennywayInfraConfigGroup; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnablePennywayInfraConfig({ + PennywayInfraConfigGroup.FCM +}) +public class InfraConfig { +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/dto/DailySpendingNotification.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/dto/DailySpendingNotification.java new file mode 100644 index 000000000..0c69f224e --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/dto/DailySpendingNotification.java @@ -0,0 +1,54 @@ +package kr.co.pennyway.batch.dto; + +import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import lombok.Builder; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +@Builder +public record DailySpendingNotification( + Long userId, + String title, + String content, + Set deviceTokens +) { + public DailySpendingNotification { + Objects.requireNonNull(userId, "userId must not be null"); + Objects.requireNonNull(title, "title must not be null"); + Objects.requireNonNull(content, "content must not be null"); + Objects.requireNonNull(deviceTokens, "deviceTokens must not be null"); + } + + /** + * {@link DeviceTokenOwner}를 DailySpendingNotification DTO로 변환하는 정적 팩토리 메서드 + *

+ * DeviceToken은 List로 변환되어 멤버 변수로 관리하게 된다. + */ + public static DailySpendingNotification from(DeviceTokenOwner owner) { + Announcement announcement = Announcement.DAILY_SPENDING; + Set deviceTokens = new HashSet<>(); + deviceTokens.add(owner.deviceToken()); + + return DailySpendingNotification.builder() + .userId(owner.userId()) + .title(announcement.createFormattedTitle(owner.name())) + .content(announcement.getContent()) + .deviceTokens(deviceTokens) + .build(); + } + + public void addDeviceToken(String deviceToken) { + deviceTokens.add(deviceToken); + } + + /** + * DeviceToken을 List로 변환하여 View를 반환한다. + */ + public List deviceTokensForList() { + return List.copyOf(deviceTokens); + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyJobConfig.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyJobConfig.java new file mode 100644 index 000000000..c65197ca9 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyJobConfig.java @@ -0,0 +1,29 @@ +package kr.co.pennyway.batch.job; + +import kr.co.pennyway.batch.step.SendSpendingNotifyStepConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class DailySpendingNotifyJobConfig { + private final JobRepository jobRepository; + private final SendSpendingNotifyStepConfig sendSpendingNotifyStepConfig; + + @Bean + public Job dailyNotificationJob(PlatformTransactionManager transactionManager) { + return new JobBuilder("dailyNotificationJob", jobRepository) + .start(sendSpendingNotifyStepConfig.sendSpendingNotifyStep(transactionManager)) + .on("FAILED") + .stopAndRestart(sendSpendingNotifyStepConfig.sendSpendingNotifyStep(transactionManager)) + .on("*") + .end() + .end() + .build(); + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/NotificationProcessor.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/NotificationProcessor.java new file mode 100644 index 000000000..560f61d35 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/NotificationProcessor.java @@ -0,0 +1,18 @@ +package kr.co.pennyway.batch.processor; + +import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class NotificationProcessor implements ItemProcessor { + + @Override + public DeviceTokenOwner process(@NonNull DeviceTokenOwner deviceTokenOwner) throws Exception { + log.info("NotificationProcessor 실행"); + return deviceTokenOwner; + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java new file mode 100644 index 000000000..cf6b2d450 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java @@ -0,0 +1,33 @@ +package kr.co.pennyway.batch.reader; + +import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; +import kr.co.pennyway.domain.domains.device.repository.DeviceTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.util.HashMap; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ActiveDeviceTokenReader { + private final DeviceTokenRepository deviceTokenRepository; + + @Bean + public RepositoryItemReader execute() { + return new RepositoryItemReaderBuilder() + .name("execute") + .repository(deviceTokenRepository) + .methodName("findActivatedDeviceTokenOwners") + .pageSize(100) + .sorts(new HashMap<>() {{ + put("id", Sort.Direction.ASC); + }}) + .build(); + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/scheduler/SpendingNotifyScheduler.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/scheduler/SpendingNotifyScheduler.java new file mode 100644 index 000000000..8d36bd04b --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/scheduler/SpendingNotifyScheduler.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.batch.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.JobParametersInvalidException; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.core.repository.JobExecutionAlreadyRunningException; +import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException; +import org.springframework.batch.core.repository.JobRestartException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SpendingNotifyScheduler { + private final JobLauncher jobLauncher; + private final Job dailyNotificationJob; + + @Scheduled(cron = "0 0 20 * * ?") + public void runDailyNotificationJob() { + JobParameters jobParameters = new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()) + .toJobParameters(); + + try { + jobLauncher.run(dailyNotificationJob, jobParameters); + } catch (JobExecutionAlreadyRunningException | JobRestartException + | JobInstanceAlreadyCompleteException | JobParametersInvalidException e) { + log.error("Failed to run dailyNotificationJob", e); + } + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/step/SendSpendingNotifyStepConfig.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/step/SendSpendingNotifyStepConfig.java new file mode 100644 index 000000000..2ea94c62b --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/step/SendSpendingNotifyStepConfig.java @@ -0,0 +1,32 @@ +package kr.co.pennyway.batch.step; + +import kr.co.pennyway.batch.processor.NotificationProcessor; +import kr.co.pennyway.batch.reader.ActiveDeviceTokenReader; +import kr.co.pennyway.batch.writer.NotificationWriter; +import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class SendSpendingNotifyStepConfig { + private final JobRepository jobRepository; + private final ActiveDeviceTokenReader reader; + private final NotificationProcessor processor; + private final NotificationWriter writer; + + @Bean + public Step sendSpendingNotifyStep(PlatformTransactionManager transactionManager) { + return new StepBuilder("sendSpendingNotifyStep", jobRepository) + .chunk(100, transactionManager) + .reader(reader.execute()) + .processor(processor) + .writer(writer) + .build(); + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java new file mode 100644 index 000000000..15707ae2b --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java @@ -0,0 +1,50 @@ +package kr.co.pennyway.batch.writer; + +import kr.co.pennyway.batch.dto.DailySpendingNotification; +import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; +import kr.co.pennyway.domain.domains.notification.repository.NotificationRepository; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.infra.common.event.NotificationEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationWriter implements ItemWriter { + private final NotificationRepository notificationRepository; + private final ApplicationEventPublisher publisher; + + @Override + @Transactional + public void write(@NonNull Chunk owners) throws Exception { + log.info("Writer 실행: {}", owners.size()); + LocalDateTime publishedAt = LocalDateTime.now(); + + Map notificationMap = new HashMap<>(); + + for (DeviceTokenOwner owner : owners) { + notificationMap.computeIfAbsent(owner.userId(), k -> DailySpendingNotification.from(owner)).addDeviceToken(owner.deviceToken()); + } + + List userIds = new ArrayList<>(notificationMap.keySet()); + + notificationRepository.saveDailySpendingAnnounceInBulk(userIds, publishedAt, Announcement.DAILY_SPENDING); + + for (DailySpendingNotification notification : notificationMap.values()) { + publisher.publishEvent(NotificationEvent.of(notification.title(), notification.content(), notification.deviceTokensForList(), "")); + } + } +} diff --git a/pennyway-batch/src/main/resources/application.yml b/pennyway-batch/src/main/resources/application.yml index 37b946c67..57673c162 100644 --- a/pennyway-batch/src/main/resources/application.yml +++ b/pennyway-batch/src/main/resources/application.yml @@ -4,6 +4,18 @@ spring: local: common, domain, infra dev: common, domain, infra + batch: + job: + enabled: false + + task: + scheduling: + pool: + size: 5 + shutdown: + await-termination: true # 애플리케이션 종료 시 모든 Task가 종료될 때까지 대기 + await-termination-period: 60000 # 대기 시간 60초 + --- spring: config: diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AnnouncementConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AnnouncementConverter.java new file mode 100644 index 000000000..6c995aa96 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AnnouncementConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.notification.type.Announcement; + +@Converter +public class AnnouncementConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "공지 타입"; + + public AnnouncementConverter() { + super(Announcement.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/NoticeTypeConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/NoticeTypeConverter.java new file mode 100644 index 000000000..0653382d9 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/NoticeTypeConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; + +@Converter +public class NoticeTypeConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "알림 타입"; + + public NoticeTypeConverter() { + super(NoticeType.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/dto/DeviceTokenOwner.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/dto/DeviceTokenOwner.java new file mode 100644 index 000000000..15c4a8c9f --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/dto/DeviceTokenOwner.java @@ -0,0 +1,11 @@ +package kr.co.pennyway.domain.domains.device.dto; + +/** + * 디바이스 토큰과 유저 아이디를 담은 DTO + */ +public record DeviceTokenOwner( + Long userId, + String name, + String deviceToken +) { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepository.java new file mode 100644 index 000000000..c10dd3579 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepository.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.domain.domains.device.repository; + +import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface DeviceTokenCustomRepository { + /** + * 사용자 아이디, 이름 그리고 디바이스 토큰 리스트를 조회하여, {@link DeviceTokenOwner} 객체로 반환한다. + *

+ * 이 때, 사용자의 계좌북 알림 설정이 활성화되어 있어야 하며, 디바이스 토큰은 활성화되어 있어야 한다. + *

+ * + * @apiNote 이 메서드는 페이징 처리를 하고 있으며, 사용자 아이디를 기준으로 오름차순 정렬한다. + * 이 때, size가 100이고 한 명의 사용자가 여러 개의 디바이스 토큰(각각 pk가 99, 100, 101)을 가지고 있다면, + * 101번에 대한 토큰은 다음 페이지로 넘어가게 되므로 이에 대한 예외 처리가 필요하다. + * + *

+     * {@code
+     *      SELECT d.token, u.id, u.name
+     *      FROM device_token d
+     *      LEFT JOIN user u ON d.user_id = u.id
+     *      WHERE d.activated = true AND u.account_book_notify = true
+     *      ORDER BY u.id ASC
+     *      LIMIT ${pageable.pageSize} OFFSET ${pageable.offset}
+     *      ;
+     * }
+     * 
+ */ + Page findActivatedDeviceTokenOwners(Pageable pageable); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepositoryImpl.java new file mode 100644 index 000000000..41f56b49c --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepositoryImpl.java @@ -0,0 +1,52 @@ +package kr.co.pennyway.domain.domains.device.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.domain.domains.device.domain.QDeviceToken; +import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class DeviceTokenCustomRepositoryImpl implements DeviceTokenCustomRepository { + private final JPAQueryFactory queryFactory; + + private final QUser user = QUser.user; + private final QDeviceToken deviceToken = QDeviceToken.deviceToken; + + @Override + public Page findActivatedDeviceTokenOwners(Pageable pageable) { + List content = queryFactory + .select( + Projections.constructor( + DeviceTokenOwner.class, + user.id, + user.name, + deviceToken.token + ) + ) + .from(deviceToken) + .leftJoin(user).on(deviceToken.user.id.eq(user.id)) + .where(deviceToken.activated.isTrue().and(user.notifySetting.accountBookNotify.isTrue())) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(user.id.asc()) + .fetch(); + + JPAQuery count = queryFactory + .select(deviceToken.id.count()) + .from(deviceToken) + .leftJoin(user).on(deviceToken.user.id.eq(user.id)) + .where(deviceToken.activated.isTrue().and(user.notifySetting.accountBookNotify.isTrue())); + + return PageableExecutionUtils.getPage(content, pageable, () -> count.fetch().size()); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java index da2f4135d..510da3f9e 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java @@ -1,12 +1,12 @@ package kr.co.pennyway.domain.domains.device.repository; import kr.co.pennyway.domain.domains.device.domain.DeviceToken; -import org.springframework.data.repository.ListCrudRepository; +import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; import java.util.Optional; -public interface DeviceTokenRepository extends ListCrudRepository { +public interface DeviceTokenRepository extends JpaRepository, DeviceTokenCustomRepository { Optional findByUser_IdAndToken(Long userId, String token); List findAllByUser_Id(Long userId); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java new file mode 100644 index 000000000..e8ffa392e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java @@ -0,0 +1,90 @@ +package kr.co.pennyway.domain.domains.notification.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.AnnouncementConverter; +import kr.co.pennyway.domain.common.converter.NoticeTypeConverter; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "notification") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private LocalDateTime readAt; + @Convert(converter = NoticeTypeConverter.class) + private NoticeType type; + @Convert(converter = AnnouncementConverter.class) + private Announcement announcement; // 공지 종류 + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver") + private User receiver; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender") + private User sender; + + private Notification(LocalDateTime readAt, NoticeType type, Announcement announcement, User receiver, User sender) { + this.readAt = readAt; + this.type = Objects.requireNonNull(type); + this.announcement = Objects.requireNonNull(announcement); + this.receiver = receiver; + this.sender = sender; + } + + @Override + public String toString() { + return "Notification{" + + "id=" + id + + ", readAt=" + readAt + + ", type=" + type + + ", announcement=" + announcement + + '}'; + } + + public static class Builder { + private LocalDateTime readAt; + private NoticeType type; + private Announcement announcement; + + private User receiver = null; + private User sender = null; + + public Builder(NoticeType type, Announcement announcement) { + this.type = type; + this.announcement = announcement; + } + + public Builder readAt(LocalDateTime readAt) { + this.readAt = readAt; + return this; + } + + public Builder receiver(User receiver) { + this.receiver = receiver; + return this; + } + + public Builder sender(User sender) { + this.sender = sender; + return this; + } + + public Notification build() { + return new Notification(readAt, type, announcement, receiver, sender); + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java new file mode 100644 index 000000000..03c80ed5d --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import kr.co.pennyway.domain.domains.notification.type.Announcement; + +import java.time.LocalDateTime; +import java.util.List; + +public interface NotificationCustomRepository { + /** + * 사용자들에게 정기 지출 등록 알림을 저장한다. (발송이 아님) + * 만약 이미 publishedAt의 년-월-일에 해당하는 알림이 존재하고, 그 알림의 announcement까지 같다면 저장하지 않는다. + * + *
+     * {@code
+     * INSERT INTO notification(id, type, read_at, created_at, updated_at, receiver, announcement)
+     * SELECT NULL, '0', NULL, NOW(), NOW(), u.id, '1'
+     * FROM user u
+     * WHERE u.id IN (?)
+     * AND NOT EXISTS (
+     * 	SELECT n.receiver
+     * 	FROM notification n
+     * 	WHERE n.receiver = u.id
+     *     AND n.created_at >= CURDATE()
+     *     AND n.created_at < CURDATE() + INTERVAL 1 DAY
+     * 	AND n.type = '0'
+     * 	AND n.announcement = 1
+     * );
+     * }
+     * 
+ * + * @param userIds : 등록할 사용자 아이디 목록 + * @param publishedAt : 알림 발송 시간, 공지 알림 중복 저장 방지를 위해 조건식에 사용 + * @param announcement : 알림 타입 {@link Announcement} + */ + void saveDailySpendingAnnounceInBulk(List userIds, LocalDateTime publishedAt, Announcement announcement); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java new file mode 100644 index 000000000..0088b2ace --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java @@ -0,0 +1,76 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class NotificationCustomRepositoryImpl implements NotificationCustomRepository { + private final JdbcTemplate jdbcTemplate; + + private int batchSize = 500; + + @Override + public void saveDailySpendingAnnounceInBulk(List userIds, LocalDateTime publishedAt, Announcement announcement) { + int batchCount = 0; + List subItems = new ArrayList<>(); + + for (int i = 0; i < userIds.size(); ++i) { + subItems.add(userIds.get(i)); + + if ((i + 1) % batchSize == 0) { + batchCount = batchInsert(batchCount, subItems, publishedAt, announcement); + } + } + + if (!subItems.isEmpty()) { + batchInsert(batchCount, subItems, publishedAt, announcement); + } + + log.info("Notification saved. announcement: {}, count: {}", announcement, userIds.size()); + } + + private int batchInsert(int batchCount, List userIds, LocalDateTime publishedAt, Announcement announcement) { + String sql = "INSERT INTO notification(id, type, read_at, created_at, updated_at, receiver, announcement) " + + "SELECT NULL, '0', NULL, NOW(), NOW(), u.id, ? " + + "FROM user u " + + "WHERE u.id IN (?) " + + "AND NOT EXISTS ( " + + " SELECT n.receiver " + + " FROM notification n " + + " WHERE n.receiver = u.id " + + " AND n.created_at >= CURDATE() " + + " AND n.created_at < CURDATE() + INTERVAL 1 DAY " + + " AND n.type = '0' " + + " AND n.announcement = ? " + + ");"; + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ps.setString(1, announcement.getCode()); + ps.setLong(2, userIds.get(i)); + ps.setString(3, announcement.getCode()); + } + + @Override + public int getBatchSize() { + return userIds.size(); + } + }); + + userIds.clear(); + return ++batchCount; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java new file mode 100644 index 000000000..99531e3ae --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationRepository extends JpaRepository, NotificationCustomRepository { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java new file mode 100644 index 000000000..72b8181da --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java @@ -0,0 +1,40 @@ +package kr.co.pennyway.domain.domains.notification.type; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.Getter; +import org.springframework.util.StringUtils; + +@Getter +public enum Announcement implements LegacyCommonType { + NOT_ANNOUNCE("0", "", ""), + + // 정기 지출 알림 + DAILY_SPENDING("1", "%s님, 3분 카레보다 빨리 끝나요!", "많은 친구들이 소비 기록에 참여하고 있어요👀"), + MONTHLY_TARGET_AMOUNT("2", "6월의 첫 시작! 두구두구..🥁", "%s님의 이번 달 목표 소비 금액은?"); + + private final String code; + private final String title; + private final String content; + + Announcement(String code, String title, String content) { + this.code = code; + this.title = title; + this.content = content; + } + + public String createFormattedTitle(String name) { + validateName(name); + return String.format(title, name); + } + + public String createFormattedContent(String name) { + validateName(name); + return String.format(content, name); + } + + private void validateName(String name) { + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name must not be empty"); + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/NoticeType.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/NoticeType.java new file mode 100644 index 000000000..05bc2646b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/NoticeType.java @@ -0,0 +1,32 @@ +package kr.co.pennyway.domain.domains.notification.type; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.Getter; + +/** + * 알림 종류를 정의하기 위한 타입 + * + *

+ * 알림 타입은 [도메인]_[액션]_[FROM]_[TO?] 형태로 정의한다. + * 각 알림 타입에 대한 이름, 제목, 내용 형식을 지정하며, 알림 타입에 따라 내용을 생성하는 기능을 제공한다. + *

+ * + * @author YANG JAESEO + * @since 2024-07-04 + */ +@Getter +public enum NoticeType implements LegacyCommonType { + ANNOUNCEMENT("0", "%s", "%s"); // 공지 사항은 별도 제목을 설정하여 사용한다. + + private final String code; + private final String title; + private final String contentFormat; + private final String navigablePlaceholders = "{%s_%d}"; + private final String plainTextPlaceholders = "%s"; + + NoticeType(String code, String title, String contentFormat) { + this.code = code; + this.title = title; + this.contentFormat = contentFormat; + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerDBTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerDBTestConfig.java index 0548812ab..fdfd1817c 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerDBTestConfig.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerDBTestConfig.java @@ -28,6 +28,7 @@ public class ContainerDBTestConfig { .withDatabaseName("pennyway") .withUsername("root") .withPassword("testpass") + .withCommand("--sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION") .withReuse(true); REDIS_CONTAINER.start(); diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java index 3a2511284..4af33fca1 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java @@ -20,6 +20,7 @@ public class ContainerMySqlTestConfig { .withDatabaseName("pennyway") .withUsername("root") .withPassword("testpass") + .withCommand("--sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION") .withReuse(true); MYSQL_CONTAINER.start(); diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/device/repository/ActivatedDeviceSearchTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/device/repository/ActivatedDeviceSearchTest.java new file mode 100644 index 000000000..d851aefbd --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/device/repository/ActivatedDeviceSearchTest.java @@ -0,0 +1,139 @@ +package kr.co.pennyway.domain.domains.device.repository; + +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.TestJpaConfig; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertNotEquals; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = JpaConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(TestJpaConfig.class) +@ActiveProfiles("test") +public class ActivatedDeviceSearchTest extends ContainerMySqlTestConfig { + @Autowired + private DeviceTokenRepository deviceTokenRepository; + @Autowired + private UserRepository userRepository; + + @Test + @Transactional + @DisplayName("비활성화된 디바이스 토큰을 제외하고, 알림을 허용한 사용자의 활성화된 디바이스 토큰을 조회한다.") + public void selectActivatedDeviceTokenThatNotifyTrueUser() { + // given + User user = userRepository.save(createUser("jayang")); + List deviceTokens = List.of( + DeviceToken.of("deviceToken1", user), + DeviceToken.of("deviceToken2", user), + DeviceToken.of("deviceToken3", user) + ); + deviceTokens.get(1).deactivate(); + deviceTokenRepository.saveAll(deviceTokens); + Pageable pageable = Pageable.ofSize(100); + + // when + Page owners = deviceTokenRepository.findActivatedDeviceTokenOwners(pageable); + + // then + assertEquals("조회 결과 원소 개수는 2여야 합니다.", owners.getTotalElements(), 2L); + for (DeviceTokenOwner owner : owners) { + assertNotEquals("deviceToken2는 비활성화 토큰입니다.", "deviceToken2", owner.deviceToken()); + } + } + + @Test + @Transactional + @DisplayName("알림을 허용하지 않은 사용자의 활성화된 디바이스 토큰을 조회하지 않는다.") + public void notSelectNotifyFalseUser() { + // given + User activeUser = userRepository.save(createUser("jayang")); + User deactiveUser = userRepository.save(createUser("mock")); + + List deviceTokens = List.of( + DeviceToken.of("deviceToken1", activeUser), + DeviceToken.of("deviceToken2", deactiveUser)); + deviceTokens.get(1).deactivate(); + + deviceTokenRepository.saveAll(deviceTokens); + + Pageable pageable = Pageable.ofSize(100); + + // when + Page owners = deviceTokenRepository.findActivatedDeviceTokenOwners(pageable); + + // then + assertEquals("조회 결과는 하나여야 합니다.", 1L, owners.getTotalElements()); + assertEquals("알림을 허용하지 않은 사용자의 디바이스 토큰은 조회되지 않아야 합니다.", "jayang", owners.getContent().get(0).name()); + } + + @Test + @Transactional + @DisplayName("사용자 별로 디바이스 토큰 리스트를 받을 수 있다.") + public void selectDeviceTokenListByUserId() { + // given + User user1 = userRepository.save(createUser("jayang")); + User user2 = userRepository.save(createUser("mock")); + + List deviceTokens = List.of( + DeviceToken.of("deviceToken1", user1), + DeviceToken.of("deviceToken2", user1), + DeviceToken.of("deviceToken3", user1), + DeviceToken.of("deviceToken4", user2), + DeviceToken.of("deviceToken5", user2) + ); + deviceTokenRepository.saveAll(deviceTokens); + + Pageable pageable = Pageable.ofSize(100); + + // when + Page owners = deviceTokenRepository.findActivatedDeviceTokenOwners(pageable); + Map> deviceTokenMap = new HashMap<>(); + for (DeviceTokenOwner owner : owners) { + deviceTokenMap.computeIfAbsent(owner.name(), k -> new ArrayList<>()).add(owner.deviceToken()); + } + + // then + assertEquals("전체 결과 개수는 5개여야 합니다.", 5L, owners.getTotalElements()); + assertEquals("jayang의 디바이스 토큰 개수는 3개여야 합니다.", 3, deviceTokenMap.get("jayang").size()); + assertEquals("mock의 디바이스 토큰 개수는 2개여야 합니다.", 2, deviceTokenMap.get("mock").size()); + } + + private User createUser(String name) { + return User.builder() + .username("test") + .name(name) + .password("test") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/SaveDailySpendingAnnounceInBulkTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/SaveDailySpendingAnnounceInBulkTest.java new file mode 100644 index 000000000..970c05c78 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/SaveDailySpendingAnnounceInBulkTest.java @@ -0,0 +1,101 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.TestJpaConfig; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.springframework.test.util.AssertionErrors.assertEquals; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = JpaConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(TestJpaConfig.class) +@ActiveProfiles("test") +public class SaveDailySpendingAnnounceInBulkTest extends ContainerMySqlTestConfig { + @Autowired + private UserRepository userRepository; + @Autowired + private NotificationRepository notificationRepository; + + @Test + @Transactional + @DisplayName("여러 사용자에게 일일 소비 알림을 저장할 수 있다.") + public void saveDailySpendingAnnounceInBulk() { + // given + User user1 = userRepository.save(createUser("jayang")); + User user2 = userRepository.save(createUser("mock")); + User user3 = userRepository.save(createUser("test")); + + // when + notificationRepository.saveDailySpendingAnnounceInBulk( + List.of(user1.getId(), user2.getId(), user3.getId()), + LocalDateTime.now(), + Announcement.DAILY_SPENDING + ); + + // then + notificationRepository.findAll().forEach(notification -> { + log.info("notification: {}", notification); + assertEquals("알림 타입이 일일 소비 알림이어야 한다.", Announcement.DAILY_SPENDING, notification.getAnnouncement()); + }); + } + + @Test + @Transactional + @DisplayName("이미 당일에 알림을 받은 사용자에게 데이터가 중복 저장되지 않아야 한다.") + public void notSaveDuplicateNotification() { + // given + User user1 = userRepository.save(createUser("jayang")); + User user2 = userRepository.save(createUser("mock")); + + Notification notification = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING) + .receiver(user1) + .build(); + notificationRepository.save(notification); + + // when + notificationRepository.saveDailySpendingAnnounceInBulk( + List.of(user1.getId(), user2.getId()), + LocalDateTime.now(), + Announcement.DAILY_SPENDING + ); + + // then + List notifications = notificationRepository.findAll(); + assertEquals("알림이 중복 저장되지 않아야 한다.", 2, notifications.size()); + } + + private User createUser(String name) { + return User.builder() + .username("test") + .name(name) + .password("test") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/fcm/FcmManager.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/fcm/FcmManager.java new file mode 100644 index 000000000..07944a7f7 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/google/fcm/FcmManager.java @@ -0,0 +1,64 @@ +package kr.co.pennyway.infra.client.google.fcm; + +import com.google.api.core.ApiFuture; +import com.google.firebase.messaging.*; +import kr.co.pennyway.infra.common.event.NotificationEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class FcmManager { + private final FirebaseMessaging firebaseMessaging; + + /** + * {@link NotificationEvent}를 받아서 메시지를 전송한다. + *

+ * 디바이스 토큰이 1개인 경우에는 단일 메시지를, 2개 이상인 경우에는 다중 메시지를 전송한다. + * 만약 디바이스 토큰이 존재하지 않는 경우에는 메시지 전송을 하지 않는다. + *

+ */ + public ApiFuture sendMessage(NotificationEvent event) { + if (event.deviceTokensSize() == 0) { + log.info("메시지 전송을 위한 디바이스 토큰이 존재하지 않습니다."); + return null; + } + + if (event.deviceTokensSize() == 1) { + return sendSingleMessage(event); + } else { + return sendMulticastMessage(event); + } + } + + private ApiFuture sendSingleMessage(NotificationEvent event) { + log.info("단일 메시지 전송 : {}", event); + Message message = event.buildSingleMessage().setApnsConfig(getApnsConfig(event)).build(); + + return firebaseMessaging.sendAsync(message); + } + + private ApiFuture sendMulticastMessage(NotificationEvent event) { + log.info("다중 메시지 전송 : {}", event); + MulticastMessage messages = event.buildMulticastMessage().setApnsConfig(getApnsConfig(event)).build(); + + return firebaseMessaging.sendEachForMulticastAsync(messages); + } + + private ApnsConfig getApnsConfig(NotificationEvent event) { + ApsAlert alert = ApsAlert.builder() + .setTitle(event.title()) + .setBody(event.content()) + .setLaunchImage(event.imageUrl()) + .build(); + + Aps aps = Aps.builder() + .setAlert(alert) + .setSound("default") + .build(); + + return ApnsConfig.builder() + .setAps(aps) + .build(); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/FcmNotificationEventHandler.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/FcmNotificationEventHandler.java new file mode 100644 index 000000000..5bc54f1e7 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/FcmNotificationEventHandler.java @@ -0,0 +1,39 @@ +package kr.co.pennyway.infra.common.event; + +import com.google.api.core.ApiFuture; +import kr.co.pennyway.infra.client.google.fcm.FcmManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.concurrent.Executors; + +/** + * FCM 푸시 알림을 처리하는 핸들러 + */ +@Slf4j +@RequiredArgsConstructor +public class FcmNotificationEventHandler implements NotificationEventHandler { + private final FcmManager fcmManager; + + @Async + @Override + @TransactionalEventListener + public void handleEvent(NotificationEvent event) { + log.debug("handleEvent: {}", event); + ApiFuture response = fcmManager.sendMessage(event); + + if (response == null) { + return; + } + + response.addListener(() -> { + try { + log.info("Successfully sent message: " + response.get()); + } catch (Exception e) { + log.error("Failed to send message: " + e.getMessage()); + } + }, Executors.newCachedThreadPool()); // FIXME: 알림이 매우 많은 경우 out of memory 발생 가능성 있음 (Thread pool size 제한 필요) + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/NotificationEvent.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/NotificationEvent.java new file mode 100644 index 000000000..e36f1fcce --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/NotificationEvent.java @@ -0,0 +1,62 @@ +package kr.co.pennyway.infra.common.event; + +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MulticastMessage; +import com.google.firebase.messaging.Notification; +import org.springframework.util.StringUtils; + +import java.util.List; + +/** + * FCM 푸시 알림에 필요한 정보를 담은 Event 클래스 + *

+ * 제목, 내용, 디바이스 토큰 리스트, 푸시 알림 이미지를 필드로 갖는다. + */ +public record NotificationEvent( + String title, + String content, + List deviceTokens, + String imageUrl +) { + public NotificationEvent { + if (!StringUtils.hasText(title)) { + throw new IllegalArgumentException("제목은 반드시 null 혹은 공백이 아니어야 합니다."); + } + if (!StringUtils.hasText(content)) { + throw new IllegalArgumentException("내용은 반드시 null 혹은 공백이 아니어야 합니다."); + } + if (deviceTokens == null) { + throw new IllegalArgumentException("디바이스 토큰은 반드시 null이 아니어야 합니다."); + } + } + + public static NotificationEvent of(String title, String content, List deviceTokens, String imageUrl) { + return new NotificationEvent(title, content, deviceTokens, imageUrl); + } + + public int deviceTokensSize() { + return deviceTokens.size(); + } + + /** + * 단일 메시지를 전송하기 위한 Message.Builder를 생성한다. + */ + public Message.Builder buildSingleMessage() { + return Message.builder().setNotification(toNotification()).setToken(deviceTokens.get(0)); + } + + /** + * 다중 메시지를 전송하기 위한 MulticastMessage.Builder를 생성한다. + */ + public MulticastMessage.Builder buildMulticastMessage() { + return MulticastMessage.builder().setNotification(toNotification()).addAllTokens(deviceTokens); + } + + private Notification toNotification() { + return Notification.builder() + .setTitle(title) + .setBody(content) + .setImage(imageUrl) + .build(); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/NotificationEventHandler.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/NotificationEventHandler.java new file mode 100644 index 000000000..317cb4475 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/common/event/NotificationEventHandler.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.infra.common.event; + +/** + * 푸시 알림을 처리하는 핸들러 인터페이스 + *

+ * 푸시 알림을 포함한 기능을 테스트할 때는 해당 인터페이스를 구현한 Mock 객체를 사용한다. + * + * @author YANG JAESEO + * @since 2024-07-09 + */ +public interface NotificationEventHandler { + void handleEvent(NotificationEvent event); +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AsyncConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AsyncConfig.java new file mode 100644 index 000000000..e961db982 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/AsyncConfig.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.infra.config; + +import org.springframework.scheduling.annotation.EnableAsync; + +@EnableAsync +public class AsyncConfig { +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/FcmConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/FcmConfig.java index a127a5e3e..92a54d09d 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/FcmConfig.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/FcmConfig.java @@ -5,6 +5,9 @@ import com.google.firebase.FirebaseOptions; import com.google.firebase.messaging.FirebaseMessaging; import jakarta.annotation.PostConstruct; +import kr.co.pennyway.infra.client.google.fcm.FcmManager; +import kr.co.pennyway.infra.common.event.FcmNotificationEventHandler; +import kr.co.pennyway.infra.common.event.NotificationEventHandler; import kr.co.pennyway.infra.common.importer.PennywayInfraConfig; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -16,7 +19,6 @@ @Slf4j @Profile({"local", "dev", "prod"}) -// TODO: 2024.05.17 우선 테스트 통과를 위해 임시로 처리함. Push Notification 기능 테스트 시 문제가 발생하면 수정이 필요함. public class FcmConfig implements PennywayInfraConfig { private final ClassPathResource firebaseResource; private final String projectId; @@ -43,4 +45,14 @@ public void init() throws IOException { FirebaseMessaging firebaseMessaging() { return FirebaseMessaging.getInstance(FirebaseApp.getInstance()); } + + @Bean + FcmManager fcmManager(FirebaseMessaging firebaseMessaging) { + return new FcmManager(firebaseMessaging); + } + + @Bean + NotificationEventHandler notificationEventHandler(FcmManager fcmManager) { + return new FcmNotificationEventHandler(fcmManager); + } } diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MailConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MailConfig.java index 93e088d3c..4bc55dca8 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MailConfig.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/MailConfig.java @@ -5,12 +5,10 @@ import org.springframework.context.annotation.Configuration; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSenderImpl; -import org.springframework.scheduling.annotation.EnableAsync; import java.util.Properties; @Configuration -@EnableAsync public class MailConfig { @Value("${app.mail.host}") private String host; From 00e745014ca7d521c5150316b0260d0ab7b8f8ea Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sat, 13 Jul 2024 22:42:37 +0900 Subject: [PATCH 121/152] =?UTF-8?q?=F0=9F=90=9B=20OIDC=20key=20caching=20t?= =?UTF-8?q?tl=20fix=20(#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * fix: oidc secret key 캐싱 기간 3일로 수정 --- .../src/main/java/kr/co/pennyway/infra/config/CacheConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java index 91e8a08ce..3da866cb3 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/config/CacheConfig.java @@ -32,7 +32,7 @@ public class CacheConfig { private final long defaultCacheTtlSec = 60; private final long securityUserCacheTtlSec = 30; - private final long oidcCacheTtlDay = 7; + private final long oidcCacheTtlDay = 3; public CacheConfig( @Value("${spring.data.redis.host}") String host, From 634f50fc70ea29dcf2a0d1290940c0cad26a882a Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sat, 13 Jul 2024 22:46:44 +0900 Subject: [PATCH 122/152] =?UTF-8?q?Api:=20=E2=9C=8F=EF=B8=8F=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=95=84=EC=9D=B4=EB=94=94,=20=EC=A0=84?= =?UTF-8?q?=ED=99=94=EB=B2=88=ED=98=B8=20=EC=88=98=EC=A0=95=20API=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * fix: update_username_and_phone -> update_phone * fix: 회원가입 dto name, username 정규 표현식 수정 * fix: 사용자 프로필 수정 dto name, username 정규식 수정 및 username_phone_req -> phone_req * fix: usecase update_username_and_phone -> update_phone * fix: patch_profile -> patch_phone && 요청 메서드와 함수 이름 불일치하는 메서드 수정 * docs: swagger 문서 수정 * test: 기존 프로필 수정 테스트 변경 * fix: 사용자 아이디 변경 시, 중복 검사 추가 * docs: swagger에 사용자 아이디 중복 검사 예외 응답 추가 * test: 사용자 이름, 아이디 유효성 검사 테스트 예외 메시지 기대값 수정 --- .../pennyway/api/apis/auth/dto/SignUpReq.java | 14 ++--- .../api/apis/users/api/UserAccountApi.java | 26 +++++----- .../controller/UserAccountController.java | 12 ++--- .../apis/users/dto/UserProfileUpdateDto.java | 10 ++-- .../service/UserProfileUpdateService.java | 26 ++++------ .../users/usecase/UserAccountUseCase.java | 4 +- .../AuthControllerValidationTest.java | 8 +-- ...eTest.java => PhoneUpdateServiceTest.java} | 52 ++----------------- 8 files changed, 50 insertions(+), 102 deletions(-) rename pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/{UserProfileUpdateServiceTest.java => PhoneUpdateServiceTest.java} (58%) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java index dad332063..3ac9a7e7f 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/SignUpReq.java @@ -60,11 +60,11 @@ public User toUser() { public record General( @Schema(description = "아이디", example = "pennyway") @NotBlank(message = "아이디를 입력해주세요") - @Pattern(regexp = "^[a-z-_.]{5,20}$", message = "5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.") + @Pattern(regexp = "^[a-z0-9-_.]{5,20}$", message = "영문 소문자, 숫자, 특수기호 (-), (_), (.) 만 사용하여, 5~20자의 아이디를 입력해 주세요") String username, @Schema(description = "이름", example = "페니웨이") @NotBlank(message = "이름을 입력해주세요") - @Pattern(regexp = "^[가-힣a-z0-9]{2,8}$", message = "2~8자의 한글, 영문 소문자, 숫자만 사용 가능합니다.") + @Pattern(regexp = "^[가-힣a-zA-Z]{2,8}$", message = "한글과 영문 대, 소문자만 가능해요") String name, @Schema(description = "비밀번호", example = "pennyway1234") @NotBlank(message = "비밀번호를 입력해주세요") @@ -115,14 +115,14 @@ public record Oauth( @Schema(description = "OIDC nonce") @NotBlank(message = "OIDC nonce는 필수 입력값입니다.") String nonce, - @Schema(description = "이름", example = "페니웨이") - @NotBlank(message = "이름을 입력해주세요") - @Pattern(regexp = "^[가-힣a-z]{2,8}$", message = "2~8자의 한글, 영문 소문자만 사용 가능합니다.") - String name, @Schema(description = "아이디", example = "pennyway") @NotBlank(message = "아이디를 입력해주세요") - @Pattern(regexp = "^[a-z-_.]{5,20}$", message = "5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.") + @Pattern(regexp = "^[a-z0-9-_.]{5,20}$", message = "영문 소문자, 숫자, 특수기호 (-), (_), (.) 만 사용하여, 5~20자의 아이디를 입력해 주세요") String username, + @Schema(description = "이름", example = "페니웨이") + @NotBlank(message = "이름을 입력해주세요") + @Pattern(regexp = "^[가-힣a-zA-Z]{2,8}$", message = "한글과 영문 대, 소문자만 가능해요") + String name, @Schema(description = "전화번호", example = "010-1234-5678") @NotBlank(message = "전화번호를 입력해주세요") @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java index edb3ae305..9c967298b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java @@ -63,10 +63,18 @@ public interface UserAccountApi { ResponseEntity getMyAccount(@AuthenticationPrincipal SecurityUserDetails user); @Operation(summary = "사용자 이름 수정") - ResponseEntity putName(@RequestBody @Validated UserProfileUpdateDto.NameReq request, @AuthenticationPrincipal SecurityUserDetails user); + ResponseEntity patchName(@RequestBody @Validated UserProfileUpdateDto.NameReq request, @AuthenticationPrincipal SecurityUserDetails user); @Operation(summary = "사용자 아이디 수정") - ResponseEntity putUsername(@RequestBody @Validated UserProfileUpdateDto.UsernameReq request, @AuthenticationPrincipal SecurityUserDetails user); + @ApiResponse(responseCode = "409", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "검증 실패 - 이미 존재하는 아이디", description = "현재 사용하는 아이디로 요청해도 동일한 예외가 발생한다.", value = """ + { + "code": "4091", + "message": "이미 존재하는 아이디입니다." + } + """) + })) + ResponseEntity patchUsername(@RequestBody @Validated UserProfileUpdateDto.UsernameReq request, @AuthenticationPrincipal SecurityUserDetails user); @Operation(summary = "사용자 비밀번호 검증") @ApiResponses({ @@ -117,7 +125,7 @@ ResponseEntity postPasswordVerification(@RequestBody @Validated UserProfileUp }) ResponseEntity patchPassword(@RequestBody @Validated UserProfileUpdateDto.PasswordReq request, @AuthenticationPrincipal SecurityUserDetails user); - @Operation(summary = "사용자 프로필 수정") + @Operation(summary = "사용자 전화번호 수정") @ApiResponses({ @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = { @ExampleObject(name = "검증 실패", value = """ @@ -136,13 +144,7 @@ ResponseEntity postPasswordVerification(@RequestBody @Validated UserProfileUp """) })), @ApiResponse(responseCode = "409", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "검증 실패 - 이미 존재하는 아이디", value = """ - { - "code": "4091", - "message": "이미 존재하는 아이디입니다." - } - """), - @ExampleObject(name = "검증 실패 - 이미 존재하는 휴대폰 번호", value = """ + @ExampleObject(name = "검증 실패 - 이미 존재하는 휴대폰 번호", description = "현재 사용하는 전화번호로 요청해도 동일한 예외가 발생한다.", value = """ { "code": "4091", "message": "이미 존재하는 휴대폰 번호입니다." @@ -150,7 +152,7 @@ ResponseEntity postPasswordVerification(@RequestBody @Validated UserProfileUp """) })) }) - ResponseEntity patchProfile(@RequestBody @Validated UserProfileUpdateDto.UsernameAndPhoneReq request, @AuthenticationPrincipal SecurityUserDetails user); + ResponseEntity patchPhone(@RequestBody @Validated UserProfileUpdateDto.PhoneReq request, @AuthenticationPrincipal SecurityUserDetails user); @Operation(summary = "사용자 알림 활성화") @Parameter(name = "type", description = "알림 타입", examples = { @@ -254,5 +256,5 @@ ResponseEntity postPasswordVerification(@RequestBody @Validated UserProfileUp """) })) }) - ResponseEntity postProfileImage(@RequestBody @Validated UserProfileUpdateDto.ProfileImageReq request, @AuthenticationPrincipal SecurityUserDetails user); + ResponseEntity putProfileImage(@RequestBody @Validated UserProfileUpdateDto.ProfileImageReq request, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java index 01b78ecb9..bff9c79f9 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java @@ -48,7 +48,7 @@ public ResponseEntity getMyAccount(@AuthenticationPrincipal SecurityUserDetai @Override @PatchMapping("/name") @PreAuthorize("isAuthenticated()") - public ResponseEntity putName(UserProfileUpdateDto.NameReq request, SecurityUserDetails user) { + public ResponseEntity patchName(UserProfileUpdateDto.NameReq request, SecurityUserDetails user) { userAccountUseCase.updateName(user.getUserId(), request.name()); return ResponseEntity.ok(SuccessResponse.noContent()); } @@ -56,7 +56,7 @@ public ResponseEntity putName(UserProfileUpdateDto.NameReq request, SecurityU @Override @PatchMapping("/username") @PreAuthorize("isAuthenticated()") - public ResponseEntity putUsername(UserProfileUpdateDto.UsernameReq request, SecurityUserDetails user) { + public ResponseEntity patchUsername(UserProfileUpdateDto.UsernameReq request, SecurityUserDetails user) { userAccountUseCase.updateUsername(user.getUserId(), request.username()); return ResponseEntity.ok(SuccessResponse.noContent()); } @@ -78,10 +78,10 @@ public ResponseEntity patchPassword(UserProfileUpdateDto.PasswordReq request, } @Override - @PatchMapping("/profile") + @PatchMapping("/phone") @PreAuthorize("isAuthenticated()") - public ResponseEntity patchProfile(@RequestBody @Validated UserProfileUpdateDto.UsernameAndPhoneReq request, @AuthenticationPrincipal SecurityUserDetails user) { - userAccountUseCase.updateUsernameAndPhone(user.getUserId(), request); + public ResponseEntity patchPhone(@RequestBody @Validated UserProfileUpdateDto.PhoneReq request, @AuthenticationPrincipal SecurityUserDetails user) { + userAccountUseCase.updatePhone(user.getUserId(), request); return ResponseEntity.ok(SuccessResponse.noContent()); } @@ -110,7 +110,7 @@ public ResponseEntity deleteAccount(@AuthenticationPrincipal SecurityUserDeta @Override @PutMapping("/profile-image") @PreAuthorize("isAuthenticated()") - public ResponseEntity postProfileImage(@Validated UserProfileUpdateDto.ProfileImageReq request, SecurityUserDetails user) { + public ResponseEntity putProfileImage(@Validated UserProfileUpdateDto.ProfileImageReq request, SecurityUserDetails user) { userAccountUseCase.updateProfileImage(user.getUserId(), request); return ResponseEntity.ok(SuccessResponse.noContent()); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java index 004f4c978..1928c62fd 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileUpdateDto.java @@ -11,7 +11,7 @@ public class UserProfileUpdateDto { public record NameReq( @Schema(description = "이름", example = "페니웨이") @NotBlank(message = "이름을 입력해주세요") - @Pattern(regexp = "^[가-힣a-z0-9]{2,8}$", message = "2~8자의 한글, 영문 소문자, 숫자만 사용 가능합니다.") + @Pattern(regexp = "^[가-힣a-zA-Z]{2,8}$", message = "한글과 영문 대, 소문자만 가능해요") String name ) { } @@ -20,7 +20,7 @@ public record NameReq( public record UsernameReq( @Schema(description = "아이디", example = "pennyway") @NotBlank(message = "아이디를 입력해주세요") - @Pattern(regexp = "^[a-z-_.]{5,20}$", message = "5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.") + @Pattern(regexp = "^[a-z0-9-_.]{5,20}$", message = "영문 소문자, 숫자, 특수기호 (-), (_), (.) 만 사용하여, 5~20자의 아이디를 입력해 주세요") String username ) { } @@ -69,11 +69,7 @@ public record ProfileImageReq( } @Schema(title = "사용자 아이디, 전화번호 변경 DTO") - public record UsernameAndPhoneReq( - @Schema(description = "변경할 아이디", example = "pennyway") - @NotBlank(message = "아이디를 입력해주세요") - @Pattern(regexp = "^[a-z-_.]{5,20}$", message = "5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.") - String username, + public record PhoneReq( @Schema(description = "전화번호", example = "010-2629-4624") @NotBlank(message = "전화번호는 필수입니다.") @Pattern(regexp = "^01[01]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java index 17ba91a4b..43419963e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java @@ -39,6 +39,10 @@ public void updateName(Long userId, String newName) { public void updateUsername(Long userId, String newUsername) { User user = readUserOrThrow(userId); + if (userService.isExistUsername(newUsername)) { + throw new UserErrorException(UserErrorCode.ALREADY_EXIST_USERNAME); + } + user.updateUsername(newUsername); } @@ -61,27 +65,17 @@ public void updateProfileImage(Long userId, String profileImageUrl) { } @Transactional - public void updateUsernameAndPhone(Long userId, String username, String phone, String code) { + public void updatePhone(Long userId, String phone, String code) { User user = readUserOrThrow(userId); - if (!user.getUsername().equals(username)) { - if (userService.isExistUsername(username)) { - throw new UserErrorException(UserErrorCode.ALREADY_EXIST_USERNAME); - } + phoneVerificationService.isValidCode(PhoneVerificationDto.VerifyCodeReq.of(phone, code), PhoneCodeKeyType.PHONE); + phoneCodeService.delete(phone, PhoneCodeKeyType.PHONE); - user.updateUsername(username); + if (userService.isExistPhone(phone)) { + throw new UserErrorException(UserErrorCode.ALREADY_EXIST_PHONE); } - if (!user.getPhone().equals(phone)) { - phoneVerificationService.isValidCode(PhoneVerificationDto.VerifyCodeReq.of(phone, code), PhoneCodeKeyType.PHONE); - phoneCodeService.delete(phone, PhoneCodeKeyType.PHONE); - - if (userService.isExistPhone(phone)) { - throw new UserErrorException(UserErrorCode.ALREADY_EXIST_PHONE); - } - - user.updatePhone(phone); - } + user.updatePhone(phone); } @Transactional diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java index b3e931d9c..29ba697fc 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java @@ -60,8 +60,8 @@ public void updateProfileImage(Long userId, UserProfileUpdateDto.ProfileImageReq userProfileUpdateService.updateProfileImage(userId, request.profileImageUrl()); } - public void updateUsernameAndPhone(Long userId, UserProfileUpdateDto.UsernameAndPhoneReq request) { - userProfileUpdateService.updateUsernameAndPhone(userId, request.username(), request.phone(), request.code()); + public void updatePhone(Long userId, UserProfileUpdateDto.PhoneReq request) { + userProfileUpdateService.updatePhone(userId, request.phone(), request.code()); } public UserProfileUpdateDto.NotifySettingUpdateRes activateNotification(Long userId, NotifySetting.NotifyType type) { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java index c012c7c6e..5b136269f 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java @@ -80,7 +80,7 @@ void requiredInputError() throws Exception { .andDo(print()); } - @DisplayName("[2] 아이디는 5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.") + @DisplayName("[2] 아이디는 영문 소문자, 숫자, 특수기호 (-), (_), (.) 만 사용하여, 5~20자의 아이디를 입력해 주세요") @Test void idValidError() throws Exception { // given @@ -97,7 +97,7 @@ void idValidError() throws Exception { // then resultActions .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.username").value("5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.")) + .andExpect(jsonPath("$.fieldErrors.username").value("영문 소문자, 숫자, 특수기호 (-), (_), (.) 만 사용하여, 5~20자의 아이디를 입력해 주세요")) .andDo(print()); } @@ -118,7 +118,7 @@ void nameValidError() throws Exception { // then resultActions .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.fieldErrors.name").value("2~8자의 한글, 영문 소문자, 숫자만 사용 가능합니다.")) + .andExpect(jsonPath("$.fieldErrors.name").value("한글과 영문 대, 소문자만 가능해요")) .andDo(print()); } @@ -213,7 +213,7 @@ void someFieldMissingError() throws Exception { @Test void signUp() throws Exception { // given - SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이123", "pennyway1234", + SignUpReq.General request = new SignUpReq.General("pennyway123", "페니웨이", "pennyway1234", "010-1234-5678", "123456"); ResponseCookie expectedCookie = ResponseCookie.from("refreshToken", "refreshToken") .maxAge(Duration.ofDays(7).toSeconds()).httpOnly(true).path("/").build(); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/PhoneUpdateServiceTest.java similarity index 58% rename from pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateServiceTest.java rename to pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/PhoneUpdateServiceTest.java index dd3416e94..0730611b2 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/PhoneUpdateServiceTest.java @@ -30,7 +30,7 @@ @Slf4j @ExtendWith(MockitoExtension.class) -public class UserProfileUpdateServiceTest { +public class PhoneUpdateServiceTest { private final Long userId = 1L; private User user = UserFixture.GENERAL_USER.toUser(); @InjectMocks @@ -50,33 +50,7 @@ void setUp() { } @Test - @DisplayName("수정 요청한 아이디와 전화번호가 기존 정보와 일치할 경우, 변경이 발생하지 않는다.") - void updateSameUsermameAndPhone() { - // when - userProfileUpdateService.updateUsernameAndPhone(userId, user.getUsername(), user.getPhone(), "000000"); - - // then - verifyNoInteractions(awsS3Provider, phoneVerificationService, phoneCodeService); - } - - @Test - @DisplayName("수정 요청한 아이디만 기존 정보와 다를 경우, 아이디만 변경이 발생한다.") - void updateDifferentUsername() { - // given - String newUsername = "newUsername"; - String expectedPhone = user.getPhone(); - - // when - userProfileUpdateService.updateUsernameAndPhone(userId, newUsername, user.getPhone(), "000000"); - - // then - assertEquals(newUsername, user.getUsername()); - assertEquals(expectedPhone, user.getPhone()); - verifyNoInteractions(awsS3Provider, phoneVerificationService, phoneCodeService); - } - - @Test - @DisplayName("수정 요청한 전화번호만 기존 정보와 다를 경우, 전화번호만 변경이 발생한다.") + @DisplayName("수정 요청한 전화번호가 DB에 존재하지 않고, 유효한 인증 코드를 가진 경우 수정에 성공한다.") void updateDifferentPhone() { // given String expectedUsername = user.getUsername(); @@ -85,7 +59,7 @@ void updateDifferentPhone() { willDoNothing().given(phoneCodeService).delete(newPhone, PhoneCodeKeyType.PHONE); // when - userProfileUpdateService.updateUsernameAndPhone(userId, user.getUsername(), newPhone, "000000"); + userProfileUpdateService.updatePhone(userId, newPhone, "000000"); // then assertEquals(expectedUsername, user.getUsername()); @@ -93,24 +67,6 @@ void updateDifferentPhone() { verifyNoInteractions(awsS3Provider); } - @Test - @DisplayName("수정 요청한 아이디가 이미 존재하면, ALREADY_EXIST_USERNAME 에러를 반환한다.") - void updateAlreadyExistUsername() { - // given - String newUsername = "newUsername"; - given(userService.isExistUsername(newUsername)).willReturn(true); - - // when - UserErrorException exception = assertThrows(UserErrorException.class, () -> userProfileUpdateService.updateUsernameAndPhone(userId, newUsername, user.getPhone(), "000000")); - - // then - assertEquals(UserErrorCode.ALREADY_EXIST_USERNAME, exception.getBaseErrorCode()); - verifyNoInteractions(awsS3Provider, phoneVerificationService, phoneCodeService); - } - - /** - * 트랜잭션이 활성화되지 않아서, username 변경이 되지 않음을 확인할 수는 없다. - */ @Test @DisplayName("수정 요청한 전화번호가 이미 존재하면, ALREADY_EXIST_PHONE 에러를 반환한다.") void updateAlreadyExistPhone() { @@ -121,7 +77,7 @@ void updateAlreadyExistPhone() { willDoNothing().given(phoneCodeService).delete(newPhone, PhoneCodeKeyType.PHONE); // when - UserErrorException exception = assertThrows(UserErrorException.class, () -> userProfileUpdateService.updateUsernameAndPhone(userId, user.getUsername(), newPhone, "000000")); + UserErrorException exception = assertThrows(UserErrorException.class, () -> userProfileUpdateService.updatePhone(userId, newPhone, "000000")); // then assertEquals(UserErrorCode.ALREADY_EXIST_PHONE, exception.getBaseErrorCode()); From b5a98777bbd4fb8c27e20a0cc0c9345e40a19d17 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 15 Jul 2024 17:55:04 +0900 Subject: [PATCH 123/152] =?UTF-8?q?cd:=20=E2=9C=8F=EF=B8=8F=20=EB=A6=B4?= =?UTF-8?q?=EB=A6=AC=EC=A6=88=20CD=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=A4=ED=96=89=20=EC=8A=A4=ED=82=B5=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * chore: tagging and release cd pipeline add ignore keyword * chore: tag_prefix 인자 수정 * chore: custome_release_rules에 cd 키워드 추가 --- .github/workflows/create-tag-and-release.yml | 29 ++++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/.github/workflows/create-tag-and-release.yml b/.github/workflows/create-tag-and-release.yml index c506a9cd1..b924dbd97 100644 --- a/.github/workflows/create-tag-and-release.yml +++ b/.github/workflows/create-tag-and-release.yml @@ -15,7 +15,6 @@ jobs: repository-projects: write outputs: module: ${{ steps.module_prefix.outputs.module }} - tag: ${{ steps.tag_version.outputs.new_tag }} steps: - name: Checkout PR @@ -27,7 +26,7 @@ jobs: run: | PR_TITLE="${{ github.event.pull_request.title }}" echo "PR title : $PR_TITLE" - if [[ "$PR_TITLE" =~ ^(Api|Batch|Admin|Socket): ]]; then + if [[ "$PR_TITLE" =~ ^(Api|Batch|Admin|Socket|Ignore): ]]; then PREFIX="${BASH_REMATCH[1]}" echo "Prefix: $PREFIX" echo "module=$PREFIX" >> $GITHUB_OUTPUT @@ -36,7 +35,19 @@ jobs: exit 1 fi - # 병합된 PR commit 이력으로 부터 버전 추출 (ex. v1.0.0) + release: + needs: extract-info + if: ${{ needs.extract-info.outputs.module != 'Ignore' }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + repository-projects: write + + outputs: + tag: ${{ steps.tag_version.outputs.new_tag }} + + steps: - name: version and tag id: tag_version uses: mathieudutour/github-tag-action@v6.2 @@ -44,8 +55,8 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} default_bump: patch release_branches: main,dev.* - custom_release_rules: release:major, feat:minor:Features, refactor:minor:Refactoring, fix:patch:Bug Fixes, hotfix:patch:Hotfixes, docs:patch:Documentation, style:patch:Styles, perf:patch:Performance Improvements, test:patch:Tests, ci:patch:Continuous Integration, chore:patch:Chores, revert:patch:Reverts - tag_prefix: '${{ steps.module_prefix.outputs.module }}-v' + custom_release_rules: release:major, feat:minor:Features, refactor:minor:Refactoring, fix:patch:Bug Fixes, hotfix:patch:Hotfixes, docs:patch:Documentation, style:patch:Styles, perf:patch:Performance Improvements, test:patch:Tests, ci:patch:Continuous Integration, cd:patch:Continuous Deployment, chore:patch:Chores, revert:patch:Reverts + tag_prefix: '${{ needs.extract-info.outputs.module }}-v' # 추출된 버전 및 변경 이력 로그 출력 - name: check output @@ -62,17 +73,17 @@ jobs: body: ${{ steps.tag_version.outputs.changelog }} call-external-api-deploy: - needs: extract-info + needs: [ extract-info, release ] if: ${{ needs.extract-info.outputs.module == 'Api' }} uses: ./.github/workflows/deploy-external-api.yml secrets: inherit with: - tags: ${{ needs.extract-info.outputs.tag }} + tags: ${{ needs.release.outputs.tag }} call-batch-deploy: - needs: extract-info + needs: [ extract-info, release ] if: ${{ needs.extract-info.outputs.module == 'Batch' }} uses: ./.github/workflows/deploy-batch.yml secrets: inherit with: - tags: ${{ needs.extract-info.outputs.tag }} \ No newline at end of file + tags: ${{ needs.release.outputs.tag }} \ No newline at end of file From 6935304413623e68430453b363f2b0ef1b82b9e1 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 15 Jul 2024 18:45:37 +0900 Subject: [PATCH 124/152] =?UTF-8?q?ci:=20=E2=9C=A8=20Open=20API=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20CI=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=B6=94=EA=B0=80=20(#1?= =?UTF-8?q?30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * chore: chat gpt code review ci pipeline 추가 * chore: chat gpt code review ci pipeline 추가 * rename: create-tag-and-release 파이프라인 주석 주가 * chore: review ci model 수정 * fix: model 옵션 제거 --- .github/workflows/create-tag-and-release.yml | 2 +- .github/workflows/open-api-code-review.yml | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/open-api-code-review.yml diff --git a/.github/workflows/create-tag-and-release.yml b/.github/workflows/create-tag-and-release.yml index b924dbd97..0c7d48319 100644 --- a/.github/workflows/create-tag-and-release.yml +++ b/.github/workflows/create-tag-and-release.yml @@ -37,7 +37,7 @@ jobs: release: needs: extract-info - if: ${{ needs.extract-info.outputs.module != 'Ignore' }} + if: ${{ needs.extract-info.outputs.module != 'Ignore' }} # Ignore로 시작하는 PR은 파이프라인 중단 runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/open-api-code-review.yml b/.github/workflows/open-api-code-review.yml new file mode 100644 index 000000000..1297b5008 --- /dev/null +++ b/.github/workflows/open-api-code-review.yml @@ -0,0 +1,19 @@ +name: Code Review + +permissions: + contents: read + pull-requests: write + +on: + pull_request: + types: [ opened, synchronize ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: anc95/ChatGPT-CodeReview@main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPEN_API_KEY }} + LANGUAGE: Korean From ba28c4e4a0b4bee8543c033203c91be980dfa7a0 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 15 Jul 2024 19:05:59 +0900 Subject: [PATCH 125/152] =?UTF-8?q?cd:=20=E2=9C=8F=EF=B8=8F=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20CD=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8?= =?UTF-8?q?=20FCM=20Admin=20SDK=20=EC=83=9D=EC=84=B1=20step=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 배포 파이프라인 이미지 빌드 버전 추가 * fix: batch cd json 생성 step 추가 --- .github/workflows/deploy-batch.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-batch.yml b/.github/workflows/deploy-batch.yml index 913a9b7f7..8d58684ef 100644 --- a/.github/workflows/deploy-batch.yml +++ b/.github/workflows/deploy-batch.yml @@ -36,14 +36,22 @@ jobs: java-version: '17' distribution: 'temurin' - # 4. Build Gradle + # 4. FCM Admin SDK 파일 생성 + - name: Create Json + uses: jsdaniell/create-json@v1.2.2 + with: + name: ${{ secrets.FIREBASE_ADMIN_SDK_FILE }} + json: ${{ secrets.FIREBASE_ADMIN_SDK }} + dir: ${{ secrets.FIREBASE_ADMIN_SDK_DIR }} + + # 5. Build Gradle - name: Build Gradle run: | chmod +x ./gradlew ./gradlew :pennyway-batch:build --parallel --stacktrace --info -x test shell: bash - # 5. Docker 이미지 build 및 push + # 6. Docker 이미지 build 및 push - name: docker build and push run: | docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} @@ -52,7 +60,7 @@ jobs: docker push pennyway/pennyway-batch:${{ steps.get_version.outputs.VERSION }} docker push pennyway/pennyway-batch:latest - # 6. AWS SSM을 통한 Run-Command (Docker 이미지 pull 후 docker-compose를 통한 실행) + # 7. AWS SSM을 통한 Run-Command (Docker 이미지 pull 후 docker-compose를 통한 실행) - name: AWS SSM Send-Command uses: peterkimzz/aws-ssm-send-command@master id: ssm From 3bc5ea631eb4c814fab2e6d5a289a4ea8eb3856d Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 15 Jul 2024 19:51:03 +0900 Subject: [PATCH 126/152] =?UTF-8?q?fix:=20=F0=9F=90=9B=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EA=B8=88=EC=95=A1=20DELETE=20=EC=9C=A0=EC=A6=88=20?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A7=81=20=EC=A0=9C=EA=B1=B0=20(#132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: target-amount 삭제 요청 시, amount == -1인 경우 필터링 로직 제거 * test: target amount의 값이 -1이어도 is_read가 성공적으로 true로 바뀌어야 한다 --- .../apis/ledger/service/TargetAmountDeleteService.java | 1 - .../integration/TargetAmountIntegrationTest.java | 10 +++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountDeleteService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountDeleteService.java index 57ba14fa4..db6ebd2b6 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountDeleteService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountDeleteService.java @@ -16,7 +16,6 @@ public class TargetAmountDeleteService { @Transactional public void execute(Long targetAmountId) { TargetAmount targetAmount = targetAmountService.readTargetAmount(targetAmountId) - .filter(TargetAmount::isAllocatedAmount) .orElseThrow(() -> new TargetAmountErrorException(TargetAmountErrorCode.NOT_FOUND_TARGET_AMOUNT)); if (!targetAmount.isThatMonth()) { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java index 784b52da8..0a678a50e 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java @@ -27,6 +27,7 @@ import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -270,18 +271,21 @@ void deleteTargetAmountNotThatMonth() throws Exception { } @Test - @DisplayName("당월 목표 금액 pk에 대한 접근 권한이 있지만, amount가 이미 -1인 경우 404 Not Found 에러 응답을 반환한다.") - @Transactional + @DisplayName("당월 목표 금액 pk에 대한 접근 권한이 있으며, amount == -1이어도 isRead가 true로 변경된다.") void deleteTargetAmountNotFound() throws Exception { // given User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); TargetAmount targetAmount = targetAmountService.createTargetAmount(TargetAmount.of(-1, user)); + Long targetAmountId = targetAmount.getId(); // when ResultActions result = performDeleteTargetAmount(targetAmount.getId(), user); // then - result.andDo(print()).andExpect(status().isNotFound()); + result.andDo(print()).andExpect(status().isOk()); + TargetAmount deletedTargetAmount = targetAmountService.readTargetAmount(targetAmountId).orElseThrow(); + assertEquals(-1, deletedTargetAmount.getAmount()); + assertTrue(deletedTargetAmount.isRead()); } @Test From 127cf5c9215bc496111a7cd68794cea587a4c9d1 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:49:46 +0900 Subject: [PATCH 127/152] =?UTF-8?q?fix:=20=E2=9C=8F=EF=B8=8F=20=EC=B4=9D?= =?UTF-8?q?=20=EC=A7=80=EC=B6=9C=20=EC=B4=9D=ED=95=A9=20=EA=B0=92=EC=9D=98?= =?UTF-8?q?=20=EC=A0=95=EC=88=98=20=EB=B2=94=EC=9C=84=20=EC=B4=88=EA=B3=BC?= =?UTF-8?q?=20=EC=BC=80=EC=9D=B4=EC=8A=A4=EB=A5=BC=20=EA=B3=A0=EB=A0=A4?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: target_amount_dto 원시 타입으로 수정 * fix: spending_search_res daily_total_amount int -> long * fix: daily_total_amount total_spending 계산 시, 반환 타입 long으로 수정 * fix: recent_target_amount_res 필드 원시 타입으로 수정 * style: spending_service 총 지출 금액 조회 메서드 인접하게 위치 수정 * fix: 총 지출 금액 dto amount 필드 int -> long * style: target_amount_usecase 함수 호출 스니펫과 return 문 사이 공백 추가 * fix: 가장 최근 목표 금액의 amount가 0인 경우가 존재할 수 있으므로, 타입을 래퍼 타입으로 수정 * feat: total_spending_amount year_month 반환 메서드 추가 * fix: int 타입 long으로 변환 및 코드 정리 * fix: target amount update int -> long 타입 수정 * fix: querydsl total_spending_amount 기본 타입 expression 정의 * test: target amount controller unit test get_id() null 에러 핸들링 * test: max integer 범위 초과한 경우 diff_amount, total_spending 출력 확인 * fix: to_year_month_map 동시성 환경을 고려하여, concurrent_map 타입으로 반환 --- .github/workflows/open-api-code-review.yml | 2 +- .../apis/ledger/dto/SpendingSearchRes.java | 2 +- .../api/apis/ledger/dto/TargetAmountDto.java | 49 ++++++------------- .../apis/ledger/mapper/SpendingMapper.java | 4 +- .../ledger/mapper/TargetAmountMapper.java | 43 +++++++--------- .../service/TargetAmountSaveService.java | 2 +- .../ledger/usecase/TargetAmountUseCase.java | 3 +- .../TargetAmountControllerUnitTest.java | 12 ++--- .../TargetAmountIntegrationTest.java | 26 ++++++++++ .../api/config/fixture/SpendingFixture.java | 3 +- .../spending/dto/TotalSpendingAmount.java | 20 ++++++-- .../SpendingCustomRepositoryImpl.java | 6 +-- .../spending/service/SpendingService.java | 16 +++--- 13 files changed, 101 insertions(+), 87 deletions(-) diff --git a/.github/workflows/open-api-code-review.yml b/.github/workflows/open-api-code-review.yml index 1297b5008..c35fd3472 100644 --- a/.github/workflows/open-api-code-review.yml +++ b/.github/workflows/open-api-code-review.yml @@ -6,7 +6,7 @@ permissions: on: pull_request: - types: [ opened, synchronize ] + types: [ opened ] jobs: test: diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java index 10e6a0cd9..94c1597f2 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/SpendingSearchRes.java @@ -51,7 +51,7 @@ public record Daily( @Schema(description = "일") int day, @Schema(description = "일별 총 지출 금액") - int dailyTotalAmount, + long dailyTotalAmount, @Schema(description = "개별 지출 내역") List individuals ) { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java index bc8179f4d..8eb6cab10 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/dto/TargetAmountDto.java @@ -17,9 +17,8 @@ public class TargetAmountDto { @Schema(title = "목표 금액의 amount 유효성 검사를 위한 요청 파라미터", hidden = true) public record AmountParam( @Schema(description = "등록하려는 목표 금액 (0이상의 정수)", example = "100000", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "amount 값은 필수입니다.") @Min(value = 0, message = "amount 값은 0 이상이어야 합니다.") - Integer amount + int amount ) { } @@ -40,44 +39,28 @@ public record DateParam( @Schema(title = "목표 금액 및 총 지출 금액 조회 응답") public record WithTotalSpendingRes( @Schema(description = "조회 년도", example = "2024", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "year 값은 필수입니다.") - Integer year, + int year, @Schema(description = "조회 월", example = "5", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "month 값은 필수입니다.") - Integer month, + int month, @Schema(description = "목표 금액", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(message = "targetAmountDetail 값은 필수입니다.") TargetAmountInfo targetAmountDetail, @Schema(description = "총 지출 금액", example = "100000", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "totalSpending 값은 필수입니다.") - Integer totalSpending, + long totalSpending, @Schema(description = "목표 금액과 총 지출 금액의 차액(총 치줄 금액 - 목표 금액). 양수면 초과, 음수면 절약", example = "-50000", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "diffAmount 값은 필수입니다.") - Integer diffAmount + long diffAmount ) { } @Schema(title = "목표 금액 상세 정보") public record TargetAmountInfo( @Schema(description = "목표 금액 pk. 실제 저장된 데이터가 아니라면 -1", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "id 값은 필수입니다.") - Long id, + long id, @Schema(description = "목표 금액. -1이면 설정한 목표 금액이 존재하지 않음을 의미한다.", example = "50000", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "amount 값은 필수입니다.") - Integer amount, + int amount, @Schema(description = "사용자 확인 여부", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) boolean isRead ) { - public TargetAmountInfo { - if (id == null) { - id = -1L; - } - - if (amount == null) { - amount = -1; - } - } - /** * {@link TargetAmount} -> {@link TargetAmountInfo} 변환하는 메서드
* 만약, 인자로 들어온 값이 null이라면 모든 값을 -1로 초기화한 더미 데이터를 반환한다. @@ -95,29 +78,29 @@ public record RecentTargetAmountRes( @Schema(description = "최근 목표 금액 존재 여부로써 데이터가 존재하지 않으면 false, 존재하면 true", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) boolean isPresent, @Schema(description = "최근 목표 금액 년도 정보. isPresent가 false인 경우 필드가 존재하지 않는다.", example = "2024", requiredMode = Schema.RequiredMode.REQUIRED) - @JsonInclude(JsonInclude.Include.NON_NULL) - Integer year, + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + int year, @Schema(description = "최근 목표 금액 월 정보. isPresent가 false인 경우 필드가 존재하지 않는다.", example = "6", requiredMode = Schema.RequiredMode.REQUIRED) - @JsonInclude(JsonInclude.Include.NON_NULL) - Integer month, + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + int month, @Schema(description = "최근 목표 금액 정보. isPresent가 false인 경우 필드가 존재하지 않는다.", requiredMode = Schema.RequiredMode.REQUIRED) @JsonInclude(JsonInclude.Include.NON_NULL) Integer amount ) { public RecentTargetAmountRes { if (!isPresent) { - assert year == null; - assert month == null; + assert year == 0; + assert month == 0; assert amount == null; } } public static RecentTargetAmountRes notPresent() { - return new RecentTargetAmountRes(false, null, null, null); + return new RecentTargetAmountRes(false, 0, 0, null); } - public static RecentTargetAmountRes of(Integer year, Integer month, Integer amount) { - return (amount.equals(-1)) ? new RecentTargetAmountRes(false, null, null, null) : new RecentTargetAmountRes(true, year, month, amount); + public static RecentTargetAmountRes of(int year, int month, int amount) { + return (amount == -1) ? new RecentTargetAmountRes(false, 0, 0, null) : new RecentTargetAmountRes(true, year, month, amount); } } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java index 62921b0f8..64132abb4 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/SpendingMapper.java @@ -84,7 +84,7 @@ public static SpendingSearchRes.Individual toSpendingSearchResIndividual(Spendin /** * 하루 지출 내역의 총 금액을 계산하는 메서드 */ - private static int calculateDailyTotalAmount(List spendings) { - return spendings.stream().mapToInt(Spending::getAmount).sum(); + private static long calculateDailyTotalAmount(List spendings) { + return spendings.stream().mapToLong(Spending::getAmount).sum(); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java index 289bfe198..ea3d7e9ff 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/mapper/TargetAmountMapper.java @@ -22,7 +22,7 @@ public class TargetAmountMapper { * @param totalSpending {@link TotalSpendingAmount} : 값이 없을 경우 null */ public static TargetAmountDto.WithTotalSpendingRes toWithTotalSpendingResponse(TargetAmount targetAmount, TotalSpendingAmount totalSpending, LocalDate date) { - Integer totalSpendingAmount = (totalSpending != null) ? totalSpending.totalSpending() : 0; + long totalSpendingAmount = (totalSpending != null) ? totalSpending.totalSpending() : 0L; return createWithTotalSpendingRes(targetAmount, totalSpendingAmount, date); } @@ -38,8 +38,8 @@ public static List toWithTotalSpendingResp LocalDate startAt = getOldestDate(targetAmounts); int monthLength = (endAt.getYear() - startAt.getYear()) * 12 + (endAt.getMonthValue() - startAt.getMonthValue()); - Map targetAmountsByDates = toYearMonthMap(targetAmounts, targetAmount -> YearMonth.of(targetAmount.getCreatedAt().getYear(), targetAmount.getCreatedAt().getMonthValue()), Function.identity()); - Map totalSpendingAmounts = toYearMonthMap(totalSpendings, totalSpendingAmount -> YearMonth.of(totalSpendingAmount.year(), totalSpendingAmount.month()), TotalSpendingAmount::totalSpending); + Map targetAmountsByDates = toYearMonthMap(targetAmounts, ta -> YearMonth.from(ta.getCreatedAt()), Function.identity()); + Map totalSpendingAmounts = toYearMonthMap(totalSpendings, TotalSpendingAmount::getYearMonth, TotalSpendingAmount::totalSpending); return createWithTotalSpendingResponses(targetAmountsByDates, totalSpendingAmounts, startAt, monthLength).stream() .sorted(Comparator.comparing(TargetAmountDto.WithTotalSpendingRes::year).reversed() @@ -53,42 +53,39 @@ public static List toWithTotalSpendingResp * @return {@link TargetAmountDto.RecentTargetAmountRes} */ public static TargetAmountDto.RecentTargetAmountRes toRecentTargetAmountResponse(Optional targetAmount) { - if (targetAmount.isEmpty()) { - return TargetAmountDto.RecentTargetAmountRes.notPresent(); - } - - Integer year = targetAmount.get().getCreatedAt().getYear(); - Integer month = targetAmount.get().getCreatedAt().getMonthValue(); - Integer amount = targetAmount.get().getAmount(); - - return TargetAmountDto.RecentTargetAmountRes.of(year, month, amount); + return targetAmount.map(ta -> { + LocalDate createdAt = ta.getCreatedAt().toLocalDate(); + return TargetAmountDto.RecentTargetAmountRes.of(createdAt.getYear(), createdAt.getMonthValue(), ta.getAmount()); + }).orElseGet(TargetAmountDto.RecentTargetAmountRes::notPresent); } - private static List createWithTotalSpendingResponses(Map targetAmounts, Map totalSpendings, LocalDate startAt, int monthLength) { + private static List createWithTotalSpendingResponses(Map targetAmounts, Map totalSpendings, LocalDate startAt, int monthLength) { List withTotalSpendingResponses = new ArrayList<>(monthLength + 1); + LocalDate date = startAt; for (int i = 0; i < monthLength + 1; i++) { - LocalDate date = startAt.plusMonths(i); - YearMonth yearMonth = YearMonth.of(date.getYear(), date.getMonthValue()); + YearMonth yearMonth = YearMonth.from(date); - TargetAmount targetAmount = targetAmounts.getOrDefault(yearMonth, null); - Integer totalSpending = totalSpendings.getOrDefault(yearMonth, 0); + TargetAmount targetAmount = targetAmounts.get(yearMonth); + Long totalSpending = totalSpendings.getOrDefault(yearMonth, 0L); withTotalSpendingResponses.add(createWithTotalSpendingRes(targetAmount, totalSpending, date)); + date = date.plusMonths(1); } return withTotalSpendingResponses; } - private static TargetAmountDto.WithTotalSpendingRes createWithTotalSpendingRes(TargetAmount targetAmount, Integer totalSpending, LocalDate date) { + private static TargetAmountDto.WithTotalSpendingRes createWithTotalSpendingRes(TargetAmount targetAmount, Long totalSpending, LocalDate date) { TargetAmountDto.TargetAmountInfo targetAmountInfo = TargetAmountDto.TargetAmountInfo.from(targetAmount); + long diffAmount = (targetAmountInfo.amount() == -1) ? 0 : totalSpending - (long) targetAmountInfo.amount(); return TargetAmountDto.WithTotalSpendingRes.builder() .year(date.getYear()) .month(date.getMonthValue()) .targetAmountDetail(targetAmountInfo) .totalSpending(totalSpending) - .diffAmount((targetAmountInfo.amount() == -1) ? 0 : totalSpending - targetAmountInfo.amount()) + .diffAmount(diffAmount) .build(); } @@ -112,12 +109,6 @@ private static LocalDate getOldestDate(List targetAmounts) { * @param valueMapper : Value로 변환할 Function */ private static Map toYearMonthMap(List list, Function keyMapper, Function valueMapper) { - return list.stream().collect( - Collectors.toMap( - keyMapper, - valueMapper, - (existing, replacement) -> existing - ) - ); + return list.stream().collect(Collectors.toConcurrentMap(keyMapper, valueMapper, (existing, replacement) -> existing)); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java index abaaf64f3..f63e12cbc 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/TargetAmountSaveService.java @@ -36,7 +36,7 @@ public TargetAmount createTargetAmount(String key, Long userId, LocalDate date) } @Transactional - public TargetAmount updateTargetAmount(Long targetAmountId, Integer amount) { + public TargetAmount updateTargetAmount(Long targetAmountId, int amount) { TargetAmount targetAmount = targetAmountService.readTargetAmount(targetAmountId) .orElseThrow(() -> new TargetAmountErrorException(TargetAmountErrorCode.NOT_FOUND_TARGET_AMOUNT)); diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java index ad1571d5e..0d9228baf 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/TargetAmountUseCase.java @@ -39,6 +39,7 @@ public TargetAmountDto.TargetAmountInfo createTargetAmount(Long userId, int year public TargetAmountDto.WithTotalSpendingRes getTargetAmountAndTotalSpending(Long userId, LocalDate date) { TargetAmount targetAmount = targetAmountSearchService.readTargetAmountThatMonth(userId, date); Optional totalSpending = spendingSearchService.readTotalSpendingAmountByUserIdThatMonth(userId, date); + return TargetAmountMapper.toWithTotalSpendingResponse(targetAmount, totalSpending.orElse(null), date); } @@ -56,7 +57,7 @@ public TargetAmountDto.RecentTargetAmountRes getRecentTargetAmount(Long userId) } @Transactional - public TargetAmountDto.TargetAmountInfo updateTargetAmount(Long targetAmountId, Integer amount) { + public TargetAmountDto.TargetAmountInfo updateTargetAmount(Long targetAmountId, int amount) { TargetAmount targetAmount = targetAmountSaveService.updateTargetAmount(targetAmountId, amount); return TargetAmountDto.TargetAmountInfo.from(targetAmount); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java index b585a78af..6588289a2 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java @@ -3,11 +3,12 @@ import kr.co.pennyway.api.apis.ledger.dto.TargetAmountDto; import kr.co.pennyway.api.apis.ledger.usecase.TargetAmountUseCase; import kr.co.pennyway.api.config.WebConfig; -import kr.co.pennyway.api.config.fixture.UserFixture; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; -import kr.co.pennyway.domain.domains.target.domain.TargetAmount; import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; -import org.junit.jupiter.api.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; @@ -30,7 +31,6 @@ @WebMvcTest(controllers = {TargetAmountController.class}, excludeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) @ActiveProfiles("test") -@TestClassOrder(ClassOrderer.OrderAnnotation.class) public class TargetAmountControllerUnitTest { @Autowired private MockMvc mockMvc; @@ -111,8 +111,8 @@ void putTargetAmountWithNegativeAmount() throws Exception { @WithSecurityMockUser void putTargetAmountWithValidRequest() throws Exception { // given - Integer amount = 100000; - given(targetAmountUseCase.updateTargetAmount(1L, amount)).willReturn(TargetAmountDto.TargetAmountInfo.from(TargetAmount.of(amount, UserFixture.GENERAL_USER.toUser()))); + int amount = 100000; + given(targetAmountUseCase.updateTargetAmount(1L, amount)).willReturn(TargetAmountDto.TargetAmountInfo.from(null)); // when ResultActions result = performPutTargetAmount(1L, amount); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java index 0a678a50e..d67d806b2 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java @@ -8,6 +8,7 @@ import kr.co.pennyway.api.config.fixture.SpendingFixture; import kr.co.pennyway.api.config.fixture.TargetAmountFixture; import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.target.domain.TargetAmount; import kr.co.pennyway.domain.domains.target.service.TargetAmountService; import kr.co.pennyway.domain.domains.user.domain.User; @@ -31,6 +32,7 @@ import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @Slf4j @@ -46,6 +48,10 @@ public class TargetAmountIntegrationTest extends ExternalApiDBTestConfig { @Autowired private TargetAmountService targetAmountService; + + @Autowired + private SpendingService spendingService; + @PersistenceContext private EntityManager em; @@ -140,6 +146,26 @@ void getTargetAmountAndTotalSpendingNotFound() throws Exception { result.andDo(print()).andExpect(status().isNotFound()); } + @Test + @DisplayName("당월 지출 금액의 총합이 int 범위를 초과해도 200 OK 응답을 반환한다.") + @Transactional + void getTargetAmountAndTotalSpendingWithIntOverflow() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + TargetAmount targetAmount = targetAmountService.createTargetAmount(TargetAmountFixture.GENERAL_TARGET_AMOUNT.toTargetAmount(user)); + spendingService.createSpending(SpendingFixture.MAX_SPENDING.toSpending(user)); + spendingService.createSpending(SpendingFixture.MAX_SPENDING.toSpending(user)); + + // when + ResultActions result = performGetTargetAmountAndTotalSpending(user, LocalDate.now()); + + // then + result.andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.targetAmount.totalSpending").value("4294967294")) + .andExpect(jsonPath("$.data.targetAmount.diffAmount").value(String.valueOf(4294967294L - (long) targetAmount.getAmount()))); + } + private ResultActions performGetTargetAmountAndTotalSpending(User requestUser, LocalDate date) throws Exception { UserDetails userDetails = SecurityUserDetails.from(requestUser); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java index 520d93644..edbb95834 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java @@ -18,7 +18,8 @@ public enum SpendingFixture { GENERAL_SPENDING(10000, SpendingCategory.FOOD, LocalDateTime.now(), "카페인 수혈", "아메리카노 1잔"), - CUSTOM_CATEGORY_SPENDING(10000, SpendingCategory.CUSTOM, LocalDateTime.now(), "커스텀 카페인 수혈", "아메리카노 1잔"); + CUSTOM_CATEGORY_SPENDING(10000, SpendingCategory.CUSTOM, LocalDateTime.now(), "커스텀 카페인 수혈", "아메리카노 1잔"), + MAX_SPENDING(Integer.MAX_VALUE, SpendingCategory.HOBBY, LocalDateTime.now(), "부가티", "이것이 인생"); private final int amount; private final SpendingCategory category; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java index 21f7db28d..a21c4504d 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java @@ -1,16 +1,28 @@ package kr.co.pennyway.domain.domains.spending.dto; +import java.time.YearMonth; + /** * 사용자의 해당 년/월 총 지출 금액을 담는 DTO */ public record TotalSpendingAmount( - Integer year, - Integer month, - Integer totalSpending + int year, + int month, + long totalSpending ) { - public TotalSpendingAmount(Integer year, Integer month, Integer totalSpending) { + public TotalSpendingAmount(int year, int month, long totalSpending) { this.year = year; this.month = month; this.totalSpending = totalSpending; } + + /** + * YearMonth 객체로 변환하는 메서드 + * + * @return 해당 년/월을 나타내는 YearMonth 객체 + */ + public YearMonth getYearMonth() { + return YearMonth.of(year, month); + } + } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java index 12bacd057..7c780895c 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java @@ -32,9 +32,9 @@ public Optional findTotalSpendingAmountByUserId(Long userId TotalSpendingAmount result = queryFactory.select( Projections.constructor( TotalSpendingAmount.class, - spending.spendAt.year(), - spending.spendAt.month(), - spending.amount.sum() + spending.spendAt.year().intValue(), + spending.spendAt.month().intValue(), + spending.amount.sum().longValue() ) ).from(user) .leftJoin(spending).on(user.id.eq(spending.user.id)) diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java index 392a349a9..59bb154b1 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -45,11 +45,6 @@ public Optional readSpending(Long spendingId) { return spendingRepository.findById(spendingId); } - @Transactional(readOnly = true) - public Optional readTotalSpendingAmountByUserId(Long userId, LocalDate date) { - return spendingRepository.findTotalSpendingAmountByUserId(userId, date.getYear(), date.getMonthValue()); - } - @Transactional(readOnly = true) public List readSpendings(Long userId, int year, int month) { return spendingRepository.findByYearAndMonth(userId, year, month); @@ -106,6 +101,11 @@ public Slice readSpendingsSliceByCategory(Long userId, SpendingCategor return SliceUtil.toSlice(spendingRepository.findList(predicate, queryHandler, sort), pageable); } + @Transactional(readOnly = true) + public Optional readTotalSpendingAmountByUserId(Long userId, LocalDate date) { + return spendingRepository.findTotalSpendingAmountByUserId(userId, date.getYear(), date.getMonthValue()); + } + @Transactional(readOnly = true) public List readTotalSpendingsAmountByUserId(Long userId) { Predicate predicate = user.id.eq(userId); @@ -116,9 +116,9 @@ public List readTotalSpendingsAmountByUserId(Long userId) { Sort sort = Sort.by(Sort.Order.desc("year(spendAt)"), Sort.Order.desc("month(spendAt)")); Map> bindings = new LinkedHashMap<>(); - bindings.put("year", spending.spendAt.year()); - bindings.put("month", spending.spendAt.month()); - bindings.put("totalSpending", spending.amount.sum()); + bindings.put("year", spending.spendAt.year().intValue()); + bindings.put("month", spending.spendAt.month().intValue()); + bindings.put("totalSpending", spending.amount.sum().longValue()); return spendingRepository.selectList(predicate, TotalSpendingAmount.class, bindings, queryHandler, sort); } From 869be8d88640aa87f9d2e36f9a3b659cb61051f6 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 17 Jul 2024 21:51:25 +0900 Subject: [PATCH 128/152] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EC=88=98=EC=8B=A0=ED=95=9C=20=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=EC=88=9C=20=EC=A1=B0=ED=9A=8C=20(#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: notifications controller unit test 작성 * feat: notification controller 클래스 생성 * feat: notification use case 클래스 작성 * test: pageable param 전달 테스트 케이스 * test: given 절 반환 dto 수정 * feat: notification info & slice dto 정의 * fix: notification use case 껍데기 구현 * fix: controller use case 호출 * test: notification fixture 상수 및 dto 생성 메서드 추가 * test: 응답 json 경로 수정 * fix: get notifications controller success response로 응답 포맷 수정 * test: controller 응답 포맷 테스트 * fix: get notifications pageable size default 20 -> 30 * feat: notification slide select 메서드 추가 * fix: notification repository jpa_repository -> extended_repository 인터페이스 변경 * fix: controller pagable sort dirction 내림차순 옵션 추가 * feat: notificaiton search service impl * feat: notification use case 내, service 및 mapper 호출 로직 처리 * feat: notification dto info builder 추가 * fix: notification use case import notification mapper * feat: notification mapper 메서드 정의 * feat: notification table 수정 squash merge * fix: formatting 메서드 수정 * refactor: 포매팅된 title, content 로직을 notification 엔티티 메서드로 제공 * test: notification fixture to_entity 주입 방식 수정 및 dummy dto 생성 로직 제거 * refactor: notification info dto 생성 로직 mapper -> dto로 이전 * docs: swagger config에 notification와 storage 태깅 추가 * docs: swagger 문서 추가 * test: domain 모듈 notification service unit test * fix: notification 정기 지출 알림 쿼리 조건문 수정 * rename: 공지 타입 포맷팅 메서드 주석 추가 * rename: notification entity 내 포맷팅 메서드 주석 추가 --- .../notification/api/NotificationApi.java | 63 +++++++++ .../controller/NotificationController.java | 38 ++++++ .../notification/dto/NotificationDto.java | 80 ++++++++++++ .../mapper/NotificationMapper.java | 24 ++++ .../service/NotificationSearchService.java | 22 ++++ .../usecase/NotificationUseCase.java | 24 ++++ .../co/pennyway/api/config/SwaggerConfig.java | 12 +- .../GetNotificationsControllerUnitTest.java | 108 ++++++++++++++++ .../config/fixture/NotificationFixture.java | 23 ++++ .../batch/writer/NotificationWriter.java | 4 +- .../notification/domain/Notification.java | 86 +++++++++---- .../NotificationCustomRepository.java | 12 +- .../NotificationCustomRepositoryImpl.java | 28 ++-- .../repository/NotificationRepository.java | 4 +- .../service/NotificationService.java | 37 ++++++ .../notification/type/Announcement.java | 26 ++++ .../ReadNotificationsSliceUnitTest.java | 121 ++++++++++++++++++ .../SaveDailySpendingAnnounceInBulkTest.java | 7 +- 18 files changed, 664 insertions(+), 55 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/dto/NotificationDto.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/mapper/NotificationMapper.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSearchService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/notification/controller/GetNotificationsControllerUnitTest.java create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/NotificationFixture.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java create mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/ReadNotificationsSliceUnitTest.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java new file mode 100644 index 000000000..45ff0b21f --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java @@ -0,0 +1,63 @@ +package kr.co.pennyway.api.apis.notification.api; + + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.SchemaProperty; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.apis.notification.dto.NotificationDto; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.data.web.SortDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +@Tag(name = "[알림 API]") +public interface NotificationApi { + @Operation(summary = "수신한 알림 목록 무한 스크롤 조회") + @Parameters({ + @Parameter( + in = ParameterIn.QUERY, + description = "조회하려는 페이지 (0..N) (기본 값 : 0)", + name = "page", + example = "0", + schema = @Schema( + type = "integer", + defaultValue = "0" + ) + ), + @Parameter( + in = ParameterIn.QUERY, + description = "페이지 내 데이터 수 (기본 값 : 30)", + name = "size", + example = "30", + schema = @Schema( + type = "integer", + defaultValue = "30" + ) + ), + @Parameter( + in = ParameterIn.QUERY, + description = "정렬 기준 (기본 값 : notification.createdAt,DESC)", + name = "sort", + example = "notification.createdAt,DESC", + array = @ArraySchema( + schema = @Schema( + type = "string" + ) + ) + ), @Parameter(name = "pageable", hidden = true)}) + @ApiResponse(responseCode = "200", description = "알림 목록 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "notifications", schema = @Schema(implementation = NotificationDto.SliceRes.class)))) + ResponseEntity getNotifications( + @PageableDefault(page = 0, size = 30) @SortDefault(sort = "notification.createdAt", direction = Sort.Direction.DESC) Pageable pageable, + @AuthenticationPrincipal SecurityUserDetails user + ); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java new file mode 100644 index 000000000..fae7eff42 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java @@ -0,0 +1,38 @@ +package kr.co.pennyway.api.apis.notification.controller; + +import kr.co.pennyway.api.apis.notification.api.NotificationApi; +import kr.co.pennyway.api.apis.notification.usecase.NotificationUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.data.web.SortDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/notifications") +public class NotificationController implements NotificationApi { + private static final String NOTIFICATIONS = "notifications"; + + private final NotificationUseCase notificationUseCase; + + @Override + @GetMapping("") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getNotifications( + @PageableDefault(page = 0, size = 30) @SortDefault(sort = "notification.createdAt", direction = Sort.Direction.DESC) Pageable pageable, + @AuthenticationPrincipal SecurityUserDetails user + ) { + return ResponseEntity.ok(SuccessResponse.from(NOTIFICATIONS, notificationUseCase.getNotifications(user.getUserId(), pageable))); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/dto/NotificationDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/dto/NotificationDto.java new file mode 100644 index 000000000..ff157e658 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/dto/NotificationDto.java @@ -0,0 +1,80 @@ +package kr.co.pennyway.api.apis.notification.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import lombok.Builder; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; + +public class NotificationDto { + @Schema(title = "푸시 알림 슬라이스 응답") + public record SliceRes( + @Schema(description = "푸시 알림 리스트") + List content, + @Schema(description = "현재 페이지 번호") + int currentPageNumber, + @Schema(description = "페이지 크기") + int pageSize, + @Schema(description = "전체 요소 개수") + int numberOfElements, + @Schema(description = "다음 페이지 존재 여부") + boolean hasNext + ) { + public static SliceRes from(List notifications, Pageable pageable, int numberOfElements, boolean hasNext) { + return new SliceRes(notifications, pageable.getPageNumber(), pageable.getPageSize(), numberOfElements, hasNext); + } + } + + @Builder + @Schema(title = "푸시 알림 상세 정보", description = "푸시 알림 pk, 읽음 여부, 제목, 내용, 타입 그리고 딥 링크 정보를 담고 있다.") + public record Info( + @Schema(description = "푸시 알림 pk", example = "1") + Long id, + @Schema(description = "푸시 알림 읽음 여부", example = "true") + boolean isRead, + @Schema(description = "푸시 알림 제목", example = "페니웨이 공지") + String title, + @Schema(description = "푸시 알림 내용", example = "안녕하세요. 페니웨이입니다.") + String content, + @Schema(description = "푸시 알림 타입. ex) ANNOUNCEMENT", example = "FEED_LIKE_FROM_TO") + String type, + @Schema(description = "푸시 알림 행위자. ex) 다른 사용자 ", example = "pennyway") + @JsonInclude(JsonInclude.Include.NON_NULL) + String from, + @Schema(description = "푸시 알림 행위자 pk ", example = "1") + @JsonInclude(JsonInclude.Include.NON_NULL) + Long fromId, + @Schema(description = "푸시 알림 행위자가 액션을 취한 대상 pk. ex) 피드 pk, 댓글 pk ", example = "3") + @JsonInclude(JsonInclude.Include.NON_NULL) + Long toId, + @Schema(description = "푸시 알림 생성 시간", example = "2024-07-17 12:00:00") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt + ) { + public static NotificationDto.Info from(Notification notification) { + NotificationDto.Info.InfoBuilder builder = NotificationDto.Info.builder() + .id(notification.getId()) + .isRead(notification.getReadAt() != null) + .title(notification.createFormattedTitle()) + .content(notification.createFormattedContent()) + .type(notification.getType().name()) + .createdAt(notification.getCreatedAt()); + + if (!notification.getType().equals(NoticeType.ANNOUNCEMENT)) { + builder.from(notification.getSenderName()) + .fromId(notification.getSender().getId()) + .toId(notification.getToId()); + } + + return builder.build(); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/mapper/NotificationMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/mapper/NotificationMapper.java new file mode 100644 index 000000000..7da87bdb3 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/mapper/NotificationMapper.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.api.apis.notification.mapper; + +import kr.co.pennyway.api.apis.notification.dto.NotificationDto; +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +@Slf4j +@Mapper +public class NotificationMapper { + /** + * Slice 타입을 무한 스크롤 응답 형태로 변환한다. + */ + public static NotificationDto.SliceRes toSliceRes(Slice notifications, Pageable pageable) { + return NotificationDto.SliceRes.from( + notifications.getContent().stream().map(NotificationDto.Info::from).toList(), + pageable, + notifications.getNumberOfElements(), + notifications.hasNext() + ); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSearchService.java new file mode 100644 index 000000000..5a8d980d5 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSearchService.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.api.apis.notification.service; + +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import kr.co.pennyway.domain.domains.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationSearchService { + private final NotificationService notificationService; + + @Transactional(readOnly = true) + public Slice getNotifications(Long userId, Pageable pageable) { + return notificationService.readNotificationsSlice(userId, pageable); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java new file mode 100644 index 000000000..178fbc4d2 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.api.apis.notification.usecase; + +import kr.co.pennyway.api.apis.notification.dto.NotificationDto; +import kr.co.pennyway.api.apis.notification.mapper.NotificationMapper; +import kr.co.pennyway.api.apis.notification.service.NotificationSearchService; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class NotificationUseCase { + private final NotificationSearchService notificationSearchService; + + public NotificationDto.SliceRes getNotifications(Long userId, Pageable pageable) { + Slice notifications = notificationSearchService.getNotifications(userId, pageable); + + return NotificationMapper.toSliceRes(notifications, pageable); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java index a3c63fbdc..ded3ca280 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java @@ -65,7 +65,7 @@ public GroupedOpenApi authApi() { @Bean public GroupedOpenApi userApi() { - String[] targets = {"kr.co.pennyway.api.apis.users"}; + String[] targets = {"kr.co.pennyway.api.apis.users", "kr.co.pennyway.api.apis.notification"}; return GroupedOpenApi.builder() .packagesToScan(targets) @@ -73,6 +73,16 @@ public GroupedOpenApi userApi() { .build(); } + @Bean + public GroupedOpenApi storageApi() { + String[] targets = {"kr.co.pennyway.api.apis.storage"}; + + return GroupedOpenApi.builder() + .packagesToScan(targets) + .group("정적 파일 저장") + .build(); + } + @Bean public GroupedOpenApi ledgerApi() { String[] targets = {"kr.co.pennyway.api.apis.ledger"}; diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/notification/controller/GetNotificationsControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/notification/controller/GetNotificationsControllerUnitTest.java new file mode 100644 index 000000000..5887b6a1b --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/notification/controller/GetNotificationsControllerUnitTest.java @@ -0,0 +1,108 @@ +package kr.co.pennyway.api.apis.notification.controller; + +import kr.co.pennyway.api.apis.notification.dto.NotificationDto; +import kr.co.pennyway.api.apis.notification.usecase.NotificationUseCase; +import kr.co.pennyway.api.config.WebConfig; +import kr.co.pennyway.api.config.fixture.NotificationFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = {NotificationController.class}, excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) +@ActiveProfiles("test") +public class GetNotificationsControllerUnitTest { + @Autowired + private MockMvc mockMvc; + @MockBean + private NotificationUseCase notificationUseCase; + + @BeforeEach + void setUp(WebApplicationContext context) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .defaultRequest(post("/**").with(csrf())) + .defaultRequest(put("/**").with(csrf())) + .defaultRequest(delete("/**").with(csrf())) + .build(); + } + + @Test + @WithSecurityMockUser + @DisplayName("쿼리 파라미터로 page 외의 파라미터는 기본값을 갖는다.") + void getNotificationsWithDefaultParameters() throws Exception { + // when + int page = 0, currentPageNumber = 0, pageSize = 20, numberOfElements = 1; + Pageable pa = Pageable.ofSize(pageSize).withPage(currentPageNumber); + + Notification notification = NotificationFixture.ANNOUNCEMENT_DAILY_SPENDING.toEntity(UserFixture.GENERAL_USER.toUser()); + NotificationDto.Info info = NotificationDto.Info.from(notification); + + given(notificationUseCase.getNotifications(eq(1L), any())).willReturn(NotificationDto.SliceRes.from(List.of(info), pa, numberOfElements, false)); + + // when + ResultActions result = performGetNotifications(page); + + // then + result.andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.notifications.currentPageNumber").value(page)); + } + + @Test + @WithSecurityMockUser + @DisplayName("응답은 무한 스크롤 방식으로 제공되며, content, currentPageNumber, pageSize, numberOfElements, hasNext 필드를 포함한다.") + void getNotificationsWithInfiniteScroll() throws Exception { + // when + int page = 0, currentPageNumber = 0, pageSize = 20, numberOfElements = 1; + Pageable pa = Pageable.ofSize(pageSize).withPage(currentPageNumber); + + Notification notification = NotificationFixture.ANNOUNCEMENT_DAILY_SPENDING.toEntity(UserFixture.GENERAL_USER.toUser()); + NotificationDto.Info info = NotificationDto.Info.from(notification); + NotificationDto.SliceRes sliceRes = NotificationDto.SliceRes.from(List.of(info), pa, numberOfElements, false); + + given(notificationUseCase.getNotifications(eq(1L), any())).willReturn(sliceRes); + + // when + ResultActions result = performGetNotifications(page); + + // then + result.andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.notifications.content").exists()) + .andExpect(jsonPath("$.data.notifications.currentPageNumber").value(sliceRes.currentPageNumber())) + .andExpect(jsonPath("$.data.notifications.pageSize").value(sliceRes.pageSize())) + .andExpect(jsonPath("$.data.notifications.numberOfElements").value(sliceRes.numberOfElements())) + .andExpect(jsonPath("$.data.notifications.hasNext").value(sliceRes.hasNext())); + } + + private ResultActions performGetNotifications(int page) throws Exception { + return mockMvc.perform(get("/v2/notifications") + .param("page", String.valueOf(page))); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/NotificationFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/NotificationFixture.java new file mode 100644 index 000000000..a97517400 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/NotificationFixture.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.api.config.fixture; + +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDateTime; + +@RequiredArgsConstructor +public enum NotificationFixture { + ANNOUNCEMENT_DAILY_SPENDING(null, NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING); + + private final LocalDateTime readAt; + private final NoticeType type; + private final Announcement announcement; + + public Notification toEntity(User receiver) { + return new Notification.Builder(this.type, this.announcement, receiver) + .build(); + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java index 15707ae2b..2e7e85178 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java @@ -14,7 +14,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -31,7 +30,6 @@ public class NotificationWriter implements ItemWriter { @Transactional public void write(@NonNull Chunk owners) throws Exception { log.info("Writer 실행: {}", owners.size()); - LocalDateTime publishedAt = LocalDateTime.now(); Map notificationMap = new HashMap<>(); @@ -41,7 +39,7 @@ public void write(@NonNull Chunk owners) throws Exce List userIds = new ArrayList<>(notificationMap.keySet()); - notificationRepository.saveDailySpendingAnnounceInBulk(userIds, publishedAt, Announcement.DAILY_SPENDING); + notificationRepository.saveDailySpendingAnnounceInBulk(userIds, Announcement.DAILY_SPENDING); for (DailySpendingNotification notification : notificationMap.values()) { publisher.publishEvent(NotificationEvent.of(notification.title(), notification.content(), notification.deviceTokensForList(), "")); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java index e8ffa392e..01de09e64 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java @@ -29,20 +29,26 @@ public class Notification extends DateAuditable { @Convert(converter = AnnouncementConverter.class) private Announcement announcement; // 공지 종류 - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "receiver") - private User receiver; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "sender") private User sender; + private String senderName; - private Notification(LocalDateTime readAt, NoticeType type, Announcement announcement, User receiver, User sender) { - this.readAt = readAt; + private Long toId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver") + private User receiver; + private String receiverName; + + private Notification(NoticeType type, Announcement announcement, User sender, String senderName, Long toId, User receiver, String receiverName) { this.type = Objects.requireNonNull(type); this.announcement = Objects.requireNonNull(announcement); - this.receiver = receiver; - this.sender = sender; + this.sender = (!type.equals(NoticeType.ANNOUNCEMENT)) ? Objects.requireNonNull(sender) : sender; + this.senderName = (!type.equals(NoticeType.ANNOUNCEMENT)) ? Objects.requireNonNull(senderName) : senderName; + this.toId = toId; + this.receiver = Objects.requireNonNull(receiver); + this.receiverName = Objects.requireNonNull(receiverName); } @Override @@ -52,39 +58,71 @@ public String toString() { ", readAt=" + readAt + ", type=" + type + ", announcement=" + announcement + + ", senderName='" + senderName + '\'' + + ", toId=" + toId + + ", receiverName='" + receiverName + '\'' + '}'; } + /** + * 공지 제목을 생성한다. + *
+ * 이 메서드는 내부적으로 알림 타입의 종류에 따라 공지 제목을 포맷팅한다. + * + * @apiNote 이 메서드는 {@link NoticeType#ANNOUNCEMENT} 타입에 대해서만 동작한다. 다른 타입의 알림을 포맷팅해야 하는 경우 해당 메서드를 확장해야 한다. + */ + public String createFormattedTitle() { + if (type.equals(NoticeType.ANNOUNCEMENT)) { + return announcement.createFormattedTitle(receiverName); + } + return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함. + } + + /** + * 공지 내용을 생성한다. + *
+ * 이 메서드는 내부적으로 알림 타입의 종류에 따라 공지 내용을 포맷팅한다. + * + * @apiNote 이 메서드는 {@link NoticeType#ANNOUNCEMENT} 타입에 대해서만 동작한다. 다른 타입의 알림을 포맷팅해야 하는 경우 해당 메서드를 확장해야 한다. + */ + public String createFormattedContent() { + if (type.equals(NoticeType.ANNOUNCEMENT)) { + return announcement.createFormattedContent(receiverName); + } + return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함. + } + public static class Builder { - private LocalDateTime readAt; - private NoticeType type; - private Announcement announcement; + private final NoticeType type; + private final Announcement announcement; + private final User receiver; + private final String receiverName; + + private User sender; + private String senderName; - private User receiver = null; - private User sender = null; + private Long toId; - public Builder(NoticeType type, Announcement announcement) { + public Builder(NoticeType type, Announcement announcement, User receiver) { this.type = type; this.announcement = announcement; - } - - public Builder readAt(LocalDateTime readAt) { - this.readAt = readAt; - return this; - } - - public Builder receiver(User receiver) { this.receiver = receiver; - return this; + this.receiverName = receiver.getName(); } public Builder sender(User sender) { this.sender = sender; + this.senderName = sender.getName(); + return this; + } + + public Builder toId(Long toId) { + this.toId = toId; return this; } public Notification build() { - return new Notification(readAt, type, announcement, receiver, sender); + return new Notification(type, announcement, sender, senderName, toId, receiver, receiverName); } } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java index 03c80ed5d..e986b921f 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java @@ -2,18 +2,17 @@ import kr.co.pennyway.domain.domains.notification.type.Announcement; -import java.time.LocalDateTime; import java.util.List; public interface NotificationCustomRepository { /** * 사용자들에게 정기 지출 등록 알림을 저장한다. (발송이 아님) - * 만약 이미 publishedAt의 년-월-일에 해당하는 알림이 존재하고, 그 알림의 announcement까지 같다면 저장하지 않는다. + * 만약 이미 전송하려는 데이터가 년-월-일에 해당하는 생성일을 가지고 있고, 그 알림의 announcement 타입까지 같다면 저장하지 않는다. * *

      * {@code
-     * INSERT INTO notification(id, type, read_at, created_at, updated_at, receiver, announcement)
-     * SELECT NULL, '0', NULL, NOW(), NOW(), u.id, '1'
+     * INSERT INTO notification(type, announcement, created_at, updated_at, receiver, receiver_name)
+     * SELECT ?, ?, NOW(), NOW(), u.id, u.name
      * FROM user u
      * WHERE u.id IN (?)
      * AND NOT EXISTS (
@@ -29,8 +28,7 @@ public interface NotificationCustomRepository {
      * 
* * @param userIds : 등록할 사용자 아이디 목록 - * @param publishedAt : 알림 발송 시간, 공지 알림 중복 저장 방지를 위해 조건식에 사용 - * @param announcement : 알림 타입 {@link Announcement} + * @param announcement : 공지 타입 {@link Announcement} */ - void saveDailySpendingAnnounceInBulk(List userIds, LocalDateTime publishedAt, Announcement announcement); + void saveDailySpendingAnnounceInBulk(List userIds, Announcement announcement); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java index 0088b2ace..aea64c3b4 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java @@ -1,6 +1,7 @@ package kr.co.pennyway.domain.domains.notification.repository; import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.BatchPreparedStatementSetter; @@ -9,7 +10,6 @@ import java.sql.PreparedStatement; import java.sql.SQLException; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -19,31 +19,31 @@ public class NotificationCustomRepositoryImpl implements NotificationCustomRepository { private final JdbcTemplate jdbcTemplate; - private int batchSize = 500; + private final int BATCH_SIZE = 500; @Override - public void saveDailySpendingAnnounceInBulk(List userIds, LocalDateTime publishedAt, Announcement announcement) { + public void saveDailySpendingAnnounceInBulk(List userIds, Announcement announcement) { int batchCount = 0; List subItems = new ArrayList<>(); for (int i = 0; i < userIds.size(); ++i) { subItems.add(userIds.get(i)); - if ((i + 1) % batchSize == 0) { - batchCount = batchInsert(batchCount, subItems, publishedAt, announcement); + if ((i + 1) % BATCH_SIZE == 0) { + batchCount = batchInsert(batchCount, subItems, NoticeType.ANNOUNCEMENT, announcement); } } if (!subItems.isEmpty()) { - batchInsert(batchCount, subItems, publishedAt, announcement); + batchInsert(batchCount, subItems, NoticeType.ANNOUNCEMENT, announcement); } log.info("Notification saved. announcement: {}, count: {}", announcement, userIds.size()); } - private int batchInsert(int batchCount, List userIds, LocalDateTime publishedAt, Announcement announcement) { - String sql = "INSERT INTO notification(id, type, read_at, created_at, updated_at, receiver, announcement) " + - "SELECT NULL, '0', NULL, NOW(), NOW(), u.id, ? " + + private int batchInsert(int batchCount, List userIds, NoticeType noticeType, Announcement announcement) { + String sql = "INSERT INTO notification(id, read_at, type, announcement, created_at, updated_at, receiver, receiver_name) " + + "SELECT NULL, NULL, ?, ?, NOW(), NOW(), u.id, u.name " + "FROM user u " + "WHERE u.id IN (?) " + "AND NOT EXISTS ( " + @@ -52,16 +52,18 @@ private int batchInsert(int batchCount, List userIds, LocalDateTime publis " WHERE n.receiver = u.id " + " AND n.created_at >= CURDATE() " + " AND n.created_at < CURDATE() + INTERVAL 1 DAY " + - " AND n.type = '0' " + + " AND n.type = ? " + " AND n.announcement = ? " + ");"; jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement ps, int i) throws SQLException { - ps.setString(1, announcement.getCode()); - ps.setLong(2, userIds.get(i)); - ps.setString(3, announcement.getCode()); + ps.setString(1, noticeType.getCode()); + ps.setString(2, announcement.getCode()); + ps.setLong(3, userIds.get(i)); + ps.setString(4, noticeType.getCode()); + ps.setString(5, announcement.getCode()); } @Override diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java index 99531e3ae..4a1e5b208 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java @@ -1,7 +1,7 @@ package kr.co.pennyway.domain.domains.notification.repository; +import kr.co.pennyway.domain.common.repository.ExtendedRepository; import kr.co.pennyway.domain.domains.notification.domain.Notification; -import org.springframework.data.jpa.repository.JpaRepository; -public interface NotificationRepository extends JpaRepository, NotificationCustomRepository { +public interface NotificationRepository extends ExtendedRepository, NotificationCustomRepository { } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java new file mode 100644 index 000000000..dec047c58 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java @@ -0,0 +1,37 @@ +package kr.co.pennyway.domain.domains.notification.service; + +import com.querydsl.core.types.Predicate; +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.common.repository.QueryHandler; +import kr.co.pennyway.domain.common.util.SliceUtil; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import kr.co.pennyway.domain.domains.notification.domain.QNotification; +import kr.co.pennyway.domain.domains.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class NotificationService { + private final NotificationRepository notificationRepository; + + private final QNotification notification = QNotification.notification; + + @Transactional(readOnly = true) + public Slice readNotificationsSlice(Long userId, Pageable pageable) { + Predicate predicate = notification.receiver.id.eq(userId); + + QueryHandler queryHandler = query -> query + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1); + + Sort sort = pageable.getSort(); + + return SliceUtil.toSlice(notificationRepository.findList(predicate, queryHandler, sort), pageable); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java index 72b8181da..b538cf531 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java @@ -22,13 +22,39 @@ public enum Announcement implements LegacyCommonType { this.content = content; } + /** + * 수신자의 이름을 받아서 공지 제목을 생성한다. + *
+ * 만약 해당 타입의 제목에서 % 문자가 없다면 그대로 반환한다. + * + * @param name 수신자의 이름 + * @return 포맷팅된 공지 제목 + */ public String createFormattedTitle(String name) { validateName(name); + + if (this.title.indexOf("%") == -1) { + return this.title; + } + return String.format(title, name); } + /** + * 수신자의 이름을 받아서 공지 내용을 생성한다. + *
+ * 만약 해당 타입의 내용에서 % 문자가 없다면 그대로 반환한다. + * + * @param name 수신자의 이름 + * @return 포맷팅된 공지 내용 + */ public String createFormattedContent(String name) { validateName(name); + + if (this.content.indexOf("%") == -1) { + return this.content; + } + return String.format(content, name); } diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/ReadNotificationsSliceUnitTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/ReadNotificationsSliceUnitTest.java new file mode 100644 index 000000000..66aaaf358 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/ReadNotificationsSliceUnitTest.java @@ -0,0 +1,121 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.TestJpaConfig; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import kr.co.pennyway.domain.domains.notification.service.NotificationService; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertTrue; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = {JpaConfig.class, NotificationService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(TestJpaConfig.class) +@ActiveProfiles("test") +public class ReadNotificationsSliceUnitTest extends ContainerMySqlTestConfig { + @Autowired + private UserRepository userRepository; + + @Autowired + private NotificationService notificationService; + + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + @Test + @Transactional + @DisplayName("특정 사용자의 알림 목록을 슬라이스로 조회하며, 결과는 최신순으로 정렬되어야 한다.") + public void readNotificationsSliceSorted() { + // given + User user = userRepository.save(createUser("jayang")); + Pageable pa = PageRequest.of(0, 5, Sort.by(Sort.Order.desc("notification.createdAt"))); + + List notifications = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Notification notification = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + notifications.add(notification); + } + bulkInsertNotifications(notifications); + + // when + Slice result = notificationService.readNotificationsSlice(user.getId(), pa); + + // then + assertEquals("Slice 데이터 개수는 5개여야 한다.", 5, result.getNumberOfElements()); + assertTrue("hasNext()는 true여야 한다.", result.hasNext()); + for (int i = 0; i < result.getNumberOfElements() - 1; i++) { + Notification current = result.getContent().get(i); + Notification next = result.getContent().get(i + 1); + log.debug("current: {}, next: {}", current.getCreatedAt(), next.getCreatedAt()); + log.debug("notification: {}", current); + assert current.getCreatedAt().isAfter(next.getCreatedAt()); + } + } + + private User createUser(String name) { + return User.builder() + .username("test") + .name(name) + .password("test") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } + + private void bulkInsertNotifications(List notifications) { + String sql = String.format(""" + INSERT INTO `%s` (type, announcement, created_at, updated_at, receiver, receiver_name) + VALUES (:type, :announcement, :createdAt, :updatedAt, :receiver, :receiverName); + """, "notification"); + + LocalDateTime date = LocalDateTime.now(); + SqlParameterSource[] params = new SqlParameterSource[notifications.size()]; + + for (int i = 0; i < notifications.size(); i++) { + Notification notification = notifications.get(i); + params[i] = new MapSqlParameterSource() + .addValue("type", notification.getType().getCode()) + .addValue("announcement", notification.getAnnouncement().getCode()) + .addValue("createdAt", date) + .addValue("updatedAt", date) + .addValue("receiver", notification.getReceiver().getId()) + .addValue("receiverName", notification.getReceiverName()); + date = date.minusDays(1); + } + + jdbcTemplate.batchUpdate(sql, params); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/SaveDailySpendingAnnounceInBulkTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/SaveDailySpendingAnnounceInBulkTest.java index 970c05c78..8e94aa7ea 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/SaveDailySpendingAnnounceInBulkTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/SaveDailySpendingAnnounceInBulkTest.java @@ -22,7 +22,6 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; import java.util.List; import static org.springframework.test.util.AssertionErrors.assertEquals; @@ -51,7 +50,6 @@ public void saveDailySpendingAnnounceInBulk() { // when notificationRepository.saveDailySpendingAnnounceInBulk( List.of(user1.getId(), user2.getId(), user3.getId()), - LocalDateTime.now(), Announcement.DAILY_SPENDING ); @@ -70,20 +68,19 @@ public void notSaveDuplicateNotification() { User user1 = userRepository.save(createUser("jayang")); User user2 = userRepository.save(createUser("mock")); - Notification notification = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING) - .receiver(user1) + Notification notification = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user1) .build(); notificationRepository.save(notification); // when notificationRepository.saveDailySpendingAnnounceInBulk( List.of(user1.getId(), user2.getId()), - LocalDateTime.now(), Announcement.DAILY_SPENDING ); // then List notifications = notificationRepository.findAll(); + log.debug("notifications: {}", notifications); assertEquals("알림이 중복 저장되지 않아야 한다.", 2, notifications.size()); } From 4f51e7b62a0c371e671f61361162360166bafd1f Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 18 Jul 2024 13:34:15 +0900 Subject: [PATCH 129/152] =?UTF-8?q?feat:=20=E2=9C=A8=20=EB=AF=B8=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=EC=B2=98=EB=A6=AC=20API=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: notification update read_at 메서드 unit 테스트 * test: save 3번 -> save_all 수정 * test: notification repository unit test 파일 통합 * test: 미확인 알림 조회 메서드 테스트 * feat: 미확인 알림 리스트 조회 메서드 쿼리 추가 * style: count 파라미터 순서 변경 * feat: notification service 메서드 count, bulk update 메서드 추가 * feat: notification manager 구현 * feat: 읽음 요청 dto 정의 * feat: notification 읽음 처리 controller 정의 * feat: notification update usecase & service 작성 * feat: 불필요한 user_id 파라미터 제거 * docs: swagger 문서 작성 * fix: notification service read_only 옵션 제거 --- .../notification/api/NotificationApi.java | 22 +++++++-- .../controller/NotificationController.java | 14 ++++-- .../notification/dto/NotificationDto.java | 9 ++++ .../service/NotificationSaveService.java | 24 ++++++++++ .../usecase/NotificationUseCase.java | 8 ++++ .../authorization/NotificationManager.java | 26 ++++++++++ .../repository/NotificationRepository.java | 13 +++++ .../service/NotificationService.java | 12 +++++ ...va => NotificationRepositoryUnitTest.java} | 48 ++++++++++++++++++- 9 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSaveService.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/NotificationManager.java rename pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/{SaveDailySpendingAnnounceInBulkTest.java => NotificationRepositoryUnitTest.java} (64%) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java index 45ff0b21f..97d8aac57 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java @@ -5,11 +5,9 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.SchemaProperty; +import io.swagger.v3.oas.annotations.media.*; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.co.pennyway.api.apis.notification.dto.NotificationDto; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; @@ -19,6 +17,8 @@ import org.springframework.data.web.SortDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestBody; @Tag(name = "[알림 API]") public interface NotificationApi { @@ -60,4 +60,18 @@ ResponseEntity getNotifications( @PageableDefault(page = 0, size = 30) @SortDefault(sort = "notification.createdAt", direction = Sort.Direction.DESC) Pageable pageable, @AuthenticationPrincipal SecurityUserDetails user ); + + @Operation(summary = "수신한 알림 읽음 처리", description = "사용자가 수신한 알림을 읽음처리 합니다. 단, 읽음 처리할 알림의 pk는 사용자가 receiver여야 하며, 미확인 알림만 포함되어 있어야 합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "알림 읽음 처리 성공"), + @ApiResponse(responseCode = "403", description = "사용자가 접근할 권한이 없는 pk가 포함되어 있거나, 이미 읽음 처리된 알림이 하나라도 존재하는 경우", content = @Content(examples = + @ExampleObject(""" + { + "code": "4030", + "message": "ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN" + } + """) + )) + }) + ResponseEntity updateNotifications(@RequestBody @Validated NotificationDto.ReadReq readReq); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java index fae7eff42..1a4126360 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java @@ -1,6 +1,7 @@ package kr.co.pennyway.api.apis.notification.controller; import kr.co.pennyway.api.apis.notification.api.NotificationApi; +import kr.co.pennyway.api.apis.notification.dto.NotificationDto; import kr.co.pennyway.api.apis.notification.usecase.NotificationUseCase; import kr.co.pennyway.api.common.response.SuccessResponse; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; @@ -13,9 +14,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; @Slf4j @RestController @@ -35,4 +35,12 @@ public ResponseEntity getNotifications( ) { return ResponseEntity.ok(SuccessResponse.from(NOTIFICATIONS, notificationUseCase.getNotifications(user.getUserId(), pageable))); } + + @Override + @PatchMapping("") + @PreAuthorize("isAuthenticated() and @notificationManager.hasPermission(principal.userId, #readReq.notificationIds())") + public ResponseEntity updateNotifications(@RequestBody @Validated NotificationDto.ReadReq readReq) { + notificationUseCase.updateNotificationsToRead(readReq.notificationIds()); + return ResponseEntity.ok(SuccessResponse.noContent()); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/dto/NotificationDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/dto/NotificationDto.java index ff157e658..4f06df4c7 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/dto/NotificationDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/dto/NotificationDto.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; import kr.co.pennyway.domain.domains.notification.domain.Notification; import kr.co.pennyway.domain.domains.notification.type.NoticeType; import lombok.Builder; @@ -14,6 +15,14 @@ import java.util.List; public class NotificationDto { + @Schema(title = "푸시 알림 읽음 처리 요청") + public record ReadReq( + @Schema(description = "푸시 알림 pk 리스트", example = "[1, 2, 3]") + @NotEmpty(message = "notificationIds는 비어있을 수 없습니다.") + List notificationIds + ) { + } + @Schema(title = "푸시 알림 슬라이스 응답") public record SliceRes( @Schema(description = "푸시 알림 리스트") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSaveService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSaveService.java new file mode 100644 index 000000000..a753e09a0 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSaveService.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.api.apis.notification.service; + +import kr.co.pennyway.domain.domains.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationSaveService { + private final NotificationService notificationService; + + /** + * 알림 목록을 읽음 상태로 업데이트합니다. + * + * @param notificationIds 읽음 처리할 알림 ID 목록 + */ + public void updateNotificationsToRead(List notificationIds) { + notificationService.updateReadAtByIdsInBulk(notificationIds); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java index 178fbc4d2..2edc0118e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java @@ -2,6 +2,7 @@ import kr.co.pennyway.api.apis.notification.dto.NotificationDto; import kr.co.pennyway.api.apis.notification.mapper.NotificationMapper; +import kr.co.pennyway.api.apis.notification.service.NotificationSaveService; import kr.co.pennyway.api.apis.notification.service.NotificationSearchService; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.domains.notification.domain.Notification; @@ -10,15 +11,22 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import java.util.List; + @Slf4j @UseCase @RequiredArgsConstructor public class NotificationUseCase { private final NotificationSearchService notificationSearchService; + private final NotificationSaveService notificationSaveService; public NotificationDto.SliceRes getNotifications(Long userId, Pageable pageable) { Slice notifications = notificationSearchService.getNotifications(userId, pageable); return NotificationMapper.toSliceRes(notifications, pageable); } + + public void updateNotificationsToRead(List notificationIds) { + notificationSaveService.updateNotificationsToRead(notificationIds); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/NotificationManager.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/NotificationManager.java new file mode 100644 index 000000000..1880e31d6 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/authorization/NotificationManager.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.api.common.security.authorization; + +import kr.co.pennyway.domain.domains.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Component("notificationManager") +@RequiredArgsConstructor +public class NotificationManager { + private final NotificationService notificationService; + + /** + * 사용자가 알림 리스트에 대한 전체 접근 권한이 있는지 확인한다. + *

+ * 조회 결과와 요청 파라미터의 개수가 동일해야 하며, 읽음 상태의 알림은 포함되어선 안 된다. + */ + @Transactional(readOnly = true) + public boolean hasPermission(Long userId, List notificationIds) { + return notificationService.countUnreadNotifications(userId, notificationIds) == (long) notificationIds.size(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java index 4a1e5b208..ad7217f11 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java @@ -2,6 +2,19 @@ import kr.co.pennyway.domain.common.repository.ExtendedRepository; import kr.co.pennyway.domain.domains.notification.domain.Notification; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; public interface NotificationRepository extends ExtendedRepository, NotificationCustomRepository { + @Modifying(clearAutomatically = true) + @Transactional + @Query("update Notification n set n.readAt = current_timestamp where n.id in ?1") + void updateReadAtByIdsInBulk(List notificationIds); + + @Transactional(readOnly = true) + @Query("select count(n) from Notification n where n.receiver.id = ?1 and n.id in ?2 and n.readAt is null") + long countUnreadNotificationsByIds(Long userId, List notificationIds); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java index dec047c58..013df51b9 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java @@ -14,6 +14,8 @@ import org.springframework.data.domain.Sort; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Slf4j @DomainService @RequiredArgsConstructor @@ -34,4 +36,14 @@ public Slice readNotificationsSlice(Long userId, Pageable pageable return SliceUtil.toSlice(notificationRepository.findList(predicate, queryHandler, sort), pageable); } + + @Transactional(readOnly = true) + public long countUnreadNotifications(Long userId, List notificationIds) { + return notificationRepository.countUnreadNotificationsByIds(userId, notificationIds); + } + + @Transactional + public void updateReadAtByIdsInBulk(List notificationIds) { + notificationRepository.updateReadAtByIdsInBulk(notificationIds); + } } diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/SaveDailySpendingAnnounceInBulkTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java similarity index 64% rename from pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/SaveDailySpendingAnnounceInBulkTest.java rename to pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java index 8e94aa7ea..aeab6c0d5 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/SaveDailySpendingAnnounceInBulkTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java @@ -25,6 +25,7 @@ import java.util.List; import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertNotNull; @Slf4j @DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) @@ -32,7 +33,7 @@ @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @Import(TestJpaConfig.class) @ActiveProfiles("test") -public class SaveDailySpendingAnnounceInBulkTest extends ContainerMySqlTestConfig { +public class NotificationRepositoryUnitTest extends ContainerMySqlTestConfig { @Autowired private UserRepository userRepository; @Autowired @@ -84,6 +85,51 @@ public void notSaveDuplicateNotification() { assertEquals("알림이 중복 저장되지 않아야 한다.", 2, notifications.size()); } + @Test + @DisplayName("사용자의 여러 알림을 읽음 처리할 수 있다.") + void updateReadAtSuccessfully() { + // given + User user = userRepository.save(createUser("jayang")); + + List notifications = notificationRepository.saveAll(List.of( + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(), + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(), + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build())); + + // when + notificationRepository.updateReadAtByIdsInBulk(notifications.stream().map(Notification::getId).toList()); + + // then + notificationRepository.findAll().forEach(notification -> { + log.info("notification: {}", notification); + assertNotNull("알림이 읽음 처리 되어야 한다.", notification.getReadAt()); + }); + } + + @Test + @DisplayName("사용자의 읽지 않은 알림 개수를 조회할 수 있다.") + void countUnreadNotificationsByIds() { + // given + User user = userRepository.save(createUser("jayang")); + + List notifications = notificationRepository.saveAll(List.of( + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(), + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(), + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build())); + List ids = notifications.stream().map(Notification::getId).toList(); + + notificationRepository.updateReadAtByIdsInBulk(List.of(ids.get(1))); + + // when + long count = notificationRepository.countUnreadNotificationsByIds( + user.getId(), + notifications.stream().map(Notification::getId).toList() + ); + + // then + assertEquals("읽지 않은 알림 개수가 2개여야 한다.", 2L, count); + } + private User createUser(String name) { return User.builder() .username("test") From 381f3ea40eb50d8e1cd1bb072f4edc9575139b9a Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Wed, 24 Jul 2024 22:04:37 +0900 Subject: [PATCH 130/152] =?UTF-8?q?refactor:=20=E2=9C=8F=EF=B8=8F=20?= =?UTF-8?q?=EB=A7=A4=EC=9D=BC=20=EC=A0=95=EA=B8=B0=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=B0=B0=EC=B9=98=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20(#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: application-domain.yml jdbc url query parameter 수정 * style: step config 파일 삭제 -> job config에 통합 * style: dto 패키지 경로 common 하위로 수정 * fix: notificaion batch insert ; 제거 && batch size 1000으로 수정 * feat: where 함수형 인터페이스 정의 * feat: where expression 정의 * feat: order expression 상수 정의 * feat: expression 상수 정의 * feat: querydsl_no_offset_options 추상 클래스 정의 * feat: no offset의 타입이 number인 경우를 위한 구현체 정의 * rename: 정적 팩토리 메서드 주석에 주의 사항 추가 * feat: no offset의 타입이 string인 경우를 위한 구현체 정의 * feat: querydsl_paging_item_reader 추가 * feat: querydsl_no_offset_paging_item_reader 정의 * fix: repository_item_reader -> querydsl_no_offset_paging_item_reader 변경 * fix: @job_scope 및 @step_scope 추가 && step reader 수정 * fix: device_token_custom_repository 제거 * test: device_token_cutome_repository 테스트 제거 * style: device_token_owner 경로 domain -> batch로 수정 * test: redisson 테스트 ignore 처리 * chore: batch application db connection pool 2개로 수정 --- .../dto/DailySpendingNotification.java | 3 +- .../batch/common}/dto/DeviceTokenOwner.java | 2 +- .../QuerydslNoOffsetPagingItemReader.java | 69 +++++++++ .../reader/QuerydslPagingItemReader.java | 141 ++++++++++++++++++ .../common/reader/expression/Expression.java | 39 +++++ .../reader/expression/OrderExpression.java | 5 + .../reader/expression/WhereExpression.java | 35 +++++ .../expression/WhereNumberFunction.java | 10 ++ .../expression/WhereStringFunction.java | 9 ++ .../QuerydslNoOffsetNumberOptions.java | 138 +++++++++++++++++ .../options/QuerydslNoOffsetOptions.java | 72 +++++++++ .../QuerydslNoOffsetStringOptions.java | 135 +++++++++++++++++ .../batch/job/DailySpendingNotifyConfig.java | 45 ++++++ .../job/DailySpendingNotifyJobConfig.java | 29 ---- .../kr/co/pennyway/batch/processor/.gitkeep | 0 .../processor/NotificationProcessor.java | 18 --- .../batch/reader/ActiveDeviceTokenReader.java | 49 +++--- .../step/SendSpendingNotifyStepConfig.java | 32 ---- .../batch/writer/NotificationWriter.java | 6 +- .../src/main/resources/application.yml | 4 + .../DeviceTokenCustomRepository.java | 31 ---- .../DeviceTokenCustomRepositoryImpl.java | 52 ------- .../repository/DeviceTokenRepository.java | 2 +- .../NotificationCustomRepositoryImpl.java | 4 +- .../src/main/resources/application-domain.yml | 2 +- .../redisson/CouponDecreaseLockTest.java | 2 + .../repository/ActivatedDeviceSearchTest.java | 139 ----------------- 27 files changed, 745 insertions(+), 328 deletions(-) rename pennyway-batch/src/main/java/kr/co/pennyway/batch/{ => common}/dto/DailySpendingNotification.java (94%) rename {pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device => pennyway-batch/src/main/java/kr/co/pennyway/batch/common}/dto/DeviceTokenOwner.java (77%) create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReader.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslPagingItemReader.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/Expression.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/OrderExpression.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereExpression.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereNumberFunction.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereStringFunction.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetNumberOptions.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetOptions.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetStringOptions.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyConfig.java delete mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyJobConfig.java create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/.gitkeep delete mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/NotificationProcessor.java delete mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/step/SendSpendingNotifyStepConfig.java delete mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepository.java delete mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepositoryImpl.java delete mode 100644 pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/device/repository/ActivatedDeviceSearchTest.java diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/dto/DailySpendingNotification.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DailySpendingNotification.java similarity index 94% rename from pennyway-batch/src/main/java/kr/co/pennyway/batch/dto/DailySpendingNotification.java rename to pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DailySpendingNotification.java index 0c69f224e..aebce3313 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/dto/DailySpendingNotification.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DailySpendingNotification.java @@ -1,6 +1,5 @@ -package kr.co.pennyway.batch.dto; +package kr.co.pennyway.batch.common.dto; -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; import kr.co.pennyway.domain.domains.notification.type.Announcement; import lombok.Builder; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/dto/DeviceTokenOwner.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DeviceTokenOwner.java similarity index 77% rename from pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/dto/DeviceTokenOwner.java rename to pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DeviceTokenOwner.java index 15c4a8c9f..dc49d649c 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/dto/DeviceTokenOwner.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DeviceTokenOwner.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.domain.domains.device.dto; +package kr.co.pennyway.batch.common.dto; /** * 디바이스 토큰과 유저 아이디를 담은 DTO diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReader.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReader.java new file mode 100644 index 000000000..d6fb308bb --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReader.java @@ -0,0 +1,69 @@ +package kr.co.pennyway.batch.common.reader; + +import com.querydsl.jpa.JPQLQuery; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.EntityTransaction; +import kr.co.pennyway.batch.common.reader.options.QuerydslNoOffsetOptions; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; + +import java.util.function.Function; + +public class QuerydslNoOffsetPagingItemReader extends QuerydslPagingItemReader { + private QuerydslNoOffsetOptions options; + + private QuerydslNoOffsetPagingItemReader() { + super(); + setName(ClassUtils.getShortName(QuerydslNoOffsetPagingItemReader.class)); + } + + public QuerydslNoOffsetPagingItemReader(EntityManagerFactory entityManagerFactory, + int pageSize, + QuerydslNoOffsetOptions options, + Function> queryFunction) { + super(entityManagerFactory, pageSize, queryFunction); + setName(ClassUtils.getShortName(QuerydslNoOffsetPagingItemReader.class)); + this.options = options; + } + + @Override + @SuppressWarnings("unchecked") + protected void doReadPage() { + + EntityTransaction tx = getTxOrNull(); + + JPQLQuery query = createQuery().limit(getPageSize()); + + initResults(); + + fetchQuery(query, tx); + + resetCurrentIdIfNotLastPage(); + } + + @Override + protected JPAQuery createQuery() { + JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager); + JPAQuery query = queryFunction.apply(queryFactory); + options.initKeys(query, getPage()); // 제일 첫번째 페이징시 시작해야할 ID 찾기 + + return options.createQuery(query, getPage()); + } + + private void resetCurrentIdIfNotLastPage() { + if (isNotEmptyResults()) { + options.resetCurrentId(getLastItem()); + } + } + + // 조회결과가 Empty이면 results에 null이 담긴다 + private boolean isNotEmptyResults() { + return !CollectionUtils.isEmpty(results) && results.get(0) != null; + } + + private T getLastItem() { + return results.get(results.size() - 1); + } +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslPagingItemReader.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslPagingItemReader.java new file mode 100644 index 000000000..0fb2163b4 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslPagingItemReader.java @@ -0,0 +1,141 @@ +package kr.co.pennyway.batch.common.reader; + +import com.querydsl.jpa.JPQLQuery; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.EntityTransaction; +import org.springframework.batch.item.database.AbstractPagingItemReader; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; + +/** + * Querydsl을 이용한 커스텀 PagingItemReader + *

+ * 이동욱님 깃헙 참고 + */ +public class QuerydslPagingItemReader extends AbstractPagingItemReader { + + protected final Map jpaPropertyMap = new HashMap<>(); + protected EntityManagerFactory entityManagerFactory; + protected EntityManager entityManager; + protected Function> queryFunction; + protected boolean transacted = true; // default value + + protected QuerydslPagingItemReader() { + setName(ClassUtils.getShortName(QuerydslPagingItemReader.class)); + } + + public QuerydslPagingItemReader(EntityManagerFactory entityManagerFactory, + int pageSize, + Function> queryFunction) { + this(entityManagerFactory, pageSize, true, queryFunction); + } + + public QuerydslPagingItemReader(EntityManagerFactory entityManagerFactory, + int pageSize, + boolean transacted, + Function> queryFunction) { + this(); + this.entityManagerFactory = entityManagerFactory; + this.queryFunction = queryFunction; + setPageSize(pageSize); + setTransacted(transacted); + } + + /** + * Reader의 트랜잭션격리 옵션
+ * - false: 격리 시키지 않고, Chunk 트랜잭션에 의존한다
+ * (hibernate.default_batch_fetch_size 옵션 사용가능)
+ * - true: 격리 시킨다
+ * (Reader 조회 결과를 삭제하고 다시 조회했을때 삭제된게 반영되고 조회되길 원할때 사용한다.) + */ + public void setTransacted(boolean transacted) { + this.transacted = transacted; + } + + @Override + protected void doOpen() throws Exception { + super.doOpen(); + + entityManager = entityManagerFactory.createEntityManager(jpaPropertyMap); + if (entityManager == null) { + throw new DataAccessResourceFailureException("Unable to obtain an EntityManager"); + } + } + + @Override + @SuppressWarnings("unchecked") + protected void doReadPage() { + EntityTransaction tx = getTxOrNull(); + + JPQLQuery query = createQuery() + .offset(getPage() * getPageSize()) + .limit(getPageSize()); + + initResults(); + + fetchQuery(query, tx); + } + + protected EntityTransaction getTxOrNull() { + if (transacted) { + EntityTransaction tx = entityManager.getTransaction(); + tx.begin(); + + entityManager.flush(); + entityManager.clear(); + return tx; + } + + return null; + } + + protected JPAQuery createQuery() { + JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager); + return queryFunction.apply(queryFactory); + } + + protected void initResults() { + if (CollectionUtils.isEmpty(results)) { + results = new CopyOnWriteArrayList<>(); + } else { + results.clear(); + } + } + + /** + * where 의 조건은 id max/min 을 이용한 제한된 범위를 가지게 한다 + * + * @param query + * @param tx + */ + protected void fetchQuery(JPQLQuery query, EntityTransaction tx) { + if (transacted) { + results.addAll(query.fetch()); + if (tx != null) { + tx.commit(); + } + } else { + List queryResult = query.fetch(); + for (T entity : queryResult) { + entityManager.detach(entity); + results.add(entity); + } + } + } + + @Override + protected void doClose() throws Exception { + entityManager.close(); + super.doClose(); + } +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/Expression.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/Expression.java new file mode 100644 index 000000000..49111586c --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/Expression.java @@ -0,0 +1,39 @@ +package kr.co.pennyway.batch.common.reader.expression; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberPath; +import com.querydsl.core.types.dsl.StringPath; + +public enum Expression { + ASC(WhereExpression.GT, OrderExpression.ASC), + DESC(WhereExpression.LT, OrderExpression.DESC); + + private final WhereExpression where; + private final OrderExpression order; + + Expression(WhereExpression where, OrderExpression order) { + this.where = where; + this.order = order; + } + + public boolean isAsc() { + return this == ASC; + } + + public BooleanExpression where(StringPath id, int page, String currentId) { + return where.expression(id, page, currentId); + } + + public > BooleanExpression where(NumberPath id, int page, N currentId) { + return where.expression(id, page, currentId); + } + + public OrderSpecifier order(StringPath id) { + return isAsc() ? id.asc() : id.desc(); + } + + public > OrderSpecifier order(NumberPath id) { + return isAsc() ? id.asc() : id.desc(); + } +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/OrderExpression.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/OrderExpression.java new file mode 100644 index 000000000..b243ed100 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/OrderExpression.java @@ -0,0 +1,5 @@ +package kr.co.pennyway.batch.common.reader.expression; + +public enum OrderExpression { + ASC, DESC +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereExpression.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereExpression.java new file mode 100644 index 000000000..4e128d4e3 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereExpression.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.batch.common.reader.expression; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberPath; +import com.querydsl.core.types.dsl.StringPath; + +/** + * 첫페이지 조회시에는 >=, <= + * 두번째 페이지부터는 >, < + */ +public enum WhereExpression { + GT( + (id, page, currentId) -> page == 0 ? id.goe(currentId) : id.gt(currentId), + (id, page, currentId) -> page == 0 ? id.goe(currentId) : id.gt(currentId)), + LT( + (id, page, currentId) -> page == 0 ? id.loe(currentId) : id.lt(currentId), + (id, page, currentId) -> page == 0 ? id.loe(currentId) : id.lt(currentId) + ); + + private final WhereStringFunction string; + private final WhereNumberFunction number; + + WhereExpression(WhereStringFunction string, WhereNumberFunction number) { + this.string = string; + this.number = number; + } + + public BooleanExpression expression(StringPath id, int page, String currentId) { + return this.string.apply(id, page, currentId); + } + + public > BooleanExpression expression(NumberPath id, int page, N currentId) { + return this.number.apply(id, page, currentId); + } +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereNumberFunction.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereNumberFunction.java new file mode 100644 index 000000000..2bde88c94 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereNumberFunction.java @@ -0,0 +1,10 @@ +package kr.co.pennyway.batch.common.reader.expression; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberPath; + +@FunctionalInterface +public interface WhereNumberFunction> { + BooleanExpression apply(NumberPath id, int page, N currentId); + +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereStringFunction.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereStringFunction.java new file mode 100644 index 000000000..5e3655bf9 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereStringFunction.java @@ -0,0 +1,9 @@ +package kr.co.pennyway.batch.common.reader.expression; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.StringPath; + +@FunctionalInterface +public interface WhereStringFunction { + BooleanExpression apply(StringPath id, int page, String currentId); +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetNumberOptions.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetNumberOptions.java new file mode 100644 index 000000000..263e04562 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetNumberOptions.java @@ -0,0 +1,138 @@ +package kr.co.pennyway.batch.common.reader.options; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberPath; +import com.querydsl.jpa.impl.JPAQuery; +import jakarta.annotation.Nonnull; +import kr.co.pennyway.batch.common.reader.expression.Expression; + +public class QuerydslNoOffsetNumberOptions> extends QuerydslNoOffsetOptions { + + private final NumberPath field; + private N currentId; + private N lastId; + + private QuerydslNoOffsetNumberOptions(@Nonnull NumberPath field, + @Nonnull Expression expression) { + super(field, expression); + this.field = field; + } + + private QuerydslNoOffsetNumberOptions(@Nonnull NumberPath field, + @Nonnull Expression expression, + String idName) { + super(idName, expression); + this.field = field; + } + + /** + * QEntity의 NumberPath 필드를 사용하여 offset을 설정하는 옵션을 생성합니다. + * + * @param field {@link NumberPath} : offset으로 사용할 필드 + * @param expression {@link Expression} : 정렬 방향 + */ + public static > QuerydslNoOffsetNumberOptions of(@Nonnull NumberPath field, @Nonnull Expression expression) { + return new QuerydslNoOffsetNumberOptions<>(field, expression); + } + + /** + * QEintity의 NumberPath 필드를 사용하여 offset을 설정하는 옵션을 생성합니다. + *

+ * 만약, 쿼리의 응답을 QEntity가 아닌 Dto를 사용한 경우 마지막으로 조회한 offset의 값이 저장된 필드를 idName으로 지정해야 하며, 참조될 dto의 필드는 Number 타입이어야 합니다. + *

+ * 해당 클래스는 idName의 유효성을 검사하지 않습니다. 따라서, idName에 해당하는 필드가 존재하지 않거나 타입이 다른 경우 예기치 못한 에러가 발생할 수 있습니다. + * + * @param field {@link NumberPath} : offset으로 사용할 필드 + * @param expression {@link Expression} : 정렬 방향 + * @param idName {@link String} : 마지막으로 조회한 offset이 저장된 필드 이름 + */ + public static > QuerydslNoOffsetNumberOptions of(@Nonnull NumberPath field, @Nonnull Expression expression, String idName) { + return new QuerydslNoOffsetNumberOptions<>(field, expression, idName); + } + + public N getCurrentId() { + return currentId; + } + + public N getLastId() { + return lastId; + } + + @Override + public void initKeys(JPAQuery query, int page) { + if (page == 0) { + initFirstId(query); + initLastId(query); + + if (logger.isDebugEnabled()) { + logger.debug("First Key= " + currentId + ", Last Key= " + lastId); + } + } + } + + @Override + protected void initFirstId(JPAQuery query) { + JPAQuery clone = query.clone(); + boolean isGroupByQuery = isGroupByQuery(clone); + + if (isGroupByQuery) { + currentId = clone + .select(field) + .orderBy(expression.isAsc() ? field.asc() : field.desc()) + .fetchFirst(); + } else { + currentId = clone + .select(expression.isAsc() ? field.min() : field.max()) + .fetchFirst(); + } + + } + + @Override + protected void initLastId(JPAQuery query) { + JPAQuery clone = query.clone(); + boolean isGroupByQuery = isGroupByQuery(clone); + + if (isGroupByQuery) { + lastId = clone + .select(field) + .orderBy(expression.isAsc() ? field.desc() : field.asc()) + .fetchFirst(); + } else { + lastId = clone + .select(expression.isAsc() ? field.max() : field.min()) + .fetchFirst(); + } + } + + @Override + public JPAQuery createQuery(JPAQuery query, int page) { + if (currentId == null) { + return query; + } + + return query + .where(whereExpression(page)) + .orderBy(orderExpression()); + } + + private BooleanExpression whereExpression(int page) { + return expression.where(field, page, currentId) + .and(expression.isAsc() ? field.loe(lastId) : field.goe(lastId)); + } + + private OrderSpecifier orderExpression() { + return expression.order(field); + } + + @Override + public void resetCurrentId(T item) { + //noinspection unchecked + currentId = (N) getFiledValue(item); + + if (logger.isDebugEnabled()) { + logger.debug("Current Select Key= " + currentId); + } + } +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetOptions.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetOptions.java new file mode 100644 index 000000000..5255f0349 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetOptions.java @@ -0,0 +1,72 @@ +package kr.co.pennyway.batch.common.reader.options; + +import com.querydsl.core.types.Path; +import com.querydsl.jpa.impl.JPAQuery; +import jakarta.annotation.Nonnull; +import kr.co.pennyway.batch.common.reader.expression.Expression; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.lang.reflect.Field; + +/** + * Querydsl No Offset의 기준을 설정하는 클래스 + */ +public abstract class QuerydslNoOffsetOptions { + protected final String fieldName; + protected final Expression expression; + protected Log logger = LogFactory.getLog(getClass()); + + protected QuerydslNoOffsetOptions(@Nonnull Path field, @Nonnull Expression expression) { + String[] qField = field.toString().split("\\."); + this.fieldName = qField[qField.length - 1]; + this.expression = expression; + + if (logger.isDebugEnabled()) { + logger.debug("fieldName= " + fieldName); + } + } + + protected QuerydslNoOffsetOptions(@Nonnull String dtoField, @Nonnull Expression expression) { + this.fieldName = dtoField; + this.expression = expression; + + if (logger.isDebugEnabled()) { + logger.debug("fieldName= " + fieldName); + } + } + + public String getFieldName() { + return fieldName; + } + + public abstract void initKeys(JPAQuery query, int page); + + protected abstract void initFirstId(JPAQuery query); + + protected abstract void initLastId(JPAQuery query); + + public abstract JPAQuery createQuery(JPAQuery query, int page); + + public abstract void resetCurrentId(T item); + + protected Object getFiledValue(T item) { + try { + Field field = item.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(item); + } catch (NoSuchFieldException | IllegalAccessException e) { + logger.error("Not Found or Not Access Field= " + fieldName, e); + throw new IllegalArgumentException("Not Found or Not Access Field"); + } + } + + public boolean isGroupByQuery(JPAQuery query) { + return isGroupByQuery(query.toString()); + } + + public boolean isGroupByQuery(String sql) { + return sql.contains("group by"); + + } +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetStringOptions.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetStringOptions.java new file mode 100644 index 000000000..6cb8b60e8 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetStringOptions.java @@ -0,0 +1,135 @@ +package kr.co.pennyway.batch.common.reader.options; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.StringPath; +import com.querydsl.jpa.impl.JPAQuery; +import jakarta.annotation.Nonnull; +import kr.co.pennyway.batch.common.reader.expression.Expression; + +public class QuerydslNoOffsetStringOptions extends QuerydslNoOffsetOptions { + private final StringPath field; + private String currentId; + private String lastId; + + private QuerydslNoOffsetStringOptions(@Nonnull StringPath field, + @Nonnull Expression expression) { + super(field, expression); + this.field = field; + } + + private QuerydslNoOffsetStringOptions(@Nonnull StringPath field, + @Nonnull Expression expression, + String idName) { + super(idName, expression); + this.field = null; + } + + /** + * QEntity의 StringPath 필드를 사용하여 offset을 설정하는 옵션을 생성합니다. + * + * @param field {@link StringPath} : offset으로 사용할 필드 + * @param expression {@link Expression} : 정렬 방향 + */ + public static QuerydslNoOffsetStringOptions of(@Nonnull StringPath field, @Nonnull Expression expression) { + return new QuerydslNoOffsetStringOptions<>(field, expression); + } + + /** + * QEntity의 StringPath 필드를 사용하여 offset을 설정하는 옵션을 생성합니다. + *

+ * 만약, 쿼리의 응답을 QEntity가 아닌 Dto를 사용한 경우 마지막으로 조회한 offset의 값이 저장된 필드를 idName으로 지정해야 하며, String 타입이어야 합니다. + *

+ * 해당 클래스는 idName의 유효성을 검사하지 않습니다. 따라서, idName에 해당하는 필드가 존재하지 않거나 타입이 다른 경우 예기치 못한 에러가 발생할 수 있습니다. + * + * @param field {@link StringPath} : offset으로 사용할 필드 + * @param expression {@link Expression} : 정렬 방향 + * @param idName {@link String} : 마지막으로 조회한 offset이 저장된 필드 이름 + */ + public static QuerydslNoOffsetStringOptions of(@Nonnull StringPath field, @Nonnull Expression expression, String idName) { + return new QuerydslNoOffsetStringOptions<>(field, expression, idName); + } + + public String getCurrentId() { + return currentId; + } + + public String getLastId() { + return lastId; + } + + @Override + public void initKeys(JPAQuery query, int page) { + if (page == 0) { + initFirstId(query); + initLastId(query); + + if (logger.isDebugEnabled()) { + logger.debug("First Key= " + currentId + ", Last Key= " + lastId); + } + } + } + + @Override + protected void initFirstId(JPAQuery query) { + JPAQuery clone = query.clone(); + boolean isGroupByQuery = isGroupByQuery(clone); + + if (isGroupByQuery) { + currentId = clone + .select(field) + .orderBy(expression.isAsc() ? field.asc() : field.desc()) + .fetchFirst(); + } else { + currentId = clone + .select(expression.isAsc() ? field.min() : field.max()) + .fetchFirst(); + } + + } + + @Override + protected void initLastId(JPAQuery query) { + JPAQuery clone = query.clone(); + boolean isGroupByQuery = isGroupByQuery(clone); + + if (isGroupByQuery) { + lastId = clone + .select(field) + .orderBy(expression.isAsc() ? field.desc() : field.asc()) + .fetchFirst(); + } else { + lastId = clone + .select(expression.isAsc() ? field.max() : field.min()) + .fetchFirst(); + } + } + + @Override + public JPAQuery createQuery(JPAQuery query, int page) { + if (currentId == null) { + return query; + } + + return query + .where(whereExpression(page)) + .orderBy(orderExpression()); + } + + private BooleanExpression whereExpression(int page) { + return expression.where(field, page, currentId) + .and(expression.isAsc() ? field.loe(lastId) : field.goe(lastId)); + } + + private OrderSpecifier orderExpression() { + return expression.order(field); + } + + @Override + public void resetCurrentId(T item) { + currentId = (String) getFiledValue(item); + if (logger.isDebugEnabled()) { + logger.debug("Current Select Key= " + currentId); + } + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyConfig.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyConfig.java new file mode 100644 index 000000000..8130ace3b --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyConfig.java @@ -0,0 +1,45 @@ +package kr.co.pennyway.batch.job; + +import kr.co.pennyway.batch.common.dto.DeviceTokenOwner; +import kr.co.pennyway.batch.reader.ActiveDeviceTokenReader; +import kr.co.pennyway.batch.writer.NotificationWriter; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class DailySpendingNotifyConfig { + private final JobRepository jobRepository; + private final ActiveDeviceTokenReader reader; + private final NotificationWriter writer; + + @Bean + public Job dailyNotificationJob(PlatformTransactionManager transactionManager) { + return new JobBuilder("dailyNotificationJob", jobRepository) + .start(dailyNotificationStep(transactionManager)) + .on("FAILED") + .stopAndRestart(dailyNotificationStep(transactionManager)) + .on("*") + .end() + .end() + .build(); + } + + @Bean + @JobScope + public Step dailyNotificationStep(PlatformTransactionManager transactionManager) { + return new StepBuilder("sendSpendingNotifyStep", jobRepository) + .chunk(1000, transactionManager) + .reader(reader.querydslNoOffsetPagingItemReader()) + .writer(writer) + .build(); + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyJobConfig.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyJobConfig.java deleted file mode 100644 index c65197ca9..000000000 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyJobConfig.java +++ /dev/null @@ -1,29 +0,0 @@ -package kr.co.pennyway.batch.job; - -import kr.co.pennyway.batch.step.SendSpendingNotifyStepConfig; -import lombok.RequiredArgsConstructor; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.job.builder.JobBuilder; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; - -@Configuration -@RequiredArgsConstructor -public class DailySpendingNotifyJobConfig { - private final JobRepository jobRepository; - private final SendSpendingNotifyStepConfig sendSpendingNotifyStepConfig; - - @Bean - public Job dailyNotificationJob(PlatformTransactionManager transactionManager) { - return new JobBuilder("dailyNotificationJob", jobRepository) - .start(sendSpendingNotifyStepConfig.sendSpendingNotifyStep(transactionManager)) - .on("FAILED") - .stopAndRestart(sendSpendingNotifyStepConfig.sendSpendingNotifyStep(transactionManager)) - .on("*") - .end() - .end() - .build(); - } -} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/.gitkeep b/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/NotificationProcessor.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/NotificationProcessor.java deleted file mode 100644 index 560f61d35..000000000 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/NotificationProcessor.java +++ /dev/null @@ -1,18 +0,0 @@ -package kr.co.pennyway.batch.processor; - -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.item.ItemProcessor; -import org.springframework.lang.NonNull; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -public class NotificationProcessor implements ItemProcessor { - - @Override - public DeviceTokenOwner process(@NonNull DeviceTokenOwner deviceTokenOwner) throws Exception { - log.info("NotificationProcessor 실행"); - return deviceTokenOwner; - } -} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java index cf6b2d450..2b7b704d3 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java @@ -1,33 +1,46 @@ package kr.co.pennyway.batch.reader; -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; -import kr.co.pennyway.domain.domains.device.repository.DeviceTokenRepository; +import com.querydsl.core.types.Projections; +import jakarta.persistence.EntityManagerFactory; +import kr.co.pennyway.batch.common.dto.DeviceTokenOwner; +import kr.co.pennyway.batch.common.reader.QuerydslNoOffsetPagingItemReader; +import kr.co.pennyway.batch.common.reader.expression.Expression; +import kr.co.pennyway.batch.common.reader.options.QuerydslNoOffsetNumberOptions; +import kr.co.pennyway.batch.common.reader.options.QuerydslNoOffsetOptions; +import kr.co.pennyway.domain.domains.device.domain.QDeviceToken; +import kr.co.pennyway.domain.domains.user.domain.QUser; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.item.data.RepositoryItemReader; -import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.context.annotation.Bean; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; -import java.util.HashMap; - @Slf4j @Component @RequiredArgsConstructor public class ActiveDeviceTokenReader { - private final DeviceTokenRepository deviceTokenRepository; + private final EntityManagerFactory emf; + + private final QUser user = QUser.user; + private final QDeviceToken deviceToken = QDeviceToken.deviceToken; @Bean - public RepositoryItemReader execute() { - return new RepositoryItemReaderBuilder() - .name("execute") - .repository(deviceTokenRepository) - .methodName("findActivatedDeviceTokenOwners") - .pageSize(100) - .sorts(new HashMap<>() {{ - put("id", Sort.Direction.ASC); - }}) - .build(); + @StepScope + public QuerydslNoOffsetPagingItemReader querydslNoOffsetPagingItemReader() { + QuerydslNoOffsetOptions options = QuerydslNoOffsetNumberOptions.of(user.id, Expression.ASC, "userId"); + + return new QuerydslNoOffsetPagingItemReader<>(emf, 1000, options, queryFactory -> queryFactory + .select( + Projections.constructor( + DeviceTokenOwner.class, + user.id, + user.name, + deviceToken.token + ) + ) + .from(deviceToken) + .innerJoin(user).on(deviceToken.user.id.eq(user.id)) + .where(deviceToken.activated.isTrue().and(user.notifySetting.accountBookNotify.isTrue())) + ); } } diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/step/SendSpendingNotifyStepConfig.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/step/SendSpendingNotifyStepConfig.java deleted file mode 100644 index 2ea94c62b..000000000 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/step/SendSpendingNotifyStepConfig.java +++ /dev/null @@ -1,32 +0,0 @@ -package kr.co.pennyway.batch.step; - -import kr.co.pennyway.batch.processor.NotificationProcessor; -import kr.co.pennyway.batch.reader.ActiveDeviceTokenReader; -import kr.co.pennyway.batch.writer.NotificationWriter; -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; -import lombok.RequiredArgsConstructor; -import org.springframework.batch.core.Step; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; - -@Configuration -@RequiredArgsConstructor -public class SendSpendingNotifyStepConfig { - private final JobRepository jobRepository; - private final ActiveDeviceTokenReader reader; - private final NotificationProcessor processor; - private final NotificationWriter writer; - - @Bean - public Step sendSpendingNotifyStep(PlatformTransactionManager transactionManager) { - return new StepBuilder("sendSpendingNotifyStep", jobRepository) - .chunk(100, transactionManager) - .reader(reader.execute()) - .processor(processor) - .writer(writer) - .build(); - } -} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java index 2e7e85178..e7baad6f7 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java @@ -1,12 +1,13 @@ package kr.co.pennyway.batch.writer; -import kr.co.pennyway.batch.dto.DailySpendingNotification; -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; +import kr.co.pennyway.batch.common.dto.DailySpendingNotification; +import kr.co.pennyway.batch.common.dto.DeviceTokenOwner; import kr.co.pennyway.domain.domains.notification.repository.NotificationRepository; import kr.co.pennyway.domain.domains.notification.type.Announcement; import kr.co.pennyway.infra.common.event.NotificationEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.item.Chunk; import org.springframework.batch.item.ItemWriter; import org.springframework.context.ApplicationEventPublisher; @@ -27,6 +28,7 @@ public class NotificationWriter implements ItemWriter { private final ApplicationEventPublisher publisher; @Override + @StepScope @Transactional public void write(@NonNull Chunk owners) throws Exception { log.info("Writer 실행: {}", owners.size()); diff --git a/pennyway-batch/src/main/resources/application.yml b/pennyway-batch/src/main/resources/application.yml index 57673c162..c8a285441 100644 --- a/pennyway-batch/src/main/resources/application.yml +++ b/pennyway-batch/src/main/resources/application.yml @@ -16,6 +16,10 @@ spring: await-termination: true # 애플리케이션 종료 시 모든 Task가 종료될 때까지 대기 await-termination-period: 60000 # 대기 시간 60초 + datasource: + hikari: + maximum-pool-size: 2 + --- spring: config: diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepository.java deleted file mode 100644 index c10dd3579..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepository.java +++ /dev/null @@ -1,31 +0,0 @@ -package kr.co.pennyway.domain.domains.device.repository; - -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -public interface DeviceTokenCustomRepository { - /** - * 사용자 아이디, 이름 그리고 디바이스 토큰 리스트를 조회하여, {@link DeviceTokenOwner} 객체로 반환한다. - *

- * 이 때, 사용자의 계좌북 알림 설정이 활성화되어 있어야 하며, 디바이스 토큰은 활성화되어 있어야 한다. - *

- * - * @apiNote 이 메서드는 페이징 처리를 하고 있으며, 사용자 아이디를 기준으로 오름차순 정렬한다. - * 이 때, size가 100이고 한 명의 사용자가 여러 개의 디바이스 토큰(각각 pk가 99, 100, 101)을 가지고 있다면, - * 101번에 대한 토큰은 다음 페이지로 넘어가게 되므로 이에 대한 예외 처리가 필요하다. - * - *

-     * {@code
-     *      SELECT d.token, u.id, u.name
-     *      FROM device_token d
-     *      LEFT JOIN user u ON d.user_id = u.id
-     *      WHERE d.activated = true AND u.account_book_notify = true
-     *      ORDER BY u.id ASC
-     *      LIMIT ${pageable.pageSize} OFFSET ${pageable.offset}
-     *      ;
-     * }
-     * 
- */ - Page findActivatedDeviceTokenOwners(Pageable pageable); -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepositoryImpl.java deleted file mode 100644 index 41f56b49c..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepositoryImpl.java +++ /dev/null @@ -1,52 +0,0 @@ -package kr.co.pennyway.domain.domains.device.repository; - -import com.querydsl.core.types.Projections; -import com.querydsl.jpa.impl.JPAQuery; -import com.querydsl.jpa.impl.JPAQueryFactory; -import kr.co.pennyway.domain.domains.device.domain.QDeviceToken; -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; -import kr.co.pennyway.domain.domains.user.domain.QUser; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.support.PageableExecutionUtils; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -@RequiredArgsConstructor -public class DeviceTokenCustomRepositoryImpl implements DeviceTokenCustomRepository { - private final JPAQueryFactory queryFactory; - - private final QUser user = QUser.user; - private final QDeviceToken deviceToken = QDeviceToken.deviceToken; - - @Override - public Page findActivatedDeviceTokenOwners(Pageable pageable) { - List content = queryFactory - .select( - Projections.constructor( - DeviceTokenOwner.class, - user.id, - user.name, - deviceToken.token - ) - ) - .from(deviceToken) - .leftJoin(user).on(deviceToken.user.id.eq(user.id)) - .where(deviceToken.activated.isTrue().and(user.notifySetting.accountBookNotify.isTrue())) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .orderBy(user.id.asc()) - .fetch(); - - JPAQuery count = queryFactory - .select(deviceToken.id.count()) - .from(deviceToken) - .leftJoin(user).on(deviceToken.user.id.eq(user.id)) - .where(deviceToken.activated.isTrue().and(user.notifySetting.accountBookNotify.isTrue())); - - return PageableExecutionUtils.getPage(content, pageable, () -> count.fetch().size()); - } -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java index 510da3f9e..00a1cd53e 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java @@ -6,7 +6,7 @@ import java.util.List; import java.util.Optional; -public interface DeviceTokenRepository extends JpaRepository, DeviceTokenCustomRepository { +public interface DeviceTokenRepository extends JpaRepository { Optional findByUser_IdAndToken(Long userId, String token); List findAllByUser_Id(Long userId); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java index aea64c3b4..200ca1feb 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java @@ -19,7 +19,7 @@ public class NotificationCustomRepositoryImpl implements NotificationCustomRepository { private final JdbcTemplate jdbcTemplate; - private final int BATCH_SIZE = 500; + private final int BATCH_SIZE = 1000; @Override public void saveDailySpendingAnnounceInBulk(List userIds, Announcement announcement) { @@ -54,7 +54,7 @@ private int batchInsert(int batchCount, List userIds, NoticeType noticeTyp " AND n.created_at < CURDATE() + INTERVAL 1 DAY " + " AND n.type = ? " + " AND n.announcement = ? " + - ");"; + ")"; jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { @Override diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml index d18e61916..c01c48e3f 100644 --- a/pennyway-domain/src/main/resources/application-domain.yml +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -5,7 +5,7 @@ spring: dev: common datasource: - url: ${DB_URL:jdbc:mysql://localhost:3300/pennyway?serverTimezone=UTC&characterEncoding=utf8} + url: ${DB_URL:jdbc:mysql://localhost:3300/pennyway?serverTimezone=Asia/Seoul&characterEncoding=utf8&postfileSQL=true&logger=Slf4JLogger&rewriteBatchedStatements=true} username: ${DB_USER_NAME:root} password: ${DB_PASSWORD:password} driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java index ff622f2fa..1256ab26a 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java @@ -7,6 +7,7 @@ import kr.co.pennyway.domain.domains.coupon.TestCouponDecreaseService; import kr.co.pennyway.domain.domains.coupon.TestCouponRepository; import lombok.extern.slf4j.Slf4j; +import org.junit.Ignore; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.domain.EntityScan; @@ -19,6 +20,7 @@ import static org.assertj.core.api.Assertions.assertThat; +@Ignore @Slf4j @DomainIntegrationTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/device/repository/ActivatedDeviceSearchTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/device/repository/ActivatedDeviceSearchTest.java deleted file mode 100644 index d851aefbd..000000000 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/device/repository/ActivatedDeviceSearchTest.java +++ /dev/null @@ -1,139 +0,0 @@ -package kr.co.pennyway.domain.domains.device.repository; - -import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; -import kr.co.pennyway.domain.config.JpaConfig; -import kr.co.pennyway.domain.config.TestJpaConfig; -import kr.co.pennyway.domain.domains.device.domain.DeviceToken; -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; -import kr.co.pennyway.domain.domains.user.domain.NotifySetting; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.repository.UserRepository; -import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; -import kr.co.pennyway.domain.domains.user.type.Role; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.springframework.test.util.AssertionErrors.assertEquals; -import static org.springframework.test.util.AssertionErrors.assertNotEquals; - -@Slf4j -@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) -@ContextConfiguration(classes = JpaConfig.class) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Import(TestJpaConfig.class) -@ActiveProfiles("test") -public class ActivatedDeviceSearchTest extends ContainerMySqlTestConfig { - @Autowired - private DeviceTokenRepository deviceTokenRepository; - @Autowired - private UserRepository userRepository; - - @Test - @Transactional - @DisplayName("비활성화된 디바이스 토큰을 제외하고, 알림을 허용한 사용자의 활성화된 디바이스 토큰을 조회한다.") - public void selectActivatedDeviceTokenThatNotifyTrueUser() { - // given - User user = userRepository.save(createUser("jayang")); - List deviceTokens = List.of( - DeviceToken.of("deviceToken1", user), - DeviceToken.of("deviceToken2", user), - DeviceToken.of("deviceToken3", user) - ); - deviceTokens.get(1).deactivate(); - deviceTokenRepository.saveAll(deviceTokens); - Pageable pageable = Pageable.ofSize(100); - - // when - Page owners = deviceTokenRepository.findActivatedDeviceTokenOwners(pageable); - - // then - assertEquals("조회 결과 원소 개수는 2여야 합니다.", owners.getTotalElements(), 2L); - for (DeviceTokenOwner owner : owners) { - assertNotEquals("deviceToken2는 비활성화 토큰입니다.", "deviceToken2", owner.deviceToken()); - } - } - - @Test - @Transactional - @DisplayName("알림을 허용하지 않은 사용자의 활성화된 디바이스 토큰을 조회하지 않는다.") - public void notSelectNotifyFalseUser() { - // given - User activeUser = userRepository.save(createUser("jayang")); - User deactiveUser = userRepository.save(createUser("mock")); - - List deviceTokens = List.of( - DeviceToken.of("deviceToken1", activeUser), - DeviceToken.of("deviceToken2", deactiveUser)); - deviceTokens.get(1).deactivate(); - - deviceTokenRepository.saveAll(deviceTokens); - - Pageable pageable = Pageable.ofSize(100); - - // when - Page owners = deviceTokenRepository.findActivatedDeviceTokenOwners(pageable); - - // then - assertEquals("조회 결과는 하나여야 합니다.", 1L, owners.getTotalElements()); - assertEquals("알림을 허용하지 않은 사용자의 디바이스 토큰은 조회되지 않아야 합니다.", "jayang", owners.getContent().get(0).name()); - } - - @Test - @Transactional - @DisplayName("사용자 별로 디바이스 토큰 리스트를 받을 수 있다.") - public void selectDeviceTokenListByUserId() { - // given - User user1 = userRepository.save(createUser("jayang")); - User user2 = userRepository.save(createUser("mock")); - - List deviceTokens = List.of( - DeviceToken.of("deviceToken1", user1), - DeviceToken.of("deviceToken2", user1), - DeviceToken.of("deviceToken3", user1), - DeviceToken.of("deviceToken4", user2), - DeviceToken.of("deviceToken5", user2) - ); - deviceTokenRepository.saveAll(deviceTokens); - - Pageable pageable = Pageable.ofSize(100); - - // when - Page owners = deviceTokenRepository.findActivatedDeviceTokenOwners(pageable); - Map> deviceTokenMap = new HashMap<>(); - for (DeviceTokenOwner owner : owners) { - deviceTokenMap.computeIfAbsent(owner.name(), k -> new ArrayList<>()).add(owner.deviceToken()); - } - - // then - assertEquals("전체 결과 개수는 5개여야 합니다.", 5L, owners.getTotalElements()); - assertEquals("jayang의 디바이스 토큰 개수는 3개여야 합니다.", 3, deviceTokenMap.get("jayang").size()); - assertEquals("mock의 디바이스 토큰 개수는 2개여야 합니다.", 2, deviceTokenMap.get("mock").size()); - } - - private User createUser(String name) { - return User.builder() - .username("test") - .name(name) - .password("test") - .phone("010-1234-5678") - .role(Role.USER) - .profileVisibility(ProfileVisibility.PUBLIC) - .notifySetting(NotifySetting.of(true, true, true)) - .build(); - } -} From 3d0b5d1ef690112aa9f81b5812faf39dc4369168 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 25 Jul 2024 12:27:30 +0900 Subject: [PATCH 131/152] =?UTF-8?q?fix:=20=F0=9F=90=9B=20Sms=20type=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EC=8B=9C,=20=ED=9C=B4=EB=A8=BC=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=98=88?= =?UTF-8?q?=EB=B0=A9=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: verification type 휴먼 에러 발생 위험 제거 * docs: sms api 쿼리 파라미터 type에 phone 추가 --- .../co/pennyway/api/apis/auth/api/SmsApi.java | 2 +- .../api/common/query/VerificationType.java | 28 ++++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/SmsApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/SmsApi.java index 1e789beb1..a422d0a7a 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/SmsApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/SmsApi.java @@ -21,7 +21,7 @@ public interface SmsApi { @Operation(summary = "전화번호로 인증코드 전송", description = "전화번호로 인증번호를 전송합니다. 미인증 사용자만 가능합니다.") @Parameters({ @Parameter(name = "type", description = "인증 타입", required = true, examples = { - @ExampleObject(name = "일반 회원가입", value = "general"), @ExampleObject(name = "소셜 회원가입", value = "oauth"), @ExampleObject(name = "아이디 찾기", value = "username"), @ExampleObject(name = "비밀번호 찾기", value = "password") + @ExampleObject(name = "일반 회원가입", value = "general"), @ExampleObject(name = "소셜 회원가입", value = "oauth"), @ExampleObject(name = "아이디 찾기", value = "username"), @ExampleObject(name = "비밀번호 찾기", value = "password"), @ExampleObject(name = "휴대폰 번호 변경", value = "phone") }, in = ParameterIn.QUERY), @Parameter(name = "provider", description = "소셜 로그인 제공자. type이 oauth인 경우 반드시 포함되어야 한다.", required = false, examples = { @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java index 9c63c6d7d..ae325fdde 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/VerificationType.java @@ -1,27 +1,29 @@ package kr.co.pennyway.api.common.query; +import jakarta.annotation.Nonnull; import kr.co.pennyway.domain.common.redis.phone.PhoneCodeKeyType; import kr.co.pennyway.domain.domains.oauth.type.Provider; public enum VerificationType { - GENERAL("general"), - OAUTH("oauth"), - USERNAME("username"), - PASSWORD("password"), - PHONE("phone"); + GENERAL("general", PhoneCodeKeyType.SIGN_UP), + OAUTH("oauth", null), + USERNAME("username", PhoneCodeKeyType.FIND_USERNAME), + PASSWORD("password", PhoneCodeKeyType.FIND_PASSWORD), + PHONE("phone", PhoneCodeKeyType.PHONE); private final String type; + private final PhoneCodeKeyType phoneCodeKeyType; - VerificationType(String type) { + VerificationType(String type, PhoneCodeKeyType phoneCodeKeyType) { this.type = type; + this.phoneCodeKeyType = phoneCodeKeyType; } - public PhoneCodeKeyType toPhoneVerificationType(Provider provider) { - return switch (this) { - case OAUTH -> PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider); - case USERNAME -> PhoneCodeKeyType.FIND_USERNAME; - case PASSWORD -> PhoneCodeKeyType.FIND_PASSWORD; - default -> PhoneCodeKeyType.SIGN_UP; - }; + public PhoneCodeKeyType toPhoneVerificationType(@Nonnull Provider provider) { + if (this.equals(OAUTH)) { + return PhoneCodeKeyType.getOauthSignUpTypeByProvider(provider); + } + + return phoneCodeKeyType; } } From ab0de1efbd0af44e67036cd38ce183438af72adf Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 25 Jul 2024 14:09:40 +0900 Subject: [PATCH 132/152] =?UTF-8?q?feat:=20=E2=9C=A8=20=EB=AF=B8=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EC=A1=B4?= =?UTF-8?q?=EC=9E=AC=20=EC=97=AC=EB=B6=80=20=ED=99=95=EC=9D=B8=20API=20(#1?= =?UTF-8?q?39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: repository unit test * feat: 사용자가 읽지 않은 알림 존재 확인을 위한 repository 메서드 추가 * feat: notification service has_unread_notification 메서드 추가 * feat: usecase 미확인 알림 존재 여부 체크 분기 메서드 추가 * feat: controller api 추가 * test: query join 제거가 가능하도록 test 수정 * fix: jpa method 제거 후 query dsl로 수정 * docs: swagger 문서 작성 * rename: read unread notification() -> is_exists_unread_notification() --- .../notification/api/NotificationApi.java | 4 ++ .../controller/NotificationController.java | 8 +++ .../service/NotificationSearchService.java | 5 ++ .../usecase/NotificationUseCase.java | 4 ++ .../NotificationCustomRepository.java | 2 + .../NotificationCustomRepositoryImpl.java | 15 ++++++ .../service/NotificationService.java | 5 ++ .../NotificationRepositoryUnitTest.java | 50 ++++++++++++++++++- 8 files changed, 91 insertions(+), 2 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java index 97d8aac57..c256a165a 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/api/NotificationApi.java @@ -61,6 +61,10 @@ ResponseEntity getNotifications( @AuthenticationPrincipal SecurityUserDetails user ); + @Operation(summary = "수신한 알림 중 미확인 알림 존재 여부 조회") + @ApiResponse(responseCode = "200", description = "미확인 알림 존재 여부 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "hasUnread", schema = @Schema(type = "boolean")))) + ResponseEntity getUnreadNotifications(@AuthenticationPrincipal SecurityUserDetails user); + @Operation(summary = "수신한 알림 읽음 처리", description = "사용자가 수신한 알림을 읽음처리 합니다. 단, 읽음 처리할 알림의 pk는 사용자가 receiver여야 하며, 미확인 알림만 포함되어 있어야 합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "알림 읽음 처리 성공"), diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java index 1a4126360..60c705b8d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/controller/NotificationController.java @@ -22,6 +22,7 @@ @RequiredArgsConstructor @RequestMapping("/v2/notifications") public class NotificationController implements NotificationApi { + private static final String HAS_UNREAD = "hasUnread"; private static final String NOTIFICATIONS = "notifications"; private final NotificationUseCase notificationUseCase; @@ -36,6 +37,13 @@ public ResponseEntity getNotifications( return ResponseEntity.ok(SuccessResponse.from(NOTIFICATIONS, notificationUseCase.getNotifications(user.getUserId(), pageable))); } + @Override + @GetMapping("/unread") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getUnreadNotifications(@AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from(HAS_UNREAD, notificationUseCase.hasUnreadNotification(user.getUserId()))); + } + @Override @PatchMapping("") @PreAuthorize("isAuthenticated() and @notificationManager.hasPermission(principal.userId, #readReq.notificationIds())") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSearchService.java index 5a8d980d5..ce92594b9 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSearchService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/service/NotificationSearchService.java @@ -19,4 +19,9 @@ public class NotificationSearchService { public Slice getNotifications(Long userId, Pageable pageable) { return notificationService.readNotificationsSlice(userId, pageable); } + + @Transactional(readOnly = true) + public boolean isExistsUnreadNotification(Long userId) { + return notificationService.isExistsUnreadNotification(userId); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java index 2edc0118e..bd652f112 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/notification/usecase/NotificationUseCase.java @@ -26,6 +26,10 @@ public NotificationDto.SliceRes getNotifications(Long userId, Pageable pageable) return NotificationMapper.toSliceRes(notifications, pageable); } + public boolean hasUnreadNotification(Long userId) { + return notificationSearchService.isExistsUnreadNotification(userId); + } + public void updateNotificationsToRead(List notificationIds) { notificationSaveService.updateNotificationsToRead(notificationIds); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java index e986b921f..e85b146b2 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java @@ -5,6 +5,8 @@ import java.util.List; public interface NotificationCustomRepository { + boolean existsUnreadNotification(Long userId); + /** * 사용자들에게 정기 지출 등록 알림을 저장한다. (발송이 아님) * 만약 이미 전송하려는 데이터가 년-월-일에 해당하는 생성일을 가지고 있고, 그 알림의 announcement 타입까지 같다면 저장하지 않는다. diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java index 200ca1feb..7e20aa278 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java @@ -1,5 +1,7 @@ package kr.co.pennyway.domain.domains.notification.repository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.domain.domains.notification.domain.QNotification; import kr.co.pennyway.domain.domains.notification.type.Announcement; import kr.co.pennyway.domain.domains.notification.type.NoticeType; import lombok.RequiredArgsConstructor; @@ -17,10 +19,23 @@ @Repository @RequiredArgsConstructor public class NotificationCustomRepositoryImpl implements NotificationCustomRepository { + private final JPAQueryFactory queryFactory; private final JdbcTemplate jdbcTemplate; + private final QNotification notification = QNotification.notification; + private final int BATCH_SIZE = 1000; + @Override + public boolean existsUnreadNotification(Long userId) { + return queryFactory + .select(notification.id) + .from(notification) + .where(notification.receiver.id.eq(userId) + .and(notification.readAt.isNull())) + .fetchFirst() != null; + } + @Override public void saveDailySpendingAnnounceInBulk(List userIds, Announcement announcement) { int batchCount = 0; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java index 013df51b9..599a66cb4 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java @@ -37,6 +37,11 @@ public Slice readNotificationsSlice(Long userId, Pageable pageable return SliceUtil.toSlice(notificationRepository.findList(predicate, queryHandler, sort), pageable); } + @Transactional(readOnly = true) + public boolean isExistsUnreadNotification(Long userId) { + return notificationRepository.existsUnreadNotification(userId); + } + @Transactional(readOnly = true) public long countUnreadNotifications(Long userId, List notificationIds) { return notificationRepository.countUnreadNotificationsByIds(userId, notificationIds); diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java index aeab6c0d5..c703263b8 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java @@ -20,12 +20,13 @@ import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; -import static org.springframework.test.util.AssertionErrors.assertEquals; -import static org.springframework.test.util.AssertionErrors.assertNotNull; +import static org.springframework.test.util.AssertionErrors.*; @Slf4j @DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) @@ -130,6 +131,51 @@ void countUnreadNotificationsByIds() { assertEquals("읽지 않은 알림 개수가 2개여야 한다.", 2L, count); } + @Test + @DisplayName("사용자의 읽지 않은 알림이 존재하면 true를 반환한다.") + void existsTopByReceiver_IdAndReadAtIsNull() { + // given + User user = userRepository.save(createUser("jayang")); + + Notification notification1 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + Notification notification2 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + Notification notification3 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + + ReflectionTestUtils.setField(notification1, "readAt", LocalDateTime.now()); + ReflectionTestUtils.setField(notification2, "readAt", LocalDateTime.now()); + + notificationRepository.saveAll(List.of(notification1, notification2, notification3)); + + // when + boolean exists = notificationRepository.existsUnreadNotification(user.getId()); + + // then + assertTrue("읽지 않은 알림이 존재하면 true를 반환해야 한다.", exists); + } + + @Test + @DisplayName("사용자의 읽지 않은 알림이 존재하지 않으면 false를 반환한다.") + void notExistsTopByReceiver_IdAndReadAtIsNull() { + // given + User user = userRepository.save(createUser("jayang")); + + Notification notification1 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + Notification notification2 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + Notification notification3 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + + ReflectionTestUtils.setField(notification1, "readAt", LocalDateTime.now()); + ReflectionTestUtils.setField(notification2, "readAt", LocalDateTime.now()); + ReflectionTestUtils.setField(notification3, "readAt", LocalDateTime.now()); + + notificationRepository.saveAll(List.of(notification1, notification2, notification3)); + + // when + boolean exists = notificationRepository.existsUnreadNotification(user.getId()); + + // then + assertFalse("읽지 않은 알림이 존재하지 않으면 false를 반환해야 한다.", exists); + } + private User createUser(String name) { return User.builder() .username("test") From 6bd74c25876ecdcf5327c2d07a4763405fbee207 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 25 Jul 2024 22:36:32 +0900 Subject: [PATCH 133/152] =?UTF-8?q?fix:=20=F0=9F=90=9B=20=EC=A0=95?= =?UTF-8?q?=EA=B8=B0=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EC=BF=BC=EB=A6=AC=20=ED=94=BD=EC=8A=A4=20&=20ItemR?= =?UTF-8?q?eader=20=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: device_token_owner dto query 최적화를 위한 device_token pk 필드 추가 * fix: querydsl_no_offset_string_options id_name 받는 정적 팩토리 메서드에서 field=null 수정 * fix: query dsl no offset paging item reader id_select_query 추가 * feat: step builder 패턴을 적용한 querydsl_no_offset_paging_item_reader_builder 클래스 정의 * fix: active_device_token_reader query 수정 --- .../batch/common/dto/DeviceTokenOwner.java | 1 + .../QuerydslNoOffsetPagingItemReader.java | 12 +- ...erydslNoOffsetPagingItemReaderBuilder.java | 129 ++++++++++++++++++ .../QuerydslNoOffsetStringOptions.java | 4 +- .../batch/reader/ActiveDeviceTokenReader.java | 35 +++-- 5 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReaderBuilder.java diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DeviceTokenOwner.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DeviceTokenOwner.java index dc49d649c..b163d881b 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DeviceTokenOwner.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DeviceTokenOwner.java @@ -5,6 +5,7 @@ */ public record DeviceTokenOwner( Long userId, + Long deviceTokenId, String name, String deviceToken ) { diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReader.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReader.java index d6fb308bb..2e2cffbbd 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReader.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReader.java @@ -13,6 +13,7 @@ public class QuerydslNoOffsetPagingItemReader extends QuerydslPagingItemReader { private QuerydslNoOffsetOptions options; + private Function> idSelectQuery; private QuerydslNoOffsetPagingItemReader() { super(); @@ -28,6 +29,15 @@ public QuerydslNoOffsetPagingItemReader(EntityManagerFactory entityManagerFactor this.options = options; } + public QuerydslNoOffsetPagingItemReader(EntityManagerFactory entityManagerFactory, + int pageSize, + QuerydslNoOffsetOptions options, + Function> queryFunction, + Function> idSelectQuery) { + this(entityManagerFactory, pageSize, options, queryFunction); + this.idSelectQuery = idSelectQuery; + } + @Override @SuppressWarnings("unchecked") protected void doReadPage() { @@ -47,7 +57,7 @@ protected void doReadPage() { protected JPAQuery createQuery() { JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager); JPAQuery query = queryFunction.apply(queryFactory); - options.initKeys(query, getPage()); // 제일 첫번째 페이징시 시작해야할 ID 찾기 + options.initKeys((idSelectQuery != null) ? idSelectQuery.apply(queryFactory) : query, getPage()); // 제일 첫번째 페이징시 시작해야할 ID 찾기 return options.createQuery(query, getPage()); } diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReaderBuilder.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReaderBuilder.java new file mode 100644 index 000000000..33157cee2 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReaderBuilder.java @@ -0,0 +1,129 @@ +package kr.co.pennyway.batch.common.reader; + +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManagerFactory; +import kr.co.pennyway.batch.common.reader.options.QuerydslNoOffsetOptions; + +import java.util.function.Function; + +/** + * {@link QuerydslNoOffsetPagingItemReader}를 생성하기 위한 빌더 클래스 + *

+ * Step Builder 패턴을 사용하였으며, 각 메소드는 해당하는 설정값을 설정하고 다음 단계의 빌더를 반환한다. + * + * @author YANG JAESEO + */ +public class QuerydslNoOffsetPagingItemReaderBuilder { + private QuerydslNoOffsetPagingItemReaderBuilder() { + } + + public static EntityManagerFactoryStep builder() { + return new Steps<>(); + } + + public interface EntityManagerFactoryStep { + /** + * The {@link EntityManagerFactory} to be used for executing the configured queryFunction. + * + * @param emf {@link EntityManagerFactory} used to create + * {@link jakarta.persistence.EntityManager} + * @return this instance for method chaining + */ + PageSizeStep entityManagerFactory(EntityManagerFactory emf); + } + + public interface PageSizeStep { + /** + * The number of items to be read with each page. + * + * @param pageSize number of items + * @return this instance for method chaining + */ + OptionsStep pageSize(int pageSize); + } + + public interface OptionsStep { + /** + * The {@link QuerydslNoOffsetOptions} to be used for configuring the reader. + * + * @param options {@link QuerydslNoOffsetOptions} to be used + * @return this instance for method chaining + */ + QueryFunctionStep options(QuerydslNoOffsetOptions options); + } + + public interface QueryFunctionStep { + /** + * The function that creates the query to be executed. + * + * @param queryFunction function that creates the query + * @return this instance for method chaining + */ + BuildStep queryFunction(Function> queryFunction); + } + + public interface BuildStep { + /** + * The function that creates the query to be executed to select the currentId and lastId. + * This is used to determine the currentId when the reader is not on the last page. + * If this is not provided, the reader will use the queryFunction to determine the currentId and lastId. + * + * @param idSelectQuery function that creates the query to select the currentId and lastId + * @return this instance for method chaining + */ + BuildStep idSelectQuery(Function> idSelectQuery); + + QuerydslNoOffsetPagingItemReader build(); + } + + private static class Steps implements + EntityManagerFactoryStep, PageSizeStep, OptionsStep, QueryFunctionStep, BuildStep { + private EntityManagerFactory entityManagerFactory; + private int pageSize; + private QuerydslNoOffsetOptions options; + private Function> queryFunction; + private Function> idSelectQuery; + + @Override + public PageSizeStep entityManagerFactory(EntityManagerFactory emf) { + this.entityManagerFactory = emf; + return this; + } + + @Override + public OptionsStep pageSize(int pageSize) { + this.pageSize = pageSize; + return this; + } + + @Override + public QueryFunctionStep options(QuerydslNoOffsetOptions options) { + this.options = options; + return this; + } + + @Override + public BuildStep queryFunction(Function> queryFunction) { + this.queryFunction = queryFunction; + return this; + } + + @Override + public BuildStep idSelectQuery(Function> idSelectQuery) { + this.idSelectQuery = idSelectQuery; + return this; + } + + @Override + public QuerydslNoOffsetPagingItemReader build() { + return new QuerydslNoOffsetPagingItemReader<>( + entityManagerFactory, + pageSize, + options, + queryFunction, + idSelectQuery + ); + } + } +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetStringOptions.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetStringOptions.java index 6cb8b60e8..3a09b44b1 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetStringOptions.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetStringOptions.java @@ -11,7 +11,7 @@ public class QuerydslNoOffsetStringOptions extends QuerydslNoOffsetOptions private final StringPath field; private String currentId; private String lastId; - + private QuerydslNoOffsetStringOptions(@Nonnull StringPath field, @Nonnull Expression expression) { super(field, expression); @@ -22,7 +22,7 @@ private QuerydslNoOffsetStringOptions(@Nonnull StringPath field, @Nonnull Expression expression, String idName) { super(idName, expression); - this.field = null; + this.field = field; } /** diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java index 2b7b704d3..1467a588b 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java @@ -1,9 +1,11 @@ package kr.co.pennyway.batch.reader; +import com.querydsl.core.types.ConstructorExpression; import com.querydsl.core.types.Projections; import jakarta.persistence.EntityManagerFactory; import kr.co.pennyway.batch.common.dto.DeviceTokenOwner; import kr.co.pennyway.batch.common.reader.QuerydslNoOffsetPagingItemReader; +import kr.co.pennyway.batch.common.reader.QuerydslNoOffsetPagingItemReaderBuilder; import kr.co.pennyway.batch.common.reader.expression.Expression; import kr.co.pennyway.batch.common.reader.options.QuerydslNoOffsetNumberOptions; import kr.co.pennyway.batch.common.reader.options.QuerydslNoOffsetOptions; @@ -27,20 +29,29 @@ public class ActiveDeviceTokenReader { @Bean @StepScope public QuerydslNoOffsetPagingItemReader querydslNoOffsetPagingItemReader() { - QuerydslNoOffsetOptions options = QuerydslNoOffsetNumberOptions.of(user.id, Expression.ASC, "userId"); + QuerydslNoOffsetOptions options = QuerydslNoOffsetNumberOptions.of(deviceToken.id, Expression.ASC, "deviceTokenId"); - return new QuerydslNoOffsetPagingItemReader<>(emf, 1000, options, queryFactory -> queryFactory - .select( - Projections.constructor( - DeviceTokenOwner.class, - user.id, - user.name, - deviceToken.token - ) + return QuerydslNoOffsetPagingItemReaderBuilder.builder() + .entityManagerFactory(emf) + .pageSize(1000) + .options(options) + .queryFunction(queryFactory -> queryFactory + .select(createConstructorExpression()) + .from(deviceToken) + .innerJoin(user).on(deviceToken.user.id.eq(user.id)) + .where(deviceToken.activated.isTrue().and(user.notifySetting.accountBookNotify.isTrue())) ) - .from(deviceToken) - .innerJoin(user).on(deviceToken.user.id.eq(user.id)) - .where(deviceToken.activated.isTrue().and(user.notifySetting.accountBookNotify.isTrue())) + .idSelectQuery(queryFactory -> queryFactory.select(createConstructorExpression()).from(deviceToken)) + .build(); + } + + private ConstructorExpression createConstructorExpression() { + return Projections.constructor( + DeviceTokenOwner.class, + user.id, + deviceToken.id, + user.name, + deviceToken.token ); } } From 9175dfad0aaeb617b8a61b0cc40d247b56f205ac Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Fri, 26 Jul 2024 01:35:12 +0900 Subject: [PATCH 134/152] =?UTF-8?q?feat:=20=E2=9C=A8=20=EB=A7=A4=EC=9B=94?= =?UTF-8?q?=20=EB=AA=A9=ED=91=9C=20=EA=B8=88=EC=95=A1=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EA=B3=B5=EC=A7=80=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * rename: notification writer -> daily_spending_notify_writer * fix: announcement enum class not_announce 타입 필터링 * fix: daily_notication dto 범용성 확장하여 announce_notification_dto로 수정 * feat: 매월 목표 금액 설정 공지 writer 작성 * feat: monthly_target_amount_notify_config job $ step impl * feat: 매월 정기 목표 금액 설정 알림 스케줄 설정 * fix: 목표 금액 알림 메시지 조회 시, title 해당 월 삽입되도록 수정 * fix: monthly_target_amount title %s누락 수정 * fix: announce notification dto 내부에서 월별 목표 금액 title 생성 시, 분기 처리 --- ...tion.java => AnnounceNotificationDto.java} | 27 +++++----- .../batch/job/DailySpendingNotifyConfig.java | 4 +- .../job/MonthlyTargetAmountNotifyConfig.java | 45 +++++++++++++++++ .../scheduler/SpendingNotifyScheduler.java | 15 ++++++ ...er.java => DailySpendingNotifyWriter.java} | 10 ++-- .../MonthlyTotalAmountNotifyWriter.java | 50 +++++++++++++++++++ .../notification/domain/Notification.java | 22 +++++--- .../notification/type/Announcement.java | 6 ++- 8 files changed, 153 insertions(+), 26 deletions(-) rename pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/{DailySpendingNotification.java => AnnounceNotificationDto.java} (59%) create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/job/MonthlyTargetAmountNotifyConfig.java rename pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/{NotificationWriter.java => DailySpendingNotifyWriter.java} (80%) create mode 100644 pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/MonthlyTotalAmountNotifyWriter.java diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DailySpendingNotification.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/AnnounceNotificationDto.java similarity index 59% rename from pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DailySpendingNotification.java rename to pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/AnnounceNotificationDto.java index aebce3313..f9b591da3 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DailySpendingNotification.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/AnnounceNotificationDto.java @@ -3,43 +3,46 @@ import kr.co.pennyway.domain.domains.notification.type.Announcement; import lombok.Builder; +import java.time.LocalDateTime; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; @Builder -public record DailySpendingNotification( +public record AnnounceNotificationDto( Long userId, String title, String content, Set deviceTokens ) { - public DailySpendingNotification { + public AnnounceNotificationDto { Objects.requireNonNull(userId, "userId must not be null"); Objects.requireNonNull(title, "title must not be null"); Objects.requireNonNull(content, "content must not be null"); Objects.requireNonNull(deviceTokens, "deviceTokens must not be null"); } - /** - * {@link DeviceTokenOwner}를 DailySpendingNotification DTO로 변환하는 정적 팩토리 메서드 - *

- * DeviceToken은 List로 변환되어 멤버 변수로 관리하게 된다. - */ - public static DailySpendingNotification from(DeviceTokenOwner owner) { - Announcement announcement = Announcement.DAILY_SPENDING; + public static AnnounceNotificationDto from(DeviceTokenOwner owner, Announcement announcement) { Set deviceTokens = new HashSet<>(); deviceTokens.add(owner.deviceToken()); - return DailySpendingNotification.builder() + return AnnounceNotificationDto.builder() .userId(owner.userId()) - .title(announcement.createFormattedTitle(owner.name())) - .content(announcement.getContent()) + .title(createFormattedTitle(owner, announcement)) + .content(announcement.createFormattedContent(owner.name())) .deviceTokens(deviceTokens) .build(); } + private static String createFormattedTitle(DeviceTokenOwner owner, Announcement announcement) { + if (announcement.equals(Announcement.MONTHLY_TARGET_AMOUNT)) { + return announcement.createFormattedTitle(String.valueOf(LocalDateTime.now().getMonthValue())); + } + + return announcement.createFormattedTitle(owner.name()); + } + public void addDeviceToken(String deviceToken) { deviceTokens.add(deviceToken); } diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyConfig.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyConfig.java index 8130ace3b..10a7f99f1 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyConfig.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyConfig.java @@ -2,7 +2,7 @@ import kr.co.pennyway.batch.common.dto.DeviceTokenOwner; import kr.co.pennyway.batch.reader.ActiveDeviceTokenReader; -import kr.co.pennyway.batch.writer.NotificationWriter; +import kr.co.pennyway.batch.writer.DailySpendingNotifyWriter; import lombok.RequiredArgsConstructor; import org.springframework.batch.core.Job; import org.springframework.batch.core.Step; @@ -19,7 +19,7 @@ public class DailySpendingNotifyConfig { private final JobRepository jobRepository; private final ActiveDeviceTokenReader reader; - private final NotificationWriter writer; + private final DailySpendingNotifyWriter writer; @Bean public Job dailyNotificationJob(PlatformTransactionManager transactionManager) { diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/MonthlyTargetAmountNotifyConfig.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/MonthlyTargetAmountNotifyConfig.java new file mode 100644 index 000000000..6708bd346 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/MonthlyTargetAmountNotifyConfig.java @@ -0,0 +1,45 @@ +package kr.co.pennyway.batch.job; + +import kr.co.pennyway.batch.common.dto.DeviceTokenOwner; +import kr.co.pennyway.batch.reader.ActiveDeviceTokenReader; +import kr.co.pennyway.batch.writer.MonthlyTotalAmountNotifyWriter; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class MonthlyTargetAmountNotifyConfig { + private final JobRepository jobRepository; + private final ActiveDeviceTokenReader reader; + private final MonthlyTotalAmountNotifyWriter writer; + + @Bean + public Job monthlyNotificationJob(PlatformTransactionManager transactionManager) { + return new JobBuilder("monthlyNotificationJob", jobRepository) + .start(monthlyNotificationStep(transactionManager)) + .on("FAILED") + .stopAndRestart(monthlyNotificationStep(transactionManager)) + .on("*") + .end() + .end() + .build(); + } + + @Bean + @JobScope + public Step monthlyNotificationStep(PlatformTransactionManager transactionManager) { + return new StepBuilder("sendMonthlyNotifyStep", jobRepository) + .chunk(1000, transactionManager) + .reader(reader.querydslNoOffsetPagingItemReader()) + .writer(writer) + .build(); + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/scheduler/SpendingNotifyScheduler.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/scheduler/SpendingNotifyScheduler.java index 8d36bd04b..a07982f7e 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/scheduler/SpendingNotifyScheduler.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/scheduler/SpendingNotifyScheduler.java @@ -19,6 +19,7 @@ public class SpendingNotifyScheduler { private final JobLauncher jobLauncher; private final Job dailyNotificationJob; + private final Job monthlyNotificationJob; @Scheduled(cron = "0 0 20 * * ?") public void runDailyNotificationJob() { @@ -33,4 +34,18 @@ public void runDailyNotificationJob() { log.error("Failed to run dailyNotificationJob", e); } } + + @Scheduled(cron = "0 0 10 1 * ?") + public void runMonthlyNotificationJob() { + JobParameters jobParameters = new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()) + .toJobParameters(); + + try { + jobLauncher.run(monthlyNotificationJob, jobParameters); + } catch (JobExecutionAlreadyRunningException | JobRestartException + | JobInstanceAlreadyCompleteException | JobParametersInvalidException e) { + log.error("Failed to run monthlyNotificationJob", e); + } + } } diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/DailySpendingNotifyWriter.java similarity index 80% rename from pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java rename to pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/DailySpendingNotifyWriter.java index e7baad6f7..3c2853cb0 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/DailySpendingNotifyWriter.java @@ -1,6 +1,6 @@ package kr.co.pennyway.batch.writer; -import kr.co.pennyway.batch.common.dto.DailySpendingNotification; +import kr.co.pennyway.batch.common.dto.AnnounceNotificationDto; import kr.co.pennyway.batch.common.dto.DeviceTokenOwner; import kr.co.pennyway.domain.domains.notification.repository.NotificationRepository; import kr.co.pennyway.domain.domains.notification.type.Announcement; @@ -23,7 +23,7 @@ @Slf4j @Component @RequiredArgsConstructor -public class NotificationWriter implements ItemWriter { +public class DailySpendingNotifyWriter implements ItemWriter { private final NotificationRepository notificationRepository; private final ApplicationEventPublisher publisher; @@ -33,17 +33,17 @@ public class NotificationWriter implements ItemWriter { public void write(@NonNull Chunk owners) throws Exception { log.info("Writer 실행: {}", owners.size()); - Map notificationMap = new HashMap<>(); + Map notificationMap = new HashMap<>(); for (DeviceTokenOwner owner : owners) { - notificationMap.computeIfAbsent(owner.userId(), k -> DailySpendingNotification.from(owner)).addDeviceToken(owner.deviceToken()); + notificationMap.computeIfAbsent(owner.userId(), k -> AnnounceNotificationDto.from(owner, Announcement.DAILY_SPENDING)).addDeviceToken(owner.deviceToken()); } List userIds = new ArrayList<>(notificationMap.keySet()); notificationRepository.saveDailySpendingAnnounceInBulk(userIds, Announcement.DAILY_SPENDING); - for (DailySpendingNotification notification : notificationMap.values()) { + for (AnnounceNotificationDto notification : notificationMap.values()) { publisher.publishEvent(NotificationEvent.of(notification.title(), notification.content(), notification.deviceTokensForList(), "")); } } diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/MonthlyTotalAmountNotifyWriter.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/MonthlyTotalAmountNotifyWriter.java new file mode 100644 index 000000000..ac769945b --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/MonthlyTotalAmountNotifyWriter.java @@ -0,0 +1,50 @@ +package kr.co.pennyway.batch.writer; + +import kr.co.pennyway.batch.common.dto.AnnounceNotificationDto; +import kr.co.pennyway.batch.common.dto.DeviceTokenOwner; +import kr.co.pennyway.domain.domains.notification.repository.NotificationRepository; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.infra.common.event.NotificationEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MonthlyTotalAmountNotifyWriter implements ItemWriter { + private final NotificationRepository notificationRepository; + private final ApplicationEventPublisher publisher; + + @Override + @StepScope + @Transactional + public void write(@NonNull Chunk owners) throws Exception { + log.info("Writer 실행: {}", owners.size()); + + Map notificationMap = new HashMap<>(); + + for (DeviceTokenOwner owner : owners) { + notificationMap.computeIfAbsent(owner.userId(), k -> AnnounceNotificationDto.from(owner, Announcement.MONTHLY_TARGET_AMOUNT)).addDeviceToken(owner.deviceToken()); + } + + List userIds = new ArrayList<>(notificationMap.keySet()); + + notificationRepository.saveDailySpendingAnnounceInBulk(userIds, Announcement.MONTHLY_TARGET_AMOUNT); + + for (AnnounceNotificationDto notification : notificationMap.values()) { + publisher.publishEvent(NotificationEvent.of(notification.title(), notification.content(), notification.deviceTokensForList(), "")); + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java index 01de09e64..49034994b 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java @@ -72,10 +72,19 @@ public String toString() { * @apiNote 이 메서드는 {@link NoticeType#ANNOUNCEMENT} 타입에 대해서만 동작한다. 다른 타입의 알림을 포맷팅해야 하는 경우 해당 메서드를 확장해야 한다. */ public String createFormattedTitle() { - if (type.equals(NoticeType.ANNOUNCEMENT)) { - return announcement.createFormattedTitle(receiverName); + if (!type.equals(NoticeType.ANNOUNCEMENT)) { + return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함. } - return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함. + + return formatAnnouncementTitle(); + } + + private String formatAnnouncementTitle() { + if (announcement.equals(Announcement.MONTHLY_TARGET_AMOUNT)) { + return announcement.createFormattedTitle(String.valueOf(getCreatedAt().getMonthValue())); + } + + return announcement.createFormattedTitle(receiverName); } /** @@ -86,10 +95,11 @@ public String createFormattedTitle() { * @apiNote 이 메서드는 {@link NoticeType#ANNOUNCEMENT} 타입에 대해서만 동작한다. 다른 타입의 알림을 포맷팅해야 하는 경우 해당 메서드를 확장해야 한다. */ public String createFormattedContent() { - if (type.equals(NoticeType.ANNOUNCEMENT)) { - return announcement.createFormattedContent(receiverName); + if (!type.equals(NoticeType.ANNOUNCEMENT)) { + return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함. } - return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함. + + return announcement.createFormattedContent(receiverName); } public static class Builder { diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java index b538cf531..30fd87df9 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java @@ -10,7 +10,7 @@ public enum Announcement implements LegacyCommonType { // 정기 지출 알림 DAILY_SPENDING("1", "%s님, 3분 카레보다 빨리 끝나요!", "많은 친구들이 소비 기록에 참여하고 있어요👀"), - MONTHLY_TARGET_AMOUNT("2", "6월의 첫 시작! 두구두구..🥁", "%s님의 이번 달 목표 소비 금액은?"); + MONTHLY_TARGET_AMOUNT("2", "%s월의 첫 시작! 두구두구..🥁", "%s님의 이번 달 목표 소비 금액은?"); private final String code; private final String title; @@ -62,5 +62,9 @@ private void validateName(String name) { if (!StringUtils.hasText(name)) { throw new IllegalArgumentException("name must not be empty"); } + + if (this == NOT_ANNOUNCE) { + throw new IllegalArgumentException("NOT_ANNOUNCE type is not allowed"); + } } } From 2f0a10094e37d43c0317b9d70406ec0ecec82b9a Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 1 Aug 2024 03:02:56 +0900 Subject: [PATCH 135/152] =?UTF-8?q?Api:=20=F0=9F=90=9B=20Spending=20Custom?= =?UTF-8?q?=20Category=20Delete=20Query=20Fix=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: spending_custom_category sql_delete 쿼리 수정 * test: 테스트 db jdbc url 수정 * test: 도메인 모듈 db 컨테이너는 utc로 맞춤 --- .../java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java | 2 +- .../domain/domains/spending/domain/SpendingCustomCategory.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java index b42de3dce..5873c3dbf 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/ExternalApiDBTestConfig.java @@ -40,7 +40,7 @@ public static void setRedisProperties(DynamicPropertyRegistry registry) { registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); registry.add("spring.data.redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(6379))); registry.add("spring.data.redis.password", () -> "testpass"); - registry.add("spring.datasource.url", () -> String.format("jdbc:mysql://%s:%s/pennyway?serverTimezone=UTC&characterEncoding=utf8", MYSQL_CONTAINER.getHost(), MYSQL_CONTAINER.getMappedPort(3306))); + registry.add("spring.datasource.url", () -> String.format("jdbc:mysql://%s:%s/pennyway?serverTimezone=Asia/Seoul&characterEncoding=utf8&postfileSQL=true&logger=Slf4JLogger&rewriteBatchedStatements=true", MYSQL_CONTAINER.getHost(), MYSQL_CONTAINER.getMappedPort(3306))); registry.add("spring.datasource.username", () -> "root"); registry.add("spring.datasource.password", () -> "testpass"); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java index 6e040dce1..083f6a3c3 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java @@ -18,7 +18,7 @@ @Table(name = "spending_custom_category") @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLRestriction("deleted_at IS NULL") -@SQLDelete(sql = "UPDATE spending_category SET deleted_at = NOW() WHERE id = ?") +@SQLDelete(sql = "UPDATE spending_custom_category SET deleted_at = NOW() WHERE id = ?") public class SpendingCustomCategory extends DateAuditable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) From 5566c29cc7aa4aa2fcb1054ebf65ff195c4a6d0e Mon Sep 17 00:00:00 2001 From: DinoDeveloper <79460319+asn6878@users.noreply.github.com> Date: Thu, 1 Aug 2024 03:17:15 +0900 Subject: [PATCH 136/152] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A0=95=EC=9D=98=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=9D=B4=EB=8F=99=20API=20(#125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: controller, usecase 작성 * feat: update service, domain service 작성 * docs: swagger 작성 * test: 지출 카테고리 수정 테스트 작성 * fix: controller 파라미터 수정 * fix: spel 표현식 제거 * test: 기본 카테고리 이동 테스트 작성 * docs: api 문서 이동 * refactor: controller 및 usecase를 spending 에서 spendingcategory로 이전 * feat: 기본 카테고리로부터의 이전 기능 추가 * feat: 권한검사 추가 * test: 각케이스별 테스트케이스 작성 * fix: 권한검사 로직 이동 * fix: spendingerrorcode 상수 제거 * fix: service 메서드 접두사 udpate로 수정 * fix: 접두사 udpate로 추가 수정 * fix: 불필요한 schema 제거 * fix: service 및 repository 메서드명 prefix customcategory로변경 --- .../apis/ledger/api/SpendingCategoryApi.java | 29 +++++ .../SpendingCategoryController.java | 18 +++ .../ledger/service/SpendingUpdateService.java | 26 ++++- .../usecase/SpendingCategoryUseCase.java | 11 +- .../apis/ledger/usecase/SpendingUseCase.java | 1 + .../common/query/SpendingCategoryType.java | 2 +- .../SpendingCategoryIntegrationTest.java | 104 ++++++++++++++++++ .../service/SpendingUpdateServiceTest.java | 5 +- .../repository/SpendingRepository.java | 20 ++++ .../spending/service/SpendingService.java | 22 ++++ 10 files changed, 231 insertions(+), 7 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java index 556915401..d6f9b20e2 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java @@ -128,6 +128,35 @@ ResponseEntity getSpendingsByCategory( }) @ApiResponse(responseCode = "200", description = "지출 카테고리 등록 성공", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "spendingCategory", schema = @Schema(implementation = SpendingCategoryDto.Res.class)))) ResponseEntity patchSpendingCategory(@PathVariable Long categoryId, @Validated SpendingCategoryDto.CreateParamReq param); + + @Operation(summary = "지출 내역 카테코리 이동", method = "PATCH", description = "카테고리에 존재하는 지출내역들을 다른 카테고리로 옮깁니다.") + @Parameters({ + @Parameter(name = "fromId", description = "현재 선택된 카테고리 ID", required = true, in = ParameterIn.PATH), + @Parameter(name = "fromType", description = "현재 선택된 지출 카테고리 타입", required = true, in = ParameterIn.QUERY, examples = { + @ExampleObject(name = "기본", value = "default"), @ExampleObject(name = "사용자 정의", value = "custom") + }), + @Parameter(name = "toId", description = "이동 하고자 하는 카테고리 ID", required = true, in = ParameterIn.QUERY), + @Parameter(name = "toType", description = "이동 하고자 하는 지출 카테고리 타입", required = true, in = ParameterIn.QUERY, examples = { + @ExampleObject(name = "기본", value = "default"), @ExampleObject(name = "사용자 정의", value = "custom") + }) + }) + @ApiResponse(responseCode = "403", description = "지출 카테고리에 대한 권한이 없습니다.", content = @Content(examples = { + @ExampleObject(name = "지출 카테고리 권한 오류", description = "지출 카테고리에 대한 권한이 없습니다.", + value = """ + { + "code": "4030", + "message": "ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN" + } + """ + ) + })) + public ResponseEntity migrateSpendingsByCategory( + @PathVariable Long fromId, + @RequestParam(value = "fromType") SpendingCategoryType fromType, + @RequestParam(value = "toId") Long toId, + @RequestParam(value = "toType") SpendingCategoryType toType, + @AuthenticationPrincipal SecurityUserDetails user + ); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java index 07d4de34a..4bc300d36 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java @@ -96,4 +96,22 @@ public ResponseEntity patchSpendingCategory(@PathVariable Long categoryId, @V return ResponseEntity.ok(SuccessResponse.from("spendingCategory", spendingCategoryUseCase.updateSpendingCategory(categoryId, param.name(), param.icon()))); } + + @Override + @PatchMapping({"{fromId}/migration"}) + @PreAuthorize("isAuthenticated() and @spendingCategoryManager.hasPermission(principal.userId, #fromId, #fromType) and @spendingCategoryManager.hasPermission(principal.userId, #toId, #toType)") + public ResponseEntity migrateSpendingsByCategory( + @PathVariable Long fromId, + @RequestParam(value = "fromType") SpendingCategoryType fromType, + @RequestParam(value = "toId") Long toId, + @RequestParam(value = "toType") SpendingCategoryType toType, + @AuthenticationPrincipal SecurityUserDetails user + ) { + Long userId = user.getUserId(); + spendingCategoryUseCase.migrateSpendingsByCategory(fromId, fromType, toId, toType, userId); + + return ResponseEntity.ok(SuccessResponse.noContent()); + } + + } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java index 1c9a3b93c..01dcba0bb 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateService.java @@ -1,12 +1,15 @@ package kr.co.pennyway.api.apis.ledger.service; import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; +import kr.co.pennyway.api.common.query.SpendingCategoryType; +import kr.co.pennyway.api.common.security.authorization.SpendingCategoryManager; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorException; import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -18,6 +21,7 @@ public class SpendingUpdateService { private final SpendingService spendingService; private final SpendingCustomCategoryService spendingCustomCategoryService; + private final SpendingCategoryManager spendingCategoryManager; @Transactional public Spending updateSpending(Long spendingId, SpendingReq request) { @@ -31,4 +35,24 @@ public Spending updateSpending(Long spendingId, SpendingReq request) { return spending; } -} + + @Transactional + public void migrateSpendings(Long fromId, SpendingCategoryType fromType, Long toId, SpendingCategoryType toType, Long userId) { + if (fromType.equals(SpendingCategoryType.DEFAULT)) { + SpendingCategory fromCategory = SpendingCategory.fromCode(fromId.toString()); + if (toType.equals(SpendingCategoryType.CUSTOM)) { + spendingService.updateCategoryByCustomCategory(fromCategory, toId); + } else { + SpendingCategory spendingCategory = SpendingCategory.fromCode(toId.toString()); + spendingService.updateCategoryByCategory(fromCategory, spendingCategory); + } + } else { + if (toType.equals(SpendingCategoryType.CUSTOM)) { + spendingService.updateCustomCategoryByCustomCategory(fromId, toId); + } else { + SpendingCategory spendingCategory = SpendingCategory.fromCode(toId.toString()); + spendingService.updateCustomCategoryByCategory(fromId, spendingCategory); + } + } + } +} \ No newline at end of file diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java index 6e6b40b60..332f21ff1 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingCategoryUseCase.java @@ -4,10 +4,7 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; import kr.co.pennyway.api.apis.ledger.mapper.SpendingCategoryMapper; import kr.co.pennyway.api.apis.ledger.mapper.SpendingMapper; -import kr.co.pennyway.api.apis.ledger.service.SpendingCategoryDeleteService; -import kr.co.pennyway.api.apis.ledger.service.SpendingCategorySaveService; -import kr.co.pennyway.api.apis.ledger.service.SpendingCategorySearchService; -import kr.co.pennyway.api.apis.ledger.service.SpendingSearchService; +import kr.co.pennyway.api.apis.ledger.service.*; import kr.co.pennyway.api.common.query.SpendingCategoryType; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.domains.spending.domain.Spending; @@ -30,6 +27,7 @@ public class SpendingCategoryUseCase { private final SpendingCategoryDeleteService spendingCategoryDeleteService; private final SpendingSearchService spendingSearchService; + private final SpendingUpdateService spendingUpdateService; @Transactional public SpendingCategoryDto.Res createSpendingCategory(Long userId, String categoryName, SpendingCategory icon) { @@ -68,4 +66,9 @@ public SpendingCategoryDto.Res updateSpendingCategory(Long categoryId, String na return SpendingCategoryMapper.toResponse(category); } + + @Transactional + public void migrateSpendingsByCategory(Long fromId, SpendingCategoryType fromType, Long toId, SpendingCategoryType toType, Long userId) { + spendingUpdateService.migrateSpendings(fromId, fromType, toId, toType, userId); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java index 6f13cdf1c..9a3a1d59e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java @@ -61,4 +61,5 @@ public void deleteSpending(Long spendingId) { public void deleteSpendings(List spendingIds) { spendingDeleteService.deleteSpendings(spendingIds); } + } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingCategoryType.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingCategoryType.java index 048164750..9dacb124d 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingCategoryType.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/query/SpendingCategoryType.java @@ -3,7 +3,7 @@ public enum SpendingCategoryType { DEFAULT("default"), CUSTOM("custom"); - + private final String type; SpendingCategoryType(String type) { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryIntegrationTest.java index c62785173..a47faeb32 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingCategoryIntegrationTest.java @@ -1,5 +1,6 @@ package kr.co.pennyway.api.apis.ledger.integration; +import kr.co.pennyway.api.common.query.SpendingCategoryType; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; @@ -10,6 +11,7 @@ import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.extern.slf4j.Slf4j; @@ -71,4 +73,106 @@ private ResultActions performDeleteSpendingCategory(Long categoryId, User reques } + @Test + @DisplayName("사용자는 커스텀 카테고리에서 기본 카테고리로 지출내역들을 옮길 수 있다.") + void migrateSpendingsCtoD() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + SpendingCustomCategory fromCategory = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user)); + Spending spending = spendingService.createSpending(SpendingFixture.CUSTOM_CATEGORY_SPENDING.toCustomCategorySpending(user, fromCategory)); + Long spendingId = spending.getId(); + SpendingCategory toCategory = SpendingCategory.TRANSPORTATION; + + // when + ResultActions resultActions = performMigrateSpendingsByCategory(fromCategory.getId(), SpendingCategoryType.CUSTOM, 2L, SpendingCategoryType.DEFAULT, user); + + // then + resultActions + .andDo(print()) + .andExpect(status().isOk()); + + Spending spendingAfterMigration = spendingService.readSpending(spendingId).orElseThrow(); + Assertions.assertEquals(spendingAfterMigration.getCategory().icon(), toCategory); + Assertions.assertEquals(spendingAfterMigration.getSpendingCustomCategory(), null); + } + + @Test + @DisplayName("사용자는 커스텀 카테고리에서 커스텀 카테고리로 지출내역들을 옮길 수 있다.") + void migrateSpendingsCtoC() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + SpendingCustomCategory fromCategory = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user)); + Spending spending = spendingService.createSpending(SpendingFixture.CUSTOM_CATEGORY_SPENDING.toCustomCategorySpending(user, fromCategory)); + + SpendingCustomCategory toCategory = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user)); + Long toCategoryId = toCategory.getId(); + Long spendingId = spending.getId(); + + // when + ResultActions resultActions = performMigrateSpendingsByCategory(fromCategory.getId(), SpendingCategoryType.CUSTOM, toCategoryId, SpendingCategoryType.CUSTOM, user); + + // then + resultActions + .andDo(print()) + .andExpect(status().isOk()); + + Spending spendingAfterMigration = spendingService.readSpending(spendingId).orElseThrow(); + Assertions.assertEquals(spendingAfterMigration.getSpendingCustomCategory().getId(), toCategory.getId()); + } + + @Test + @DisplayName("사용자는 기본 카테고리에서 커스텀 카테고리로 지출내역들을 옮길 수 있다.") + void migrateSpendingsDtoC() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + Spending spending = spendingService.createSpending(SpendingFixture.GENERAL_SPENDING.toSpending(user)); + + SpendingCustomCategory toCategory = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user)); + Long toCategoryId = toCategory.getId(); + Long spendingId = spending.getId(); + + // when + ResultActions resultActions = performMigrateSpendingsByCategory(1L, SpendingCategoryType.DEFAULT, toCategoryId, SpendingCategoryType.CUSTOM, user); + + // then + resultActions + .andDo(print()) + .andExpect(status().isOk()); + + Spending spendingAfterMigration = spendingService.readSpending(spendingId).orElseThrow(); + Assertions.assertEquals(spendingAfterMigration.getSpendingCustomCategory().getId(), toCategory.getId()); + } + + @Test + @DisplayName("사용자는 기본 카테고리에서 기본 카테고리로 지출내역들을 옮길 수 있다.") + void migrateSpendingsDtoD() throws Exception { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + Spending spending = spendingService.createSpending(SpendingFixture.GENERAL_SPENDING.toSpending(user)); + Long spendingId = spending.getId(); + SpendingCategory toCategory = SpendingCategory.TRANSPORTATION; + + // when + ResultActions resultActions = performMigrateSpendingsByCategory(1L, SpendingCategoryType.DEFAULT, 2L, SpendingCategoryType.DEFAULT, user); + + // then + resultActions + .andDo(print()) + .andExpect(status().isOk()); + + Spending spendingAfterMigration = spendingService.readSpending(spendingId).orElseThrow(); + Assertions.assertEquals(spendingAfterMigration.getCategory().icon(), toCategory); + } + + private ResultActions performMigrateSpendingsByCategory(Long fromId, SpendingCategoryType fromType, Long toId, SpendingCategoryType toType, User requestUser) throws Exception { + UserDetails userDetails = SecurityUserDetails.from(requestUser); + + return mockMvc.perform(MockMvcRequestBuilders.patch("/v2/spending-categories/{fromId}/migration", fromId) + .param("fromType", fromType.toString()) + .param("toId", toId.toString()) + .param("toType", toType.toString()) + .with(user(userDetails)) + .contentType("application/json")); + } + } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java index 773065940..058df3b5a 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingUpdateServiceTest.java @@ -1,6 +1,7 @@ package kr.co.pennyway.api.apis.ledger.service; import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; +import kr.co.pennyway.api.common.security.authorization.SpendingCategoryManager; import kr.co.pennyway.api.config.fixture.UserFixture; import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; @@ -31,6 +32,8 @@ public class SpendingUpdateServiceTest { private SpendingCustomCategoryService spendingCustomCategoryService; @Mock private SpendingService spendingService; + @Mock + private SpendingCategoryManager spendingCategoryManager; private Spending spending; private Spending spendingWithCustomCategory; @@ -42,7 +45,7 @@ public class SpendingUpdateServiceTest { @BeforeEach void setUp() { - spendingUpdateService = new SpendingUpdateService(spendingService, spendingCustomCategoryService); + spendingUpdateService = new SpendingUpdateService(spendingService, spendingCustomCategoryService, spendingCategoryManager); request = new SpendingReq(10000, -1L, SpendingCategory.FOOD, LocalDate.now(), "소비처", "메모"); requestWithCustomCategory = new SpendingReq(10000, 1L, SpendingCategory.CUSTOM, LocalDate.now(), "소비처", "메모"); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java index b49c81773..ac4342610 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java @@ -31,4 +31,24 @@ public interface SpendingRepository extends ExtendedRepository, @Transactional @Query("UPDATE Spending s SET s.deletedAt = NOW() where s.id IN :spendingIds AND s.deletedAt IS NULL") void deleteAllByIdAndDeletedAtNullInQuery(List spendingIds); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId, s.category = :custom WHERE s.category = :fromCategory AND s.deletedAt IS NULL") + void updateCategoryByCustomCategoryInQuery(SpendingCategory fromCategory, Long toCategoryId, SpendingCategory custom); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.category = :toCategory WHERE s.category = :fromCategory AND s.deletedAt IS NULL") + void updateCategoryByCategoryInQuery(SpendingCategory fromCategory, SpendingCategory toCategory); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId WHERE s.spendingCustomCategory.id = :fromCategoryId AND s.deletedAt IS NULL") + void updateCustomCategoryByCustomCategoryInQuery(Long fromCategoryId, Long toCategoryId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.spendingCustomCategory = null, s.category = :toCategory WHERE s.spendingCustomCategory.id = :fromCategoryId AND s.deletedAt IS NULL") + void updateCustomCategoryByCategoryInQuery(Long fromCategoryId, SpendingCategory toCategory); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java index 59bb154b1..5f52240dc 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -147,4 +147,26 @@ public void deleteSpendingsInQuery(List spendingIds) { public long countByUserIdAndIdIn(Long userId, List spendingIds) { return spendingRepository.countByUserIdAndIdIn(userId, spendingIds); } + + @Transactional + public void updateCategoryByCustomCategory(SpendingCategory fromCategory, Long toId) { + SpendingCategory custom = SpendingCategory.CUSTOM; + spendingRepository.updateCategoryByCustomCategoryInQuery(fromCategory, toId, custom); + } + + @Transactional + public void updateCategoryByCategory(SpendingCategory fromCategory, SpendingCategory toCategory) { + + spendingRepository.updateCategoryByCategoryInQuery(fromCategory, toCategory); + } + + @Transactional + public void updateCustomCategoryByCustomCategory(Long fromId, Long toId) { + spendingRepository.updateCustomCategoryByCustomCategoryInQuery(fromId, toId); + } + + @Transactional + public void updateCustomCategoryByCategory(Long fromId, SpendingCategory toCategory) { + spendingRepository.updateCustomCategoryByCategoryInQuery(fromId, toCategory); + } } From 100ad7ccb1d51367c9d8e89c613448c87dfc9a30 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sun, 4 Aug 2024 12:51:19 +0900 Subject: [PATCH 137/152] =?UTF-8?q?fix:=20=E2=9C=8F=EF=B8=8F=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * rename: oauth bulk delete 메서드 컨벤션에 맞게 in_query 접미사 추가 * feat: 사용자 아이디 기반 spending 삭제 bulk 메서드 추가 * feat: 사용자 아이디 기반 spending_custom_category 삭제 bulk 메서드 추가 * fix: 사용자 삭제 서비스 내에서 지출 데이터 삭제 로직 추가 * feat: device_token delete bulk 메서드 추가 * fix: device_token_service 사용하지 않는 메서드 제거 * fix: 사용자 삭제 시, device token 비활성화 추가 * test: 사용자 삭제 테스트 시 디바이스 삭제 -> 비활성화 테스트로 수정 * fix: user_id로 device_token 조회 시, join되는 문제 제거 * test: spending 삭제 테스트 * fix: 불필요한 delete_at is null 옵션 제거 (이미 sql_restriction으로 쿼리에 반영됨) --- .../apis/users/service/UserDeleteService.java | 24 ++++++++- .../users/usecase/UserDeleteServiceTest.java | 50 +++++++++++++++---- .../co/pennyway/api/config/TestJpaConfig.java | 20 ++++++++ .../repository/DeviceTokenRepository.java | 9 ++++ .../device/service/DeviceTokenService.java | 14 ++---- .../domains/oauth/service/OauthService.java | 2 +- .../SpendingCustomCategoryRepository.java | 7 +++ .../repository/SpendingRepository.java | 33 ++++++------ .../SpendingCustomCategoryService.java | 5 ++ .../spending/service/SpendingService.java | 35 +++++++------ 10 files changed, 148 insertions(+), 51 deletions(-) create mode 100644 pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/TestJpaConfig.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java index b347c53d3..3f99e7c5e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserDeleteService.java @@ -1,6 +1,9 @@ package kr.co.pennyway.api.apis.users.service; +import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; import kr.co.pennyway.domain.domains.user.service.UserService; @@ -22,14 +25,31 @@ public class UserDeleteService { private final UserService userService; private final OauthService oauthService; + private final DeviceTokenService deviceTokenService; + private final SpendingService spendingService; + private final SpendingCustomCategoryService spendingCustomCategoryService; + + /** + * 사용자와 관련한 모든 데이터를 삭제(soft delete)하는 메서드 + *

+ * hard delete가 수행되어야 할 데이터는 삭제하지 않으며, 사용자 데이터 유지 기간이 만료될 때 DBA가 수행한다. + * + * @param userId + * @todo [2024-05-03] 채팅 기능이 추가되는 경우 채팅방장 탈퇴를 제한해야 하며, 추가로 삭제될 엔티티 삭제 로직을 추가해야 한다. + */ @Transactional public void execute(Long userId) { if (!userService.isExistUser(userId)) throw new UserErrorException(UserErrorCode.NOT_FOUND); // TODO: [2024-05-03] 하나라도 채팅방의 방장으로 참여하는 경우 삭제 불가능 처리 - - oauthService.deleteOauthsByUserId(userId); + + oauthService.deleteOauthsByUserIdInQuery(userId); + deviceTokenService.deleteDevicesByUserIdInQuery(userId); + + spendingService.deleteSpendingsByUserIdInQuery(userId); + spendingCustomCategoryService.deleteSpendingCustomCategoriesByUserIdInQuery(userId); + userService.deleteUser(userId); } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserDeleteServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserDeleteServiceTest.java index b3fc3ea50..a1c5bef36 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserDeleteServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/UserDeleteServiceTest.java @@ -1,9 +1,11 @@ package kr.co.pennyway.api.apis.users.usecase; -import com.querydsl.jpa.impl.JPAQueryFactory; import kr.co.pennyway.api.apis.users.service.UserDeleteService; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.TestJpaConfig; import kr.co.pennyway.api.config.fixture.DeviceTokenFixture; +import kr.co.pennyway.api.config.fixture.SpendingCustomCategoryFixture; +import kr.co.pennyway.api.config.fixture.SpendingFixture; import kr.co.pennyway.api.config.fixture.UserFixture; import kr.co.pennyway.domain.config.JpaConfig; import kr.co.pennyway.domain.domains.device.domain.DeviceToken; @@ -11,10 +13,15 @@ import kr.co.pennyway.domain.domains.oauth.domain.Oauth; import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.service.SpendingCustomCategoryService; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -22,19 +29,20 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; import org.springframework.test.context.ContextConfiguration; import org.springframework.transaction.annotation.Transactional; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.springframework.test.util.AssertionErrors.assertEquals; -import static org.springframework.test.util.AssertionErrors.assertTrue; +import static org.springframework.test.util.AssertionErrors.*; +@Slf4j @ExtendWith(MockitoExtension.class) @DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") -@ContextConfiguration(classes = {JpaConfig.class, UserDeleteService.class, UserService.class, OauthService.class, DeviceTokenService.class}) +@ContextConfiguration(classes = {JpaConfig.class, UserDeleteService.class, UserService.class, OauthService.class, DeviceTokenService.class, SpendingService.class, SpendingCustomCategoryService.class}) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(TestJpaConfig.class) public class UserDeleteServiceTest extends ExternalApiDBTestConfig { @Autowired private UserService userService; @@ -48,8 +56,11 @@ public class UserDeleteServiceTest extends ExternalApiDBTestConfig { @Autowired private UserDeleteService userDeleteService; - @MockBean - private JPAQueryFactory queryFactory; + @Autowired + private SpendingService spendingService; + + @Autowired + private SpendingCustomCategoryService spendingCustomCategoryService; @Test @Transactional @@ -90,6 +101,7 @@ void deleteAccountWithSocialAccounts() { // when - then assertDoesNotThrow(() -> userDeleteService.execute(user.getId())); + assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); assertTrue("카카오 계정이 삭제되어 있어야 한다.", oauthService.readOauth(kakao.getId()).get().isDeleted()); assertTrue("구글 계정이 삭제되어 있어야 한다.", oauthService.readOauth(google.getId()).get().isDeleted()); @@ -97,7 +109,7 @@ void deleteAccountWithSocialAccounts() { @Test @Transactional - @DisplayName("사용자 삭제 시, 디바이스 정보는 CASCADE로 삭제되어야 한다.") + @DisplayName("사용자 삭제 시, 디바이스 정보는 비활성화되어야 한다.") void deleteAccountWithDevices() { // given User user = UserFixture.GENERAL_USER.toUser(); @@ -109,7 +121,27 @@ void deleteAccountWithDevices() { // when - then assertDoesNotThrow(() -> userDeleteService.execute(user.getId())); assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); - assertTrue("디바이스가 삭제되어 있어야 한다.", deviceTokenService.readDeviceByUserIdAndToken(user.getId(), deviceToken.getToken()).isEmpty()); + assertFalse("디바이스가 비활성화 있어야 한다.", deviceTokenService.readDeviceByUserIdAndToken(user.getId(), deviceToken.getToken()).get().getActivated()); + } + + @Test + @Transactional + @DisplayName("사용자가 등록한 지출 정보는 삭제되어야 한다.") + void deleteAccountWithSpending() { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + + SpendingCustomCategory category = spendingCustomCategoryService.createSpendingCustomCategory(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user)); + + Spending spending1 = spendingService.createSpending(SpendingFixture.GENERAL_SPENDING.toSpending(user)); + Spending spending2 = spendingService.createSpending(SpendingFixture.CUSTOM_CATEGORY_SPENDING.toCustomCategorySpending(user, category)); + Spending spending3 = spendingService.createSpending(SpendingFixture.MAX_SPENDING.toSpending(user)); + + // when - then + assertDoesNotThrow(() -> userDeleteService.execute(user.getId())); + assertTrue("사용자가 삭제되어 있어야 한다.", userService.readUser(user.getId()).isEmpty()); + assertTrue("지출 정보가 삭제되어 있어야 한다.", spendingService.readSpendings(user.getId(), spending1.getSpendAt().getYear(), spending1.getSpendAt().getMonthValue()).isEmpty()); + assertTrue("지출 카테고리가 삭제되어 있어야 한다.", spendingCustomCategoryService.readSpendingCustomCategory(category.getId()).isEmpty()); } private Oauth createOauth(Provider provider, String providerId, User user) { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/TestJpaConfig.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/TestJpaConfig.java new file mode 100644 index 000000000..281d722b3 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/TestJpaConfig.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.api.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class TestJpaConfig { + @PersistenceContext + private EntityManager em; + + @Bean + @ConditionalOnMissingBean + public JPAQueryFactory testJpaQueryFactory() { + return new JPAQueryFactory(em); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java index 00a1cd53e..4e2ace8b4 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java @@ -2,12 +2,21 @@ import kr.co.pennyway.domain.domains.device.domain.DeviceToken; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; public interface DeviceTokenRepository extends JpaRepository { + @Query("SELECT d FROM DeviceToken d WHERE d.user.id = :userId AND d.token = :token") Optional findByUser_IdAndToken(Long userId, String token); List findAllByUser_Id(Long userId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE DeviceToken d SET d.activated = false WHERE d.user.id = :userId") + void deleteAllByUserIdInQuery(Long userId); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java index fde0c0573..b85df9169 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java @@ -7,7 +7,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; -import java.util.List; import java.util.Optional; @Slf4j @@ -26,18 +25,13 @@ public Optional readDeviceByUserIdAndToken(Long userId, String toke return deviceTokenRepository.findByUser_IdAndToken(userId, token); } - @Transactional(readOnly = true) - public List readDevicesByUserId(Long userId) { - return deviceTokenRepository.findAllByUser_Id(userId); - } - @Transactional - public void deleteDevice(Long deviceId) { - deviceTokenRepository.deleteById(deviceId); + public void deleteDevice(DeviceToken deviceToken) { + deviceTokenRepository.delete(deviceToken); } @Transactional - public void deleteDevice(DeviceToken deviceToken) { - deviceTokenRepository.delete(deviceToken); + public void deleteDevicesByUserIdInQuery(Long userId) { + deviceTokenRepository.deleteAllByUserIdInQuery(userId); } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java index 775e334a3..e71bd9ea0 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java @@ -54,7 +54,7 @@ public void deleteOauth(Oauth oauth) { } @Transactional - public void deleteOauthsByUserId(Long userId) { + public void deleteOauthsByUserIdInQuery(Long userId) { oauthRepository.deleteAllByUser_IdAndDeletedAtNullInQuery(userId); } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java index b56b65c3b..7632740da 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java @@ -2,6 +2,8 @@ import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -12,4 +14,9 @@ public interface SpendingCustomCategoryRepository extends JpaRepository findAllByUser_Id(Long userId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE SpendingCustomCategory s SET s.deletedAt = NOW() WHERE s.user.id = :userId") + void deleteAllByUserIdInQuery(Long userId); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java index ac4342610..f9e65322b 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java @@ -13,11 +13,6 @@ public interface SpendingRepository extends ExtendedRepository, @Transactional(readOnly = true) boolean existsByIdAndUser_Id(Long id, Long userId); - @Transactional - @Modifying(clearAutomatically = true) - @Query("UPDATE Spending s SET s.deletedAt = NOW() WHERE s.spendingCustomCategory.id = :categoryId AND s.deletedAt IS NULL") - void deleteAllByCategoryIdAndDeletedAtNullInQuery(Long categoryId); - @Transactional(readOnly = true) int countByUser_IdAndSpendingCustomCategory_Id(Long userId, Long categoryId); @@ -29,26 +24,36 @@ public interface SpendingRepository extends ExtendedRepository, @Modifying(clearAutomatically = true) @Transactional - @Query("UPDATE Spending s SET s.deletedAt = NOW() where s.id IN :spendingIds AND s.deletedAt IS NULL") - void deleteAllByIdAndDeletedAtNullInQuery(List spendingIds); - - @Modifying(clearAutomatically = true) - @Transactional - @Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId, s.category = :custom WHERE s.category = :fromCategory AND s.deletedAt IS NULL") + @Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId, s.category = :custom WHERE s.category = :fromCategory") void updateCategoryByCustomCategoryInQuery(SpendingCategory fromCategory, Long toCategoryId, SpendingCategory custom); @Modifying(clearAutomatically = true) @Transactional - @Query("UPDATE Spending s SET s.category = :toCategory WHERE s.category = :fromCategory AND s.deletedAt IS NULL") + @Query("UPDATE Spending s SET s.category = :toCategory WHERE s.category = :fromCategory") void updateCategoryByCategoryInQuery(SpendingCategory fromCategory, SpendingCategory toCategory); @Modifying(clearAutomatically = true) @Transactional - @Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId WHERE s.spendingCustomCategory.id = :fromCategoryId AND s.deletedAt IS NULL") + @Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId WHERE s.spendingCustomCategory.id = :fromCategoryId") void updateCustomCategoryByCustomCategoryInQuery(Long fromCategoryId, Long toCategoryId); @Modifying(clearAutomatically = true) @Transactional - @Query("UPDATE Spending s SET s.spendingCustomCategory = null, s.category = :toCategory WHERE s.spendingCustomCategory.id = :fromCategoryId AND s.deletedAt IS NULL") + @Query("UPDATE Spending s SET s.spendingCustomCategory = null, s.category = :toCategory WHERE s.spendingCustomCategory.id = :fromCategoryId") void updateCustomCategoryByCategoryInQuery(Long fromCategoryId, SpendingCategory toCategory); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.deletedAt = NOW() where s.id IN :spendingIds") + void deleteAllByIdAndDeletedAtNullInQuery(List spendingIds); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.deletedAt = NOW() WHERE s.user.id = :userId") + void deleteAllByUserIdInQuery(Long userId); + + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE Spending s SET s.deletedAt = NOW() WHERE s.spendingCustomCategory.id = :categoryId") + void deleteAllByCategoryIdAndDeletedAtNullInQuery(Long categoryId); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java index b71ea8436..57f0cd43e 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java @@ -40,4 +40,9 @@ public boolean isExistsSpendingCustomCategory(Long userId, Long categoryId) { public void deleteSpendingCustomCategory(Long categoryId) { spendingCustomCategoryRepository.deleteById(categoryId); } + + @Transactional + public void deleteSpendingCustomCategoriesByUserIdInQuery(Long userId) { + spendingCustomCategoryRepository.deleteAllByUserIdInQuery(userId); + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java index 5f52240dc..f8bff76cd 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -123,26 +123,11 @@ public List readTotalSpendingsAmountByUserId(Long userId) { return spendingRepository.selectList(predicate, TotalSpendingAmount.class, bindings, queryHandler, sort); } - @Transactional - public void deleteSpendingsByCategoryIdInQuery(Long categoryId) { - spendingRepository.deleteAllByCategoryIdAndDeletedAtNullInQuery(categoryId); - } - @Transactional(readOnly = true) public boolean isExistsSpending(Long userId, Long spendingId) { return spendingRepository.existsByIdAndUser_Id(spendingId, userId); } - @Transactional - public void deleteSpending(Spending spending) { - spendingRepository.delete(spending); - } - - @Transactional - public void deleteSpendingsInQuery(List spendingIds) { - spendingRepository.deleteAllByIdAndDeletedAtNullInQuery(spendingIds); - } - @Transactional(readOnly = true) public long countByUserIdAndIdIn(Long userId, List spendingIds) { return spendingRepository.countByUserIdAndIdIn(userId, spendingIds); @@ -169,4 +154,24 @@ public void updateCustomCategoryByCustomCategory(Long fromId, Long toId) { public void updateCustomCategoryByCategory(Long fromId, SpendingCategory toCategory) { spendingRepository.updateCustomCategoryByCategoryInQuery(fromId, toCategory); } + + @Transactional + public void deleteSpending(Spending spending) { + spendingRepository.delete(spending); + } + + @Transactional + public void deleteSpendingsInQuery(List spendingIds) { + spendingRepository.deleteAllByIdAndDeletedAtNullInQuery(spendingIds); + } + + @Transactional + public void deleteSpendingsByUserIdInQuery(Long userId) { + spendingRepository.deleteAllByUserIdInQuery(userId); + } + + @Transactional + public void deleteSpendingsByCategoryIdInQuery(Long categoryId) { + spendingRepository.deleteAllByCategoryIdAndDeletedAtNullInQuery(categoryId); + } } From f3c722674ed5f2d42367ec9e8edffa9b0d433a0c Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Sun, 4 Aug 2024 16:43:58 +0900 Subject: [PATCH 138/152] =?UTF-8?q?fix:=20batch=20application=20timezone?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20(#144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/co/pennyway/PennywayBatchApplication.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/PennywayBatchApplication.java b/pennyway-batch/src/main/java/kr/co/pennyway/PennywayBatchApplication.java index 149f6ae04..f20ac066b 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/PennywayBatchApplication.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/PennywayBatchApplication.java @@ -1,13 +1,21 @@ package kr.co.pennyway; +import jakarta.annotation.PostConstruct; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; +import java.util.TimeZone; + @SpringBootApplication @EnableScheduling public class PennywayBatchApplication { public static void main(String[] args) { SpringApplication.run(PennywayBatchApplication.class, args); } + + @PostConstruct + public void init() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } } From 84dab2bf0a93d5a80e622173aa3123e581a04440 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 8 Aug 2024 01:10:18 +0900 Subject: [PATCH 139/152] =?UTF-8?q?feat:=20=E2=9C=A8=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=82=AC=EC=A7=84=20=EC=82=AD=EC=A0=9C=20API=20?= =?UTF-8?q?=EB=B0=8F=20=EC=88=98=EC=A0=95=20API=20=ED=94=BD=EC=8A=A4,=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: aws_s3_provider delete object 메서드 추가 * fix: copy_object return 값 추가 * refactor: aws s3 provider 호출 로직 tx 외부로 분리 * fix: presigned_url 발급 api 사용자 아이디 쿼리 파라미터 제거 * test: storage controller test 수정 * feat: s3 adapter get prefix 메서드 추가 * fix: 사용자 프로필 주소 dto 삽입 전 prefix 추가 * feat: 프로필 이미지 삭제 api * fix: 사용자 프로필 업데이트 시, 기존 이미지가 있다면 삭제 로직 추가 * feat: 사용자 프로필 not found 예외 추가 * fix: 사용자 프로필이 없는 경우 404 예외 처리 * docs: 사용자 프로필 삭제 api 스웨거 작성 * fix: put_profile_image 응답으로 저장된 경로 반환 * docs: 이미지 수정 스웨거 문서 수정 * test: 프로필 정상 요청 테스트 케이스 수정 * fix: 안정성을 위해 user_dto에서 profile_image_url null 방지 스니펫 유지 --- .../api/apis/storage/api/StorageApi.java | 66 ++++----- .../storage/controller/StorageController.java | 27 ++-- .../api/apis/storage/dto/PresignedUrlDto.java | 2 - .../apis/storage/usecase/StorageUseCase.java | 12 +- .../api/apis/users/api/UserAccountApi.java | 12 ++ .../controller/UserAccountController.java | 11 +- .../api/apis/users/dto/UserProfileDto.java | 4 +- .../apis/users/mapper/UserProfileMapper.java | 6 +- .../service/UserProfileSearchService.java | 12 +- .../service/UserProfileUpdateService.java | 35 +++-- .../users/usecase/UserAccountUseCase.java | 33 ++++- .../api/common/storage/AwsS3Adapter.java | 52 +++++++ .../controller/StorageControllerTest.java | 136 ++++++++---------- .../UserAccountControllerUnitTest.java | 1 + .../domains/user/exception/UserErrorCode.java | 3 + .../infra/client/aws/s3/AwsS3Provider.java | 23 ++- 16 files changed, 276 insertions(+), 159 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/storage/AwsS3Adapter.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/api/StorageApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/api/StorageApi.java index f6838b0b1..0a163e0d5 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/api/StorageApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/api/StorageApi.java @@ -1,8 +1,5 @@ package kr.co.pennyway.api.apis.storage.api; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; @@ -14,37 +11,40 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; @Tag(name = "[S3 이미지 저장을 위한 Presigned URL 발급 API]") public interface StorageApi { - @Operation(summary = "S3 이미지 저장을 위한 Presigned URL 발급", description = "S3에 이미지를 저장하기 위한 Presigned URL을 발급합니다.") - @Parameters({ - @Parameter(name = "type", description = "이미지 종류", required = true, in = ParameterIn.QUERY, examples = { - @ExampleObject(value = "PROFILE"), - @ExampleObject(value = "FEED"), - @ExampleObject(value = "CHATROOM_PROFILE"), - @ExampleObject(value = "CHAT"), - @ExampleObject(value = "CHAT_PROFILE") - }), - @Parameter(name = "ext", description = "파일 확장자", required = true, examples = { - @ExampleObject(value = "jpg"), - @ExampleObject(value = "png"), - @ExampleObject(value = "jpeg") - }), - @Parameter(name = "userId", description = "사용자 ID", example = "1"), - @Parameter(name = "chatroomId", description = "채팅방 ID", example = "12345678-1234-5678-1234-567812345678"), - @Parameter(name = "request", hidden = true) - }) - @ApiResponses({ - @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = PresignedUrlDto.Res.class))), - @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "필수 파라미터 누락", value = """ - { - "code": "4001", - "message": "필수 파라미터가 누락되었습니다." - } - """) - })), - }) - ResponseEntity getPresignedUrl(@Validated PresignedUrlDto.Req req); + @Operation(summary = "S3 이미지 저장을 위한 Presigned URL 발급", description = "S3에 이미지를 저장하기 위한 Presigned URL을 발급합니다.") + @Parameters({ + @Parameter(name = "type", description = "이미지 종류", required = true, in = ParameterIn.QUERY, examples = { + @ExampleObject(value = "PROFILE"), + @ExampleObject(value = "FEED"), + @ExampleObject(value = "CHATROOM_PROFILE"), + @ExampleObject(value = "CHAT"), + @ExampleObject(value = "CHAT_PROFILE") + }), + @Parameter(name = "ext", description = "파일 확장자", required = true, examples = { + @ExampleObject(value = "jpg"), + @ExampleObject(value = "png"), + @ExampleObject(value = "jpeg") + }), + @Parameter(name = "chatroomId", description = "채팅방 ID", example = "12345678-1234-5678-1234-567812345678"), + @Parameter(name = "request", hidden = true) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = PresignedUrlDto.Res.class))), + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "필수 파라미터 누락", value = """ + { + "code": "4001", + "message": "필수 파라미터가 누락되었습니다." + } + """) + })), + }) + ResponseEntity getPresignedUrl(@Validated PresignedUrlDto.Req req, @AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/controller/StorageController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/controller/StorageController.java index 490239f20..9d614370c 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/controller/StorageController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/controller/StorageController.java @@ -1,29 +1,30 @@ package kr.co.pennyway.api.apis.storage.controller; +import kr.co.pennyway.api.apis.storage.api.StorageApi; +import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; +import kr.co.pennyway.api.apis.storage.usecase.StorageUseCase; +import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import kr.co.pennyway.api.apis.storage.api.StorageApi; -import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; -import kr.co.pennyway.api.apis.storage.usecase.StorageUseCase; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - @Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/v1/storage") public class StorageController implements StorageApi { - private final StorageUseCase storageUseCase; + private final StorageUseCase storageUseCase; - @Override - @GetMapping("/presigned-url") - @PreAuthorize("isAuthenticated()") - public ResponseEntity getPresignedUrl(@Validated PresignedUrlDto.Req request) { - return ResponseEntity.ok(storageUseCase.getPresignedUrl(request)); - } + @Override + @GetMapping("/presigned-url") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getPresignedUrl(@Validated PresignedUrlDto.Req request, @AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(storageUseCase.getPresignedUrl(user.getUserId(), request)); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java index c86397767..c1e767a15 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/dto/PresignedUrlDto.java @@ -14,8 +14,6 @@ public record Req( @Schema(description = "파일 확장자", example = "jpg/png/jpeg") @NotBlank(message = "파일 확장자는 필수입니다.") String ext, - @Schema(description = "사용자 ID", example = "1") - String userId, @Schema(description = "채팅방 ID", example = "12345678-1234-5678-1234-567812345678") String chatroomId ) { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/usecase/StorageUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/usecase/StorageUseCase.java index a4d97d3a4..6db3b3ebb 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/usecase/StorageUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/usecase/StorageUseCase.java @@ -10,11 +10,11 @@ @UseCase @RequiredArgsConstructor public class StorageUseCase { - private final AwsS3Provider awsS3Provider; + private final AwsS3Provider awsS3Provider; - public PresignedUrlDto.Res getPresignedUrl(PresignedUrlDto.Req request) { - return PresignedUrlDto.Res.of( - awsS3Provider.generatedPresignedUrl(request.type(), request.ext(), request.userId(), request.chatroomId()) - ); - } + public PresignedUrlDto.Res getPresignedUrl(Long userId, PresignedUrlDto.Req request) { + return PresignedUrlDto.Res.of( + awsS3Provider.generatedPresignedUrl(request.type(), request.ext(), userId.toString(), request.chatroomId()) + ); + } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java index 9c967298b..725e916e7 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java @@ -239,6 +239,7 @@ ResponseEntity postPasswordVerification(@RequestBody @Validated UserProfileUp @Operation(summary = "사용자 프로필 사진 등록", description = "사용자의 프로필 사진을 수정합니다.") @ApiResponses({ + @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "profileImageUrl", schema = @Schema(implementation = String.class, example = "https://cdn.co.kr/abc.jpg", description = "이미지 저장 경로")))), @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { @ExampleObject(name = "프로필 사진 URL이 유효하지 않은 경우", value = """ { @@ -257,4 +258,15 @@ ResponseEntity postPasswordVerification(@RequestBody @Validated UserProfileUp })) }) ResponseEntity putProfileImage(@RequestBody @Validated UserProfileUpdateDto.ProfileImageReq request, @AuthenticationPrincipal SecurityUserDetails user); + + @Operation(summary = "사용자 프로필 사진 삭제", description = "사용자의 프로필 사진을 삭제합니다.") + @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "프로필 사진 URL이 존재하지 않는 경우", value = """ + { + "code": "4040", + "message": "프로필 이미지가 할당되지 않았습니다." + } + """) + })) + ResponseEntity deleteProfileImage(@AuthenticationPrincipal SecurityUserDetails user); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java index bff9c79f9..1b7ad6b43 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/controller/UserAccountController.java @@ -110,8 +110,15 @@ public ResponseEntity deleteAccount(@AuthenticationPrincipal SecurityUserDeta @Override @PutMapping("/profile-image") @PreAuthorize("isAuthenticated()") - public ResponseEntity putProfileImage(@Validated UserProfileUpdateDto.ProfileImageReq request, SecurityUserDetails user) { - userAccountUseCase.updateProfileImage(user.getUserId(), request); + public ResponseEntity putProfileImage(@Validated UserProfileUpdateDto.ProfileImageReq request, @AuthenticationPrincipal SecurityUserDetails user) { + return ResponseEntity.ok(SuccessResponse.from("profileImageUrl", userAccountUseCase.updateProfileImage(user.getUserId(), request))); + } + + @Override + @DeleteMapping("/profile-image") + @PreAuthorize("isAuthenticated()") + public ResponseEntity deleteProfileImage(@AuthenticationPrincipal SecurityUserDetails user) { + userAccountUseCase.deleteProfileImage(user.getUserId()); return ResponseEntity.ok(SuccessResponse.noContent()); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java index 72fce5281..ee6b5e2f6 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/dto/UserProfileDto.java @@ -59,13 +59,13 @@ public record UserProfileDto( Objects.requireNonNull(oauthAccount); } - public static UserProfileDto from(User user, OauthAccountDto oauthAccount) { + public static UserProfileDto from(User user, String profileImageUrl, OauthAccountDto oauthAccount) { return UserProfileDto.builder() .id(user.getId()) .username(user.getUsername()) .name(user.getName()) .passwordUpdatedAt(user.getPasswordUpdatedAt()) - .profileImageUrl(Objects.toString(user.getProfileImageUrl(), "")) + .profileImageUrl(Objects.toString(profileImageUrl, "")) .phone(user.getPhone()) .profileVisibility(user.getProfileVisibility()) .locked(user.isLocked()) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/UserProfileMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/UserProfileMapper.java index 96b24e225..10ddcce8b 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/UserProfileMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/mapper/UserProfileMapper.java @@ -12,7 +12,7 @@ @Mapper public class UserProfileMapper { - public static UserProfileDto toUserProfileDto(User user, Set oauths) { + public static UserProfileDto toUserProfileDto(User user, Set oauths, String objectPrefix) { boolean kakao, google, apple; kakao = google = apple = false; @@ -24,7 +24,9 @@ public static UserProfileDto toUserProfileDto(User user, Set oauths) { } } - return UserProfileDto.from(user, OauthAccountDto.of(kakao, google, apple)); + String profileImageUrl = (user.getProfileImageUrl() == null) ? "" : objectPrefix + user.getProfileImageUrl(); + + return UserProfileDto.from(user, profileImageUrl, OauthAccountDto.of(kakao, google, apple)); } public static UserProfileUpdateDto.NotifySettingUpdateRes toNotifySettingUpdateRes(NotifySetting.NotifyType type, Boolean flag) { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileSearchService.java index 0b2392aa8..19b0f48ac 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileSearchService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileSearchService.java @@ -1,7 +1,5 @@ package kr.co.pennyway.api.apis.users.service; -import kr.co.pennyway.api.apis.users.dto.UserProfileDto; -import kr.co.pennyway.api.apis.users.mapper.UserProfileMapper; import kr.co.pennyway.domain.domains.oauth.domain.Oauth; import kr.co.pennyway.domain.domains.oauth.service.OauthService; import kr.co.pennyway.domain.domains.user.domain.User; @@ -24,10 +22,12 @@ public class UserProfileSearchService { private final OauthService oauthService; @Transactional(readOnly = true) - public UserProfileDto readMyAccount(Long userId) { - User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); - Set oauths = oauthService.readOauthsByUserId(userId).stream().filter(oauth -> !oauth.isDeleted()).collect(Collectors.toUnmodifiableSet()); + public User readMyAccount(Long userId) { + return userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + } - return UserProfileMapper.toUserProfileDto(user, oauths); + @Transactional(readOnly = true) + public Set readMyOauths(Long userId) { + return oauthService.readOauthsByUserId(userId).stream().filter(oauth -> !oauth.isDeleted()).collect(Collectors.toUnmodifiableSet()); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java index 43419963e..d331621ae 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/UserProfileUpdateService.java @@ -10,9 +10,6 @@ import kr.co.pennyway.domain.domains.user.exception.UserErrorException; import kr.co.pennyway.domain.domains.user.service.UserService; import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; -import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; -import kr.co.pennyway.infra.common.exception.StorageErrorCode; -import kr.co.pennyway.infra.common.exception.StorageException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -46,22 +43,34 @@ public void updateUsername(Long userId, String newUsername) { user.updateUsername(newUsername); } + /** + * 프로필 이미지를 업데이트한다. + * + * @return 사용자가 이미 프로필 이미지를 가지고 있는 경우, 값을 교체하고 이전 이미지 키를 반환한다. (없으면 null 반환) + */ @Transactional - public void updateProfileImage(Long userId, String profileImageUrl) { + public String updateProfileImage(Long userId, String profileImageKey) { User user = readUserOrThrow(userId); + String oldProfileImageUrl = user.getProfileImageUrl(); - // Profile Image 존재 여부 확인 - if (!awsS3Provider.isObjectExist(profileImageUrl)) { - log.info("프로필 이미지 URL이 유효하지 않습니다."); - throw new StorageException(StorageErrorCode.NOT_FOUND); + user.updateProfileImageUrl(profileImageKey); + + return oldProfileImageUrl; + } + + @Transactional + public String deleteProfileImage(Long userId) { + User user = readUserOrThrow(userId); + + String profileImageUrl = user.getProfileImageUrl(); + + if (profileImageUrl == null) { + throw new UserErrorException(UserErrorCode.NOT_ALLOCATED_PROFILE_IMAGE); } - // Profile Image 원본 저장 - awsS3Provider.copyObject(ObjectKeyType.PROFILE, profileImageUrl); + user.updateProfileImageUrl(null); - // Profile Image URL 업데이트 - String originKey = ObjectKeyType.PROFILE.convertDeleteKeyToOriginKey(profileImageUrl); - user.updateProfileImageUrl(awsS3Provider.getObjectPrefix() + originKey); + return profileImageUrl; } @Transactional diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java index 29ba697fc..8bca7fb69 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/usecase/UserAccountUseCase.java @@ -6,13 +6,19 @@ import kr.co.pennyway.api.apis.users.mapper.DeviceTokenMapper; import kr.co.pennyway.api.apis.users.mapper.UserProfileMapper; import kr.co.pennyway.api.apis.users.service.*; +import kr.co.pennyway.api.common.storage.AwsS3Adapter; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; +import java.util.Set; + @Slf4j @UseCase @RequiredArgsConstructor @@ -26,6 +32,8 @@ public class UserAccountUseCase { private final PasswordUpdateService passwordUpdateService; + private final AwsS3Adapter awsS3Adapter; + @Transactional public DeviceTokenDto.RegisterRes registerDeviceToken(Long userId, DeviceTokenDto.RegisterReq request) { DeviceToken deviceToken = deviceTokenRegisterService.execute(userId, request.token()); @@ -36,8 +44,12 @@ public void unregisterDeviceToken(Long userId, String token) { deviceTokenUnregisterService.execute(userId, token); } + @Transactional(readOnly = true) public UserProfileDto getMyAccount(Long userId) { - return userProfileSearchService.readMyAccount(userId); + User user = userProfileSearchService.readMyAccount(userId); + Set oauths = userProfileSearchService.readMyOauths(userId); + + return UserProfileMapper.toUserProfileDto(user, oauths, awsS3Adapter.getObjectPrefix()); } public void updateName(Long userId, String newName) { @@ -56,8 +68,21 @@ public void updatePassword(Long userId, String oldPassword, String newPassword) passwordUpdateService.execute(userId, oldPassword, newPassword); } - public void updateProfileImage(Long userId, UserProfileUpdateDto.ProfileImageReq request) { - userProfileUpdateService.updateProfileImage(userId, request.profileImageUrl()); + public String updateProfileImage(Long userId, UserProfileUpdateDto.ProfileImageReq request) { + String originImageUrl = awsS3Adapter.saveImage(request.profileImageUrl(), ObjectKeyType.PROFILE); + String oldImageUrl = userProfileUpdateService.updateProfileImage(userId, originImageUrl); + + if (oldImageUrl != null) { + awsS3Adapter.deleteImage(oldImageUrl); + } + + return awsS3Adapter.getObjectPrefix() + originImageUrl; + } + + public void deleteProfileImage(Long userId) { + String profileImageUrl = userProfileUpdateService.deleteProfileImage(userId); + + awsS3Adapter.deleteImage(profileImageUrl); } public void updatePhone(Long userId, UserProfileUpdateDto.PhoneReq request) { @@ -66,11 +91,13 @@ public void updatePhone(Long userId, UserProfileUpdateDto.PhoneReq request) { public UserProfileUpdateDto.NotifySettingUpdateRes activateNotification(Long userId, NotifySetting.NotifyType type) { userProfileUpdateService.updateNotifySetting(userId, type, Boolean.TRUE); + return UserProfileMapper.toNotifySettingUpdateRes(type, Boolean.TRUE); } public UserProfileUpdateDto.NotifySettingUpdateRes deactivateNotification(Long userId, NotifySetting.NotifyType type) { userProfileUpdateService.updateNotifySetting(userId, type, Boolean.FALSE); + return UserProfileMapper.toNotifySettingUpdateRes(type, Boolean.FALSE); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/storage/AwsS3Adapter.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/storage/AwsS3Adapter.java new file mode 100644 index 000000000..81a3aace2 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/storage/AwsS3Adapter.java @@ -0,0 +1,52 @@ +package kr.co.pennyway.api.common.storage; + +import kr.co.pennyway.common.annotation.Adapter; +import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; +import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType; +import kr.co.pennyway.infra.common.exception.StorageErrorCode; +import kr.co.pennyway.infra.common.exception.StorageException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Adapter +@RequiredArgsConstructor +public class AwsS3Adapter { + private final AwsS3Provider awsS3Provider; + + /** + * 임시 저장 경로에서 원본 저장 경로로 사진을 복사하고, 원본이 저장된 키를 반환합니다. + * + * @param deleteImageUrl 임시 저장 이미지 URL + * @param type 프로필 이미지 타입 {@link ObjectKeyType} + * @return 프로필 이미지 원본이 저장된 key + * @throws StorageException 프로필 이미지 URL이 유효하지 않을 때 + */ + public String saveImage(String deleteImageUrl, ObjectKeyType type) { + if (!awsS3Provider.isObjectExist(deleteImageUrl)) { + log.info("프로필 이미지 URL이 유효하지 않습니다."); + throw new StorageException(StorageErrorCode.NOT_FOUND); + } + + return awsS3Provider.copyObject(type, deleteImageUrl); + } + + /** + * 프로필 이미지를 삭제합니다. + * + * @param key 프로필 이미지 key + * @throws StorageException 프로필 이미지 URL이 유효하지 않을 때 + */ + public void deleteImage(String key) { + if (!awsS3Provider.isObjectExist(key)) { + log.info("프로필 이미지 URL이 유효하지 않습니다."); + throw new StorageException(StorageErrorCode.NOT_FOUND); + } + + awsS3Provider.deleteObject(key); + } + + public String getObjectPrefix() { + return awsS3Provider.getObjectPrefix(); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java index fee9b137d..bd5639a54 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java @@ -1,10 +1,11 @@ package kr.co.pennyway.api.apis.storage.controller; -import static org.mockito.BDDMockito.*; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - +import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; +import kr.co.pennyway.api.apis.storage.usecase.StorageUseCase; +import kr.co.pennyway.api.config.WebConfig; +import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; +import kr.co.pennyway.infra.common.exception.StorageErrorCode; +import kr.co.pennyway.infra.common.exception.StorageException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -19,77 +20,64 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; -import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; -import kr.co.pennyway.api.apis.storage.usecase.StorageUseCase; -import kr.co.pennyway.api.config.WebConfig; -import kr.co.pennyway.infra.common.exception.StorageErrorCode; -import kr.co.pennyway.infra.common.exception.StorageException; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(controllers = StorageController.class, excludeFilters = { - @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) @ActiveProfiles("test") class StorageControllerTest { - @Autowired - private MockMvc mockMvc; - - @MockBean - private StorageUseCase storageUseCase; - - @BeforeEach - void setUp(WebApplicationContext webApplicationContext) { - this.mockMvc = MockMvcBuilders - .webAppContextSetup(webApplicationContext) - .defaultRequest(post("/**").with(csrf())) - .build(); - } - - @Test - @DisplayName("Type이 PROFILE이고, UserId가 NULL일 때 400 응답을 반환한다.") - void getPresignedUrlWithNullUserId() throws Exception { - // given - PresignedUrlDto.Req request = new PresignedUrlDto.Req("PROFILE", "jpg", null, null); - given(storageUseCase.getPresignedUrl(request)).willThrow(new StorageException(StorageErrorCode.MISSING_REQUIRED_PARAMETER)); - - // when - ResultActions resultActions = getPresignedUrlRequest(request); - - // then - resultActions.andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("Type이 CHAT이고, ChatroomId가 NULL일 때 400 응답을 반환한다.") - void getPresignedUrlWithNullChatroomId() throws Exception { - // given - PresignedUrlDto.Req request = new PresignedUrlDto.Req("CHAT", "jpg", "userId", null); - given(storageUseCase.getPresignedUrl(request)).willThrow(new StorageException(StorageErrorCode.MISSING_REQUIRED_PARAMETER)); - - // when - ResultActions resultActions = getPresignedUrlRequest(request); - - // then - resultActions.andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("Type이 CHATROOM_PROFILE이고, ChatroomId가 NULL일 때 400 응답을 반환한다.") - void getPresignedUrlWithNullChatroomIdForChatroomProfile() throws Exception { - // given - PresignedUrlDto.Req request = new PresignedUrlDto.Req("CHATROOM_PROFILE", "jpg", "userId", null); - given(storageUseCase.getPresignedUrl(request)).willThrow(new StorageException(StorageErrorCode.MISSING_REQUIRED_PARAMETER)); - - // when - ResultActions resultActions = getPresignedUrlRequest(request); - - // then - resultActions.andExpect(status().isBadRequest()); - } - - private ResultActions getPresignedUrlRequest(PresignedUrlDto.Req request) throws Exception { - return mockMvc.perform(get("/v1/storage/presigned-url") - .param("type", request.type()) - .param("ext", request.ext()) - .param("userId", request.userId()) - .param("chatRoomId", request.chatroomId())); - } + @Autowired + private MockMvc mockMvc; + + @MockBean + private StorageUseCase storageUseCase; + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(post("/**").with(csrf())) + .build(); + } + + @Test + @WithSecurityMockUser + @DisplayName("Type이 CHAT이고, ChatroomId가 NULL일 때 400 응답을 반환한다.") + void getPresignedUrlWithNullChatroomId() throws Exception { + // given + PresignedUrlDto.Req request = new PresignedUrlDto.Req("CHAT", "jpg", null); + given(storageUseCase.getPresignedUrl(1L, request)).willThrow(new StorageException(StorageErrorCode.MISSING_REQUIRED_PARAMETER)); + + // when + ResultActions resultActions = getPresignedUrlRequest(request); + + // then + resultActions.andExpect(status().isBadRequest()); + } + + @Test + @WithSecurityMockUser + @DisplayName("Type이 CHATROOM_PROFILE이고, ChatroomId가 NULL일 때 400 응답을 반환한다.") + void getPresignedUrlWithNullChatroomIdForChatroomProfile() throws Exception { + // given + PresignedUrlDto.Req request = new PresignedUrlDto.Req("CHATROOM_PROFILE", "jpg", null); + given(storageUseCase.getPresignedUrl(1L, request)).willThrow(new StorageException(StorageErrorCode.MISSING_REQUIRED_PARAMETER)); + + // when + ResultActions resultActions = getPresignedUrlRequest(request); + + // then + resultActions.andExpect(status().isBadRequest()); + } + + private ResultActions getPresignedUrlRequest(PresignedUrlDto.Req request) throws Exception { + return mockMvc.perform(get("/v1/storage/presigned-url") + .param("type", request.type()) + .param("ext", request.ext()) + .param("chatRoomId", request.chatroomId())); + } } \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java index 3b7f3e46d..db2af3450 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java @@ -540,6 +540,7 @@ void registerProfileImageNotFound() throws Exception { void registerProfileImageSuccess() throws Exception { // given String profileImageUrl = "delete/profile/1/154aa3bd-da02-4311-a735-3bf7e4bb68d2_1717446100295.jpeg"; + given(userAccountUseCase.updateProfileImage(1L, new UserProfileUpdateDto.ProfileImageReq(profileImageUrl))).willReturn("https://cdn.com/adj.jbg"); // when ResultActions result = performRegisterProfileImageRequest(profileImageUrl); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java index 4fa7fda92..a44a5b225 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java @@ -19,6 +19,9 @@ public enum UserErrorCode implements BaseErrorCode { ALREADY_WITHDRAWAL(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "이미 탈퇴한 유저입니다."), DO_NOT_GENERAL_SIGNED_UP(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "일반 회원가입 계정이 아닙니다."), + /* 404 NOT_FOUND */ + NOT_ALLOCATED_PROFILE_IMAGE(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "프로필 이미지가 할당되지 않았습니다."), + /* 409 Conflict */ ALREADY_SIGNUP(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 회원가입한 유저입니다."), ALREADY_EXIST_USERNAME(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 존재하는 아이디입니다."), diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java index 9a67a7020..570ce270a 100644 --- a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/AwsS3Provider.java @@ -81,7 +81,6 @@ private String generateObjectKey(String type, String ext, String userId, String * @param ext : 파일 확장자 (jpg, png, jpeg) * @param userId : 사용자 ID (PK) - PROFILE, CHAT_PROFILE * @param chatroomId : 채팅방 ID (PK) - CHATROOM_PROFILE, CHAT, CHAT_PROFILE - * @return */ private Map generateObjectKeyVariables(String type, String ext, String userId, String chatroomId) { ObjectKeyType objectType; @@ -125,13 +124,15 @@ public boolean isObjectExist(String key) { * @param sourceKey : 복사할 파일의 키 * @return 복사된 파일의 키 */ - public void copyObject(ObjectKeyType type, String sourceKey) { + public String copyObject(ObjectKeyType type, String sourceKey) { + String originKey = type.convertDeleteKeyToOriginKey(sourceKey); + try { CopyObjectRequest copyObjRequest = CopyObjectRequest.builder() .sourceBucket(awsS3Config.getBucketName()) .sourceKey(sourceKey) .destinationBucket(awsS3Config.getBucketName()) - .destinationKey(type.convertDeleteKeyToOriginKey(sourceKey)) + .destinationKey(originKey) .storageClass(StorageClass.ONEZONE_IA) .build(); @@ -140,9 +141,25 @@ public void copyObject(ObjectKeyType type, String sourceKey) { log.error("파일 복사 중 오류 발생", e); throw new StorageException(StorageErrorCode.INVALID_FILE); } + + return originKey; } public String getObjectPrefix() { return awsS3Config.getObjectPrefix(); } + + public void deleteObject(String key) { + try { + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(awsS3Config.getBucketName()) + .key(key) + .build(); + + s3Client.deleteObject(deleteObjectRequest); + } catch (Exception e) { + log.error("파일 삭제 중 오류 발생", e); + throw new StorageException(StorageErrorCode.INVALID_FILE); + } + } } From 03994d76970e8cf1c267817ce6b7b3750eaaed27 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 8 Aug 2024 01:29:52 +0900 Subject: [PATCH 140/152] =?UTF-8?q?feat:=20=E2=9C=A8=20=EC=95=A0=ED=94=8C?= =?UTF-8?q?=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20=ED=97=AC=EC=8A=A4=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20Actuator=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: api 모듈 actuator 의존성 추가 * fix: security filter actuator public endpoint 옵션 설정 --- pennyway-app-external-api/build.gradle | 3 + .../api/config/security/SecurityConfig.java | 111 +++++++++--------- 2 files changed, 58 insertions(+), 56 deletions(-) diff --git a/pennyway-app-external-api/build.gradle b/pennyway-app-external-api/build.gradle index 6e36e0521..0625ae69e 100644 --- a/pennyway-app-external-api/build.gradle +++ b/pennyway-app-external-api/build.gradle @@ -30,6 +30,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web:3.2.3' implementation 'org.springframework.boot:spring-boot-starter-validation:3.2.3' + /* Actuator */ + implementation 'org.springframework.boot:spring-boot-starter-actuator:3.3.2' + /* testcontainer */ testImplementation "org.junit.jupiter:junit-jupiter:5.8.1" testImplementation "org.testcontainers:testcontainers:1.19.7" diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java index cd39d3aab..c29b83092 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/security/SecurityConfig.java @@ -1,7 +1,6 @@ package kr.co.pennyway.api.config.security; -import static kr.co.pennyway.api.config.security.WebSecurityUrls.*; - +import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity; import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; @@ -22,69 +21,69 @@ import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.web.cors.CorsConfigurationSource; -import lombok.RequiredArgsConstructor; +import static kr.co.pennyway.api.config.security.WebSecurityUrls.AUTHENTICATED_ENDPOINTS; @Configuration @EnableWebSecurity @ConditionalOnDefaultWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/v1/duplicate/**"}; - private static final String[] PUBLIC_ENDPOINTS = {"/v1/questions/**"}; - private static final String[] ANONYMOUS_ENDPOINTS = {"/v1/auth/**", "/v1/phone/**", "/v1/find/**"}; - private static final String[] SWAGGER_ENDPOINTS = {"/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger",}; + private static final String[] READ_ONLY_PUBLIC_ENDPOINTS = {"/favicon.ico", "/v1/duplicate/**", "/actuator/health"}; + private static final String[] PUBLIC_ENDPOINTS = {"/v1/questions/**"}; + private static final String[] ANONYMOUS_ENDPOINTS = {"/v1/auth/**", "/v1/phone/**", "/v1/find/**"}; + private static final String[] SWAGGER_ENDPOINTS = {"/api-docs/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger",}; - private final SecurityAdapterConfig securityAdapterConfig; - private final CorsConfigurationSource corsConfigurationSource; - private final AccessDeniedHandler accessDeniedHandler; - private final AuthenticationEntryPoint authenticationEntryPoint; + private final SecurityAdapterConfig securityAdapterConfig; + private final CorsConfigurationSource corsConfigurationSource; + private final AccessDeniedHandler accessDeniedHandler; + private final AuthenticationEntryPoint authenticationEntryPoint; - @Bean - @Profile({"local", "dev", "test"}) - @Order(SecurityProperties.BASIC_AUTH_ORDER) - public SecurityFilterChain filterChainDev(HttpSecurity http) throws Exception { - return defaultSecurity(http) - .cors((cors) -> cors.configurationSource(corsConfigurationSource)) - .authorizeHttpRequests( - auth -> defaultAuthorizeHttpRequests(auth) - .requestMatchers(SWAGGER_ENDPOINTS).permitAll() - .anyRequest().authenticated() - ).build(); - } + @Bean + @Profile({"local", "dev", "test"}) + @Order(SecurityProperties.BASIC_AUTH_ORDER) + public SecurityFilterChain filterChainDev(HttpSecurity http) throws Exception { + return defaultSecurity(http) + .cors((cors) -> cors.configurationSource(corsConfigurationSource)) + .authorizeHttpRequests( + auth -> defaultAuthorizeHttpRequests(auth) + .requestMatchers(SWAGGER_ENDPOINTS).permitAll() + .anyRequest().authenticated() + ).build(); + } - @Bean - @Profile({"prod"}) - @Order(SecurityProperties.BASIC_AUTH_ORDER) - public SecurityFilterChain filterChainProd(HttpSecurity http) throws Exception { - return defaultSecurity(http) - .cors(AbstractHttpConfigurer::disable) - .authorizeHttpRequests( - auth -> defaultAuthorizeHttpRequests(auth).anyRequest().authenticated() - ).build(); - } + @Bean + @Profile({"prod"}) + @Order(SecurityProperties.BASIC_AUTH_ORDER) + public SecurityFilterChain filterChainProd(HttpSecurity http) throws Exception { + return defaultSecurity(http) + .cors(AbstractHttpConfigurer::disable) + .authorizeHttpRequests( + auth -> defaultAuthorizeHttpRequests(auth).anyRequest().authenticated() + ).build(); + } - private HttpSecurity defaultSecurity(HttpSecurity http) throws Exception { - return http.httpBasic(AbstractHttpConfigurer::disable) - .csrf(AbstractHttpConfigurer::disable) - .sessionManagement((sessionManagement) -> - sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .formLogin(AbstractHttpConfigurer::disable) - .logout(AbstractHttpConfigurer::disable) - .with(securityAdapterConfig, Customizer.withDefaults()) - .exceptionHandling( - exception -> exception - .accessDeniedHandler(accessDeniedHandler) - .authenticationEntryPoint(authenticationEntryPoint) - ); - } + private HttpSecurity defaultSecurity(HttpSecurity http) throws Exception { + return http.httpBasic(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement((sessionManagement) -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .with(securityAdapterConfig, Customizer.withDefaults()) + .exceptionHandling( + exception -> exception + .accessDeniedHandler(accessDeniedHandler) + .authenticationEntryPoint(authenticationEntryPoint) + ); + } - private AbstractRequestMatcherRegistry.AuthorizedUrl> defaultAuthorizeHttpRequests( - AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth) { - return auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() - .requestMatchers(HttpMethod.OPTIONS, "*").permitAll() - .requestMatchers(HttpMethod.GET, READ_ONLY_PUBLIC_ENDPOINTS).permitAll() - .requestMatchers(PUBLIC_ENDPOINTS).permitAll() - .requestMatchers(AUTHENTICATED_ENDPOINTS).authenticated() // FIXME: 2024-04-23 /v1/auth가 anonymous로 설정되어 있어서 authenticated로 덮어씀. - .requestMatchers(ANONYMOUS_ENDPOINTS).anonymous(); - } + private AbstractRequestMatcherRegistry.AuthorizedUrl> defaultAuthorizeHttpRequests( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth) { + return auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .requestMatchers(HttpMethod.OPTIONS, "*").permitAll() + .requestMatchers(HttpMethod.GET, READ_ONLY_PUBLIC_ENDPOINTS).permitAll() + .requestMatchers(PUBLIC_ENDPOINTS).permitAll() + .requestMatchers(AUTHENTICATED_ENDPOINTS).authenticated() // FIXME: 2024-04-23 /v1/auth가 anonymous로 설정되어 있어서 authenticated로 덮어씀. + .requestMatchers(ANONYMOUS_ENDPOINTS).anonymous(); + } } From b88d70c1ebe8cf0230ac0e36e4c702e22b019557 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 8 Aug 2024 01:38:52 +0900 Subject: [PATCH 141/152] =?UTF-8?q?fix:=20http=20not=20support=20method=20?= =?UTF-8?q?exception=20=ED=95=B8=EB=93=A4=EB=9F=AC=EC=97=90=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/handler/GlobalExceptionHandler.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java index 3475651ca..7bb06c056 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java @@ -19,6 +19,7 @@ import org.springframework.http.converter.HttpMessageNotWritableException; import org.springframework.security.access.AccessDeniedException; import org.springframework.validation.BindingResult; +import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; @@ -55,6 +56,21 @@ protected ResponseEntity handleGlobalErrorException(GlobalErrorEx return ResponseEntity.status(e.getBaseErrorCode().causedBy().statusCode().getCode()).body(response); } + /** + * API 호출 시 'Method' 내에 데이터 값이 유효하지 않은 경우 + * + * @see HttpRequestMethodNotSupportedException + */ + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + @JsonView(CustomJsonView.Common.class) + protected ErrorResponse handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { + log.warn("handleHttpRequestMethodNotSupportedException : {}", e.getMessage()); + String code = String.valueOf(StatusCode.BAD_REQUEST.getCode() * 10 + ReasonCode.INVALID_REQUEST.getCode()); + + return ErrorResponse.of(code, e.getMessage()); + } + /** * API 호출 시 'Header' 내에 데이터 값이 유효하지 않은 경우 * From d7da829caa3c4077e7d0b74ae1c95d480859134e Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 13 Aug 2024 12:11:31 +0900 Subject: [PATCH 142/152] =?UTF-8?q?Ignore:=20=F0=9F=90=9B=20Devcie=20Token?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C,=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EC=A0=95=EB=B3=B4=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=A7=81=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: user entity의 device token 역참조 제거 * fix: device token unregister service에서 user 영속화 제거 * fix: device_entity의 자식의 cascade 옵션 제거 --- .../apis/users/service/DeviceTokenUnregisterService.java | 7 +------ .../pennyway/domain/domains/device/domain/DeviceToken.java | 2 +- .../kr/co/pennyway/domain/domains/user/domain/User.java | 6 ------ 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java index 557352566..39b188325 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java @@ -4,9 +4,6 @@ import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException; import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; -import kr.co.pennyway.domain.domains.user.exception.UserErrorException; import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,9 +19,7 @@ public class DeviceTokenUnregisterService { @Transactional public void execute(Long userId, String token) { - User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); - - DeviceToken deviceToken = deviceTokenService.readDeviceByUserIdAndToken(user.getId(), token).orElseThrow( + DeviceToken deviceToken = deviceTokenService.readDeviceByUserIdAndToken(userId, token).orElseThrow( () -> new DeviceTokenErrorException(DeviceTokenErrorCode.NOT_FOUND_DEVICE) ); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java index 6fa11123b..616377934 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java @@ -23,7 +23,7 @@ public class DeviceToken extends DateAuditable { @ColumnDefault("true") private Boolean activated; - @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java index dfbc2d179..6bf99ffe3 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java @@ -4,7 +4,6 @@ import kr.co.pennyway.domain.common.converter.ProfileVisibilityConverter; import kr.co.pennyway.domain.common.converter.RoleConverter; import kr.co.pennyway.domain.common.model.DateAuditable; -import kr.co.pennyway.domain.domains.device.domain.DeviceToken; import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; import kr.co.pennyway.domain.domains.user.type.Role; import lombok.AccessLevel; @@ -18,8 +17,6 @@ import org.springframework.util.StringUtils; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; import java.util.Objects; @Entity @@ -54,9 +51,6 @@ public class User extends DateAuditable { @ColumnDefault("NULL") private LocalDateTime deletedAt; - @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) - private List deviceTokens = new ArrayList<>(); - @Builder private User(String username, String name, String password, LocalDateTime passwordUpdatedAt, String profileImageUrl, String phone, Role role, ProfileVisibility profileVisibility, NotifySetting notifySetting, boolean locked) { From 6d0ee608042e34cfe96a47c19252836e7b7563b1 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 13 Aug 2024 12:22:46 +0900 Subject: [PATCH 143/152] =?UTF-8?q?fix:=20missing=20request=20cookie=20exc?= =?UTF-8?q?eption=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80=20(#1?= =?UTF-8?q?49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/handler/GlobalExceptionHandler.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java index 7bb06c056..d23a74973 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/response/handler/GlobalExceptionHandler.java @@ -21,6 +21,7 @@ import org.springframework.validation.BindingResult; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestCookieException; import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -56,6 +57,21 @@ protected ResponseEntity handleGlobalErrorException(GlobalErrorEx return ResponseEntity.status(e.getBaseErrorCode().causedBy().statusCode().getCode()).body(response); } + /** + * API 호출 시 'Cookie' 내에 데이터 값이 유효하지 않은 경우 + * + * @see MissingRequestCookieException + */ + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MissingRequestCookieException.class) + @JsonView(CustomJsonView.Common.class) + protected ErrorResponse handleMissingRequestCookieException(MissingRequestCookieException e) { + log.warn("handleMissingRequestCookieException : {}", e.getMessage()); + String code = String.valueOf(StatusCode.BAD_REQUEST.getCode() * 10 + ReasonCode.MISSING_REQUIRED_PARAMETER.getCode()); + + return ErrorResponse.of(code, e.getMessage()); + } + /** * API 호출 시 'Method' 내에 데이터 값이 유효하지 않은 경우 * From 1d6a1cada4161df78db6ec4fb29fd8d3b4b6477a Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 13 Aug 2024 16:09:08 +0900 Subject: [PATCH 144/152] =?UTF-8?q?refactor:=20=F0=9F=94=A7=20Swagger=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=A0=95=EC=9D=98=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: api_exception_explain 어노테이션 정의 * fix: 에러 설명을 위한 필드 추가 * feat: 복수개의 error response를 담기 위한 중간 어노테이션 * feat: 에러 응답 파서 구현 * chore: swagger config에 파서 빈 등록 * rename: explanation 오타 수정 * fix: enum 상수 추론을 위한 field 추가 및 파서 로직 수정 * docs: 지출 내역 상세 조회 예외에 api_response_explanations 적용 * chore: swagger config grouped_open_api add_operation_customizer 적용 --- .../api/apis/ledger/api/SpendingApi.java | 23 ++--- .../annotation/ApiExceptionExplanation.java | 27 ++++++ .../annotation/ApiResponseExplanations.java | 12 +++ .../swagger/ApiExceptionExplainParser.java | 87 +++++++++++++++++++ .../co/pennyway/api/config/SwaggerConfig.java | 19 +++- 5 files changed, 153 insertions(+), 15 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/ApiExceptionExplanation.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/ApiResponseExplanations.java create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/swagger/ApiExceptionExplainParser.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java index ec81cb6e2..c0ba48b1e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingApi.java @@ -14,7 +14,10 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingIdsDto; import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.api.common.annotation.ApiExceptionExplanation; +import kr.co.pennyway.api.common.annotation.ApiResponseExplanations; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -64,19 +67,12 @@ public interface SpendingApi { @Operation(summary = "지출 내역 상세 조회", method = "GET", description = "지출 내역의 ID값으로 해당 지출의 상세 내역을 반환합니다.") @Parameter(name = "spendingId", description = "지출 내역 ID", example = "1", required = true, in = ParameterIn.PATH) - @ApiResponses({ - @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "spending", schema = @Schema(implementation = SpendingSearchRes.Individual.class)))), - @ApiResponse(responseCode = "404", description = "NOT_FOUND", content = @Content(examples = { - @ExampleObject(name = "지출 내역 조회 오류", - value = """ - { - "code": "4040", - "message": "존재하지 않는 지출 내역입니다." - } - """ - ) - })) - }) + @ApiResponse(responseCode = "200", content = @Content(schemaProperties = @SchemaProperty(name = "spending", schema = @Schema(implementation = SpendingSearchRes.Individual.class)))) + @ApiResponseExplanations( + errors = { + @ApiExceptionExplanation(name = "지출 내역 조회 오류", description = "NOT_FOUND", value = SpendingErrorCode.class, constant = "NOT_FOUND_SPENDING") + } + ) ResponseEntity getSpendingDetail(@PathVariable Long spendingId, @AuthenticationPrincipal SecurityUserDetails user); @Operation(summary = "지출 내역 수정", method = "PUT", description = """ @@ -101,7 +97,6 @@ public interface SpendingApi { })) ResponseEntity deleteSpending(@PathVariable Long spendingId, @AuthenticationPrincipal SecurityUserDetails user); - @Operation(summary = "지출 내역 복수 삭제", method = "DELETE", description = "사용자의 지출 내역의 ID목록으로 해당 지출 내역들을 삭제 합니다.") @ApiResponse(responseCode = "403", description = "지출 내역에 대한 권한이 없습니다.", content = @Content(examples = { @ExampleObject(name = "지출 내역 권한 오류", description = "지출 내역에 대한 권한이 없습니다.", diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/ApiExceptionExplanation.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/ApiExceptionExplanation.java new file mode 100644 index 000000000..cbacc3e03 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/ApiExceptionExplanation.java @@ -0,0 +1,27 @@ +package kr.co.pennyway.api.common.annotation; + +import kr.co.pennyway.common.exception.BaseErrorCode; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiExceptionExplanation { + Class value(); + + /** + * BaseErrorCode를 구현한 Enum 클래스의 상수명 + */ + String constant(); + + String name() default ""; + + String mediaType() default "application/json"; + + String summary() default ""; + + String description() default ""; +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/ApiResponseExplanations.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/ApiResponseExplanations.java new file mode 100644 index 000000000..55f3cbbfa --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/annotation/ApiResponseExplanations.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.api.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiResponseExplanations { + ApiExceptionExplanation[] errors() default {}; +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/swagger/ApiExceptionExplainParser.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/swagger/ApiExceptionExplainParser.java new file mode 100644 index 000000000..f7c78cdfb --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/swagger/ApiExceptionExplainParser.java @@ -0,0 +1,87 @@ +package kr.co.pennyway.api.common.swagger; + +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import kr.co.pennyway.api.common.annotation.ApiExceptionExplanation; +import kr.co.pennyway.api.common.annotation.ApiResponseExplanations; +import kr.co.pennyway.api.common.response.ErrorResponse; +import kr.co.pennyway.common.exception.BaseErrorCode; +import lombok.AccessLevel; +import lombok.Builder; +import org.springframework.util.StringUtils; +import org.springframework.web.method.HandlerMethod; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public final class ApiExceptionExplainParser { + public static void parse(Operation operation, HandlerMethod handlerMethod) { + ApiResponseExplanations annotation = handlerMethod.getMethodAnnotation(ApiResponseExplanations.class); + + if (annotation != null) { + generateExceptionResponseDocs(operation, annotation.errors()); + } + } + + private static void generateExceptionResponseDocs(Operation operation, ApiExceptionExplanation[] exceptions) { + ApiResponses responses = operation.getResponses(); + + Map> holders = Arrays.stream(exceptions) + .map(ExampleHolder::from) + .collect(Collectors.groupingBy(ExampleHolder::httpStatus)); + + addExamplesToResponses(responses, holders); + } + + private static void addExamplesToResponses(ApiResponses responses, Map> holders) { + holders.forEach((httpStatus, exampleHolders) -> { + Content content = new Content(); + MediaType mediaType = new MediaType(); + ApiResponse response = new ApiResponse(); + + exampleHolders.forEach(holder -> mediaType.addExamples(holder.name(), holder.holder())); + content.addMediaType("application/json", mediaType); + response.setContent(content); + + responses.addApiResponse(String.valueOf(httpStatus), response); + }); + } + + @Builder(access = AccessLevel.PRIVATE) + private record ExampleHolder(int httpStatus, String name, String mediaType, String description, Example holder) { + static ExampleHolder from(ApiExceptionExplanation annotation) { + BaseErrorCode errorCode = getErrorCode(annotation); + + return ExampleHolder.builder() + .httpStatus(errorCode.causedBy().statusCode().getCode()) + .name(StringUtils.hasText(annotation.name()) ? annotation.name() : errorCode.getExplainError()) + .mediaType(annotation.mediaType()) + .description(annotation.description()) + .holder(createExample(errorCode, annotation.summary(), annotation.description())) + .build(); + } + + @SuppressWarnings("unchecked") + public static & BaseErrorCode> E getErrorCode(ApiExceptionExplanation annotation) { + Class enumClass = (Class) annotation.value(); + return Enum.valueOf(enumClass, annotation.constant()); + } + + private static Example createExample(BaseErrorCode errorCode, String summary, String description) { + ErrorResponse response = ErrorResponse.of(errorCode.causedBy().getCode(), errorCode.getExplainError()); + + Example example = new Example(); + example.setValue(response); + example.setSummary(summary); + example.setDescription(description); + + return example; + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java index ded3ca280..ec8ed8fcc 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/SwaggerConfig.java @@ -4,16 +4,20 @@ import io.swagger.v3.oas.annotations.servers.Server; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; +import kr.co.pennyway.api.common.swagger.ApiExceptionExplainParser; import lombok.RequiredArgsConstructor; +import org.springdoc.core.customizers.OperationCustomizer; import org.springdoc.core.models.GroupedOpenApi; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; import org.springframework.util.ObjectUtils; import org.springframework.web.filter.ForwardedHeaderFilter; +import org.springframework.web.method.HandlerMethod; @Configuration @OpenAPIDefinition( @@ -38,7 +42,6 @@ public OpenAPI openAPI() { return new OpenAPI() .info(apiInfo(activeProfile)) - .addServersItem(new io.swagger.v3.oas.models.servers.Server().url("")) .addSecurityItem(securityRequirement) .components(securitySchemes()); } @@ -50,6 +53,7 @@ public GroupedOpenApi allApi() { return GroupedOpenApi.builder() .packagesToScan(targets) .group("전체 보기") + .addOperationCustomizer(customizer()) .build(); } @@ -60,6 +64,7 @@ public GroupedOpenApi authApi() { return GroupedOpenApi.builder() .packagesToScan(targets) .group("사용자 인증") + .addOperationCustomizer(customizer()) .build(); } @@ -70,6 +75,7 @@ public GroupedOpenApi userApi() { return GroupedOpenApi.builder() .packagesToScan(targets) .group("사용자 기본 기능") + .addOperationCustomizer(customizer()) .build(); } @@ -80,6 +86,7 @@ public GroupedOpenApi storageApi() { return GroupedOpenApi.builder() .packagesToScan(targets) .group("정적 파일 저장") + .addOperationCustomizer(customizer()) .build(); } @@ -90,6 +97,7 @@ public GroupedOpenApi ledgerApi() { return GroupedOpenApi.builder() .packagesToScan(targets) .group("지출 관리") + .addOperationCustomizer(customizer()) .build(); } @@ -100,6 +108,7 @@ public GroupedOpenApi backOfficeApi() { return GroupedOpenApi.builder() .packagesToScan(targets) .group("백오피스") + .addOperationCustomizer(customizer()) .build(); } @@ -108,6 +117,14 @@ ForwardedHeaderFilter forwardedHeaderFilter() { return new ForwardedHeaderFilter(); } + @Bean + public OperationCustomizer customizer() { + return (Operation operation, HandlerMethod handlerMethod) -> { + ApiExceptionExplainParser.parse(operation, handlerMethod); + return operation; + }; + } + private Components securitySchemes() { final var securitySchemeAccessToken = new SecurityScheme() .name(JWT) From a67b5890a407efcc45f7281945fb16299573f8e4 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 13 Aug 2024 18:00:53 +0900 Subject: [PATCH 145/152] =?UTF-8?q?refactor:=20=F0=9F=94=A7=20Redisson=20A?= =?UTF-8?q?uto=20Configuration=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=93=88=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=20=EC=A0=9C=EC=96=B4=20(#151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: pennyway domain config group 열거 타입 정의 * feat: domain config 인터페이스 및 어노테이션 정의 * feat: domain config import select 구현 * chore: redisson @configuration 제거, config group 상수 추가 * rename: distributed lock aop -> aspect * chore: batch 모듈 redisson auth configure exclude * chore: auto configurate 옵션 domain yml로 이전 * chore: redisson 관련 클래스 모두 @component 제거 -> 수동 bean 등록 * chore: api 모듈 domain config에 redisson 설정 추가 * test: redisson 테스트 disabled --- .../co/pennyway/api/config/DomainConfig.java | 12 ++++++++ .../src/main/resources/application.yml | 5 ++++ .../common/aop/CallTransactionFactory.java | 2 -- ...ockAop.java => DistributedLockAspect.java} | 4 +-- .../aop/RedissonCallNewTransaction.java | 2 -- .../aop/RedissonCallSameTransaction.java | 2 -- .../importer/EnablePennywayDomainConfig.java | 15 ++++++++++ .../common/importer/PennywayDomainConfig.java | 7 +++++ .../importer/PennywayDomainConfigGroup.java | 13 +++++++++ .../PennywayDomainConfigImportSelector.java | 24 +++++++++++++++ .../domain/config/RedissonConfig.java | 29 +++++++++++++++++-- .../src/main/resources/application-domain.yml | 4 +++ .../redisson/CouponDecreaseLockTest.java | 4 +-- 13 files changed, 109 insertions(+), 14 deletions(-) create mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/DomainConfig.java rename pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/{DistributedLockAop.java => DistributedLockAspect.java} (96%) create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayDomainConfig.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfig.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigGroup.java create mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigImportSelector.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/DomainConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/DomainConfig.java new file mode 100644 index 000000000..4acd02935 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/DomainConfig.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.api.config; + +import kr.co.pennyway.domain.common.importer.EnablePennywayDomainConfig; +import kr.co.pennyway.domain.common.importer.PennywayDomainConfigGroup; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnablePennywayDomainConfig({ + PennywayDomainConfigGroup.REDISSON +}) +public class DomainConfig { +} diff --git a/pennyway-batch/src/main/resources/application.yml b/pennyway-batch/src/main/resources/application.yml index c8a285441..7bc8d234a 100644 --- a/pennyway-batch/src/main/resources/application.yml +++ b/pennyway-batch/src/main/resources/application.yml @@ -20,6 +20,11 @@ spring: hikari: maximum-pool-size: 2 + data: + redis: + repositories: + enabled: false + --- spring: config: diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java index da749fa4f..77c7ed50b 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java @@ -1,9 +1,7 @@ package kr.co.pennyway.domain.common.aop; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -@Component @RequiredArgsConstructor public class CallTransactionFactory { private final RedissonCallNewTransaction redissonCallNewTransaction; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAop.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAspect.java similarity index 96% rename from pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAop.java rename to pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAspect.java index a635f7df7..a3b76c745 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAop.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAspect.java @@ -10,7 +10,6 @@ import org.aspectj.lang.reflect.MethodSignature; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; -import org.springframework.stereotype.Component; import java.lang.reflect.Method; @@ -19,9 +18,8 @@ */ @Slf4j @Aspect -@Component @RequiredArgsConstructor -public class DistributedLockAop { +public class DistributedLockAspect { private static final String REDISSON_LOCK_PREFIX = "LOCK:"; private final RedissonClient redissonClient; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java index 0749147d3..ba28b682e 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java @@ -2,11 +2,9 @@ import lombok.RequiredArgsConstructor; import org.aspectj.lang.ProceedingJoinPoint; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -@Component @RequiredArgsConstructor public class RedissonCallNewTransaction implements CallTransaction { /** diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java index d8afb0bf1..af6148eea 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java @@ -2,11 +2,9 @@ import lombok.RequiredArgsConstructor; import org.aspectj.lang.ProceedingJoinPoint; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -@Component @RequiredArgsConstructor public class RedissonCallSameTransaction implements CallTransaction { /** diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayDomainConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayDomainConfig.java new file mode 100644 index 000000000..4867a3cf3 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayDomainConfig.java @@ -0,0 +1,15 @@ +package kr.co.pennyway.domain.common.importer; + +import org.springframework.context.annotation.Import; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Import(PennywayDomainConfigImportSelector.class) +public @interface EnablePennywayDomainConfig { + PennywayDomainConfigGroup[] value(); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfig.java new file mode 100644 index 000000000..0c06696a0 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfig.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.common.importer; + +/** + * Pennyway Domain의 Configurations를 나타내는 Marker Interface + */ +public interface PennywayDomainConfig { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigGroup.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigGroup.java new file mode 100644 index 000000000..391e6483b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigGroup.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.importer; + +import kr.co.pennyway.domain.config.RedissonConfig; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PennywayDomainConfigGroup { + REDISSON(RedissonConfig.class); + + private final Class configClass; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigImportSelector.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigImportSelector.java new file mode 100644 index 000000000..01add00bf --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigImportSelector.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.common.importer; + +import kr.co.pennyway.common.util.MapUtils; +import org.springframework.context.annotation.DeferredImportSelector; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.lang.NonNull; + +import java.util.Arrays; +import java.util.Map; + +public class PennywayDomainConfigImportSelector implements DeferredImportSelector { + @NonNull + @Override + public String[] selectImports(@NonNull AnnotationMetadata metadata) { + return Arrays.stream(getGroups(metadata)) + .map(v -> v.getConfigClass().getName()) + .toArray(String[]::new); + } + + private PennywayDomainConfigGroup[] getGroups(AnnotationMetadata metadata) { + Map attributes = metadata.getAnnotationAttributes(EnablePennywayDomainConfig.class.getName()); + return (PennywayDomainConfigGroup[]) MapUtils.getObject(attributes, "value", new PennywayDomainConfigGroup[]{}); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java index 1665cc554..32e995f8e 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java @@ -1,14 +1,17 @@ package kr.co.pennyway.domain.config; +import kr.co.pennyway.domain.common.aop.CallTransactionFactory; +import kr.co.pennyway.domain.common.aop.DistributedLockAspect; +import kr.co.pennyway.domain.common.aop.RedissonCallNewTransaction; +import kr.co.pennyway.domain.common.aop.RedissonCallSameTransaction; +import kr.co.pennyway.domain.common.importer.PennywayDomainConfig; import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -@Configuration -public class RedissonConfig { +public class RedissonConfig implements PennywayDomainConfig { private static final String REDISSON_HOST_PREFIX = "redis://"; private final String host; private final int port; @@ -32,4 +35,24 @@ public RedissonClient redissonClient() { .setPassword(password); return Redisson.create(config); } + + @Bean + public RedissonCallNewTransaction redissonCallNewTransaction() { + return new RedissonCallNewTransaction(); + } + + @Bean + public RedissonCallSameTransaction redissonCallSameTransaction() { + return new RedissonCallSameTransaction(); + } + + @Bean + public CallTransactionFactory callTransactionFactory(RedissonCallNewTransaction redissonCallNewTransaction, RedissonCallSameTransaction redissonCallSameTransaction) { + return new CallTransactionFactory(redissonCallNewTransaction, redissonCallSameTransaction); + } + + @Bean + public DistributedLockAspect distributedLockAspect(RedissonClient redissonClient, CallTransactionFactory callTransactionFactory) { + return new DistributedLockAspect(redissonClient, callTransactionFactory); + } } \ No newline at end of file diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml index c01c48e3f..170b91654 100644 --- a/pennyway-domain/src/main/resources/application-domain.yml +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -15,6 +15,10 @@ spring: port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} + autoconfigure: + exclude: + - org.redisson.spring.starter.RedissonAutoConfigurationV2 + --- spring: config: diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java index 1256ab26a..f637ff6dd 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java @@ -7,7 +7,6 @@ import kr.co.pennyway.domain.domains.coupon.TestCouponDecreaseService; import kr.co.pennyway.domain.domains.coupon.TestCouponRepository; import lombok.extern.slf4j.Slf4j; -import org.junit.Ignore; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.domain.EntityScan; @@ -20,7 +19,6 @@ import static org.assertj.core.api.Assertions.assertThat; -@Ignore @Slf4j @DomainIntegrationTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @@ -42,6 +40,7 @@ void setUp() { @Test @Order(1) + @Disabled void 쿠폰차감_분산락_적용_동시성_300명_테스트() throws InterruptedException { // given int threadCount = 300; @@ -68,6 +67,7 @@ void setUp() { @Test @Order(2) + @Disabled void 쿠폰차감_분산락_미적용_동시성_300명_테스트() throws InterruptedException { // given int threadCount = 300; From 5f1e909db175c9b99cc49028c6bdf61558c0e6b5 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 13 Aug 2024 18:56:22 +0900 Subject: [PATCH 146/152] =?UTF-8?q?fix:=20=E2=9C=8F=EF=B8=8F=20Device=20To?= =?UTF-8?q?ken=20Session=20=EA=B4=80=EB=A6=AC=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: device token entity updated_at auditing 제거 -> 조회할 때마다 last_sigend_in 필드 업데이트 * fix: register service에서 이미 존재하는 토큰 조회 시, signed in at 갱신 * fix: device token 만료 에러 상수 제거 * docs: put_device 활성화되지 않은 토큰 에러 제거 * fix: device_token 삭제 요청시, deactivate 호출하는 로직으로 변경 * style: 사용하지 않는 의존성 제거 * test: 토큰 활성화, 비활성화 테스트 수정 --- .../api/apis/users/api/UserAccountApi.java | 8 ----- .../service/DeviceTokenRegisterService.java | 28 +++++++++++------- .../service/DeviceTokenUnregisterService.java | 4 +-- .../DeviceTokenRegisterServiceTest.java | 13 ++++----- .../DeviceTokenUnregisterServiceTest.java | 10 +++---- .../domains/device/domain/DeviceToken.java | 29 +++++++++++++++---- .../exception/DeviceTokenErrorCode.java | 3 -- .../device/service/DeviceTokenService.java | 7 +---- 8 files changed, 53 insertions(+), 49 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java index 725e916e7..25191c24e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/api/UserAccountApi.java @@ -27,14 +27,6 @@ public interface UserAccountApi { @Operation(summary = "디바이스 등록", description = "사용자의 디바이스 정보를 등록합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "deviceToken", schema = @Schema(implementation = DeviceTokenDto.RegisterRes.class)))), - @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "잘못된 디바이스 토큰 저장 요청", description = "서버에 동일한 이름의 토큰이 사용자에게 등록되어 있고, 해당 토큰이 만료처리되어 있을 경우에 해당한다. (애초에 발생해선 안 되는 에러)", value = """ - { - "code": "4005", - "message": "활성화되지 않은 디바이스 토큰 정보입니다." - } - """) - })), @ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = { @ExampleObject(name = "수정 요청 시, token에 매칭하는 디바이스 정보가 없는 경우", value = """ { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenRegisterService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenRegisterService.java index 3f36421ae..4a173cb38 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenRegisterService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenRegisterService.java @@ -1,8 +1,6 @@ package kr.co.pennyway.api.apis.users.service; import kr.co.pennyway.domain.domains.device.domain.DeviceToken; -import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; -import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException; import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; @@ -13,6 +11,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Slf4j @Service @RequiredArgsConstructor @@ -23,17 +23,25 @@ public class DeviceTokenRegisterService { @Transactional public DeviceToken execute(Long userId, String token) { User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); - DeviceToken deviceToken = getOrCreateDevice(user, token); - - if (!deviceToken.isActivated()) { - throw new DeviceTokenErrorException(DeviceTokenErrorCode.NOT_ACTIVATED_DEVICE); - } - return deviceToken; + return getOrCreateDevice(user, token); } + /** + * 사용자의 디바이스 토큰을 가져오거나 생성한다. + *

+ * 이미 등록된 디바이스 토큰인 경우 마지막 로그인 시간을 갱신한다. + */ private DeviceToken getOrCreateDevice(User user, String token) { - return deviceTokenService.readDeviceByUserIdAndToken(user.getId(), token) - .orElseGet(() -> deviceTokenService.createDevice(DeviceToken.of(token, user))); + Optional deviceToken = deviceTokenService.readDeviceByUserIdAndToken(user.getId(), token); + + if (deviceToken.isPresent()) { + DeviceToken device = deviceToken.get(); + device.activate(); + device.updateLastSignedInAt(); + return device; + } else { + return deviceTokenService.createDevice(DeviceToken.of(token, user)); + } } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java index 39b188325..6d50403b3 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenUnregisterService.java @@ -4,7 +4,6 @@ import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException; import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; -import kr.co.pennyway.domain.domains.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -14,7 +13,6 @@ @Service @RequiredArgsConstructor public class DeviceTokenUnregisterService { - private final UserService userService; private final DeviceTokenService deviceTokenService; @Transactional @@ -23,6 +21,6 @@ public void execute(Long userId, String token) { () -> new DeviceTokenErrorException(DeviceTokenErrorCode.NOT_FOUND_DEVICE) ); - deviceTokenService.deleteDevice(deviceToken); + deviceToken.deactivate(); } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java index c2f3230b0..4c99a9cd6 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java @@ -8,8 +8,6 @@ import kr.co.pennyway.api.config.fixture.UserFixture; import kr.co.pennyway.domain.config.JpaConfig; import kr.co.pennyway.domain.domains.device.domain.DeviceToken; -import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; -import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException; import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; @@ -25,7 +23,6 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.transaction.annotation.Transactional; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.springframework.test.util.AssertionErrors.*; @ExtendWith(MockitoExtension.class) @@ -101,7 +98,7 @@ void registerNewDeviceWhenDeviceIsAlreadyExists() { @Test @Transactional - @DisplayName("[3] token 등록 요청이 들어왔을 때, 활성화되지 않은 디바이스 토큰이 존재하는 경우 NOT_ACTIVATED_DEVICE 에러를 반환한다.") + @DisplayName("[3] token 등록 요청이 들어왔을 때, 활성화되지 않은 디바이스 토큰이 존재하는 경우 토큰을 활성화 상태로 변경한다.") void registerNewDeviceWhenDeviceIsNotActivated() { // given DeviceToken originDeviceToken = DeviceTokenFixture.INIT.toDevice(requestUser); @@ -109,8 +106,10 @@ void registerNewDeviceWhenDeviceIsNotActivated() { deviceTokenService.createDevice(originDeviceToken); DeviceTokenDto.RegisterReq request = DeviceTokenFixture.INIT.toRegisterReq(); - // when - then - DeviceTokenErrorException ex = assertThrows(DeviceTokenErrorException.class, () -> deviceTokenRegisterService.execute(requestUser.getId(), request.token())); - assertEquals("활성화되지 않은 디바이스 토큰이 존재하는 경우 Not Activated Device를 반환한다.", DeviceTokenErrorCode.NOT_ACTIVATED_DEVICE, ex.getBaseErrorCode()); + // when + DeviceToken response = deviceTokenRegisterService.execute(requestUser.getId(), request.token()); + + // then + assertTrue("디바이스가 활성화 상태여야 한다.", response.getActivated()); } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java index 11aa0bdf1..07f02b0fb 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java @@ -24,11 +24,9 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - import static org.junit.jupiter.api.Assertions.assertThrows; import static org.springframework.test.util.AssertionErrors.assertEquals; -import static org.springframework.test.util.AssertionErrors.assertNull; +import static org.springframework.test.util.AssertionErrors.assertFalse; @ExtendWith(MockitoExtension.class) @DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") @@ -56,7 +54,7 @@ void setUp() { @Test @Transactional - @DisplayName("사용자 ID와 origin token에 매칭되는 활성 디바이스가 존재하는 경우 디바이스를 삭제한다.") + @DisplayName("사용자 ID와 origin token에 매칭되는 활성 디바이스가 존재하는 경우 디바이스를 비활성화한다.") void unregisterDevice() { // given DeviceToken deviceToken = DeviceTokenFixture.INIT.toDevice(requestUser); @@ -66,8 +64,8 @@ void unregisterDevice() { deviceTokenUnregisterService.execute(requestUser.getId(), deviceToken.getToken()); // then - Optional deletedDevice = deviceTokenService.readDeviceByUserIdAndToken(requestUser.getId(), deviceToken.getToken()); - assertNull("디바이스가 삭제되어 있어야 한다.", deletedDevice.orElse(null)); + DeviceToken deletedDevice = deviceTokenService.readDeviceByUserIdAndToken(requestUser.getId(), deviceToken.getToken()).get(); + assertFalse("디바이스가 비활성화 되어있어야 한다.", deletedDevice.isActivated()); } @Test diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java index 616377934..b291ac360 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java @@ -1,28 +1,37 @@ package kr.co.pennyway.domain.domains.device.domain; import jakarta.persistence.*; -import kr.co.pennyway.domain.common.model.DateAuditable; import kr.co.pennyway.domain.domains.user.domain.User; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.ColumnDefault; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import java.time.LocalDateTime; import java.util.Objects; @Entity @Getter @Table(name = "device_token") +@EntityListeners(AuditingEntityListener.class) @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class DeviceToken extends DateAuditable { +public class DeviceToken { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String token; + @ColumnDefault("true") private Boolean activated; + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + private LocalDateTime lastSignedInAt; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; @@ -31,6 +40,7 @@ private DeviceToken(String token, Boolean activated, User user) { this.token = Objects.requireNonNull(token, "token은 null이 될 수 없습니다."); this.activated = Objects.requireNonNull(activated, "activated는 null이 될 수 없습니다."); this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다."); + this.lastSignedInAt = LocalDateTime.now(); } public static DeviceToken of(String token, User user) { @@ -49,12 +59,19 @@ public void deactivate() { this.activated = Boolean.FALSE; } + public void updateLastSignedInAt() { + this.lastSignedInAt = LocalDateTime.now(); + } + /** - * 디바이스 토큰을 갱신하고 활성화 상태로 변경한다. + * 디바이스 토큰이 만료되었는지 확인한다. + * + * @return 토큰이 갱신된지 7일이 지났으면 true, 그렇지 않으면 false */ - public void updateToken(String token) { - this.activated = Boolean.TRUE; - this.token = token; + public boolean isExpired() { + LocalDateTime now = LocalDateTime.now(); + + return lastSignedInAt.plusDays(7).isBefore(now); } @Override diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java index ec0ab16bc..32d05abf9 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java @@ -8,9 +8,6 @@ @RequiredArgsConstructor public enum DeviceTokenErrorCode implements BaseErrorCode { - /* 400 BAD_REQUEST */ - NOT_ACTIVATED_DEVICE(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "활성화되지 않은 디바이스 토큰 정보입니다."), - /* 404 NOT_FOUND */ NOT_FOUND_DEVICE(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "디바이스를 찾을 수 없습니다."); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java index b85df9169..21af5877b 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java @@ -24,12 +24,7 @@ public DeviceToken createDevice(DeviceToken deviceToken) { public Optional readDeviceByUserIdAndToken(Long userId, String token) { return deviceTokenRepository.findByUser_IdAndToken(userId, token); } - - @Transactional - public void deleteDevice(DeviceToken deviceToken) { - deviceTokenRepository.delete(deviceToken); - } - + @Transactional public void deleteDevicesByUserIdInQuery(Long userId) { deviceTokenRepository.deleteAllByUserIdInQuery(userId); From f472173d7bd177a0a511045f8e6a0705591b52bf Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 13 Aug 2024 19:59:37 +0900 Subject: [PATCH 147/152] =?UTF-8?q?fix:=20device=20token=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C,=207=EC=9D=BC=20=EC=9D=B4=EB=82=B4?= =?UTF-8?q?=EC=9D=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A7=8C=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EB=8A=94=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../co/pennyway/batch/reader/ActiveDeviceTokenReader.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java index 1467a588b..dee189e76 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java @@ -17,6 +17,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; +import java.time.LocalDateTime; + @Slf4j @Component @RequiredArgsConstructor @@ -30,6 +32,7 @@ public class ActiveDeviceTokenReader { @StepScope public QuerydslNoOffsetPagingItemReader querydslNoOffsetPagingItemReader() { QuerydslNoOffsetOptions options = QuerydslNoOffsetNumberOptions.of(deviceToken.id, Expression.ASC, "deviceTokenId"); + LocalDateTime sevenDaysAgo = LocalDateTime.now().minusDays(7); return QuerydslNoOffsetPagingItemReaderBuilder.builder() .entityManagerFactory(emf) @@ -39,7 +42,9 @@ public QuerydslNoOffsetPagingItemReader querydslNoOffsetPaging .select(createConstructorExpression()) .from(deviceToken) .innerJoin(user).on(deviceToken.user.id.eq(user.id)) - .where(deviceToken.activated.isTrue().and(user.notifySetting.accountBookNotify.isTrue())) + .where(deviceToken.activated.isTrue() + .and(user.notifySetting.accountBookNotify.isTrue()) + .and(deviceToken.lastSignedInAt.goe(sevenDaysAgo))) ) .idSelectQuery(queryFactory -> queryFactory.select(createConstructorExpression()).from(deviceToken)) .build(); From 0efc0661b8ba2607ac7914f3dc8fb481d1a0e828 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Mon, 19 Aug 2024 12:57:21 +0900 Subject: [PATCH 148/152] =?UTF-8?q?fix:=20=F0=9F=90=9B=20=EA=B5=90?= =?UTF-8?q?=ED=86=B5=EB=B9=84=20->=20=EA=B5=90=ED=86=B5=20(#155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pennyway/domain/domains/spending/type/SpendingCategory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java index 63734ef0f..aa5ad5431 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java @@ -11,7 +11,7 @@ public enum SpendingCategory implements LegacyCommonType { CUSTOM("0", "사용자 정의"), FOOD("1", "식비"), - TRANSPORTATION("2", "교통비"), + TRANSPORTATION("2", "교통"), BEAUTY_OR_FASHION("3", "뷰티/패션"), CONVENIENCE_STORE("4", "편의점/마트"), EDUCATION("5", "교육"), From 24d726266a7987ab5b8b4486c20cd42a5c11116c Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 20 Aug 2024 21:38:48 +0900 Subject: [PATCH 149/152] =?UTF-8?q?fix:=20=E2=9C=8F=EF=B8=8F=20OAuth=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EC=97=B0=EB=8F=99=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=9C=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(OAuth=20=EC=97=B0=EB=8F=99=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95)=20(#156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: provider, oauth_id candidate test * feat: already_used_oauth 에러 코드 추가 * feat: oauth_id, provider 조건으로 oauth entity 조회 메서드 추가 * fix: oauth 데이터 복구 시, user 파라미터 추가 * rename: test display name을 직관적으로 수정 * fix: 다른 사용자가 이미 사용 중인 경우 예외 처리 * test: oauth user_id 업데이트 테스트 조건 추가 * fix: oauth to_string에서 user 제거 * test: tx 제거 * test: oauth 삭제 이력 복구 로직 제거 테스트 * fix: oauth 복수 개 데이터가 조회될 수 있는 경우의 수 제거 * fix: oauth entity reverse_delete 메서드 제거 * fix: user_sync_dto 내부 sync_oauth dto 제거 * docs: swagger 에러 문서 갱신 * fix: oauth service 불필요 메서드 및 주석 제거 --- .../api/apis/auth/api/UserAuthApi.java | 17 +++--- .../api/apis/auth/dto/UserSyncDto.java | 47 +++------------ .../auth/service/UserGeneralSignService.java | 2 +- .../auth/service/UserOauthSignService.java | 37 ++++-------- .../apis/auth/usecase/UserAuthUseCase.java | 2 +- .../OAuthControllerIntegrationTest.java | 4 +- .../UserAuthControllerIntegrationTest.java | 57 +++++++++++++++++-- .../domain/domains/oauth/domain/Oauth.java | 13 ----- .../oauth/exception/OauthErrorCode.java | 1 + .../oauth/repository/OauthRepository.java | 6 +- .../domains/oauth/service/OauthService.java | 18 ++++-- 11 files changed, 99 insertions(+), 105 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java index d266e8f5b..a286eaf3e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/UserAuthApi.java @@ -13,7 +13,10 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.co.pennyway.api.apis.auth.dto.AuthStateDto; import kr.co.pennyway.api.apis.auth.dto.SignInReq; +import kr.co.pennyway.api.common.annotation.ApiExceptionExplanation; +import kr.co.pennyway.api.common.annotation.ApiResponseExplanations; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; import kr.co.pennyway.domain.domains.oauth.type.Provider; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -67,14 +70,12 @@ ResponseEntity signOut( @Parameter(name = "provider", description = "소셜 제공자", examples = { @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") }, required = true, in = ParameterIn.QUERY) - @ApiResponse(responseCode = "409", content = @Content(mediaType = "application/json", examples = { - @ExampleObject(name = "해당 provider로 로그인한 이력이 이미 존재함", value = """ - { - "code": "4091", - "message": "이미 해당 제공자로 가입된 사용자입니다." - } - """) - })) + @ApiResponseExplanations( + errors = { + @ApiExceptionExplanation(value = OauthErrorCode.class, constant = "ALREADY_USED_OAUTH", name = "다른 사용자가 사용 중"), + @ApiExceptionExplanation(value = OauthErrorCode.class, constant = "ALREADY_SIGNUP_OAUTH", name = "이미 연동된 계정") + } + ) ResponseEntity linkOauth(@RequestParam Provider provider, @RequestBody @Validated SignInReq.Oauth request, @AuthenticationPrincipal SecurityUserDetails user); @Operation(summary = "소셜 계정 연동 해제", description = "인증된 사용자의 소셜 계정 연동을 해제한다. 연동되지 않은 계정을 해제하려고 하는 경우에는 404 에러를 반환한다. 미인증 사용자는 해당 API를 사용할 수 없다.") diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/UserSyncDto.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/UserSyncDto.java index a6d0a345b..2a626d1ac 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/UserSyncDto.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/dto/UserSyncDto.java @@ -1,10 +1,5 @@ package kr.co.pennyway.api.apis.auth.dto; -import kr.co.pennyway.domain.domains.oauth.domain.Oauth; -import kr.co.pennyway.domain.domains.oauth.type.Provider; - -import java.time.LocalDateTime; - /** * 전화번호 검증 후, 시나리오 분기 정보를 위한 DTO */ @@ -13,59 +8,31 @@ public record UserSyncDto( boolean isSignUpAllowed, boolean isExistAccount, Long userId, - String username, - /* 계정과 연동하기 위한 oauth 정보. 없다면 null */ - OauthSync oauthSync + String username ) { /** * @param isSignUpAllowed boolean : 회원가입 시나리오 가능 여부 (true: 회원가입 혹은 계정 연동 가능, false: 불가능) * @param isExistAccount boolean : 이미 존재하는 계정 여부 * @param userId Long : 사용자 ID. 없다면 null * @param username String : 사용자 이름. 없다면 null - * @param oauthSync {@link OauthSync} : 연동할 Oauth 정보. 없다면 null */ - public static UserSyncDto of(boolean isSignUpAllowed, boolean isExistAccount, Long userId, String username, OauthSync oauthSync) { - return new UserSyncDto(isSignUpAllowed, isExistAccount, userId, username, oauthSync); + public static UserSyncDto of(boolean isSignUpAllowed, boolean isExistAccount, Long userId, String username) { + return new UserSyncDto(isSignUpAllowed, isExistAccount, userId, username); } /** * 이미 회원이 존재하는 경우 사용하는 편의용 메서드.
- * 내부에서 {@link UserSyncDto#of(boolean, boolean, Long, String, OauthSync)}를 호출한다. + * 내부에서 {@link UserSyncDto#of(boolean, boolean, Long, String)}를 호출한다. */ public static UserSyncDto abort(Long userId, String username) { - return UserSyncDto.of(false, true, userId, username, null); + return UserSyncDto.of(false, true, userId, username); } /** * 회원 가입 이력이 없는 경우 사용하는 편의용 메서드.
- * 내부에서 {@link UserSyncDto#of(boolean, boolean, Long, String, OauthSync)}를 호출한다. + * 내부에서 {@link UserSyncDto#of(boolean, boolean, Long, String)}를 호출한다. */ public static UserSyncDto signUpAllowed() { - return UserSyncDto.of(true, false, null, null, null); - } - - /** - * 기존의 soft delete된 Oauth 정보가 있는지 확인한다. - */ - public boolean isExistOauthAccount() { - return oauthSync != null; - } - - public record OauthSync( - Long id, - String oauthId, - Provider provider, - LocalDateTime deletedAt - ) { - /** - * Oauth 정보를 OauthSync로 변환한다.
- * Oauth 정보가 없는 경우 null을 반환한다. - */ - public static OauthSync from(Oauth oauth) { - if (oauth == null) { - return null; - } - return new OauthSync(oauth.getId(), oauth.getOauthId(), oauth.getProvider(), oauth.getDeletedAt()); - } + return UserSyncDto.of(true, false, null, null); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java index dac852447..75bf2cfee 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserGeneralSignService.java @@ -47,7 +47,7 @@ public UserSyncDto isSignUpAllowed(String phone) { } log.info("소셜 회원가입 사용자입니다. user: {}", user.get()); - return UserSyncDto.of(true, true, user.get().getId(), user.get().getUsername(), null); + return UserSyncDto.of(true, true, user.get().getId(), user.get().getUsername()); } /** diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java index fea0b0300..db804abd5 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/service/UserOauthSignService.java @@ -48,15 +48,13 @@ public UserSyncDto isSignUpAllowed(Provider provider, String phone) { return UserSyncDto.signUpAllowed(); } - Optional oauth = oauthService.readOauthByUserIdAndProvider(user.get().getId(), provider); - - if (oauth.isPresent() && !oauth.get().isDeleted()) { + if (oauthService.isExistOauthByUserIdAndProvider(user.get().getId(), provider)) { log.info("이미 동일한 Provider로 가입된 사용자입니다. phone: {}, provider: {}", phone, provider); return UserSyncDto.abort(user.get().getId(), user.get().getUsername()); } log.info("소셜 회원가입 사용자입니다. user: {}", user.get()); - return UserSyncDto.of(true, true, user.get().getId(), user.get().getUsername(), UserSyncDto.OauthSync.from(oauth.orElse(null))); + return UserSyncDto.of(true, true, user.get().getId(), user.get().getUsername()); } /** @@ -65,17 +63,20 @@ public UserSyncDto isSignUpAllowed(Provider provider, String phone) { * @return {@link UserSyncDto} */ @Transactional(readOnly = true) - public UserSyncDto isLinkAllowed(Long userId, Provider provider) { - Optional oauth = oauthService.readOauthByUserIdAndProvider(userId, provider); - - if (oauth.isPresent() && !oauth.get().isDeleted()) { - log.info("이미 동일한 Provider로 가입된 사용자입니다. userId: {}, provider: {}", userId, provider); + public UserSyncDto isLinkAllowed(Long userId, String oauthId, Provider provider) { + if (oauthService.isExistOauthByUserIdAndProvider(userId, provider)) { + log.info("이미 해당 Oauth 계정에 연동되어 있습니다. userId: {}, provider: {}", userId, provider); throw new OauthException(OauthErrorCode.ALREADY_SIGNUP_OAUTH); } + if (oauthService.isExistOauthByOauthIdAndProvider(oauthId, provider)) { + log.info("이미 다른 사용자가 사용 중인 Oauth 계정입니다. oauthId: {}, provider: {}", oauthId, provider); + throw new OauthException(OauthErrorCode.ALREADY_USED_OAUTH); + } + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); - return UserSyncDto.of(true, true, user.getId(), user.getUsername(), UserSyncDto.OauthSync.from(oauth.orElse(null))); + return UserSyncDto.of(true, true, user.getId(), user.getUsername()); } /** @@ -122,23 +123,9 @@ public User saveUser(SignUpReq.OauthInfo request, UserSyncDto userSync, Provider userService.createUser(user); } - Oauth oauth = readOrCreateOauth(userSync, provider, oauthId, user); - oauthService.createOauth(oauth); + Oauth oauth = oauthService.createOauth(Oauth.of(provider, oauthId, user)); log.info("연동된 Oauth 정보 : {}", oauth); return user; } - - private Oauth readOrCreateOauth(UserSyncDto userSync, Provider provider, String oauthId, User user) { - if (userSync.isExistOauthAccount()) { - Oauth oauth = oauthService.readOauth(userSync.oauthSync().id()).orElseThrow(() -> new OauthException(OauthErrorCode.NOT_FOUND_OAUTH)); - oauth.revertDelete(oauthId); - log.info("기존 Oauth 계정을 복구합니다. oauth: {}", oauth); - - return oauth; - } - - log.info("새로운 Oauth 계정을 생성합니다. oauthId: {}", oauthId); - return Oauth.of(provider, oauthId, user); - } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java index a3ac87289..c0798d517 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/UserAuthUseCase.java @@ -49,7 +49,7 @@ public void signOut(Long userId, String authHeader, String refreshToken) { public void linkOauth(Provider provider, SignInReq.Oauth request, Long userId) { OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.oauthId(), request.idToken(), request.nonce()); - UserSyncDto userSync = userOauthSignService.isLinkAllowed(userId, provider); + UserSyncDto userSync = userOauthSignService.isLinkAllowed(userId, request.oauthId(), provider); userOauthSignService.saveUser(null, userSync, provider, payload.sub()); } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java index 903ae61f3..af0bdc4db 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/OAuthControllerIntegrationTest.java @@ -527,7 +527,7 @@ void signUpWithSameProvider() throws Exception { @Test @WithAnonymousUser @Transactional - @DisplayName("같은 provider로 Oauth 로그인 이력이 soft delete 되었으면, Oauth 정보가 복구되고 새로운 oauth_id를 반영한다.") + @DisplayName("같은 provider로 Oauth 로그인 이력이 soft delete 되었으면, 새로운 Oauth 정보가 추가되고 로그인에 성공한다.") void signUpWithDeletedOauth() throws Exception { // given Provider provider = Provider.KAKAO; @@ -552,10 +552,8 @@ void signUpWithDeletedOauth() throws Exception { .andDo(print()); Oauth savedOauth = oauthService.readOauthByOauthIdAndProvider("newOauthId", provider).get(); assertEquals(user.getId(), savedOauth.getUser().getId()); - assertEquals(oauth.getId(), savedOauth.getId()); assertEquals("newOauthId", savedOauth.getOauthId()); assertFalse(savedOauth.isDeleted()); - log.debug("oauth : {}", savedOauth); } private ResultActions performOauthSignUpAccountLinking(Provider provider, String code, String oauthId) throws Exception { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java index 95ee754bd..62bf9599b 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/integration/UserAuthControllerIntegrationTest.java @@ -239,11 +239,11 @@ void linkOauthWithNoHistory() throws Exception { // then result.andExpect(status().isOk()).andDo(print()); - assertTrue(oauthService.isExistOauthAccount(user.getId(), expectedProvider)); + assertTrue(oauthService.isExistOauthByUserIdAndProvider(user.getId(), expectedProvider)); } @Test - @DisplayName("provider로 로그인한 이력이 있다면, 사용자는 계정 연동에 실패하고 409 에러를 반환한다.") + @DisplayName("이미 해당 소셜 계정에 연동했다면, ALREADY_SIGNUP_OAUTH 에러를 반환한다.") @Transactional void linkOauthWithHistory() throws Exception { // given @@ -264,7 +264,7 @@ void linkOauthWithHistory() throws Exception { } @Test - @DisplayName("해당 provider가 soft delete된 이력이 존재한다면, deleted_at을 null로 업데이트하고 최신 oauth_id를 반영하여 계정 연동에 성공한다.") + @DisplayName("해당 소셜 계정으로 연동했었던 이력이 삭제되어 있다면, 새로운 데이터를 생성하고 연동에 성공한다.") @Transactional void linkOauthWithDeletedHistory() throws Exception { // given @@ -281,11 +281,56 @@ void linkOauthWithDeletedHistory() throws Exception { // then result.andExpect(status().isOk()).andDo(print()); - Oauth savedOauth = oauthService.readOauth(oauth.getId()).orElse(null); + } + + @Test + @DisplayName("다른 계정에 이미 해당 소셜 계정이 연동되어 있다면, 409 ALREADY_USED_OAUTH 에러를 반환한다.") + @Transactional + void linkOauthWithAlreadyUsedOauth() throws Exception { + // given + User user1 = userService.createUser(UserFixture.GENERAL_USER.toUser()); + Provider provider = Provider.KAKAO; + String oauthId = "oauthId"; + oauthService.createOauth(Oauth.of(provider, oauthId, user1)); + + User user2 = userService.createUser(UserFixture.GENERAL_USER.toUser()); + + given(oauthOidcHelper.getPayload(provider, oauthId, "idToken", "nonce")).willReturn(new OidcDecodePayload("iss", "aud", oauthId, "email")); + + // when + ResultActions result = performLinkOauth(provider, oauthId, user2); + + // then + result + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.ALREADY_USED_OAUTH.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.ALREADY_USED_OAUTH.getExplainError())) + .andDo(print()); + } + + @Test + @DisplayName("다른 계정에서 해당 소셜 계정을 연동했었던 삭제 이력이 있다면, 새로운 데이터를 생성하고 연동에 성공한다.") + void linkOauthWithDeletedOauth() throws Exception { + // given + User user1 = userService.createUser(UserFixture.GENERAL_USER.toUser()); + Provider provider = Provider.KAKAO; + String oauthId = "oauthId"; + Oauth oauth = oauthService.createOauth(Oauth.of(provider, oauthId, user1)); + log.info("생성된 Oauth 정보 : {}", oauth); + oauthService.deleteOauth(oauth); + + User user2 = userService.createUser(UserFixture.OAUTH_USER.toUser()); + + given(oauthOidcHelper.getPayload(provider, oauthId, "idToken", "nonce")).willReturn(new OidcDecodePayload("iss", "aud", oauthId, "email")); + + // when + ResultActions result = performLinkOauth(provider, oauthId, user2); + + // then + result.andExpect(status().isOk()).andDo(print()); + Oauth savedOauth = oauthService.readOauthsByUserId(user2.getId()).stream().filter(o -> o.getProvider().equals(provider)).findFirst().orElse(null); assertNotNull(savedOauth); - assertEquals("newOauthId", savedOauth.getOauthId()); assertNull(savedOauth.getDeletedAt()); - log.info("연동된 Oauth 정보 : {}", savedOauth); } private ResultActions performLinkOauth(Provider provider, String oauthId, User requestUser) throws Exception { diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java index add81268b..0a566a20d 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java @@ -68,18 +68,6 @@ public boolean isDeleted() { return deletedAt != null; } - public void revertDelete(String oauthId) { - if (deletedAt == null) { - throw new IllegalStateException("삭제되지 않은 oauth 정보 갱신 요청입니다. oauthId: " + oauthId); - } - if (!StringUtils.hasText(oauthId)) { - throw new IllegalArgumentException("oauthId는 null이거나 빈 문자열이 될 수 없습니다."); - } - - this.oauthId = oauthId; - this.deletedAt = null; - } - @Override public String toString() { return "Oauth{" + @@ -88,7 +76,6 @@ public String toString() { ", oauthId='" + oauthId + '\'' + ", createdAt=" + createdAt + ", deletedAt=" + deletedAt + - ", user=" + user + '}'; } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java index 1963f8d3e..2c2657149 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java @@ -21,6 +21,7 @@ public enum OauthErrorCode implements BaseErrorCode { /* 409 Conflict */ CANNOT_UNLINK_OAUTH(StatusCode.CONFLICT, ReasonCode.REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE, "해당 제공자로만 가입된 사용자는 연동을 해제할 수 없습니다."), + ALREADY_USED_OAUTH(StatusCode.CONFLICT, ReasonCode.REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE, "이미 다른 계정에서 사용 중인 계정입니다."), ALREADY_SIGNUP_OAUTH(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 해당 제공자로 가입된 사용자입니다."), /* 422 Unprocessable Entity */ diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java index 042c58cbc..5012fbeaa 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java @@ -13,11 +13,13 @@ public interface OauthRepository extends JpaRepository { Optional findByOauthIdAndProviderAndDeletedAtIsNull(String oauthId, Provider provider); - Optional findByUser_IdAndProvider(Long userId, Provider provider); + Optional findByUser_IdAndProviderAndDeletedAtIsNull(Long userId, Provider provider); Set findAllByUser_Id(Long userId); - boolean existsByUser_IdAndProvider(Long userId, Provider provider); + boolean existsByUser_IdAndProviderAndDeletedAtIsNull(Long userId, Provider provider); + + boolean existsByOauthIdAndProviderAndDeletedAtIsNull(String oauthId, Provider provider); @Transactional @Modifying(clearAutomatically = true) diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java index e71bd9ea0..02ac5ca0c 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java @@ -34,18 +34,24 @@ public Optional readOauthByOauthIdAndProvider(String oauthId, Provider pr } @Transactional(readOnly = true) - public Optional readOauthByUserIdAndProvider(Long userId, Provider provider) { - return oauthRepository.findByUser_IdAndProvider(userId, provider); + public Set readOauthsByUserId(Long userId) { + return oauthRepository.findAllByUser_Id(userId); } + /** + * userId와 provider로 Oauth가 존재하는지 확인한다. 이 때, deletedAt이 null인 Oauth만 조회한다. + */ @Transactional(readOnly = true) - public Set readOauthsByUserId(Long userId) { - return oauthRepository.findAllByUser_Id(userId); + public boolean isExistOauthByUserIdAndProvider(Long userId, Provider provider) { + return oauthRepository.existsByUser_IdAndProviderAndDeletedAtIsNull(userId, provider); } + /** + * oauthId와 provider로 Oauth가 존재하는지 확인한다. 이 때, deletedAt이 null인 Oauth만 조회한다. + */ @Transactional(readOnly = true) - public boolean isExistOauthAccount(Long userId, Provider provider) { - return oauthRepository.existsByUser_IdAndProvider(userId, provider); + public boolean isExistOauthByOauthIdAndProvider(String oauthId, Provider provider) { + return oauthRepository.existsByOauthIdAndProviderAndDeletedAtIsNull(oauthId, provider); } @Transactional From 96d9bd8be71fc284b14b2d890e6df80b05890bec Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Thu, 22 Aug 2024 11:57:15 +0900 Subject: [PATCH 150/152] =?UTF-8?q?fix:=20=F0=9F=90=9B=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=B3=84=20=EC=86=8C=EB=B9=84=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=A0=95=EB=A0=AC=20=EC=A1=B0=EA=B1=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 정렬 조건 spend_at 내림차순, id 오름차순으로 수정 * docs: swagger error 커스텀 어노테이션으로 지정 --- .../apis/ledger/api/SpendingCategoryApi.java | 17 +++++++---------- .../controller/SpendingCategoryController.java | 5 ++++- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java index d6f9b20e2..cf3c0a4d2 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/api/SpendingCategoryApi.java @@ -10,8 +10,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; +import kr.co.pennyway.api.common.annotation.ApiExceptionExplanation; +import kr.co.pennyway.api.common.annotation.ApiResponseExplanations; import kr.co.pennyway.api.common.query.SpendingCategoryType; import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails; +import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; @@ -93,20 +96,14 @@ ResponseEntity getSpendingTotalCountByCategory( }), @Parameter(name = "size", description = "페이지 사이즈 (default: 30)", example = "30", in = ParameterIn.QUERY), @Parameter(name = "page", description = "페이지 번호 (default: 0)", example = "0", in = ParameterIn.QUERY), - @Parameter(name = "sort", description = "정렬 기준 (default: sending.spendAt)", example = "spending.spendAt", in = ParameterIn.QUERY), - @Parameter(name = "direction", description = "정렬 방식 (default: DESC)", example = "DESC", in = ParameterIn.QUERY), + @Parameter(name = "sort", description = "정렬 기준 (default: 소비내역 내림차순, 식별값 오름차순)", example = "spending.spendAt,DESC&sort=spending.id,ASC", in = ParameterIn.QUERY, allowReserved = true), @Parameter(name = "pageable", hidden = true) }) @ApiResponses({ @ApiResponse(responseCode = "200", description = "지출 내역 조회 성공", content = @Content(mediaType = "application/json", schemaProperties = @SchemaProperty(name = "spendings", schema = @Schema(implementation = SpendingSearchRes.MonthSlice.class)))), - @ApiResponse(responseCode = "400", description = "type과 categoryId 미스 매치", content = @Content(examples = - @ExampleObject(name = "type과 categoryId가 유효하지 않은 조합", description = "type이 default면서, categoryId가 CUSTOM(0) 혹은 OTHER(12)일 수는 없다.", value = """ - { - "code": "4005", - "message": "type의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다." - } - """ - ))) + }) + @ApiResponseExplanations(errors = { + @ApiExceptionExplanation(value = SpendingErrorCode.class, constant = "INVALID_TYPE_WITH_CATEGORY_ID", name = "type과 categoryId 미스 매치", description = "type이 default면서, categoryId가 CUSTOM(0) 혹은 OTHER(12)일 수는 없다.") }) ResponseEntity getSpendingsByCategory( @PathVariable(value = "categoryId") Long categoryId, diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java index 4bc300d36..5ba38cc0e 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryController.java @@ -76,7 +76,10 @@ public ResponseEntity getSpendingTotalCountByCategory( public ResponseEntity getSpendingsByCategory( @PathVariable(value = "categoryId") Long categoryId, @RequestParam(value = "type") SpendingCategoryType type, - @PageableDefault(size = 30, page = 0) @SortDefault(sort = "spending.spendAt", direction = Sort.Direction.DESC) Pageable pageable, + @PageableDefault(size = 30, page = 0) @SortDefault.SortDefaults({ + @SortDefault(sort = "spending.spendAt", direction = Sort.Direction.DESC), + @SortDefault(sort = "spending.id", direction = Sort.Direction.ASC) + }) Pageable pageable, @AuthenticationPrincipal SecurityUserDetails user ) { if (type.equals(SpendingCategoryType.DEFAULT) && (categoryId.equals(0L) || categoryId.equals(12L))) { From f3216b450cb6279cd331704fde8e086e61f478e5 Mon Sep 17 00:00:00 2001 From: Jinwoo Lee Date: Sat, 24 Aug 2024 15:47:10 +0900 Subject: [PATCH 151/152] =?UTF-8?q?=F0=9F=94=A7=20Blue-Green=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20deploy=20script=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: blue-green 배포를 위한 deploy script 수정 * feat: deploy script에 workflow dispatch 추가 * fix: 무중단 배포 파이프라인 수정 및 script 별도 정의 --- .github/workflows/deploy-external-api.yml | 24 ++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-external-api.yml b/.github/workflows/deploy-external-api.yml index 8e7a37006..e048b30e9 100644 --- a/.github/workflows/deploy-external-api.yml +++ b/.github/workflows/deploy-external-api.yml @@ -7,6 +7,25 @@ on: description: '배포할 Api 모듈 태그 정보 (Api-v*.*.*)' required: true type: string + workflow_dispatch: + inputs: + logLevel: + description: 'Log level' + required: true + default: 'warning' + type: choice + options: + - info + - warning + - debug + tags: + description: 'Test scenario tags' + required: false + type: boolean + environment: + description: 'Environment to run tests against' + type: environment + required: false permissions: contents: read @@ -64,6 +83,8 @@ jobs: - name: AWS SSM Send-Command uses: peterkimzz/aws-ssm-send-command@master id: ssm + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} with: aws-region: ${{ secrets.AWS_REGION }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -74,4 +95,5 @@ jobs: docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} docker system prune -a -f docker pull pennyway/pennyway-was - docker-compose up -d \ No newline at end of file + chmod +x deploy.sh + bash -x ./deploy.sh \ No newline at end of file From af1a9a5ef1835cfba074a17a8882e67fdd379038 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 27 Aug 2024 19:49:25 +0900 Subject: [PATCH 152/152] =?UTF-8?q?fix:=20=E2=9C=8F=EF=B8=8F=20Proxy=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EB=A1=9C=20IP=20Logging=20=20=EC=9C=84?= =?UTF-8?q?=EC=9E=84=EC=9D=84=20=ED=86=B5=ED=95=9C=20WAS=20=EC=95=A0?= =?UTF-8?q?=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20=EB=82=B4=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=EC=85=89=ED=84=B0=20=EC=A0=9C=EA=B1=B0=20(#1?= =?UTF-8?q?58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: domain sigin_in table 정보 제거 * fix: sigin in event redis 코드 제거 * fix: sigin event interceptor 제거 * fix: web_config jwt provider 의존성 제거 * fix: ip header converter 제거 * test: 모든 컨트롤러 테스트에서 web config 클래스 exclude 제거 --- .../interceptor/SignEventLogInterceptor.java | 125 ------------------ .../kr/co/pennyway/api/config/WebConfig.java | 13 -- .../controller/AuthCheckControllerTest.java | 6 +- .../AuthControllerValidationTest.java | 6 +- .../GetSpendingsByCategoryControllerTest.java | 6 - .../SpendingCategoryControllerUnitTest.java | 6 +- .../SpendingCategoryUpdateControllerTest.java | 6 - .../SpendingControllerUnitTest.java | 6 +- .../TargetAmountControllerUnitTest.java | 6 +- .../GetNotificationsControllerUnitTest.java | 6 +- .../controller/QuestionControllerTest.java | 6 +- .../controller/StorageControllerTest.java | 6 +- .../UserAccountControllerUnitTest.java | 6 +- .../converter/IpAddressHeaderConverter.java | 13 -- .../common/redis/sign/SignEventLog.java | 45 ------- .../redis/sign/SignEventLogRepository.java | 6 - .../redis/sign/SignEventLogService.java | 23 ---- .../domain/domains/sign/domain/SignInLog.java | 43 ------ .../domains/sign/domain/SignInLogId.java | 35 ----- .../sign/repository/SignInLogRepository.java | 8 -- .../sign/service/SignInLogService.java | 11 -- .../domains/sign/type/IpAddressHeader.java | 19 --- 22 files changed, 9 insertions(+), 398 deletions(-) delete mode 100644 pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/interceptor/SignEventLogInterceptor.java delete mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/IpAddressHeaderConverter.java delete mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLog.java delete mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogRepository.java delete mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogService.java delete mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLog.java delete mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLogId.java delete mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/repository/SignInLogRepository.java delete mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/service/SignInLogService.java delete mode 100644 pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/type/IpAddressHeader.java diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/interceptor/SignEventLogInterceptor.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/interceptor/SignEventLogInterceptor.java deleted file mode 100644 index 4f0f99e9a..000000000 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/interceptor/SignEventLogInterceptor.java +++ /dev/null @@ -1,125 +0,0 @@ -package kr.co.pennyway.api.common.interceptor; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import kr.co.pennyway.api.common.security.jwt.JwtClaimsParserUtil; -import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaimKeys; -import kr.co.pennyway.domain.common.redis.sign.SignEventLog; -import kr.co.pennyway.domain.common.redis.sign.SignEventLogService; -import kr.co.pennyway.domain.domains.sign.type.IpAddressHeader; -import kr.co.pennyway.infra.common.jwt.JwtProvider; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; -import org.springframework.http.HttpHeaders; -import org.springframework.lang.NonNull; -import org.springframework.web.servlet.HandlerInterceptor; -import org.springframework.web.servlet.ModelAndView; - -import java.time.LocalDateTime; -import java.util.regex.Pattern; - -@Slf4j -public class SignEventLogInterceptor implements HandlerInterceptor { - /** - *

- * User-Agent에서 앱 버전을 추출하기 위한 정규식 패턴이다. - * User-Agent는 "AppName/Version (platform; os; deviceModel)" 형식으로 되어있는 문자열이다. - *

- */ - private static final Pattern pattern = Pattern.compile("^(\\w+)/(\\d+\\.\\d+) \\((\\w+); (\\w+ \\d+\\.\\d+); (\\w+\\d+,\\d+)\\)$"); - private final SignEventLogService signEventLogService; - private final JwtProvider accessTokenProvider; - - public SignEventLogInterceptor(SignEventLogService signEventLogService, JwtProvider accessTokenProvider) { - this.signEventLogService = signEventLogService; - this.accessTokenProvider = accessTokenProvider; - } - - @Override - public void postHandle(@NonNull HttpServletRequest request, HttpServletResponse response, @NonNull Object handler, ModelAndView modelAndView) { - if (response.getStatus() != 200 || response.getHeader(HttpHeaders.AUTHORIZATION) == null) { - return; - } - - String accessToken = response.getHeader(HttpHeaders.AUTHORIZATION); - Long userId = JwtClaimsParserUtil.getClaimsValue(accessTokenProvider.getJwtClaimsFromToken(accessToken), AccessTokenClaimKeys.USER_ID.getValue(), Long::parseLong); - - UserAgentInfo userAgent = getUserAgentInfo(request.getHeader(HttpHeaders.USER_AGENT)); - Pair ipAddress = getClientIP(request); - - SignEventLog signEventLog = SignEventLog.builder() - .userId(userId) - .ipAddressHeader(ipAddress.getKey().getType()) - .ipAddress(ipAddress.getValue()) - .appVersion(userAgent.appVersion()) - .deviceModel(userAgent.deviceModel()) - .os(userAgent.os()) - .signedAt(LocalDateTime.now()) - .build(); - log.debug("SignEventLog: {}", signEventLog); - - signEventLogService.create(signEventLog); - } - - private Pair getClientIP(HttpServletRequest request) { - IpAddressHeader headerType = IpAddressHeader.X_FORWARDED_FOR; - String ip = request.getHeader(headerType.getType()); - - if (ip == null) { - headerType = IpAddressHeader.PROXY_CLIENT_IP; - ip = request.getHeader(headerType.getType()); - } - if (ip == null) { - headerType = IpAddressHeader.WL_PROXY_CLIENT_IP; - ip = request.getHeader(headerType.getType()); - } - if (ip == null) { - headerType = IpAddressHeader.HTTP_CLIENT_IP; - ip = request.getHeader(headerType.getType()); - } - if (ip == null) { - headerType = IpAddressHeader.HTTP_X_FORWARDED_FOR; - ip = request.getHeader(headerType.getType()); - } - if (ip == null) { - headerType = IpAddressHeader.REMOTE_ADDR; - ip = request.getRemoteAddr(); - } - - return Pair.of(headerType, ip); - } - - private UserAgentInfo getUserAgentInfo(String userAgent) { - var matcher = pattern.matcher(userAgent); - if (!matcher.matches()) { - return new UserAgentInfo("", "", "", "", ""); - } - - return new UserAgentInfo( - matcher.group(1), - matcher.group(2), - matcher.group(3), - matcher.group(5), - matcher.group(4) - ); - } - - private record UserAgentInfo( - String appName, - String appVersion, - String platform, - String deviceModel, - String os - ) { - @Override - public String toString() { - return "UserAgentInfo{" + - "appName='" + appName + '\'' + - ", appVersion='" + appVersion + '\'' + - ", platform='" + platform + '\'' + - ", deviceModel='" + deviceModel + '\'' + - ", os='" + os + '\'' + - '}'; - } - } -} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java index fbac3f61e..5aae0f7aa 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/config/WebConfig.java @@ -4,21 +4,14 @@ import kr.co.pennyway.api.common.converter.ProviderConverter; import kr.co.pennyway.api.common.converter.SpendingCategoryTypeConverter; import kr.co.pennyway.api.common.converter.VerificationTypeConverter; -import kr.co.pennyway.api.common.interceptor.SignEventLogInterceptor; -import kr.co.pennyway.domain.common.redis.sign.SignEventLogService; -import kr.co.pennyway.infra.common.jwt.JwtProvider; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.format.FormatterRegistry; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { - private final SignEventLogService signEventLogService; - private final JwtProvider accessTokenProvider; - @Override public void addFormatters(FormatterRegistry registrar) { registrar.addConverter(new ProviderConverter()); @@ -26,10 +19,4 @@ public void addFormatters(FormatterRegistry registrar) { registrar.addConverter(new NotifyTypeConverter()); registrar.addConverter(new SpendingCategoryTypeConverter()); } - - @Override - public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(new SignEventLogInterceptor(signEventLogService, accessTokenProvider)) - .addPathPatterns("/v1/auth/sign-in", "/v1/auth/oauth/sign-up", "/v1/auth/refresh"); - } } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java index 5da38c89b..6f71a5abf 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthCheckControllerTest.java @@ -4,7 +4,6 @@ import kr.co.pennyway.api.apis.auth.dto.AuthFindDto; import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.usecase.AuthCheckUseCase; -import kr.co.pennyway.api.config.WebConfig; import kr.co.pennyway.common.exception.StatusCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; import kr.co.pennyway.domain.domains.user.exception.UserErrorException; @@ -14,8 +13,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.FilterType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -30,8 +27,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(controllers = {AuthCheckController.class}, excludeFilters = { - @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) +@WebMvcTest(controllers = {AuthCheckController.class}) @ActiveProfiles("local") class AuthCheckControllerTest { private final String inputPhone = "010-1234-5678"; diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java index 5b136269f..c5ef70386 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerValidationTest.java @@ -5,7 +5,6 @@ import kr.co.pennyway.api.apis.auth.usecase.AuthUseCase; import kr.co.pennyway.api.common.security.jwt.Jwts; import kr.co.pennyway.api.common.util.CookieUtil; -import kr.co.pennyway.api.config.WebConfig; import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -13,8 +12,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.FilterType; import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; import org.springframework.test.context.ActiveProfiles; @@ -31,8 +28,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -@WebMvcTest(controllers = {AuthController.class}, excludeFilters = { - @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) +@WebMvcTest(controllers = {AuthController.class}) @ActiveProfiles("local") public class AuthControllerValidationTest { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/GetSpendingsByCategoryControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/GetSpendingsByCategoryControllerTest.java index d0f70445e..92bf30485 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/GetSpendingsByCategoryControllerTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/GetSpendingsByCategoryControllerTest.java @@ -4,8 +4,6 @@ import kr.co.pennyway.api.apis.ledger.usecase.SpendingCategoryUseCase; import kr.co.pennyway.api.common.query.SpendingCategoryType; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; -import kr.co.pennyway.domain.common.redis.sign.SignEventLogService; -import kr.co.pennyway.infra.common.jwt.JwtProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -35,10 +33,6 @@ public class GetSpendingsByCategoryControllerTest { @MockBean private SpendingCategoryUseCase spendingCategoryUseCase; - @MockBean - private SignEventLogService signEventLogService; - @MockBean - private JwtProvider accessTokenProvider; @BeforeEach void setUp(WebApplicationContext webApplicationContext) { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java index a8537f396..f81d02f1b 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryControllerUnitTest.java @@ -2,7 +2,6 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; import kr.co.pennyway.api.apis.ledger.usecase.SpendingCategoryUseCase; -import kr.co.pennyway.api.config.WebConfig; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; import kr.co.pennyway.domain.domains.spending.exception.SpendingErrorCode; @@ -13,8 +12,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.FilterType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -29,8 +26,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(controllers = {SpendingCategoryController.class}, excludeFilters = { - @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) +@WebMvcTest(controllers = {SpendingCategoryController.class}) @ActiveProfiles("test") public class SpendingCategoryControllerUnitTest { @Autowired diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryUpdateControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryUpdateControllerTest.java index 3b4d6bdde..c9ecc895e 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryUpdateControllerTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingCategoryUpdateControllerTest.java @@ -3,9 +3,7 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingCategoryDto; import kr.co.pennyway.api.apis.ledger.usecase.SpendingCategoryUseCase; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; -import kr.co.pennyway.domain.common.redis.sign.SignEventLogService; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; -import kr.co.pennyway.infra.common.jwt.JwtProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -26,10 +24,6 @@ @WebMvcTest(SpendingCategoryController.class) @ActiveProfiles("test") public class SpendingCategoryUpdateControllerTest { - @MockBean - private SignEventLogService signEventLogService; - @MockBean - private JwtProvider accessTokenProvider; @MockBean private SpendingCategoryUseCase spendingCategoryUseCase; @Autowired diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java index e41ba5714..dba17ee1d 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/SpendingControllerUnitTest.java @@ -4,7 +4,6 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; import kr.co.pennyway.api.apis.ledger.usecase.SpendingUseCase; -import kr.co.pennyway.api.config.WebConfig; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import org.apache.commons.lang3.RandomStringUtils; @@ -12,8 +11,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.FilterType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -29,8 +26,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @ActiveProfiles("test") -@WebMvcTest(controllers = SpendingController.class, excludeFilters = { - @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) +@WebMvcTest(controllers = SpendingController.class) @TestClassOrder(ClassOrderer.OrderAnnotation.class) public class SpendingControllerUnitTest { @Autowired diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java index 6588289a2..06b6f6962 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/controller/TargetAmountControllerUnitTest.java @@ -2,7 +2,6 @@ import kr.co.pennyway.api.apis.ledger.dto.TargetAmountDto; import kr.co.pennyway.api.apis.ledger.usecase.TargetAmountUseCase; -import kr.co.pennyway.api.config.WebConfig; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; import kr.co.pennyway.domain.domains.target.exception.TargetAmountErrorCode; import org.junit.jupiter.api.BeforeEach; @@ -12,8 +11,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.FilterType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -28,8 +25,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(controllers = {TargetAmountController.class}, excludeFilters = { - @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) +@WebMvcTest(controllers = {TargetAmountController.class}) @ActiveProfiles("test") public class TargetAmountControllerUnitTest { @Autowired diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/notification/controller/GetNotificationsControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/notification/controller/GetNotificationsControllerUnitTest.java index 5887b6a1b..22c403088 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/notification/controller/GetNotificationsControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/notification/controller/GetNotificationsControllerUnitTest.java @@ -2,7 +2,6 @@ import kr.co.pennyway.api.apis.notification.dto.NotificationDto; import kr.co.pennyway.api.apis.notification.usecase.NotificationUseCase; -import kr.co.pennyway.api.config.WebConfig; import kr.co.pennyway.api.config.fixture.NotificationFixture; import kr.co.pennyway.api.config.fixture.UserFixture; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; @@ -13,8 +12,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.FilterType; import org.springframework.data.domain.Pageable; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -33,8 +30,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(controllers = {NotificationController.class}, excludeFilters = { - @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) +@WebMvcTest(controllers = {NotificationController.class}) @ActiveProfiles("test") public class GetNotificationsControllerUnitTest { @Autowired diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/question/controller/QuestionControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/question/controller/QuestionControllerTest.java index f0daf6a79..7b486727b 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/question/controller/QuestionControllerTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/question/controller/QuestionControllerTest.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import kr.co.pennyway.api.apis.question.dto.QuestionReq; import kr.co.pennyway.api.apis.question.usecase.QuestionUseCase; -import kr.co.pennyway.api.config.WebConfig; import kr.co.pennyway.domain.domains.question.domain.QuestionCategory; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -11,8 +10,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.FilterType; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -25,8 +22,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(controllers = QuestionController.class, excludeFilters = { - @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) +@WebMvcTest(controllers = QuestionController.class) @ActiveProfiles("local") public class QuestionControllerTest { diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java index bd5639a54..cfdd21eb6 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/storage/controller/StorageControllerTest.java @@ -2,7 +2,6 @@ import kr.co.pennyway.api.apis.storage.dto.PresignedUrlDto; import kr.co.pennyway.api.apis.storage.usecase.StorageUseCase; -import kr.co.pennyway.api.config.WebConfig; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; import kr.co.pennyway.infra.common.exception.StorageErrorCode; import kr.co.pennyway.infra.common.exception.StorageException; @@ -12,8 +11,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.FilterType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -26,8 +23,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(controllers = StorageController.class, excludeFilters = { - @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) +@WebMvcTest(controllers = StorageController.class) @ActiveProfiles("test") class StorageControllerTest { @Autowired diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java index db2af3450..25d1178ab 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/controller/UserAccountControllerUnitTest.java @@ -4,7 +4,6 @@ import kr.co.pennyway.api.apis.users.dto.DeviceTokenDto; import kr.co.pennyway.api.apis.users.dto.UserProfileUpdateDto; import kr.co.pennyway.api.apis.users.usecase.UserAccountUseCase; -import kr.co.pennyway.api.config.WebConfig; import kr.co.pennyway.api.config.fixture.DeviceTokenFixture; import kr.co.pennyway.api.config.supporter.WithSecurityMockUser; import kr.co.pennyway.common.exception.StatusCode; @@ -16,8 +15,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.FilterType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -35,8 +32,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(controllers = {UserAccountController.class}, excludeFilters = { - @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)}) +@WebMvcTest(controllers = {UserAccountController.class}) @ActiveProfiles("local") @TestClassOrder(ClassOrderer.OrderAnnotation.class) public class UserAccountControllerUnitTest { diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/IpAddressHeaderConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/IpAddressHeaderConverter.java deleted file mode 100644 index 912aeaf83..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/IpAddressHeaderConverter.java +++ /dev/null @@ -1,13 +0,0 @@ -package kr.co.pennyway.domain.common.converter; - -import jakarta.persistence.Converter; -import kr.co.pennyway.domain.domains.sign.type.IpAddressHeader; - -@Converter -public class IpAddressHeaderConverter extends AbstractLegacyEnumAttributeConverter { - private static final String ENUM_NAME = "IP 주소 헤더"; - - public IpAddressHeaderConverter() { - super(IpAddressHeader.class, false, ENUM_NAME); - } -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLog.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLog.java deleted file mode 100644 index bcb5edf13..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLog.java +++ /dev/null @@ -1,45 +0,0 @@ -package kr.co.pennyway.domain.common.redis.sign; - -import lombok.Builder; -import lombok.Getter; -import org.springframework.data.annotation.Id; -import org.springframework.data.redis.core.RedisHash; - -import java.time.LocalDateTime; - -@Getter -@RedisHash(value = "signEventLog", timeToLive = 60 * 60 * 24) -public class SignEventLog { - @Id - private final Long userId; - private final LocalDateTime signedAt; - private final String ipAddress; - private final String ipAddressHeader; - private final String appVersion; - private final String deviceModel; - private final String os; - - @Builder - public SignEventLog(Long userId, LocalDateTime signedAt, String ipAddress, String ipAddressHeader, String appVersion, String deviceModel, String os) { - this.userId = userId; - this.signedAt = signedAt; - this.ipAddress = ipAddress; - this.ipAddressHeader = ipAddressHeader; - this.appVersion = appVersion; - this.deviceModel = deviceModel; - this.os = os; - } - - @Override - public String toString() { - return "SignEventLog{" + - "userId=" + userId + - ", signedAt=" + signedAt + - ", ipAddress='" + ipAddress + '\'' + - ", ipAddressHeader='" + ipAddressHeader + '\'' + - ", appVersion='" + appVersion + '\'' + - ", deviceModel='" + deviceModel + '\'' + - ", os='" + os + '\'' + - '}'; - } -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogRepository.java deleted file mode 100644 index 9acf112fa..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package kr.co.pennyway.domain.common.redis.sign; - -import org.springframework.data.repository.ListCrudRepository; - -public interface SignEventLogRepository extends ListCrudRepository { -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogService.java deleted file mode 100644 index e3b32e791..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/sign/SignEventLogService.java +++ /dev/null @@ -1,23 +0,0 @@ -package kr.co.pennyway.domain.common.redis.sign; - -import kr.co.pennyway.common.annotation.DomainService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import java.util.List; - -@Slf4j -@DomainService -@RequiredArgsConstructor -public class SignEventLogService { - private final SignEventLogRepository signEventLogRepository; - - public void create(SignEventLog signEventLog) { - signEventLogRepository.save(signEventLog); - log.debug("로그 저장 : {}", signEventLog); - } - - public List findAll() { - return signEventLogRepository.findAll(); - } -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLog.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLog.java deleted file mode 100644 index 09a33e177..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLog.java +++ /dev/null @@ -1,43 +0,0 @@ -package kr.co.pennyway.domain.domains.sign.domain; - -import jakarta.persistence.*; -import kr.co.pennyway.domain.common.converter.IpAddressHeaderConverter; -import kr.co.pennyway.domain.domains.sign.type.IpAddressHeader; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -@Getter -@Entity -@Table(name = "sign_in_log") -@IdClass(SignInLogId.class) -@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) -public class SignInLog { - @Id - private LocalDateTime signedAt; - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private Long userId; - private String ipAddress; - @Convert(converter = IpAddressHeaderConverter.class) - private IpAddressHeader ipAddressHeader; - private String appVersion; - private String deviceModel; - private String os; - - @Builder - public SignInLog(LocalDateTime signedAt, Long userId, String ipAddress, IpAddressHeader ipAddressHeader, String appVersion, String deviceModel, String os) { - this.signedAt = signedAt; - this.userId = userId; - this.ipAddress = ipAddress; - this.ipAddressHeader = ipAddressHeader; - this.appVersion = appVersion; - this.deviceModel = deviceModel; - this.os = os; - } -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLogId.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLogId.java deleted file mode 100644 index 7903632a3..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/domain/SignInLogId.java +++ /dev/null @@ -1,35 +0,0 @@ -package kr.co.pennyway.domain.domains.sign.domain; - -import jakarta.persistence.Column; -import jakarta.persistence.Transient; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.io.Serial; -import java.io.Serializable; -import java.time.LocalDateTime; - -@Getter -@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) -public class SignInLogId implements Serializable { - @Serial - @Transient - private static final long serialVersionUID = 1L; - - @Column(name = "signed_at") - private LocalDateTime signedAt; - @Column(name = "id") - private Long id; - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof SignInLogId that)) return false; - return signedAt.equals(that.signedAt) && id.equals(that.id); - } - - @Override - public int hashCode() { - return signedAt.hashCode() + id.hashCode(); - } -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/repository/SignInLogRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/repository/SignInLogRepository.java deleted file mode 100644 index 498a55d9e..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/repository/SignInLogRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package kr.co.pennyway.domain.domains.sign.repository; - -import kr.co.pennyway.domain.domains.sign.domain.SignInLog; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface SignInLogRepository extends JpaRepository { - -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/service/SignInLogService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/service/SignInLogService.java deleted file mode 100644 index b55ee8247..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/service/SignInLogService.java +++ /dev/null @@ -1,11 +0,0 @@ -package kr.co.pennyway.domain.domains.sign.service; - -import kr.co.pennyway.common.annotation.DomainService; -import kr.co.pennyway.domain.domains.sign.repository.SignInLogRepository; -import lombok.RequiredArgsConstructor; - -@DomainService -@RequiredArgsConstructor -public class SignInLogService { - private final SignInLogRepository signInLogRepository; -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/type/IpAddressHeader.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/type/IpAddressHeader.java deleted file mode 100644 index fbaa150e6..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/sign/type/IpAddressHeader.java +++ /dev/null @@ -1,19 +0,0 @@ -package kr.co.pennyway.domain.domains.sign.type; - -import kr.co.pennyway.domain.common.converter.LegacyCommonType; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum IpAddressHeader implements LegacyCommonType { - X_FORWARDED_FOR("0", "X-Forwarded-For"), - PROXY_CLIENT_IP("1", "Proxy-Client-IP"), - WL_PROXY_CLIENT_IP("2", "WL-Proxy-Client-IP"), - HTTP_CLIENT_IP("3", "HTTP_CLIENT_IP"), - HTTP_X_FORWARDED_FOR("4", "HTTP_X_FORWARDED_FOR"), - REMOTE_ADDR("5", "REMOTE_ADDR"); - - private final String code; - private final String type; -}