From c775e89feb7295b477326f4e5d3c0ef7010358d1 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:19:23 +0900 Subject: [PATCH 001/169] Initial commit --- .gitattributes | 3 + .gitignore | 37 +++ build.gradle | 29 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45633 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 ++++++++++++++++++ gradlew.bat | 93 +++++++ settings.gradle | 1 + .../eatsfine/EatsfineApplication.java | 13 + src/main/resources/application.yml | 3 + .../eatsfine/EatsfineApplicationTests.java | 13 + 11 files changed, 447 insertions(+) create mode 100644 .gitattributes 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 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/com/eatsfine/eatsfine/EatsfineApplicationTests.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..8af972cd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c2065bc2 --- /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 00000000..8c096207 --- /dev/null +++ b/build.gradle @@ -0,0 +1,29 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.1' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.eatsfine' +version = '0.0.1-SNAPSHOT' +description = 'Eatsfine' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f8e1ee3125fe0768e9a76ee977ac089eb657005e GIT binary patch literal 45633 zcma&NV|1n6wyqu9PQ|uu+csuwn-$x(T~Woh?Nr6KUD3(A)@l1Yd+oj6Z_U=8`RAE` z#vE6_`?!1WLs1443=Ieh3JM4ai0JG2|2{}S&_HrxszP*9^5P7#QX*pVDq?D?;6T8C z{bWO1$9at%!*8ax*TT&F99vwf1Ls+3lklsb|bC`H`~Q z_w}*E9P=Wq;PYlGYhZ^lt#N97bt5aZ#mQcOr~h^B;R>f-b0gf{y(;VA{noAt`RZzU z7vQWD{%|q!urW2j0Z&%ChtL(^9m` zgaU%|B;V#N_?%iPvu0PVkX=1m9=*SEGt-Lp#&Jh%rz6EJXlV^O5B5YfM5j{PCeElx z8sipzw8d=wVhFK+@mgrWyA)Sv3BJq=+q+cL@=wuH$2;LjY z^{&+X4*HFA0{QvlM_V4PTQjIdd;d|2YuN;s|bi!@<)r-G%TuOCHz$O(_-K z)5in&6uNN<0UfwY=K>d;cL{{WK2FR|NihJMN0Q4X+(1lE)$kY?T$7UWleIU`i zQG#X-&&m-8x^(;n@o}$@vPMYRoq~|FqC~CU3MnoiifD{(CwAGd%X#kFHq#4~%_a!{ zeX{XXDT#(DvX7NtAs7S}2ZuiZ>gtd;tCR7E)3{J^`~#Vd**9qz%~JRFAiZf{zt|Dr zvQw!)n7fNUn_gH`o9?8W8t_%x6~=y*`r46bjj(t{YU*qfqd}J}*mkgUfsXTI>Uxl6 z)Fj>#RMy{`wINIR;{_-!xGLgVaTfNJ2-)%YUfO&X5z&3^E#4?k-_|Yv$`fpgYkvnA%E{CiV zP|-zAf8+1@R`sT{rSE#)-nuU7Pwr-z>0_+CLQT|3vc-R22ExKT4ym@Gj77j$aTVns zp4Kri#Ml?t7*n(;>nkxKdhOU9Qbwz%*#i9_%K<`m4T{3aPbQ?J(Mo`6E5cDdbAk%X z+4bN%E#a(&ZXe{G#V!2Nt+^L$msKVHP z|APpBhq7knz(O2yY)$$VyI_Xg4UIC*$!i7qQG~KEZnO@Q1i89@4ZKW*3^Wh?o?zSkfPxdhnTxlO!3tAqe_ zuEqHVcAk3uQIFTpP~C{d$?>7yt3G3Fo>syXTus>o0tJdFpQWC27hDiwC%O09i|xCq z@H6l|+maB;%CYQIChyhu;PVYz9e&5a@EEQs3$DS6dLIS+;N@I0)V}%B`jdYv;JDck zd|xxp(I?aedivE7*19hesoa-@Xm$^EHbbVmh$2^W-&aTejsyc$i+}A#n2W*&0Qt`5 zJS!2A|LVV;L!(*x2N)GjJC;b1RB_f(#D&g_-};a*|BTRvfdIX}Gau<;uCylMNC;UG zzL((>6KQBQ01wr%7u9qI2HLEDY!>XisIKb#6=F?pAz)!_JX}w|>1V>X^QkMdFi@Jr z`1N*V4xUl{qvECHoF?#lXuO#Dg2#gh|AU$Wc=nuIbmVPBEGd(R#&Z`TP9*o%?%#ob zWN%ByU+55yBNfjMjkJnBjT!cVDi}+PR3N&H(f8$d^Pu;A_WV*{)c2Q{IiE7&LPsd4 z!rvkUf{sco_WNSIdW+btM#O+4n`JiceH6%`7pDV zRqJ@lj=Dt(e-Gkz$b!c2>b)H$lf(fuAPdIsLSe(dZ4E~9+Ge!{3j~>nS%r)eQZ;Iq ztWGpp=2Ptc!LK_TQ8cgJXUlU5mRu|7F2{eu*;a>_5S<;bus=t*IXcfzJRPv4xIs;s zt2<&}OM>KxkTxa=dFMfNr42=DL~I}6+_{`HT_YJBiWkpVZND1Diad~Yr*Fuq{zljr z*_+jXk=qVBdwlQkYuIrB4GG*#voba$?h*u0uRNL+87-?AjzG2X_R9mzQ7BJEawutObr|ey~%in>6k%A`K*`pb-|DF5m})!`b=~osoiW2)IFh?_y9y<3Cix_ znvC=bjBX1J820!%%9FaB@v?hAsd05e@w$^ZAvtUp*=Bi+Owkl?rLa6F#yl{s+?563 zmn2 zV95%gySAJ$L!Vvk4kx!n@mo`3Mfi`2lXUkBmd%)u)7C?Pa;oK~zUQ#p0u{a|&0;zNO#9a4`v^3df90X#~l_k$q7n&L5 z?TszF842~g+}tgUP}UG?ObLCE1(Js_$e>XS7m%o7j@@VdxePtg)w{i5an+xK95r?s zDeEhgMO-2$H?@0{p-!4NJ)}zP+3LzZB?FVap)ObHV6wp}Lrxvz$cjBND1T6ln$EfJ zZRPeR2lP}K0p8x`ahxB??Ud;i7$Y5X!5}qBFS+Zp=P^#)08nQi_HuJcN$0=x;2s53 zwoH}He9BlKT4GdWfWt)@o@$4zN$B@5gVIN~aHtwIhh{O$uHiMgYl=&Vd$w#B2 zRv+xK3>4E{!)+LXA2#*K6H~HpovXAQeXV(^Pd%G_>ro0(4_@`{2Ag(+8{9pqJ>Co$ zRRV(oX;nD+Jel_2^BlNO=cQP8q*G#~R3PTERUxvug_C4T3qwb9MQE|^{5(H*nt`fn z^%*p-RwkAhT6(r>E@5w8FaB)Q<{#`H9fTdc6QBuSr9D-x!Tb9f?wI=M{^$cB5@1;0 z+yLHh?3^c-Qte@JI<SW`$bs5Vv9!yWjJD%oY z8Cdc$a(LLy@tB2)+rUCt&0$&+;&?f~W6+3Xk3g zy9L�|d9Zj^A1Dgv5yzCONAB>8LM`TRL&7v_NKg(bEl#y&Z$py}mu<4DrT@8HHjE zqD@4|aM>vt!Yvc2;9Y#V;KJ8M>vPjiS2ycq52qkxInUK*QqA3$&OJ`jZBo zpzw&PT%w0$D94KD%}VN9c)eCueh1^)utGt2OQ+DP(BXszodfc1kFPWl~BQ5Psy*d`UIf zc}zQ8TVw35jdCSc78)MljC-g3$GX2$<0<3MEQXS&i<(ZFClz9WlL}}?%u>S2hhEk_ zyzfm&@Q%YVB-vw3KH|lU#c_)0aeG^;aDG&!bwfOz_9)6gLe;et;h(?*0d-RV0V)1l zzliq#`b9Y*c`0!*6;*mU@&EFSbW>9>L5xUX+unp%@tCW#kLfz)%3vwN{1<-R*g+B_C^W8)>?n%G z<#+`!wU$L&dn)Pz(9DGGI%RlmM2RpeDy9)31OZV$c2T>-Jl&4$6nul&e7){1u-{nP zE$uZs%gyanu+yBcAb+jTYGy(^<;&EzeLeqveN12Lvv)FQFn0o&*qAaH+gLJ)*xT9y z>`Y`W?M#K7%w26w?Oen>j7=R}EbZ;+jcowV&i}P|IfW^C5GJHt5D;Q~)|=gW3iQ;N zQGl4SQFtz=&~BGon6hO@mRnjpmM79ye^LY_L2no{f_M?j80pr`o3BrI7ice#8#Zt4 zO45G97Hpef+AUEU%jN-dLmPYHY(|t#D)9|IeB^i1X|eEq+ymld_Uj$l^zVAPRilx- z^II$sL4G~{^7?sik2BK7;ZV-VIVhrKjUxBIsf^N&K`)5;PjVg-DTm1Xtw4-tGtElU zJgVTCk4^N4#-kPuX=7p~GMf5Jj5A#>)GX)FIcOqY4lf}Vv2gjrOTuFusB@ERW-&fb zTp=E0E?gXkwzn)AMMY*QCftp%MOL-cbsG{02$0~b?-JD{-nwj58 zBHO1YL~yn~RpnZ6*;XA|MSJeBfX-D?afH*E!2uGjT%k!jtx~OG_jJ`Ln}lMQb7W41 zmTIRd%o$pu;%2}}@2J$x%fg{DZEa-Wxdu6mRP~Ea0zD2+g;Dl*to|%sO-5mUrZ`~C zjJ zUe^**YRgBvlxl<(r0LjxjSQKiTx+E<7$@9VO=RYgL9ldTyKzfqR;Y&gu^ub!fVX7u z3H@;8j#tVgga~EMuXv_#Q8<*uK@R{mGzn92eDYkF1sbxh5!P|M-D)T~Ae*SO`@u$Q z7=5s)HM)w~s2j5{I67cqSn6BLLhCMcn0=OTVE?T7bAmY!T+xZ_N3op~wZ3Oxlm6(a5qB({6KghlvBd9HJ#V6YY_zxbj-zI`%FN|C*Q`DiV z#>?Kk7VbuoE*I9tJaa+}=i7tJnMRn`P+(08 za*0VeuAz!eI7giYTsd26P|d^E2p1f#oF*t{#klPhgaShQ1*J7?#CTD@iDRQIV+Z$@ z>qE^3tR3~MVu=%U%*W(1(waaFG_1i5WE}mvAax;iwZKv^g1g}qXY7lAd;!QQa#5e= z1_8KLHje1@?^|6Wb(A{HQ_krJJP1GgE*|?H0Q$5yPBQJlGi;&Lt<3Qc+W4c}Ih~@* zj8lYvme}hwf@Js%Oj=4BxXm15E}7zS0(dW`7X0|$damJ|gJ6~&qKL>gB_eC7%1&Uh zLtOkf7N0b;B`Qj^9)Bfh-( z0or96!;EwEMnxwp!CphwxxJ+DDdP4y3F0i`zZp-sQ5wxGIHIsZCCQz5>QRetx8gq{ zA33BxQ}8Lpe!_o?^u2s3b!a-$DF$OoL=|9aNa7La{$zI#JTu_tYG{m2ly$k?>Yc); zTA9ckzd+ibu>SE6Rc=Yd&?GA9S5oaQgT~ER-|EwANJIAY74|6 z($#j^GP}EJqi%)^jURCj&i;Zl^-M9{=WE69<*p-cmBIz-400wEewWVEd^21}_@A#^ z2DQMldk_N)6bhFZeo8dDTWD@-IVunEY*nYRON_FYII-1Q@@hzzFe(lTvqm}InfjQ2 zN>>_rUG0Lhaz`s;GRPklV?0 z;~t4S8M)ZBW-ED?#UNbCrsWb=??P># zVc}MW_f80ygG_o~SW+Q6oeIUdFqV2Fzys*7+vxr^ZDeXcZZc;{kqK;(kR-DKL zByDdPnUQgnX^>x?1Tz~^wZ%Flu}ma$Xmgtc7pSmBIH%&H*Tnm=L-{GzCv^UBIrTH5 zaoPO|&G@SB{-N8Xq<+RVaM_{lHo@X-q}`zjeayVZ9)5&u*Y>1!$(wh9Qoe>yWbPgw zt#=gnjCaT_+$}w^*=pgiHD8N$hzqEuY5iVL_!Diw#>NP7mEd?1I@Io+?=$?7cU=yK zdDKk_(h_dB9A?NX+&=%k8g+?-f&`vhAR}&#zP+iG%;s}kq1~c{ac1@tfK4jP65Z&O zXj8Ew>l7c|PMp!cT|&;o+(3+)-|SK&0EVU-0-c&guW?6F$S`=hcKi zpx{Z)UJcyihmN;^E?*;fxjE3kLN4|&X?H&$md+Ege&9en#nUe=m>ep3VW#C?0V=aS zLhL6v)|%$G5AO4x?Jxy8e+?*)YR~<|-qrKO7k7`jlxpl6l5H&!C4sePiVjAT#)b#h zEwhfkpFN9eY%EAqg-h&%N>E0#%`InXY?sHyptcct{roG42Mli5l)sWt66D_nG2ed@ z#4>jF?sor7ME^`pDlPyQ(|?KL9Q88;+$C&3h*UV*B+*g$L<{yT9NG>;C^ZmPbVe(a z09K^qVO2agL`Hy{ISUJ{khPKh@5-)UG|S8Sg%xbJMF)wawbgll3bxk#^WRqmdY7qv zr_bqa3{`}CCbREypKd!>oIh^IUj4yl1I55=^}2mZAAW6z}Kpt3_o1b4__sQ;b zv)1=xHO?gE-1FL}Y$0YdD-N!US;VSH>UXnyKoAS??;T%tya@-u zfFo)@YA&Q#Q^?Mtam19`(PS*DL{PHjEZa(~LV7DNt5yoo1(;KT)?C7%^Mg;F!C)q= z6$>`--hQX4r?!aPEXn;L*bykF1r8JVDZ)x4aykACQy(5~POL;InZPU&s5aZm-w1L< z`crCS5=x>k_88n(*?zn=^w*;0+8>ui2i>t*Kr!4?aA1`yj*GXi#>$h8@#P{S)%8+N zCBeL6%!Ob1YJs5+a*yh{vZ8jH>5qpZhz_>(ph}ozKy9d#>gba1x3}`-s_zi+SqIeR z0NCd7B_Z|Fl+(r$W~l@xbeAPl5{uJ{`chq}Q;y8oUN0sUr4g@1XLZQ31z9h(fE_y( z_iQ(KB39LWd;qwPIzkvNNkL(P(6{Iu{)!#HvBlsbm`g2qy&cTsOsAbwMYOEw8!+75D!>V{9SZ?IP@pR9sFG{T#R*6ez2&BmP8*m^6+H2_ z>%9pg(+R^)*(S21iHjLmdt$fmq6y!B9L!%+;wL5WHc^MZRNjpL9EqbBMaMns2F(@h zN0BEqZ3EWGLjvY&I!8@-WV-o@>biD;nx;D}8DPapQF5ivpHVim8$G%3JrHtvN~U&) zb1;=o*lGfPq#=9Moe$H_UhQPBjzHuYw;&e!iD^U2veY8)!QX_E(X@3hAlPBIc}HoD z*NH1vvCi5xy@NS41F1Q3=Jkfu&G{Syin^RWwWX|JqUIX_`}l;_UIsj&(AFQ)ST*5$ z{G&KmdZcO;jGIoI^+9dsg{#=v5eRuPO41<*Ym!>=zHAXH#=LdeROU-nzj_@T4xr4M zJI+d{Pp_{r=IPWj&?%wfdyo`DG1~|=ef?>=DR@|vTuc)w{LHqNKVz9`Dc{iCOH;@H5T{ zc<$O&s%k_AhP^gCUT=uzrzlEHI3q`Z3em0*qOrPHpfl1v=8Xkp{!f9d2p!4 zL40+eJB4@5IT=JTTawIA=Z%3AFvv=l1A~JX>r6YUMV7GGLTSaIn-PUw| z;9L`a<)`D@Qs(@P(TlafW&-87mcZuwFxo~bpa01_M9;$>;4QYkMQlFPgmWv!eU8Ut zrV2<(`u-@1BTMc$oA*fX;OvklC1T$vQlZWS@&Wl}d!72MiXjOXxmiL8oq;sP{)oBe zS#i5knjf`OfBl}6l;BSHeY31w8c~8G>$sJ9?^^!)Z*Z*Xg zbTbkcbBpgFui(*n32hX~sC7gz{L?nlnOjJBd@ zUC4gd`o&YB4}!T9JGTe9tqo0M!JnEw4KH7WbrmTRsw^Nf z^>RxG?2A33VG3>E?iN|`G6jgr`wCzKo(#+zlOIzp-^E0W0%^a>zO)&f(Gc93WgnJ2p-%H-xhe{MqmO z8Iacz=Qvx$ML>Lhz$O;3wB(UI{yTk1LJHf+KDL2JPQ6#m%^bo>+kTj4-zQ~*YhcqS z2mOX!N!Q$d+KA^P0`EEA^%>c12X(QI-Z}-;2Rr-0CdCUOZ=7QqaxjZPvR%{pzd21HtcUSU>u1nw?)ZCy+ zAaYQGz59lqhNXR4GYONpUwBU+V&<{z+xA}`Q$fajmR86j$@`MeH}@zz*ZFeBV9Ot< ze8BLzuIIDxM&8=dS!1-hxiAB-x-cVmtpN}JcP^`LE#2r9ti-k8>Jnk{?@Gw>-WhL=v+H!*tv*mcNvtwo)-XpMnV#X>U1F z?HM?tn^zY$6#|(|S~|P!BPp6mur58i)tY=Z-9(pM&QIHq+I5?=itn>u1FkXiehCRC zW_3|MNOU)$-zrjKnU~{^@i9V^OvOJMp@(|iNnQ%|iojG2_Snnt`1Cqx2t)`vW&w2l zwb#`XLNY@FsnC-~O&9|#Lpvw7n!$wL9azSk)$O}?ygN@FEY({2%bTl)@F2wevCv`; zZb{`)uMENiwE|mti*q5U4;4puX{VWFJ#QIaa*%IHKyrU*HtjW_=@!3SlL~pqLRs?L zoqi&}JLsaP)yEH!=_)zmV-^xy!*MCtc{n|d%O zRM>N>eMG*Qi_XAxg@82*#zPe+!!f#;xBxS#6T-$ziegN-`dLm z=tTN|xpfCPng06|X^6_1JgN}dM<_;WsuL9lu#zLVt!0{%%D9*$nT2E>5@F(>Fxi%Y zpLHE%4LZSJ1=_qm0;^Wi%x56}k3h2Atro;!Ey}#g&*BpbNXXS}v>|nn=Mi0O(5?=1V7y1^1Bdt5h3}oL@VsG>NAH z1;5?|Sth=0*>dbXSQ%MQKB?eN$LRu?yBy@qQVaUl*f#p+sLy$Jd>*q;(l>brvNUbIF0OCf zk%Q;Zg!#0w0_#l)!t?3iz~`X8A>Yd3!P&A4Ov6&EdZmOixeTd4J`*Wutura(}4w@KV>i#rf(0PYL&v^89QiXBP6sj=N;q8kVxS}hA! z|3QaiYz!w+xQ%9&Zg${JgQ*Ip_bg2rmmG`JkX^}&5gbZF!Z(gDD1s5{QwarPK(li- zW9y-CiQ`5Ug1ceN1w7lCxl=2}7c*8_XH8W7y0AICn19qZ`w}z0iCJ$tJ}NjzQCH90 zc!UzpKvk%3;`XfFi2;F*q2eMQQ5fzO{!`KU1T^J?Z64|2Z}b1b6h80_H%~J)J)kbM0hsj+FV6%@_~$FjK9OG7lY}YA zRzyYxxy18z<+mCBiX?3Q{h{TrNRkHsyF|eGpLo0fKUQ|19Z0BamMNE9sW z?vq)r`Qge{9wN|ezzW=@ojpVQRwp##Q91F|B5c`a0A{HaIcW>AnqQ*0WT$wj^5sWOC1S;Xw7%)n(=%^in zw#N*+9bpt?0)PY$(vnU9SGSwRS&S!rpd`8xbF<1JmD&6fwyzyUqk){#Q9FxL*Z9%#rF$} zf8SsEkE+i91VY8d>Fap#FBacbS{#V&r0|8bQa;)D($^v2R1GdsQ8YUk(_L2;=DEyN%X*3 z;O@fS(pPLRGatI93mApLsX|H9$VL2)o(?EYqlgZMP{8oDYS8)3G#TWE<(LmZ6X{YA zRdvPLLBTatiUG$g@WK9cZzw%s6TT1Chmw#wQF&&opN6^(D`(5p0~ zNG~fjdyRsZv9Y?UCK(&#Q2XLH5G{{$9Y4vgMDutsefKVVPoS__MiT%qQ#_)3UUe=2fK)*36yXbQUp#E98ah(v`E$c3kAce_8a60#pa7rq6ZRtzSx6=I^-~A|D%>Riv{Y`F9n3CUPL>d`MZdRmBzCum2K%}z@Z(b7#K!-$Hb<+R@Rl9J6<~ z4Wo8!!y~j(!4nYsDtxPIaWKp+I*yY(ib`5Pg356Wa7cmM9sG6alwr7WB4IcAS~H3@ zWmYt|TByC?wY7yODHTyXvay9$7#S?gDlC?aS147Ed7zW!&#q$^E^_1sgB7GKfhhYu zOqe*Rojm~)8(;b!gsRgQZ$vl5mN>^LDgWicjGIcK9x4frI?ZR4Z%l1J=Q$0lSd5a9 z@(o?OxC72<>Gun*Y@Z8sq@od{7GGsf8lnBW^kl6sX|j~UA2$>@^~wtceTt^AtqMIx zO6!N}OC#Bh^qdQV+B=9hrwTj>7HvH1hfOQ{^#nf%e+l)*Kgv$|!kL5od^ka#S)BNT z{F(miX_6#U3+3k;KxPyYXE0*0CfL8;hDj!QHM@)sekF9uyBU$DRZkka4ie^-J2N8w z3PK+HEv7kMnJU1Y+>rheEpHdQ3_aTQkM3`0`tC->mpV=VtvU((Cq$^(S^p=+$P|@} zueLA}Us^NTI83TNI-15}vrC7j6s_S`f6T(BH{6Jj{Lt;`C+)d}vwPGx62x7WXOX19 z2mv1;f^p6cG|M`vfxMhHmZxkkmWHRNyu2PDTEpC(iJhH^af+tl7~h?Y(?qNDa`|Ogv{=+T@7?v344o zvge%8Jw?LRgWr7IFf%{-h>9}xlP}Y#GpP_3XM7FeGT?iN;BN-qzy=B# z=r$79U4rd6o4Zdt=$|I3nYy;WwCb^`%oikowOPGRUJ3IzChrX91DUDng5_KvhiEZwXl^y z+E!`Z6>}ijz5kq$nNM8JA|5gf_(J-);?SAn^N-(q2r6w31sQh6vLYp^ z<>+GyGLUe_6eTzX7soWpw{dDbP-*CsyKVw@I|u`kVX&6_h5m!A5&3#=UbYHYJ5GK& zLcq@0`%1;8KjwLiup&i&u&rmt*LqALkIqxh-)Exk&(V)gh9@Fn+WU=6-UG^X2~*Q-hnQ$;;+<&lRZ>g0I`~yuv!#84 zy>27(l&zrfDI!2PgzQyV*R(YFd`C`YwR_oNY+;|79t{NNMN1@fp?EaNjuM2DKuG%W z5749Br2aU6K|b=g4(IR39R8_!|B`uQ)bun^C9wR4!8isr$;w$VOtYk+1L9#CiJ#F) z)L}>^6>;X~0q&CO>>ZBo0}|Ex9$p*Hor@Ej9&75b&AGqzpGpM^dx}b~E^pPKau2i5 zr#tT^S+01mMm}z480>-WjU#q`6-gw4BJMWmW?+VXBZ#JPzPW5QQm@RM#+zbQMpr>M zX$huprL(A?yhv8Y81K}pTD|Gxs#z=K(Wfh+?#!I$js5u8+}vykZh~NcoLO?ofpg0! zlV4E9BAY_$pN~e-!VETD&@v%7J~_jdtS}<_U<4aRqEBa&LDpc?V;n72lTM?pIVG+> z*5cxz_iD@3vIL5f9HdHov{o()HQ@6<+c}hfC?LkpBEZ4xzMME^~AdB8?2F=#6ff!F740l&v7FN!n_ zoc1%OfX(q}cg4LDk-1%|iZ^=`x5Vs{oJYhXufP;BgVd*&@a04pSek6OS@*UH`*dAp z7wY#70IO^kSqLhoh9!qIj)8t4W6*`Kxy!j%Bi%(HKRtASZ2%vA0#2fZ=fHe0zDg8^ zucp;9(vmuO;Zq9tlNH)GIiPufZlt?}>i|y|haP!l#dn)rvm8raz5L?wKj9wTG znpl>V@};D!M{P!IE>evm)RAn|n=z-3M9m5J+-gkZHZ{L1Syyw|vHpP%hB!tMT+rv8 zIQ=keS*PTV%R7142=?#WHFnEJsTMGeG*h)nCH)GpaTT@|DGBJ6t>3A)XO)=jKPO<# zhkrgZtDV6oMy?rW$|*NdJYo#5?e|Nj>OAvCXHg~!MC4R;Q!W5xcMwX#+vXhI+{ywS zGP-+ZNr-yZmpm-A`e|Li#ehuWB{{ul8gB&6c98(k59I%mMN9MzK}i2s>Ejv_zVmcMsnobQLkp z)jmsJo2dwCR~lcUZs@-?3D6iNa z2k@iM#mvemMo^D1bu5HYpRfz(3k*pW)~jt8UrU&;(FDI5ZLE7&|ApGRFLZa{yynWx zEOzd$N20h|=+;~w$%yg>je{MZ!E4p4x05dc#<3^#{Fa5G4ZQDWh~%MPeu*hO-6}2*)t-`@rBMoz&gn0^@c)N>z|Ikj8|7Uvdf5@ng296rq2LiM#7KrWq{Jc7;oJ@djxbC1s6^OE>R6cuCItGJ? z6AA=5i=$b;RoVo7+GqbqKzFk>QKMOf?`_`!!S!6;PSCI~IkcQ?YGxRh_v86Q%go2) zG=snIC&_n9G^|`+KOc$@QwNE$b7wxBY*;g=K1oJnw8+ZR)ye`1Sn<@P&HZm0wDJV* z=rozX4l;bJROR*PEfHHSmFVY3M#_fw=4b_={0@MP<5k4RCa-ZShp|CIGvW^9$f|BM#Z`=3&=+=p zp%*DC-rEH3N;$A(Z>k_9rDGGj2&WPH|}=Pe3(g}v3=+`$+A=C5PLB3UEGUMk92-erU%0^)5FkU z^Yx#?Gjyt*$W>Os^Fjk-r-eu`{0ZJbhlsOsR;hD=`<~eP6ScQ)%8fEGvJ15u9+M0c|LM4@D(tTx!T(sRv zWg?;1n7&)-y0oXR+eBs9O;54ZKg=9eJ4gryudL84MAMsKwGo$85q6&cz+vi)9Y zvg#u>v&pQQ1NfOhD#L@}NNZe+l_~BQ+(xC1j-+({Cg3_jrZ(YpI{3=0F1GZsf+3&f z#+sRf=v7DVwTcYw;SiNxi5As}hE-Tpt)-2+lBmcAO)8cP55d0MXS*A3yI5A!Hq&IN zzb+)*y8d8WTE~Vm3(pgOzy%VI_e4lBx&hJEVBu!!P|g}j(^!S=rNaJ>H=Ef;;{iS$$0k-N(`n#J_K40VJP^8*3YR2S`* zED;iCzkrz@mP_(>i6ol5pMh!mnhrxM-NYm0gxPF<%(&Az*pqoRTpgaeC!~-qYKZHJ z2!g(qL_+hom-fp$7r=1#mU~Dz?(UFkV|g;&XovHh~^6 z1eq4BcKE%*aMm-a?zrj+p;2t>oJxxMgsmJ^Cm%SwDO?odL%v6fXU869KBEMoC0&x>qebmE%y+W z51;V2xca9B=wtmln74g7LcEgJe1z7o>kwc1W=K1X7WAcW%73eGwExo&{SSTnXR+pA zRL)j$LV7?Djn8{-8CVk94n|P>RAw}F9uvp$bpNz<>Yw3PgWVJo?zFYH9jzq zU|S+$C6I?B?Jm>V{P67c9aRvK283bnM(uikbL=``ew5E)AfV$SR4b8&4mPDkKT&M3 zok(sTB}>Gz%RzD{hz|7(AFjB$@#3&PZFF5_Ay&V3?c&mT8O;9(vSgWdwcy?@L-|`( z@@P4$nXBmVE&Xy(PFGHEl*K;31`*ilik77?w@N11G7IW!eL@1cz~XpM^02Z?CRv1R z5&x6kevgJ5Bh74Q8p(-u#_-3`246@>kY~V4!XlYgz|zMe18m7Vs`0+D!LQwTPzh?a zp?X169uBrRvG3p%4U@q_(*^M`uaNY!T6uoKk@>x(29EcJW_eY@I|Un z*d;^-XTsE{Vjde=Pp3`In(n!ohHxqB%V`0vSVMsYsbjN6}N6NC+Ea`Hhv~yo@ z|Ab%QndSEzidwOqoXCaF-%oZ?SFWn`*`1pjc1OIk2G8qSJ$QdrMzd~dev;uoh z>SneEICV>k}mz6&xMqp=Bs_0AW81D{_hqJXl6ZWPRNm@cC#+pF&w z{{TT0=$yGcqkPQL>NN%!#+tn}4H>ct#L#Jsg_I35#t}p)nNQh>j6(dfd6ng#+}x3^ zEH`G#vyM=;7q#SBQzTc%%Dz~faHJK+H;4xaAXn)7;)d(n*@Bv5cUDNTnM#byv)DTG zaD+~o&c-Z<$c;HIOc!sERIR>*&bsB8V_ldq?_>fT!y4X-UMddUmfumowO!^#*pW$- z_&)moxY0q!ypaJva)>Bc&tDs?D=Rta*Wc^n@uBO%dd+mnsCi0aBZ3W%?tz844FkZD zzhl+RuCVk=9Q#k;8EpXtSmR;sZUa5(o>dt+PBe96@6G}h`2)tAx(WKR4TqXy(YHIT z@feU+no42!!>y5*3Iv$!rn-B_%sKf6f4Y{2UpRgGg*dxU)B@IRQ`b{ncLrg9@Q)n$ zOZ7q3%zL99j1{56$!W(Wu{#m|@(6BBb-*zV23M!PmH7nzOD@~);0aK^iixd%>#BwR zyIlVF*t4-Ww*IPTGko3RuyJ*^bo-h}wJ{YkHa2y3mIK%U%>PFunkx0#EeIm{u93PX z4L24jUh+37=~WR47l=ug2cn_}7CLR(kWaIpH8ojFsD}GN3G}v6fI-IMK2sXnpgS5O zHt<|^d9q}_znrbP0~zxoJ-hh6o81y+N;i@6M8%S@#UT)#aKPYdm-xlbL@v*`|^%VS(M$ zMQqxcVVEKe5s~61T77N=9x7ndQ=dzWp^+#cX}v`1bbnH@&{k?%I%zUPTDB(DCWY6( zR`%eblFFkL&C{Q}T6PTF0@lW0JViFzz4s5Qt?P?wep8G8+z3QFAJ{Q8 z9J41|iAs{Um!2i{R7&sV=ESh*k(9`2MM2U#EXF4!WGl(6lI!mg_V%pRenG>dEhJug z^oLZ?bErlIPc@Jo&#@jy@~D<3Xo%x$)(5Si@~}ORyawQ{z^mzNSa$nwLYTh6E%!w_ zUe?c`JJ&RqFh1h18}LE47$L1AwR#xAny*v9NWjK$&6(=e0)H_v^+ZIJ{iVg^e_K-I z|L;t=x>(vU{1+G+P5=i7QzubN=dWIe(bqeBJ2fX85qrBYh5pj*f05=8WxcP7do(_h zkfEQ1Fhf^}%V~vr>ed9*Z2aL&OaYSRhJQFWHtirwJFFkfJdT$gZo;aq70{}E#rx((U`7NMIb~uf>{Y@Fy@-kmo{)ei*VjvpSH7AU zQG&3Eol$C{Upe`034cH43cD*~Fgt?^0R|)r(uoq3ZjaJqfj@tiI~`dQnxfcQIY8o| zx?Ye>NWZK8L1(kkb1S9^8Z8O_(anGZY+b+@QY;|DoLc>{O|aq(@x2=s^G<9MAhc~H z+C1ib(J*&#`+Lg;GpaQ^sWw~f&#%lNQ~GO}O<5{cJ@iXSW4#};tQz2#pIfu71!rQ( z4kCuX$!&s;)cMU9hv?R)rQE?_vV6Kg?&KyIEObikO?6Nay}u#c#`ywL(|Y-0_4B_| zZFZ?lHfgURDmYjMmoR8@i&Z@2Gxs;4uH)`pIv#lZ&^!198Fa^Jm;?}TWtz8sulPrL zKbu$b{{4m1$lv0`@ZWKA|0h5U!uIwqUkm{p7gFZ|dl@!5af*zlF% zpT-i|4JMt%M|0c1qZ$s8LIRgm6_V5}6l6_$cFS# z83cqh6K^W(X|r?V{bTQp14v|DQg;&;fZMu?5QbEN|DizzdZSB~$ZB%UAww;P??AT_-JFKAde%=4c z*WK^Iy5_Y`*IZ+cF`jvkCv~Urz3`nP{hF!UT7Z&e;MlB~LBDvL^hy{%; z7t5+&Ik;KwQ5H^i!;(ly8mfp@O>kH67-aW0cAAT~U)M1u`B>fG=Q2uC8k}6}DEV=% z<0n@WaN%dDBTe*&LIe^r-!r&t`a?#mEwYQuwZ69QU3&}7##(|SIP*4@y+}%v^Gb3# zrJ~68hi~77ya4=W-%{<(XErMm>&kvG`{7*$QxRf(jrz|KGXJN3Hs*8BfBx&9|5sZ1 zpFJ1(B%-bD42(%cOiT@2teyYoUBS`L%<(g;$b6nECbs|ADH5$LYxj?i3+2^#L@d{%E(US^chG<>aL7o>Fg~ zW@9wW@Mb&X;BoMz+kUPUcrDQOImm;-%|nxkXJ8xRz|MlPz5zcJHP<+yvqjB4hJAPE zRv>l{lLznW~SOGRU~u77UcOZyR#kuJrIH_){hzx!6NMX z>(OKAFh@s2V;jk|$k5-Q_ufVe;(KCrD}*^oBx{IZq^AB|7z*bH+g_-tkT~8S$bzdU zhbMY*g?Qb;-m|0`&Jm}A8SEI0twaTfXhIc=no}$>)n5^cc)v!C^YmpxLt=|kf%!%f zp5L$?mnzMt!o(fg7V`O^BLyjG=rNa}=$hiZzYo~0IVX$bp^H-hQn!;9JiFAF<3~nt zVhpABVoLWDQ}2vEEF3-?zzUA(yoYw&$YeHB#WGCXkK+YrG=+t0N~!OmTN;fK*k>^! zJW_v+4Q4n2GP7vgBmK;xHg^7zFqyTTfq|0+1^H2lXhn6PpG#TB*``?1STTC#wcaj3 zG~Q9!XHZ#1oPZo zB6h(BVIW5K+S@JG_HctDLHWb;wobZ0h(3xr6(uUspOSK0WoSHeF$ZLw@)cpoIP|kL zu`GnW>gD$rMt}J0qa9kJzn0s`@JNy1Crkb&;ve|()+_%!x%us>1_Xz|BS>9oQeD3O zy#CHX#(q^~`=@_p$XV6N&RG*~oEH$z96b8S16(6wqH)$vPs=ia!(xPVX5o&5OIYQ%E(-QAR1}CnLTIy zgu1MCqL{_wE)gkj0BAezF|AzPJs=8}H2bHAT-Q@Vuff?0GL=)t3hn{$Le?|+{-2N~`HWe24?!1a^UpC~3nK$(yZ_Gp(EzP~a{qe>xK@fN zEETlwEV_%9d1aWU0&?U>p3%4%>t5Pa@kMrL4&S@ zmSn!Dllj>DIO{6w+0^gt{RO_4fDC)f+Iq4?_cU@t8(B^je`$)eOOJh1Xs)5%u3hf; zjw$47aUJ9%1n1pGWTuBfjeBumDI)#nkldRmBPRW|;l|oDBL@cq1A~Zq`dXwO)hZkI zZ=P7a{Azp06yl(!tREU`!JsmXRps!?Z~zar>ix0-1C+}&t)%ist94(Ty$M}ZKn1sDaiZpcoW{q&ns8aWPf$bRkbMdSgG+=2BSRQ6GG_f%Lu#_F z&DxHu+nKZ!GuDhb>_o^vZn&^Sl8KWHRDV;z#6r*1Vp@QUndqwscd3kK;>7H!_nvYH zUl|agIWw_LPRj95F=+Ex$J05p??T9_#uqc|q>SXS&=+;eTYdcOOCJDhz7peuvzKoZhTAj&^RulU`#c?SktERgU|C$~O)>Q^$T8ippom{6Ze0_44rQB@UpR~wB? zPsL@8C)uCKxH7xrDor zeNvVfLLATsB!DD{STl{Fn3}6{tRWwG8*@a2OTysNQz2!b6Q2)r*|tZwIovIK9Ik#- z0k=RUmu97T$+6Lz%WQYdmL*MNII&MI^0WWWGKTTi&~H&*Ay7&^6Bpm!0yoVNlSvkB z;!l3U21sJyqc`dt)82)oXA5p>P_irU*EyG72iH%fEpUkm1K$?1^#-^$$Sb=c8_? zOWxxguW7$&-qzSI=Z{}sRGAqzy3J-%QYz2Cffj6SOU|{CshhHx z6?5L$V_QIUbI)HZ9pwP9S15 zXc%$`dxETq+S3_jrfmi$k=)YO5iUeuQ&uX}rCFvz&ubO?u)tv|^-G_`h$pb+8vn@f z7@eQe#Kx|8^37a4d0GulYIUAW|@I5|NIh%=OqHU{(>(UhKvJ}i_X*>!Geb+Rs0MWf66Lf z-cQ(4QOENSbTX$6w_9w4{5eR?14#?)Jqf2UCk5US4bnz8!e>vFduH6(cZZ=5*_!M# zUTZ_b<4v@}dSQOcH@wt-s;3JhkVDct$6k9!ETdi-tplkaxl^qF=p}Q8KMVm+ zeIa2q?RYr}nM0d_W2YWv%JKyCrGSePj8GrRN)<$Nsq8l$X=>`W;?>0eME3|8t&d$~ zH`XG45lBh>-te_f0Mh0??)=Ee0~zESx=sZPv<#!sAVv$0qTn@CmCUNJU<#=`GC)&P z9zuV~9*3_n2*ZQBUh)2xIi;0yo)9XXJxM-VB*6xpyz{Rx2ZCvFnF$2aPcYFG( zyXkO(B30?mt;5GW&{m^w3?!P`#_o;Y%P2z^A`|4%Bt2@3G?C2dcSPNy1#HMXZ>{+L z3BE#xvqR@Ub}uKfzGC=RO|W%dJpUK#m8p&Dk|6Ub8S+dN3qxf9dJ_|WFdM9CSNQv~ zjaFxIX`xx-($#Fq+EI76uB@kK=B4FS0k=9(c8UQnr(nLQxa2qWbuJyD7%`zuqH|eF zNrpM@SIBy@lKb%*$uLeRJQ->ko3yaG~8&}9|f z*KE`oMHQ(HdHlb&)jIzj5~&z8r}w?IM1KSdR=|GFYzDwbn8-uUfu+^h?80e*-9h%Nr;@)Q-TI#dN1V zQPT2;!Wk)DP`kiY<{o7*{on%It(j0&qSv=fNfg3qeNjT@CW{WT<)1Eig!g9lAGx6& zk9_Zrp2I+w_f!LRFsgxKA}gO=xSPSY``kn=c~orU4+0|^K762LWuk_~oK{!-4N8p8 zUDVu0ZhvoD0fN8!3RD~9Bz5GNEn%0~#+E-Js}NTBX;JXE@29MdGln$Aoa3Nzd@%Z= z^zuGY4xk?r(ax7i4RfxA?IPe27s87(e-2Z_KJ(~YI!7bhMQvfN4QX{!68nj@lz^-& z1Zwf=V5ir;j*30AT$nKSfB;K9(inDFwbI^%ohwEDOglz}2l}0!#LsdS3IW43= zBR#E@135bu#VExrtj?)RH^PM(K4B`d=Z6^kix`8$C1&q)w1<&?bAS?70}9fZwZU7R z5RYFo?2Q>e3RW2dl&3E^!&twE<~Lk+apY?#4PM5GWJb2xuWyZs6aAH-9gqg${<1?M zoK&n+$ZyGIi=hakHqRu{^8T4h@$xl?9OM46t;~1_mPs9}jV58E-sp!_CPH4<^A|Q5 zedUHmiyxTc2zgdxU?4PyQ{ON@r+Ucn1kjWSOsh6WzLV~Bv&vWLaj#Xz4VSDs*F#@M>#e^ixNCQ-J|iC=LcB*M4WUb>?v6C z14^8h9Ktd1>XhO$kb-rRL}SFTH)kSu+Dwds$oed7qL)Jbd zhQys4$Uw~yj03)6Kq+K-BsEDftLgjDZk@qLjAyrb5UMeuO^>D43g%0GoKJ~TO0o!D z9E$WfxEDFTT?~sT?|!7aYY*mpt`}i;WTgY|Cb4{Cscrmzb(?UE+nz1wC3#QSjbg>N zleu?7MGaQ&FtejK#?07Uq$vIZX5FqR*a=(zUm`Fq$VUl){GQ{2MA)_j4H$U8FZ`=A z&GU_an)?g%ULunbBq4EUT7uT=vI6~uapKC|H6uz1#Rqt$G(!hE7|c8_#JH%wp9+F? zX`ZigNe9GzC(|Nr8GlmwPre3*Nfu+ zF=SHtv_g@vvoVpev$Jxs|F7CH`X5#HAI=ke(>G6DQQ=h^U8>*J=t5Z3Fi>eH9}1|6 znwv3k>D=kufcp= zAyK#v05qERJxS_ts79QVns}M?sIf(hCO0Q9hKe49a@PzvqzZXTAde6a)iZLw|8V-) ziK`-s)d(oQSejO?eJki$UtP0ped)5T1b)uVFQJq*`7w8liL4TX*#K`hdS!pY9aLD+ zLt=c$c_wt^$Wp~N^!_nT(HiDVibxyq2oM^dw-jC~+3m-#=n!`h^8JYkDTP2fqcVC& zA`VWy*eJC$Eo7qIe@KK;HyTYo0c{Po-_yp=>J(1h#)aH5nV8WGT(oSP)LPgusH%N$?o%U%2I@Ftso10xd z)Tx(jT_vrmTQJDx0QI%9BRI1i!wMNy(LzFXM_wucgJGRBUefc413a9+)}~*UzvNI{KL# z_t4U&srNV|0+ZqwL(<}<%8QtjUD8kSB&p$v^y}vuEC2wyW{aXp2{LTi$EBEHjVnS# z+4=G$GUllsjw&hTbh6z%D2j=cG>gkNVlh|24QUfD*-x9OMzTO93n*pE(U7Vz7BaL% z@(c!GbEjK~fH}sqbB1JNI!~b+AYb5le<-qxDA9&r2o)|epl9@5Ya7}yVkcM)yW6KY7QOX_0-N=)+M!A$NpG? z6BvZ8Tb}Pw(i9f7S00=KbWmNvJGL(-MsAz3@aR~PM$Z>t)%AiCZu?A|?P*~UdhhFT`;Nb)MxIg*0QlkYVX+46( zSd%WoWR@kYToK7)(J=#qUD-ss;4M&27w#03y6$gk6X<-VL8AJM@NFTx#Z!n)F5T357%njjKyjro(yW8ceP{!%;*Y>DN`&_18p(z2Hg$%K zohbgJcp%+ux%q6F?(sc_mYJ<$;DxgkTEi?yjT6Du@+n(KsKtFHcO%7O z=AsfLSTdE2>7a@0^`;)?Fg|s2XOPV&fo<%Q)Izaw4s&RvrX0^+aPNq|yE?oSa7 zsnNs!+vGcTM4yM|$9so*2Nv;ngDD}b0MjH6i4e|l^O`lzCRj)-qa6f%|afJpmf(S1J2k7Nt^!;Q}0 z4ejPF?^M~Sv+@LYn&IFUk2;1h?kb8lfrT`oMm=JBm{fo5N|HY~yQQ`T*e2?!tF%*t zf+ncx15$NdF82GXrpP5rJ7!PVE3>u`ME$9Hw5RlP zUh+s#pg{9kEOsAhvu2pry#@dvbB3Lti+9VkLxPZSl;fNr9}wv1cTahUw_Py7%Xp;C zaz__|kz*ydKiYbsqK{?cXhqR(!1KMoV-+!mz>3S8S`Va4kD#(aKyqecGXB^nF*>mS z1gG>fKZc?R~Tye>%x+43D8=e zf0eKr-)>VEu7^I{%T}BT-WaGXO3+x<2w2jwnXePdc2#BdofU6wbE)ZWHsyj=_NT3o z)kySji#CTEnx8*-n=88Ld+TuNy;x$+vDpZ)=XwCr_Gx-+N=;=LCE7CqKX9 zQ-0{jIr zktqqWCgBa3PYK*qQqd=BO70DfM#|JvuW*0%zmTE{mBI$55J=Y2b2UoZ)Yk z3M%rrX7!nwk#@CXTr5=J__(3cI-8~*MC+>R);Z)0Zkj2kpsifdJeH)2uhA|9^B;S$ z4lT3;_fF@g%#qFotZ#|r-IB*zSo;fokxbsmMrfNfJEU&&TF%|!+YuN=#8jFS4^f*m zazCA-2krJ-;Tkufh!-urx#z*imYo|n6+NDGT#*EH355(vRfrGnr*x z5PWMD7>3IwEh=lO^V>O>iLP~S!GjrvI5lx<7oOg(d;6uEFqo5>IwptBQz;`>zx`n$ zjZQ#Hb)qJdQy#ML&qcfmb$KT+f_1#uYNo7HHDY}7xAw8qbl;9LWO-cndfI=5$%jBw zb}K3U%88Fg^|&0Vc~99bKl|$3JzdawRZ|`7%1S<8B7>9*rWAT0U<@mHDfnL1`~1U| zDw7m@<@}C|zqeHM(OK@di6~sKHiJvk^I0^S<LBe^_xZsUOzVkYSE)Bxn*NekQYbyTn5SRt!n{EseOo-$u)vjM(PV%6cIG3Kv$>dd}HUyXi;_Lv>}OyUj38dPe8+1Pr?{LXnIBCoTnocD60@vhsz+GG5lJB9ncgP8T6@LwuzZ)J zKETBS~AvzGE!{u^+Rd-|Gn!rc@UUnioP0{@_j_>tg8YI#?y zL-H$=&xXkCJ2Qe7&exbI!z`OyPxBp|4_ zZrrc;OAb%T4Ze%7E}FBB`8t$QN0sA3vpwU>?7QAmE%-ethXdCtby$Qm3v$lNxB2a7 ze6F5eEWV`={#W(G)Va}7?$D65WF|f0nmfZT;?=LE6Yz{{W3CV2h^Ma+LXdZ(HMVKZ z!YXJ*34lo!FA>)jSo@*!Hs_)IwmTo6pBr3c^j2u_amZ~g;&Z2jZIw!}v@w8DtZz7|A%rFksD4^HYB!xFAqX;u0HxPeG!3Z(z z4}+^N5-nckKf2YSR5R_}PD+2?Wq#BOiON74#{`u=4f59WKdy_77EYq~_|X6cNtno{ zZ?WLwbV57Z6uI|uY_;vzv~~`eiiOl($Au7C*X<&MY5v0b`KEu-GW}{2UNfmmrP!^Y zAOczy!}TIJsom=}kxH)9W`&Rp&rR6T7y&~5nXbut;wcs@M?aa^9j{ZDtx=1?P8TV{ zee2kKf%CE$mogyKKT=xQQ#)OCl9bjc)}{p2X$}aG`^B0w0yi-rI!d4e-u9uR$kJK3 zhqBG9Wx<-3DFw5olJ6neF@hB;8o(r(GB_;p1i>}cjN`JNEZg-dlxtLL=8~gfLrBy_ z1~bGh{I>_xqh(}?%bCf1U6~K@+N*i}bTi+pUAW)oM0`D*PeJq=S(-|Plxe9OqxBRg zM((r)xkSH@j!8@+=cA4US0fDL&O?W~x=Mlu>7zvHO2sy7D5_7ulP+YMecP~}F0b*K z3oO2j{o&WHd<&UWcyA(&6hvBJv}qUZ!@R<(mwKB^;y3zeE1>LzbDWSkRD1|5MZPx( zxd=&MsQi1eE@@6W+4N`cF?yh!3R5JlAV--&RONWQ#?SbrQ95<@ag>C{jQmGXpQX{) z1dbFg1_`qLxuDZnX#PKfCW*Jl3F&^7@gO&{>Nb8um$VBcF1!AL=N6`A%BFj=`QaPI z+m^`n+{o)KLif;Gt|7aQ(XXRP@x)jJt}s{&S`I3}jPTY>$@W0BD3Oif^ehs~!H7T1FUSWxLS&W;0q6+azjbWn?3!q$ z9qbmdr4H4Y)p^NOACJ^L>u}NS8T0_5hW)G z%Hv}dAqM}d@t;|hf8>+NHHPi*xePsRlqr46njzhiXXZti7i5+GTKcrlxA->OJ9*Pna`02EIA5~(SMV`T@H6F2VtwwP1$tYujbC1^VE$Yd&I`WSwB^1( zT7NP3|85z#R%&wktjwY_i*n_$RRZPM^ota{LPV%*>=>sAv%fn*cnkCIX{^SJRmwZv z!?f@T&D%Lz@*!mNYTGp{J|7)~PR*ib`;l^E)rQw@)Qn0ECnB8W1S_SbLZWdqcmo?V zX5g0_3qhn4TrN27^x#Qdq*4*G1L|)I^b8GuP_8O{p|M`uvZO6McXa>OSQRW|kQTNPZ#Zyj~SZ<`6B)Y+}jxpn+YT>MhZ!Rxyd@rU>N zP>MkDBLX|<)SJaO?Ge=!D>i+Wq&PgneO?ZXUq4IQuTq z+V{ZGkuw77o~o$!b>4ov`6CKJ)$cf=S6%1ZQyYU!kz_qiuNxY2*Bh;K9J6o_YV6xQ znW|>x+#Mymu&wF9P|3wP*(ZjwE+ou|{eFqMv}d_iEyH zQ?NSf3VX+EpbrIKmp|oD-t_rh(D#e)fp)dYbG{=yPj-3-#l+iu7r+~#w|(#wv@G0` z38`Yhf5CznhyDEhD;jzaz7fc8L?(n-m zR#|5hqq#yRoeTm+h^9J42mnB>BY>HSu&&O-Hxo6j!dqck)dGS&odS@Hsk2-*Z~x z0!%{@gT645S5DeF@JZeE$DFl*nJB8Z|JKvs%7d`KjbJ*AsA_=fEZ&V9=*+K{(TF^( ztjjYr(7@fV^tDs9c*#=8)ZRKO17A5Z`8v*)U+?hS>3sEfgh3`#vFO^7n}&&adV?}n zdy&BY1h|I@eBm=l*kqiJn>vNkOH4l$Op5Hw3K_w8lF!6T@-H)S2W|Km#6!-X#NqLJ zsiVDrc%*@I3^Gen$)6O0C_qw;8{aucF;}U^1%YE`?AYTtb`Z$B$vfhcHQF`VCB(Pf z_G#fV*Colv-k!O+=^nDNe(03?m+RTu&28d%>JrrwFNb{ND&?Ad(=DP@voz$usk1|w z&#gTB7F)#*LtY6@pIb(g72*LcnXRlTPQAD?)ZFnB*EsZqxM&Uk_KGXnR{4}K`I6i- zU9}R>tiO0De1Hx=kAy>7O+nKO@kGQEYOai&S9&WTY+flvR?uhI695W-xZnq4aRMh8 zwfp)+KYWVB#r=5AwwlSdM4@x7-R_{2;1iqz2lXL$7iu1>5W*+I)jlkMs>60=LN)Y= zbPw;;%U+%p_&{2Obemh$BLmbpDd31YxJ8#TpH3~3B8QLUMvx1X5Vl48hWSNN*UTlO zQgQyZbmyjGC-s$3tnB z0mfKUu2+_c`ZVvDVwUy#j3W*l^BSXXQ%=r6Z}C73jx8DAk!t7k{dK^udpHIcUejp# zyx}og$Hr+f>9kaZvno*Om`d|VTUce9tHM=R8thoG!a=NT$s;g@n_rAN%cp7nnLuav z6}j56TSSfPL$p#y#!5TVyqa3zTzi7@#IoeR=E6CdS`JrR+@i2DwZ?T*bh+(k5!a)0 zgRdF93z8XJ|5?>hDN!YAW5cK=+BwDLNT_+otd zqC@*{S0hCKZ+TnN*2&qx+WP;ZjHA`yytPcwKl~)uy)sQ}Q*0-&3X|YFYAjmolaciq zxS$r5^fxICetD*Dw78M9leVvhAOZ$=;SP7L!Vs?+0f1h*YCuTXIt03iAf)0=0KEvZ zB69o-zg`0C#hQ>`4`}1g=a~EID(j9HbjJG^tV-zumR-+fahTPveA{%0u2uQwMZ%}5 zwY!|}i0oTd&>^QSRhIKU+cMC#|C3f>|647?v1B(wH)EWb{vuJEJh~!#|J7%=h!x3| zCH6m}wg;>Q&?@5Ct1%n`lj%*>9a52d@wmvE`=aQjtz$sWj3V;fDns5<7d2*``)u1( zh!Ub>!#N0m=Vz1n1=El zwb2IVRw$6NIFRpGyUoM0iqc$IPehcmm7<0s7F*Yv+zq?_%pf*SS~~}s0M`m(rMbx% zi?|Wjr6fJN`_J8&B2$4+V+iO~m>s~Zr2T3Y3HGREFQ%%pEoU0N));AeSVM#gYQ>l} z0`RhgS`R^pJH31YQ~eTeJiI}g$&^|nv{!h?8mJK{{XDt+sG8D`7)$jvM#hjPI(5sS zfFW4s7wao%Lo| z#pJRC?iZOai;57ANs|vm6%}rPlGo}}Aso1t#xJn}%VW@~1WSjh(@JTgM$0x6ZQ)gB zdiox3f>kqGZY}+R<;wlNoWJ8#X-v)1;wRD*ec*wnvsN06Q@cZuD`deT-Bu&G;2fBC z0FE1%pG@{Yo2O87&dE;w???%`9s1gs=3GpM8xx_}=AB$K9y=cD);^iE*p4;T1RU%B zBPr)yqOBX<2}xt%g9qr>;z&|?4vhhw7@$a}Uy2b%_^VdB^VfzrebKUPnq;hliCNU% zVt3R5EHkhN^Pv`REF+npA@#HdCQN9IbQbqSDs^+zt(A6;rLwN+@Em}WrV5vPEo!w^ zSCd3RZ8{7a@d9@|IF&&G%irS7FHle?@49LctrtTt=rP$W)se*#RkFmyf)D1^U6EYI zfh+N?uH?-))O$9zM19VsuGn8?o~5`scXU?!P@_cWP&1U4PQqGus=sQzrX+YvKG%XBL3nt6!&M<#}wqA;Mo(}qrq<1lNkpQD-T#-y>grt|E+JNU) z2j+g+QPcA9VEFc0k;H(hSNOpp$I+!$ z&d&W6kBM9+c{X%vr_X0}tdB5dvEDyk5H2*T(QW8Yz-#tjvF?up=^Kfym``^!&O-X! z@HdfpHn;}_)y$Xjb-5cR$Q#-XdhKpmJG5pl>h*Q2(u*gt_4(>6?kG)%T3*&TT0qI( zL!aR~4HiJiaHlgdNcOQP6xx1f3AWx&8}(NEps|G!cO>J^rE2@&-t#_Jb7GYgnLnML~1ze1D$?~BwbgA^=pr55tC|d7w42vN11_8bS75u z_MRKqE7Xik8fk>6(VE5{qT}6rSzd|o}Zb>*aI*Bwg%ccE$_ytH;g2H z^i3qY!+aE*&s^BMH9TI6GLm&9c`D6)3{-+?2Pon+040Yuv$2(LqV*krKhTg5CHOj* zquacxc1&~=S(O@gR8aI#?R%)meONmw1rub9E2QzeM$pBBm2wbPNR3tab{op53<oFwaUbARdD5jSA_6zmKX7!VicEP1m)rYnk{P- zruRj;4c8S29Rd#Baf|fq_pA^r3K#qRHS;($XNoLI*`puZjM?bA0tH>FDiVc9qR*|3 zGn#nhqxkvqFwRfCB~2yA0pxWapfjCdAem$utuon-`*6}mUP?l%$CE(FjAwL%Oe7GQbu7*+&q>*(cAofJr^gg>xw>hx-SO7Lx2)I} zJ)tV1XKbkE4sS&La#-smSq>S9gBzGLH%v?KVezdGv%Xs}kDJZJi{lDl(FpLZupBta z3iDlkd6LlkRro}+El?GIObw06D%NTXpL{W}Ve*%u#{wTC=+VHS%o`sAez&cYz|Tn` zcK_~pvN%cd^8FlFypCjTjw9@ulLoJ^!QAK*++^wC2~}CFeoY;q6y~r&f^+0>LR6)n z$hSev@GzzGgDc>)#u5_;{T9^5y5I?m=z7=J!eVId8p6R5>NV8)h|bA}#3KUufq4CPGiWYvGj%0=H@Q66);F)#cDMND4 zX|?rg>Bb28q*a!_sgVF(A=OeC&je$C4>$0%yy;Fla-hl(|9Ww4!@Q#E2hpJMMxpQ2L+R;+ZMpS+|j*F`Fh}p)`a_*<`AaeFzNEq^- zlF$7BFKD%p@K+3$Vx%N{QOayKKWU#JOAwXiLO62cA6=|DiDG_Z=ef;f&gQ5-?+Pb+ z)4NsyEZXCdjq5tgDN39V9!6#w25+R1;PD7ss;hFvQn}Hnl3^3h<`ylzJdVEL>|Jj0 zg>=Pscwx&;pWEzMn`ld**$1F-nhqlMuX;G{lWrT<<4$7MZ^*4a2hAMf)3eYiT$lRz&9({j<=%DWIRpgu zoOns@gF}AQ_6Y5RhySg7yMtJcYQap6^hgy{`zX1Zv26q4<)g@t%aIi|-lmcySuRN8*5f*$aEFi8o#kMKRCMnrAY~l`= zez#50^@Qo+6r508>iKfAbbc3JwCnjnmw;~=mlMG`(H8EJz7W6mh@mdinO&)#zHX=| z&|fo@s`;njVkkCMczSnp+TnW8YPU4w2&QmzEh1}orF~KlT=V+`!!rH|PtULCcL!P*m0EaN0Ad2qBw%Gs40jfu=%`N*k@z2-p?&B?Yum-p+h?7(!D^ z&f2Bn_#t!4HM2y^*1GN;U+_x8T$Z2>U9Yx;p_9Qf=ww z2hxO^*{%p9-CwMKz}C4mTi8xvqhivltE|}Kgq5MK@f6tBT&`@RYzsFFi>*eMZ0Z6Y zKBl`GOh!U%C+PXJ|7PF)V*~#8eS80D@v-NL2U&;i62W}k+vJAC+7xF`eq%c0b?{PVTcqiDr%6jLBdkVcTwLJSd313SP)1r=;2`cORbMzrhqZxMWcTWru5-l_H8;f|?{^M%%7>sU zGx2{fX*t;7SewS|NvPR-6F5p(ji7d}CK#%7y}jsPkgj%F5cUbQ?b7uWpYks^|DL*n zau%X$^(%wXMS3c;C4=p*#q>ahmLH5woLsn-YcZP~mH-rGnRyl#KU4MsLu+G3z90+q zM$HCWgZYR`8_I%8)SYuBltP$sN`-6hcjnzhDsVl+Y}yqMN*4MWsJX_6R>Cyw8cHGQ z1>r%vkDxxc#ACA4+-ZO|QBMUz`YHrS{l-*$> zi(n_;4{Gn+d2gn)TA<9) zibWdKJv#s_f5K}vM=d0NaYrd;5A+Fy^=+WgKC`@bS>!P5@K4fzE#VYfMcNdbbvLPY zeR~!f3xU>|pfq-LOsoF=t94x%K!8>#8tR4KQ2G3Yr?Cb98^KL*+G8``rHMpNUN}-T z5HGAkiLh{WR;N$Nk3X_2^3pW=vOFTOb(LS0Wu)0)I{8sZj>}5ZGtD=va-72l&5`L= zhyzBWie2UrC|?(sTcuk$OwvV4oVlxc3ncXPj|cD%%*6(hoKMd5wzPQs^6g)B0xK#d zemOodB7D(!@v!|eYqMfx@M#b+D)PwAuvimOW#13i-xAR5)Ai; zXNX(A@M*y&+TVZI zGHo$F*Ipg~Rnp`KlMNAl2o86}r%Yv9#!O-oo`pe`880;-Y28tR)b4H%nqXXHxN9m0 zI&#!(XhT=T3$WS$)K4#Y=ceN`MsP0v1X{nIoQ14S2^--MnUp21=V3&Uv8|y}^}7Vl zI5tRbOp#?@ay6uncZFE0hg}kt(k%piw^M8;0yynsK_!l~uP??IqzmKJMUqAW^GG{~ z7Fg)Q&zBlp z%Tj8jOUpuR>YHP6zYsX?)aJ`)_pRwu+Tn8I;brOW_`v$u$`$9T)cO*O$j=?mg>dW$ zw=&3=v||fqCr`-$okN*$S9(Nyrs}+Lu#IwDg2xSBz_VfU*?A&26vwv>&>*U_TT7-7 zS~X}fT%9+q(Xvc0qzOG^8gmMcZE9izi5feqvY(aY=%reP+wVZ&cRd`^y6}-gJ&_6n zR%Wdl3vQ4DOt!X9ry7j%=+7pLPdus*@7dZMBo0_WKZPD1(o{=;D> zyc9_WFI3{URv=d6EXcnOG0$(J(R#8Oz$kmuSFQ{-Y20}1027!FkodTU!fouSybwqn zRO-$2BH(w4)$wiPo<1w-4*p=Q0@YKRm^cgiA>~ho)U8^e>SBk*!@xvr0CdvnLHS#CACVuQfgzF>8qV znqf{oO1}RWhiZ3g!Tx9sk!JfLqcP`>Ksx#vZuLg-DC6h4mT!vlU zqw0`0CzZgY!EN0*{sQnDNFn;T<+e_x$zY|n;p0@d^hK*n!S!=#^;P{*D^6~h!T7r6 zoiMxtovMo-dj*{qZPy*c3gaMBEDQDkINU%d8HeBZVlRuzkCId9rx{?L= z-dLlk$w&JX5wn+8`mtqCpKnx+w+$@6DEUI}8P%xN$MEsw%S1-$9PM6r^jP-@?cS<# zhg$wl0X=s3{8EZ2U9(};p{X_b1@jJuGgx`gDK{6MpF|XON_=Rv%-<Ee1cuuy?nl9xVDa~x=+8ppnOQ9 zN$53qi4QQ!co(;f!#YJ8(=Z>_9UF#(QOVjS7T!g2)*Oecrf-R^)tFugBkQsMVNua# zS;1V^#fJS{h+!O+FgS%0=Pd9;lMa0QHn?-n(<0b2$<|@r>fjiyw6u*UoGmU$ayJM@ zfp;c4@{$b*Z_v9?8ZEp{m6Q(mDHW<``n?jg-ZN)Hhvxn*l=O1f*K%{5s77WCt!ugS?*2oG5-Q)JEJd0+W5=doeD$Wh?U$ZRg)K$v8cmQ{hba9jw_mF&X zi-dV?WITgIz!!0uB~jE?(t`&qo{WGyUspX| zc6+F2K4l5$LqxERF#`I&k^^opVIMZjGhsJ^vI0c%kV+|&_k>~}ueTtj;^Dfb@xHs` z)-39elzVA~D~n_aoyBQ1>Qd2!;E!G*pZM&RX`r*y)b`yxvP2;#vM*;CQGPg|gni)} z47`Log3PUyVfdmJ2zvHBhg7T#D-H=myzkeUa$@);WC(yB4k^*$wda3=S-UH5Q1Hx6 zPcGxMP&kXBa+4$s#Sw3-V?mlHj^8&bLpIN~GkYj;!;M!$ZxvtQY4j&Ngz_mxuQRqx zYTbN6epx@-!0jRV5yiSIJ<^mCZ<|;&x2~a)t+(eAVB!1XpCZok*Z2C5P7&>z-Oy?t zf@F(_FLsSrfCus61+Vt~svP%(u<4pzT5{w*0XqfPV%~|=%aq^$=*U+_trGQaoUxbt zBV#Yqx+ULku8yPJs4gGcC?+3iRt_6)Oi0DNLxdb(!n!cup_XUZ3eDe(!DChZ!IG&L?_;T-1GB!R;;Sk;l3Y*JQ!I|l20_f}ZyC;4D7R@6F z>%z~wV;Bj1b(*kp26Ed!Y-OKxNbt3%t))xxOrazWsmwvW;uaSaJ0ou+{01vXvU>_V z6Ha@+;giVaiyg`J8ENQf)Pq>!Nf22>XFHnXTNk84&jp-^YwmlUqnOll8)5mzlO$o! z#fSMwH8Pn+Fy7O5M5#ZGr$cKfaGf8g;XN)<*TrQjMk<}_oRf&b6qZoR38Q{Zxo{V; zby+J_hCZT1>`4~jnQxo|ji%BQ0=BLzC6c!1=B(jS5+fcp%q)JI)=c3{D|=k5;0&c2 zrbRE|qxkNqah2nvextOvjYA{T43n1c6eO7B9DH)tLqB46E7;0xKM=%#wx-*-+*OY{ zQ#7gMStz%I&2&rbo>#T20OD_#g`WYbt9+!MC08%zSMhqMoRk)7VOk%~`sD%(U6zzO zdmSC9@x0GCv2_)umYc5@#%efP0_cu+=f^}k$H9$N_>piA_(5UM_o{++8+Yf8SJ)?C zDd3l=GGm3EEy;&Z6N=+XP@IM0L=uW^ooyYQYyx1vwFR?@U~BAtAqTu%Mi2 zTCQh$K=UZA{P`Cw0I$xAh_f?fq-Goe`7I38{3L8?K3`lRhSAyB)tHT@4c!Y;bJAAS z3u>Q7qx>9SJs4$EB=hxh)u`W5jp?>^g1s_MV7<1zN zXt{FSt?Mt&8aCy67<)b@eg@h0iCW@%+pF-V>p${fyEk6_Gvp|ms{Whi-9eNId?xzZ zm|MI>F;JSuaUnQp#|}k3o&ddCZEeTI608txuU4~7K(wg9 zg%+}(7h2@(%>LI1F*puF(h$ZD`Q+ar!VoVajPY0-XS$>6F_F?sc6Mr7>SL-&{pC;2 zKx@2{@ULz7RCpaKg$iu2rcY+y*~qaPo0}^7T1K$_(NPS<1;V zTj8-xC%WvgDI_YYEG{bySvyO3M>XKY)oXgGG*eB{yDgNQ3s3)A~@n>!O#lNh0! z(-dqW#_z&mMfq#2+u61N`L^({4UoU8wE5`4c}{SGFzKb(BK8hM%cf_zj_HmC48)M& z398ICVJTGzBaz7K{L+Ew=;z^0xA``wbtPs`r+Wrb^_vzzhukq{;A`t&-ktzb zbqy`Z0#D6fdVAiodjF3J+qI*vu#=OCjiL4bIIXEf4?zmN7(H|+<+WfR7@7jrMx7FY z5*0X1enhay-q^M?j}3Pd^|U9(C3#CQU3=hlc~@y9@NQD{UZNfC^5?Cuuuu{ebn_<7 zEzudv*b@QP%)N^5jP;86nQGb<*SOytCM5wmf-=rH#K{Wd$2(X#S$jF}XIxZC1)zir zU2Wq>hIB44nCTqx2x<{_wiVzLSJR}L%P!Y|lFHtA_=bDj=OqvmmSZ}ffuqPge#V-f zZDk|XX0RK}=73LxL`H%OXxK*^I2!fp&kxatErK~&tM3@j1a(Yrq$z)R()i?}p|0^Y zhW&8!IpRA1jJ3e!p66ZY=eBmEA+$A`!%s+{Cz!s$IA`{_Dh0^jt!vn;+Nw}hx019Q z_Wg=#-G-~&@>l=&H~48$L8`LX)!Bcq%(DFa2Loc91u@WcwlHzJwo{cdur>bQ;{fr_ z`rC5QRQ_)`8EadJzz-{K&sUI~>NX>P|c4l)fKS0gkuGe_P ziaQy!%CK(CtAwj-J8&#kyU=G(k%3y`!gS9dU&1xIrGRL|!&aVMEaezUIpopoET~xE zp`%~`LZfn!Lu^+00?>v4UOfM!HeeQoLZP<#o`^9oi69|$0BM?n17R~tGpY)eJiv@$ zTV-~ZZ*}C1J{a}p`>l$Bx8qRBq91;dLdmp84auzmcd|XzJG%I|r z^E-8Tm~jRn_>as(R=@~z3I2E3<=#hXn>A=0`wfOGIxiP)N2%!cG?&^w=E#TR z`lSY@Mm36zu4p3}+S#67MpL$d{gf@dnP%*ZMW=gCXK-%0E(xAC!^+b7hCSMF$m;Rn zCTErbBK#;a)>kHX5}w6PRmnw(!Gy>m_g*2opfklHyx>eb1bu|_lwJdf!ogxhk}X^v zc+^L;F7ta!8+i%6?M}XvQn4b%aOSCpDW+4#JDDG(wvXC*9%9(XBhbv4LX3R5G&(+@ z)nbdivYRQ5pW;9~@YGf{h~Rm(@MfV8Tj&T@EejO6(C#(+z7FVNBR`@j!#wScHM5ki%j+^GykUJ2m zYgpwm;#Q)~LoozUSV($?r3vQ~#ZU_}ggl~J%z*1dYt_^4K6e7o&qs_ORz{km+D+^a zqDdUO)d}|)v9h(Zz3}#DLWyRVCY!=PMCO{=PA)Upb@)1j?c)||l{6&pI=;U#bS#Jk zOOiwVH3FM!SuJDIPnN$|ZKz5fQwHmzn8f^?B+T2ew%~PSE#X_jk`Wu;a{4}9%AHg7 zZm8^bAee$bdpwklIE`$fV15=pI+tgJpll4uQjIM;Q!gvISFc_{@=lUSc-lABE%U?+ zHW$;!NcH1&F;AS~7RH=n<=!NTKnm3t`B@YeL?8d2{WGrmSjG;yBbY*9$N&DT^e?l2 z|1A2482Or7n7KF_TpRn|nmqD}`-=?QJ0z5q$C9Td^sML&aN7OGi+W$uYjDXKJg+0W@S=FoQP2dBI=48|FH>p2mh zFrdu!AwoG$NkvnZp_KT8HEo=RNNJ4IxucGXLr2N*I5Ao>Efb+pNOm9Zw0_7_s|9ac zS6}W##>$W*cBmksip;43p#a4&iTpM)8(gRGekW+AKm5zb)xpUFT>~b+FOH`Zs!$RDgpSCE z>;CL8Uu|EWeR~TvgDX@K=mtReFed;FZ!M2SjzW35i;UqfyemM?rq5yZS#hK5Y~|wt z2#^`Q6$b~uGT_++C3+B~#(oFHdSL&hh`Z8{t5#=ZkoaWVJoLm)3vT_@5HOnZGa;s~ z;4=E`3Eo@=$BxFjS`Iu|8SALB`<#TPTeE%h(dol+#CzJ=Zb&EHpw*=0H*~8x6 z`G`b<@>L2(AS*J!NVp`DN{g!8R#h(~URslf zC8PwGM$5V}+$WcoT*C~*$WmCpS6Gis&sZo|9OfRiwjX$f*&25Gjv6$YPde1smwGw( zb@y=gbl1!8>hm-il3&~zFca0~aJN!?b97+$E>2$Gn$31OR&UnE=Tm= zH44$Dx2HNN1lrCGjfuwo@+(m2j85w-oxre9FopupEV+6HACFyTbt}s-`lCCJ8om5RIE~T#Yg_DWu1u zyAp%jp;3&%D4;CRaR6g=f*ZvPqw2BadP=*ZYy_~CV3@wFx5YA(E8)jfqx z8tjEkMf>msMqi)zaY2fWrMq`lZzZdiMcluc(@(yxK(4hPEFk0~HO3^CUZk3;?Tv3` ze-rjZ8@hBrVPzA$^4hW?<33{d2)h7Jw?$t%V6(C_m+bNhXl9vXCJcBWmMeQoLDm5b zt9|A5pDHY#Y@(rlEo_WzXila!uaZE*WVc`=IM)SSc`#liZ2Wt*~fHgm9uH^ISX2d@)XGZ)_$qnbx6?J<14_=SS(ITs#LPDk03a&%x;bAuGz=P ze^<4p@tD@J|M;88;~IsEOPpB+&3C4!3q;}Kk2tb*WuuE z2u(BE$1(2AwbbBrmU-YLI4>#K((6&QZ~m2Yp;I14x0N8hos}{uoQuMG)Wy?ogaNayqmc&`I=8y6&dPf{Fky#B7 z#F=Xy213s`NFxjKuMqH3+ibWsFRi=QtH*j$9^)Zy8F|^vSmgj~l5<04MiU;BNyAn) zlM+c20Y#%@>WgdY>5kx}H)7*!D~BZJdg8d5iHx|>(jj=!MEmr)-$kH8?A#;DyBone(uz;e^|=9nIwfuWY?yw; zC|H`;8#O$vTPm5AW1Gg-Up&#Ca$<@!JZkAUDbmd*?X}QSA5$(*c+FZ|l+}F%*L1OH z{ck}P=j@=7>6ga#cqzj|ODXHD>ckIBmOd9Fh=~>?C7$uII_3rEX%UKdywsInR~{t- zg|t`~l=L1P_QPkZN53Q>!^A*QDZ zK(f;%VVQo)n1bsy)LWL#?&|wN`hL~Rnxhd3d-bOvlRQAiybH&=i;SlnwP$3P-!%x3^o)t6aoT-zXU}ARq-l^bOW-zg$@b|19Aua zF+k$V!uO;fNwCUEi;6!|5?4_MKtTq}|C`2gXh8EhWP1bTgZ)DqHZ&-x|E2*6Ka!RZ zS5jsHN&IW7%g1yUln@bn$cO!hR2b+`P~1-3dFIx!6EltRa{a z6Z@Y$_ug)~d%u)K$+?LYfc<87}bupdiK(3|m%hiA$Pc>zKNP0hqBj{X*L0rm@j(0s(f>>t{1L0?w#rS+#E)IdBKcF5|Dq-S zZ*-X3x;NeSuOSxS<3Q%uy1zwQ+?Kj&)Ou~-|2+&J{Zi^T=lx9+&+B^K_lQ;hY2H6D zeZ9T!H&;?$+kt+MLCs%i{8QEVi8<(Pft!mFt`}r~k5Y%93jAjQ!fgoD?Zh|Vi~q5A z27G^+_!lc1Zfo3}625-J{(B@p`IW|R4(!c|yX*Pn?*SA0)3iUGUB11uH>ab1{F$$g z|7q4=O#$9cezU54J)`wKI1_%J{14{0Zj0P3wEcKU`%-=?@(1PW+Zs0qGuI`%??IID dD~*3C;60WFKt@K_BOwYX49GZ$DDV2e{|AYb(KrAA literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..23449a2b --- /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-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..adff685a --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# 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/platforms/jvm/plugins-application/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 -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || 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 + + + +# 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" ) + + 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, 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# 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 00000000..c4bdd3ab --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@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. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +: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 00000000..5a6a38cb --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'Eatsfine' diff --git a/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java b/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java new file mode 100644 index 00000000..9ed6da86 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java @@ -0,0 +1,13 @@ +package com.eatsfine.eatsfine; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class EatsfineApplication { + + public static void main(String[] args) { + SpringApplication.run(EatsfineApplication.class, args); + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..266e4f16 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + application: + name: Eatsfine diff --git a/src/test/java/com/eatsfine/eatsfine/EatsfineApplicationTests.java b/src/test/java/com/eatsfine/eatsfine/EatsfineApplicationTests.java new file mode 100644 index 00000000..b262add2 --- /dev/null +++ b/src/test/java/com/eatsfine/eatsfine/EatsfineApplicationTests.java @@ -0,0 +1,13 @@ +package com.eatsfine.eatsfine; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class EatsfineApplicationTests { + + @Test + void contextLoads() { + } + +} From 1c1bf1ed9694632148bfaefeb27b2a68a61b9bd8 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Wed, 31 Dec 2025 17:06:14 +0900 Subject: [PATCH 002/169] =?UTF-8?q?[CHORE]:=20CI=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ISSUE_TEMPLATE}/feature_request.yml | 0 .../ISSUE_TEMPLATE}/refactor_template.yml | 0 .../pull_request_template.md | 0 .github/workflows/build-test.yml | 38 +++++++++++++++++++ 4 files changed, 38 insertions(+) rename {.github /ISSUE_TEMPLATE => .github/ISSUE_TEMPLATE}/feature_request.yml (100%) rename {.github /ISSUE_TEMPLATE => .github/ISSUE_TEMPLATE}/refactor_template.yml (100%) rename {.github => .github}/pull_request_template.md (100%) create mode 100644 .github/workflows/build-test.yml diff --git a/.github /ISSUE_TEMPLATE /feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml similarity index 100% rename from .github /ISSUE_TEMPLATE /feature_request.yml rename to .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github /ISSUE_TEMPLATE /refactor_template.yml b/.github/ISSUE_TEMPLATE/refactor_template.yml similarity index 100% rename from .github /ISSUE_TEMPLATE /refactor_template.yml rename to .github/ISSUE_TEMPLATE/refactor_template.yml diff --git a/.github /pull_request_template.md b/.github/pull_request_template.md similarity index 100% rename from .github /pull_request_template.md rename to .github/pull_request_template.md diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 00000000..7118468f --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,38 @@ +name: Build-Test + +on: + pull_request: + branches: + - main + - develop + +jobs: + Build-Test: + runs-on: ubuntu-latest + + steps: + - name: Check Out (체크 아웃) + uses: actions/checkout@v4 + + - name: Setup Java Version (Java 21 설정) + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Use Gradle Cache (Gradle 캐싱) + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew (gradlew에 권한 부여) + run: chmod +x gradlew + shell: bash + + - name: Test (테스트) + run: ./gradlew build From 805bb395019b1ff6d9b350d014ebfec6836c1c37 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 1 Jan 2026 02:23:08 +0900 Subject: [PATCH 003/169] =?UTF-8?q?[CHORE]:=20CI=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++++ src/main/resources/application-local.yml | 9 +++++++++ src/main/resources/application.yml | 2 ++ src/test/resources/application-test.yml | 8 ++++++++ src/test/resources/application.yml | 5 +++++ 5 files changed, 28 insertions(+) create mode 100644 src/main/resources/application-local.yml create mode 100644 src/test/resources/application-test.yml create mode 100644 src/test/resources/application.yml diff --git a/.gitignore b/.gitignore index c2065bc2..48f23698 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ out/ ### VS Code ### .vscode/ + +### secret properties files ### +/src/main/resources/application-blue.yml +/src/main/resources/application-green.yml diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 00000000..4aaa4354 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,9 @@ +server: + port: 8080 + profile: local + +spring: + config: + activate: + on-profile: local + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 266e4f16..b6cae831 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,5 @@ spring: application: name: Eatsfine + profiles: + active: local diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..32c915a2 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,8 @@ +server: + port: 9090 + profile: test + +spring: + config: + activate: + on-profile: test diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 00000000..ce96af2e --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,5 @@ +spring: + application: + name: Eatsfine + profiles: + active: test \ No newline at end of file From 5087c22cac995ad62d377d935219082173ca8f98 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:03:36 +0900 Subject: [PATCH 004/169] =?UTF-8?q?[FEAT]:=20HealthCheck=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=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 --- build.gradle | 6 +-- .../eatsfine/EatsfineApplication.java | 3 ++ .../eatsfine/controller/HealthController.java | 21 ++++++++++ .../deploy/config/DeployProperties.java | 7 ++++ .../controller/HealthControllerTest.java | 40 +++++++++++++++++++ 5 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/controller/HealthController.java create mode 100644 src/main/java/com/eatsfine/eatsfine/system/deploy/config/DeployProperties.java create mode 100644 src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java diff --git a/build.gradle b/build.gradle index 8c096207..c6d27327 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '4.0.1' + id 'org.springframework.boot' version '3.4.1' id 'io.spring.dependency-management' version '1.1.7' } @@ -19,8 +19,8 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-webmvc' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + implementation 'org.springframework.boot:spring-boot-starter-web' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java b/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java index 9ed6da86..54ed3395 100644 --- a/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java +++ b/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java @@ -3,6 +3,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@ConfigurationPropertiesScan @SpringBootApplication public class EatsfineApplication { diff --git a/src/main/java/com/eatsfine/eatsfine/controller/HealthController.java b/src/main/java/com/eatsfine/eatsfine/controller/HealthController.java new file mode 100644 index 00000000..077f4335 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/controller/HealthController.java @@ -0,0 +1,21 @@ +package com.eatsfine.eatsfine.controller; + +import com.eatsfine.eatsfine.system.deploy.config.DeployProperties; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HealthController { + + private final DeployProperties deployProperties; + + public HealthController(DeployProperties deployProperties) { + this.deployProperties = deployProperties; + } + + @GetMapping("/api/v1/deploy/health-check") + public ResponseEntity healthCheck() { + return ResponseEntity.ok(deployProperties.profile()); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/system/deploy/config/DeployProperties.java b/src/main/java/com/eatsfine/eatsfine/system/deploy/config/DeployProperties.java new file mode 100644 index 00000000..4d961351 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/system/deploy/config/DeployProperties.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.system.deploy.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "server") +public record DeployProperties(String profile) { +} diff --git a/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java b/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java new file mode 100644 index 00000000..22dd71d2 --- /dev/null +++ b/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java @@ -0,0 +1,40 @@ +package com.eatsfine.eatsfine.controller; + +import com.eatsfine.eatsfine.system.deploy.config.DeployProperties; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ActiveProfiles("test") +@WebMvcTest(controllers = HealthController.class) +@EnableConfigurationProperties(DeployProperties.class) +@AutoConfigureMockMvc +@DisplayName("HealthController 중형 테스트: 스프링 MVC와 컨트롤러가 잘 결합하여 동작하는가?") +class HealthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("healthCheck : 현재 서버가 살아있다면 200 상태코드와 함께 활성화된 프로필을 문자열로 응답한다") + void healthCheckTest() throws Exception { + mockMvc + .perform(get("/api/v1/deploy/health-check")) + .andDo(print()) + .andExpectAll( + status().isOk(), + content().contentType("text/plain;charset=UTF-8"), + content().string("test") // active profile set to 'test' + ); + } +} From e711e34c6632e0ac7601b9c78bddf2842c3fb69a Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:28:25 +0900 Subject: [PATCH 005/169] =?UTF-8?q?[FEAT]:=20Docker=20=EB=B9=8C=EB=93=9C?= =?UTF-8?q?=20=ED=99=98=EA=B2=BD=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 5 +++++ build.gradle | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..4f94d818 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM amazoncorretto:21-alpine3.20-jdk +ARG JAR_FILE=build/libs/*.jar +ARG PROFILES +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["sh", "-c", "java -DSpring.profiles.active=${PROFILES} -Dspring.config.location=classpath:/,file:/app/config/Eatsfine/ -jar app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index c6d27327..e58e4b30 100644 --- a/build.gradle +++ b/build.gradle @@ -27,3 +27,10 @@ dependencies { tasks.named('test') { useJUnitPlatform() } +tasks.named("bootJar"){ + enabled = true +} + +tasks.named('jar') { + enabled = false +} From cc0b0d3a42c8b542b5e7a53ce0f9daf68ee59c14 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:55:18 +0900 Subject: [PATCH 006/169] =?UTF-8?q?[FEAT]:=20CD=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 150 +++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..80cf2574 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,150 @@ +name: Deploy +on: + push: + branches: + - develop + - main + +jobs: + Deploy: + runs-on: ubuntu-latest + steps: + - name: 체크 아웃 + uses: actions/checkout@v4 + + - name: Java 21 설정 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Gradle 캐싱 + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os}}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os}}-gradle- + + - name: gradlew 파일 실행권한 부여 + run: chmod +x gradlew + shell: bash + + - name: 프로젝트 빌드 + run: | + ./gradlew build + + - name: DockerHub 로그인 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} + + - name: 도커 이미지 빌드 + run: docker build --platform linux/amd64 -t ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be . + + - name: 도커 이미지 푸시 + run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest + + - name: 배포 대상 포트/PROFILE 확인 + run: | + response=$(curl -s -w "%{http_code}" "http://${{ secrets.LIVE_SERVER_IP }}/api/v1/deploy/health-check") + STATUS="${response: -3}" + BODY="${response::-3}" + + echo "STATUS=$STATUS" + + if [ "$STATUS" = "200" ]; then + CURRENT_UPSTREAM="$BODY" + else + CURRENT_UPSTREAM="green" + fi + + echo "CURRENT_UPSTREAM=$CURRENT_UPSTREAM" >> $GITHUB_ENV + + if [ "$CURRENT_UPSTREAM" = "blue" ]; then + echo "CURRENT_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV + echo "STOPPED_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV + echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV + elif [ "$CURRENT_UPSTREAM" = "green" ]; then + echo "CURRENT_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV + echo "STOPPED_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV + echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV + else + echo "error" + exit 1 + fi + + - name: GitHub Actions 실행자 IP 얻어오기 + id: GITHUB_ACTIONS_IP + uses: haythem/public-ip@v1.3 + + - name: AWS CLI 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 부여 + run: | + aws ec2 authorize-security-group-ingress \ + --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ + --ip-permissions \ + 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ + 'IpProtocol=tcp,FromPort=${{ env.STOPPED_PORT }},ToPort=${{ env.STOPPED_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' + - name: SSH Key 설정 + run: | + mkdir -p ~/.ssh + echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/eatsfine-ec2-key.pem + chmod 600 ~/.ssh/eatsfine-ec2-key.pem + echo "Host eatsfine-ec2" >> ~/.ssh/config + echo " HostName ${{ secrets.LIVE_SERVER_IP }}" >> ~/.ssh/config + echo " User ${{ secrets.EC2_USERNAME }}" >> ~/.ssh/config + echo " IdentityFile ~/.ssh/eatsfine-ec2-key.pem" >> ~/.ssh/config + echo " StrictHostKeyChecking no" >> ~/.ssh/config + + - name: 도커 이미지 풀링 및 컨테이너 실행 + run: | + ssh eatsfine-ec2 << 'EOF' + set -e + + # 필요한 프로필 파일을 서버로 복사합니다. + if [ "${{ env.TARGET_UPSTREAM }}" = "blue" ]; then + echo "${{ secrets.APPLICATION_BLUE_YML }}" | base64 --decode > /home/ec2-user/config/eatsfine/application-blue.yml + else + echo "${{ secrets.APPLICATION_GREEN_YML }}" | base64 --decode > /home/ec2-user/config/eatsfine/application-green.yml + fi + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest + docker compose -f docker-compose-${{ env.TARGET_UPSTREAM }}.yml up -d + EOF + - name: 새로 실행한 서버 컨테이너 헬스 체크 + uses: jtalk/url-health-check-action@v3 + with: + url: http://${{ secrets.LIVE_SERVER_IP }}:${{ env.STOPPED_PORT }}/api/v1/deploy/health-check + max-attempts: 3 + retry-delay: 10s + + - name: Nginx 의 대상 서버를 새로 실행한 컨테이너쪽으로 전환 + run: | + ssh eatsfine-ec2 << 'EOF' + set -e + docker exec -i nginx bash -c 'echo "set \$service_url ${{ env.TARGET_UPSTREAM }};" > /etc/nginx/conf.d/service-env.inc && nginx -s reload' + EOF + - name: 기존 배포 컨테이너 정지 + run: | + ssh eatsfine-ec2 << 'EOF' + set -e + docker stop ${{ env.CURRENT_UPSTREAM }} + docker rm ${{ env.CURRENT_UPSTREAM }} + EOF + - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 제거 + if: always() + run: | + aws ec2 revoke-security-group-ingress \ + --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ + --ip-permissions \ + 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ + 'IpProtocol=tcp,FromPort=${{env.STOPPED_PORT}},ToPort=${{env.STOPPED_PORT}},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ No newline at end of file From 39ada89ce78e669c40f329d145d8f257a6040f1b Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 1 Jan 2026 22:38:21 +0900 Subject: [PATCH 007/169] =?UTF-8?q?[FIX]:=20=EB=8F=84=EC=BB=A4=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=ED=92=80=EB=A7=81=20=EB=B0=8F=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=84=88=20=EC=8B=A4=ED=96=89=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 80cf2574..17a0c113 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -111,14 +111,18 @@ jobs: ssh eatsfine-ec2 << 'EOF' set -e + CONFIG_DIR=/home/ec2-user/config/eatsfine + DEPLOY_DIR=/home/ec2-user/deploy + # 필요한 프로필 파일을 서버로 복사합니다. if [ "${{ env.TARGET_UPSTREAM }}" = "blue" ]; then - echo "${{ secrets.APPLICATION_BLUE_YML }}" | base64 --decode > /home/ec2-user/config/eatsfine/application-blue.yml + echo "${{ secrets.APPLICATION_BLUE_YML }}" | base64 --decode > ${CONFIG_DIR}/application-blue.yml else - echo "${{ secrets.APPLICATION_GREEN_YML }}" | base64 --decode > /home/ec2-user/config/eatsfine/application-green.yml + echo "${{ secrets.APPLICATION_GREEN_YML }}" | base64 --decode > ${CONFIG_DIR}/application-green.yml fi + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest - docker compose -f docker-compose-${{ env.TARGET_UPSTREAM }}.yml up -d + docker compose -f /home/ec2-user/deploy/docker-compose-${{ env.TARGET_UPSTREAM }}.yml up -d EOF - name: 새로 실행한 서버 컨테이너 헬스 체크 uses: jtalk/url-health-check-action@v3 From 1089ffcd4d41f04816ea17328965fa4900654602 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 1 Jan 2026 22:50:56 +0900 Subject: [PATCH 008/169] =?UTF-8?q?[FIX]:=20=ED=97=AC=EC=8A=A4=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EC=8B=A4=ED=8C=A8=20=EB=B0=A9=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EC=95=B1=20=EA=B8=B0=EB=8F=99=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 17a0c113..619cc22c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -124,12 +124,16 @@ jobs: docker pull ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest docker compose -f /home/ec2-user/deploy/docker-compose-${{ env.TARGET_UPSTREAM }}.yml up -d EOF + + - name: 컨테이너 기동 대기 + run: sleep 30 + - name: 새로 실행한 서버 컨테이너 헬스 체크 uses: jtalk/url-health-check-action@v3 with: url: http://${{ secrets.LIVE_SERVER_IP }}:${{ env.STOPPED_PORT }}/api/v1/deploy/health-check - max-attempts: 3 - retry-delay: 10s + max-attempts: 10 + retry-delay: 10 - name: Nginx 의 대상 서버를 새로 실행한 컨테이너쪽으로 전환 run: | From f231ea53a269b4e59324a2850b1e1127746070a8 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 1 Jan 2026 23:06:33 +0900 Subject: [PATCH 009/169] =?UTF-8?q?[FIX]:=20invalid=20duration=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EC=97=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 619cc22c..255c5c2d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -133,7 +133,7 @@ jobs: with: url: http://${{ secrets.LIVE_SERVER_IP }}:${{ env.STOPPED_PORT }}/api/v1/deploy/health-check max-attempts: 10 - retry-delay: 10 + retry-delay: 10s - name: Nginx 의 대상 서버를 새로 실행한 컨테이너쪽으로 전환 run: | From a6190bb42d0c79f4efe4e793a34dee3b6f55b350 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Fri, 2 Jan 2026 18:18:45 +0900 Subject: [PATCH 010/169] =?UTF-8?q?[FEAT]:=20=EC=9D=98=EC=A1=B4=EC=84=B1?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index e58e4b30..c699bf0a 100644 --- a/build.gradle +++ b/build.gradle @@ -20,6 +20,8 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } From b588dfb11a4e76c8e6005679aa40768ba3896163 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Fri, 2 Jan 2026 18:52:05 +0900 Subject: [PATCH 011/169] =?UTF-8?q?[FIX]:=20=EB=8C=80=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=EB=A5=BC=20=EC=86=8C=EB=AC=B8=EC=9E=90=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b6cae831..bf93ae98 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,5 @@ spring: application: - name: Eatsfine + name: eatsfine profiles: active: local From a2d63949ebaf62e8d462f6f0819b1f22ff9a2918 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Fri, 2 Jan 2026 19:01:27 +0900 Subject: [PATCH 012/169] =?UTF-8?q?[FIX]:=20=EA=B2=BD=EB=A1=9C=20=EB=8C=80?= =?UTF-8?q?=EC=86=8C=EB=AC=B8=EC=9E=90=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- src/main/resources/application.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4f94d818..ec06757d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,4 +2,4 @@ FROM amazoncorretto:21-alpine3.20-jdk ARG JAR_FILE=build/libs/*.jar ARG PROFILES COPY ${JAR_FILE} app.jar -ENTRYPOINT ["sh", "-c", "java -DSpring.profiles.active=${PROFILES} -Dspring.config.location=classpath:/,file:/app/config/Eatsfine/ -jar app.jar"] \ No newline at end of file +ENTRYPOINT ["sh", "-c", "java -DSpring.profiles.active=${PROFILES} -Dspring.config.location=classpath:/,file:/app/config/eatsfine/ -jar app.jar"] \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bf93ae98..b6cae831 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,5 @@ spring: application: - name: eatsfine + name: Eatsfine profiles: active: local From e4c8d2f84f18ccc5fcd99c7ddd82f71b501395e1 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Fri, 2 Jan 2026 21:43:23 +0900 Subject: [PATCH 013/169] =?UTF-8?q?[FIX]:=20CI=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 255c5c2d..b3a6baf1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -145,8 +145,7 @@ jobs: run: | ssh eatsfine-ec2 << 'EOF' set -e - docker stop ${{ env.CURRENT_UPSTREAM }} - docker rm ${{ env.CURRENT_UPSTREAM }} + docker rm -f "${{ env.CURRENT_UPSTREAM }}" || true EOF - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 제거 if: always() From 99bb4c3bda9ccede5063ac72a9df90b4bbd507d1 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Fri, 2 Jan 2026 21:54:35 +0900 Subject: [PATCH 014/169] =?UTF-8?q?[FIX]:=20CI=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=9B=90=EB=9E=98=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EB=8B=A4=EC=8B=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b3a6baf1..255c5c2d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -145,7 +145,8 @@ jobs: run: | ssh eatsfine-ec2 << 'EOF' set -e - docker rm -f "${{ env.CURRENT_UPSTREAM }}" || true + docker stop ${{ env.CURRENT_UPSTREAM }} + docker rm ${{ env.CURRENT_UPSTREAM }} EOF - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 제거 if: always() From a5fdad463df853da36be6019d15bcd7b52d683ed Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Fri, 2 Jan 2026 23:05:10 +0900 Subject: [PATCH 015/169] =?UTF-8?q?[FIX]:=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 255c5c2d..86294bfe 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,15 +50,25 @@ jobs: - name: 배포 대상 포트/PROFILE 확인 run: | - response=$(curl -s -w "%{http_code}" "http://${{ secrets.LIVE_SERVER_IP }}/api/v1/deploy/health-check") - STATUS="${response: -3}" - BODY="${response::-3}" + # curl 연결 실패 시(서버 꺼짐 등)에도 에러내지 않고 넘어감 (|| true) + response=$(curl -s -w "%{http_code}" "http://${{ secrets.LIVE_SERVER_IP }}/api/v1/deploy/health-check") || true + + echo "Response: $response" + + # 응답이 비어있거나(연결실패) 200이 아니면 + if [ -z "$response" ] || [ "${#response}" -lt 3 ]; then + STATUS="ERROR" + else + STATUS="${response: -3}" + BODY="${response::-3}" + fi echo "STATUS=$STATUS" if [ "$STATUS" = "200" ]; then CURRENT_UPSTREAM="$BODY" else + # 서버가 죽어있거나 응답이 없으면 기본적으로 green을 현재 상태로 보고 blue를 배포 CURRENT_UPSTREAM="green" fi @@ -68,10 +78,12 @@ jobs: echo "CURRENT_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV echo "STOPPED_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV + echo "TARGET_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV elif [ "$CURRENT_UPSTREAM" = "green" ]; then echo "CURRENT_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV echo "STOPPED_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV + echo "TARGET_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV else echo "error" exit 1 @@ -126,12 +138,12 @@ jobs: EOF - name: 컨테이너 기동 대기 - run: sleep 30 + run: sleep 10 - name: 새로 실행한 서버 컨테이너 헬스 체크 uses: jtalk/url-health-check-action@v3 with: - url: http://${{ secrets.LIVE_SERVER_IP }}:${{ env.STOPPED_PORT }}/api/v1/deploy/health-check + url: http://${{ secrets.LIVE_SERVER_IP }}:${{ env.TARGET_PORT }}/api/v1/deploy/health-check max-attempts: 10 retry-delay: 10s From 5bf122db02d302c60b5ab8fa86817f56907232d2 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Fri, 2 Jan 2026 23:46:55 +0900 Subject: [PATCH 016/169] =?UTF-8?q?[FIX]:=20=EB=8F=84=EC=BB=A4=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index ec06757d..bc3ff9d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,3 @@ FROM amazoncorretto:21-alpine3.20-jdk ARG JAR_FILE=build/libs/*.jar -ARG PROFILES -COPY ${JAR_FILE} app.jar -ENTRYPOINT ["sh", "-c", "java -DSpring.profiles.active=${PROFILES} -Dspring.config.location=classpath:/,file:/app/config/eatsfine/ -jar app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file From 64080bfb6ad294f1e491f0b8992efc3c35fc62c7 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Fri, 2 Jan 2026 23:58:55 +0900 Subject: [PATCH 017/169] =?UTF-8?q?[FIX]:=20=EB=8F=84=EC=BB=A4=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index bc3ff9d6..6bccfe1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ FROM amazoncorretto:21-alpine3.20-jdk ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file From 31d402baf45072fbf4fed9e9c780bf26eadf75d0 Mon Sep 17 00:00:00 2001 From: SungMinju Date: Mon, 5 Jan 2026 02:16:42 +0900 Subject: [PATCH 018/169] =?UTF-8?q?[chore]=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 +++ build.gradle | 12 +++++- .../user/controller/UserController.java | 4 ++ .../domain/user/converter/UserConverter.java | 4 ++ .../eatsfine/domain/user/dto/UserRequest.java | 4 ++ .../eatsfine/domain/user/entity/User.java | 4 ++ .../user/repository/UserRepository.java | 4 ++ .../domain/user/service/UserService.java | 4 ++ .../domain/user/service/UserServiceImpl.java | 4 ++ .../global/apiPayload/ApiResponse.java | 41 ++++++++++++++++++ .../global/apiPayload/code/BaseCode.java | 6 +++ .../global/apiPayload/code/BaseErrorCode.java | 6 +++ .../apiPayload/code/ErrorReasonDto.java | 14 +++++++ .../global/apiPayload/code/ReasonDto.java | 15 +++++++ .../apiPayload/code/status/ErrorStatus.java | 40 ++++++++++++++++++ .../apiPayload/code/status/SuccessStatus.java | 38 +++++++++++++++++ .../config/DeployProperties.java | 2 +- .../eatsfine/global/config/SwaggerConfig.java | 42 +++++++++++++++++++ .../controller/HealthController.java | 4 +- .../controller/HealthControllerTest.java | 3 +- 20 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/user/controller/UserController.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/user/converter/UserConverter.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/user/dto/UserRequest.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/user/repository/UserRepository.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/user/service/UserService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/user/service/UserServiceImpl.java create mode 100644 src/main/java/com/eatsfine/eatsfine/global/apiPayload/ApiResponse.java create mode 100644 src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/BaseCode.java create mode 100644 src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/BaseErrorCode.java create mode 100644 src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/ErrorReasonDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/ReasonDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/SuccessStatus.java rename src/main/java/com/eatsfine/eatsfine/{system/deploy => global}/config/DeployProperties.java (76%) create mode 100644 src/main/java/com/eatsfine/eatsfine/global/config/SwaggerConfig.java rename src/main/java/com/eatsfine/eatsfine/{ => global}/controller/HealthController.java (83%) diff --git a/.gitignore b/.gitignore index 48f23698..a07b6aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,9 @@ out/ ### secret properties files ### /src/main/resources/application-blue.yml /src/main/resources/application-green.yml + +### Application Config (개인 설정, 보안 정보) ### +src/main/resources/application.yml + +### Gradle 설정 파일 제외 +build.gradle diff --git a/build.gradle b/build.gradle index c699bf0a..36475876 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ description = 'Eatsfine' java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + //languageVersion = JavaLanguageVersion.of(17) } } @@ -24,6 +24,16 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + + //security + implementation 'org.springframework.boot:spring-boot-starter-security' + + //swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' } tasks.named('test') { diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/controller/UserController.java b/src/main/java/com/eatsfine/eatsfine/domain/user/controller/UserController.java new file mode 100644 index 00000000..0c538442 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/controller/UserController.java @@ -0,0 +1,4 @@ +package com.eatsfine.eatsfine.domain.user.controller; + +public class UserController { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/converter/UserConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/user/converter/UserConverter.java new file mode 100644 index 00000000..ddce98db --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/converter/UserConverter.java @@ -0,0 +1,4 @@ +package com.eatsfine.eatsfine.domain.user.converter; + +public class UserConverter { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/dto/UserRequest.java b/src/main/java/com/eatsfine/eatsfine/domain/user/dto/UserRequest.java new file mode 100644 index 00000000..9528c2e4 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/dto/UserRequest.java @@ -0,0 +1,4 @@ +package com.eatsfine.eatsfine.domain.user.dto; + +public class UserRequest { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java b/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java new file mode 100644 index 00000000..32c1bdae --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java @@ -0,0 +1,4 @@ +package com.eatsfine.eatsfine.domain.user.entity; + +public class User { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/repository/UserRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/user/repository/UserRepository.java new file mode 100644 index 00000000..6caff489 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/repository/UserRepository.java @@ -0,0 +1,4 @@ +package com.eatsfine.eatsfine.domain.user.repository; + +public interface UserRepository { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/service/UserService.java b/src/main/java/com/eatsfine/eatsfine/domain/user/service/UserService.java new file mode 100644 index 00000000..6d3ef6b6 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/service/UserService.java @@ -0,0 +1,4 @@ +package com.eatsfine.eatsfine.domain.user.service; + +public interface UserService { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/service/UserServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/user/service/UserServiceImpl.java new file mode 100644 index 00000000..d4457ffb --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/service/UserServiceImpl.java @@ -0,0 +1,4 @@ +package com.eatsfine.eatsfine.domain.user.service; + +public class UserServiceImpl { +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/ApiResponse.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/ApiResponse.java new file mode 100644 index 00000000..c2a29805 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/ApiResponse.java @@ -0,0 +1,41 @@ +package com.eatsfine.eatsfine.global.apiPayload; + +import com.example.backend.global.apiPayload.code.BaseCode; +import com.example.backend.global.apiPayload.code.status.SuccessStatus; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.AllArgsConstructor; +import lombok.Getter; + + +@Getter +@AllArgsConstructor +@JsonPropertyOrder({"isSuccess","code","message","result"}) +public class ApiResponse { + @JsonProperty("isSuccess") + private final Boolean isSuccess; + private final String code; + private final String message; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private T result; + + public static ApiResponse onSuccess(T result) { + return new ApiResponse<>( + true, SuccessStatus._OK.getCode(), SuccessStatus._OK.getMessage(), result); + } + + public static ApiResponse of(BaseCode code, T result){ + return new ApiResponse<>( + true, + code.getReasonHttpStatus().getCode(), + code.getReasonHttpStatus().getMessage(), + result); + } + + public static ApiResponse onFailure(String code, String message, T data) { + return new ApiResponse<>(false, code, message, data); + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/BaseCode.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/BaseCode.java new file mode 100644 index 00000000..8337fc82 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/BaseCode.java @@ -0,0 +1,6 @@ +package com.eatsfine.eatsfine.global.apiPayload.code; + +public interface BaseCode { + ReasonDto getReason(); + ReasonDto getReasonHttpStatus(); +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/BaseErrorCode.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/BaseErrorCode.java new file mode 100644 index 00000000..54abe0d2 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/BaseErrorCode.java @@ -0,0 +1,6 @@ +package com.eatsfine.eatsfine.global.apiPayload.code; + +public interface BaseErrorCode { + ErrorReasonDto getReason(); + ErrorReasonDto getReasonHttpStatus(); +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/ErrorReasonDto.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/ErrorReasonDto.java new file mode 100644 index 00000000..f0a45b5e --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/ErrorReasonDto.java @@ -0,0 +1,14 @@ +package com.eatsfine.eatsfine.global.apiPayload.code; +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@Builder +public class ErrorReasonDto { + private HttpStatus httpStatus; + + private final Boolean isSuccess; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/ReasonDto.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/ReasonDto.java new file mode 100644 index 00000000..745c799b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/ReasonDto.java @@ -0,0 +1,15 @@ +package com.eatsfine.eatsfine.global.apiPayload.code; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@Builder +public class ReasonDto { + private HttpStatus httpStatus; + + private final Boolean isSuccess; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java new file mode 100644 index 00000000..f1538c26 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java @@ -0,0 +1,40 @@ +package com.eatsfine.eatsfine.global.apiPayload.code.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorStatus implements BaseErrorCode { + _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), + _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), + _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), + _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(true) + .message(message) + .code(code) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(true) + .code(code) + .message(message) + .build(); + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/SuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/SuccessStatus.java new file mode 100644 index 00000000..71a8567d --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/SuccessStatus.java @@ -0,0 +1,38 @@ +package com.eatsfine.eatsfine.global.apiPayload.code.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum SuccessStatus implements BaseCode { + + // 일반적인 응답 + _OK(HttpStatus.OK, "COMMON200", "성공입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return ReasonDto.builder() + .isSuccess(true) + .message(message) + .code(code) + .build(); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return ReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(true) + .code(code) + .message(message) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/system/deploy/config/DeployProperties.java b/src/main/java/com/eatsfine/eatsfine/global/config/DeployProperties.java similarity index 76% rename from src/main/java/com/eatsfine/eatsfine/system/deploy/config/DeployProperties.java rename to src/main/java/com/eatsfine/eatsfine/global/config/DeployProperties.java index 4d961351..5df6a9a0 100644 --- a/src/main/java/com/eatsfine/eatsfine/system/deploy/config/DeployProperties.java +++ b/src/main/java/com/eatsfine/eatsfine/global/config/DeployProperties.java @@ -1,4 +1,4 @@ -package com.eatsfine.eatsfine.system.deploy.config; +package com.eatsfine.eatsfine.global.config; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/SwaggerConfig.java b/src/main/java/com/eatsfine/eatsfine/global/config/SwaggerConfig.java new file mode 100644 index 00000000..1cbeb032 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/config/SwaggerConfig.java @@ -0,0 +1,42 @@ +package com.eatsfine.eatsfine.global.config; +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 io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + final String securitySchemeName = "JWT"; + + return new OpenAPI() + .info(new Info() + .title("Eatsfine API 명세서") + .description("Eatsfine 프로젝트의 Swagger 문서입니다.") + .version("1.0.0")) + + .servers(List.of( + new Server().url("https://localhost:8080").description("Local") + )) + + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components() + .addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .name("Authorization") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + ) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/controller/HealthController.java b/src/main/java/com/eatsfine/eatsfine/global/controller/HealthController.java similarity index 83% rename from src/main/java/com/eatsfine/eatsfine/controller/HealthController.java rename to src/main/java/com/eatsfine/eatsfine/global/controller/HealthController.java index 077f4335..4a549845 100644 --- a/src/main/java/com/eatsfine/eatsfine/controller/HealthController.java +++ b/src/main/java/com/eatsfine/eatsfine/global/controller/HealthController.java @@ -1,6 +1,6 @@ -package com.eatsfine.eatsfine.controller; +package com.eatsfine.eatsfine.global.controller; -import com.eatsfine.eatsfine.system.deploy.config.DeployProperties; +import com.eatsfine.eatsfine.global.config.DeployProperties; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; diff --git a/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java b/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java index 22dd71d2..af2b9bab 100644 --- a/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java +++ b/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java @@ -1,6 +1,7 @@ package com.eatsfine.eatsfine.controller; -import com.eatsfine.eatsfine.system.deploy.config.DeployProperties; +import com.eatsfine.eatsfine.global.config.DeployProperties; +import com.eatsfine.eatsfine.global.controller.HealthController; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; From 8c7980bdbd80f047d34fe244403a3e2328c085ad Mon Sep 17 00:00:00 2001 From: SungMinju Date: Mon, 5 Jan 2026 20:54:30 +0900 Subject: [PATCH 019/169] =?UTF-8?q?[chore]=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../com/eatsfine/eatsfine/global/apiPayload/ApiResponse.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 36475876..a632bc22 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ description = 'Eatsfine' java { toolchain { - //languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) } } diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/ApiResponse.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/ApiResponse.java index c2a29805..1a1eb713 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/ApiResponse.java +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/ApiResponse.java @@ -1,7 +1,7 @@ package com.eatsfine.eatsfine.global.apiPayload; -import com.example.backend.global.apiPayload.code.BaseCode; -import com.example.backend.global.apiPayload.code.status.SuccessStatus; +import com.eatsfine.eatsfine.global.apiPayload.code.BaseCode; +import com.eatsfine.eatsfine.global.apiPayload.code.status.SuccessStatus; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; From dbeadb24dc0f028e45cd7e57a19260372ab40621 Mon Sep 17 00:00:00 2001 From: SungMinju Date: Mon, 5 Jan 2026 21:22:47 +0900 Subject: [PATCH 020/169] =?UTF-8?q?[chore]=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/eatsfine/eatsfine/controller/HealthControllerTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java b/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java index af2b9bab..eadabdd1 100644 --- a/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java +++ b/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java @@ -7,6 +7,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; + import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; From 2f9eaa3f484765c1d02b9146845fcb00ebc1b792 Mon Sep 17 00:00:00 2001 From: SungMinju Date: Mon, 5 Jan 2026 21:55:35 +0900 Subject: [PATCH 021/169] =?UTF-8?q?[chore]=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index a632bc22..8de0f92d 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,7 @@ dependencies { testAnnotationProcessor 'org.projectlombok:lombok' //security - implementation 'org.springframework.boot:spring-boot-starter-security' + //implementation 'org.springframework.boot:spring-boot-starter-security' //swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' From fc6f6dd466966625b42144b99aea5adb93fd6cec Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:15:04 +0900 Subject: [PATCH 022/169] =?UTF-8?q?[FEAT]:=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=20=ED=99=98=EA=B2=BD=20=EA=B5=AC=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20Docker=20Compose=20=EB=B0=8F?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 25 ++++++++++++++++++++++++ src/main/resources/application-local.yml | 18 ++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..04cd3df9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0 + container_name: eatsfine-local-mysql + environment: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: eatsfine_local + MYSQL_USER: user + MYSQL_PASSWORD: password + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + + redis: + image: redis:alpine + container_name: eatsfine-local-redis + ports: + - "6379:6379" + +volumes: + mysql_data: \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 4aaa4354..ae2cfaf0 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -2,8 +2,24 @@ server: port: 8080 profile: local + spring: config: activate: on-profile: local - + datasource: + url: jdbc:mysql://localhost:3306/eatsfine_local?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul + username: root + password: password + driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: localhost + port: 6379 + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true \ No newline at end of file From 68a72fb1157a193bdffa17d35c67e54ebee27dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 7 Jan 2026 15:52:36 +0900 Subject: [PATCH 023/169] =?UTF-8?q?[FEAT]:=20BaseEntity=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 --- .../eatsfine/global/entity/BaseEntity.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/global/entity/BaseEntity.java diff --git a/src/main/java/com/eatsfine/eatsfine/global/entity/BaseEntity.java b/src/main/java/com/eatsfine/eatsfine/global/entity/BaseEntity.java new file mode 100644 index 00000000..a9d9405c --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/entity/BaseEntity.java @@ -0,0 +1,25 @@ +package com.eatsfine.eatsfine.global.entity; + +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 +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + + @CreatedDate + @Column( name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} From a83521ffcbc4b3a8b2363e27a76bae937ac0dafe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 7 Jan 2026 15:54:21 +0900 Subject: [PATCH 024/169] =?UTF-8?q?[FEAT]:=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0(Booki?= =?UTF-8?q?ng)=20=EA=B8=B0=EB=B3=B8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../booking/controller/BookingController.java | 4 ++ .../booking/converter/BookingConverter.java | 4 ++ .../domain/booking/entity/Booking.java | 61 +++++++++++++++++++ .../domain/booking/enums/BookingStatus.java | 6 ++ .../booking/repository/BookingRepository.java | 7 +++ .../service/BookingCommandService.java | 5 ++ .../service/BookingCommandServiceImpl.java | 10 +++ .../booking/service/BookingQueryService.java | 15 +++++ .../service/BookingQueryServiceImpl.java | 10 +++ .../eatsfine/domain/store/entity/Store.java | 7 +++ .../domain/storetable/entity/StoreTable.java | 7 +++ .../repository/StoreTableRepository.java | 7 +++ 13 files changed, 146 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/converter/BookingConverter.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/enums/BookingStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java diff --git a/build.gradle b/build.gradle index 8de0f92d..a7fd0d72 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,7 @@ repositories { } dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-redis' runtimeOnly 'com.mysql:mysql-connector-j' @@ -29,6 +30,8 @@ dependencies { testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' + testImplementation 'com.h2database:h2' + //security //implementation 'org.springframework.boot:spring-boot-starter-security' diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java new file mode 100644 index 00000000..4396400c --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java @@ -0,0 +1,4 @@ +package com.eatsfine.eatsfine.domain.booking.controller; + +public class BookingController { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/converter/BookingConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/converter/BookingConverter.java new file mode 100644 index 00000000..cfd9e42b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/converter/BookingConverter.java @@ -0,0 +1,4 @@ +package com.eatsfine.eatsfine.domain.booking.converter; + +public class BookingConverter { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java new file mode 100644 index 00000000..8d144a86 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java @@ -0,0 +1,61 @@ +package com.eatsfine.eatsfine.domain.booking.entity; + +import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.user.entity.User; +import com.eatsfine.eatsfine.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Getter +@Table(name = "booking") +public class Booking extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id", nullable = false) + private Store store; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "table_id", nullable = false) + private StoreTable table; + + @Column(name = "party_size", nullable = false) + private Integer partySize; + + //테이블 분리 허용 여부 + @Column(name = "is_split_accepted", nullable = false) + private boolean isSplitAccepted = false; + + // 예약 날짜 (YYYY-MM-DD) + @Column(name = "booking_date", nullable = false) + private LocalDate bookingDate; + + // 예약 시간 (HH:mm) + @Column(name = "booking_time", nullable = false) + private LocalTime bookingTime; + + + @Enumerated(EnumType.STRING) + private BookingStatus status; + + // 결제는 일단 보류 + // private PaymentType paymentType; + + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/enums/BookingStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/enums/BookingStatus.java new file mode 100644 index 00000000..404fafc2 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/enums/BookingStatus.java @@ -0,0 +1,6 @@ +package com.eatsfine.eatsfine.domain.booking.enums; + +public enum BookingStatus { + + PENDING, CONFIRMED, COMPLETED, CANCELLED, NOSHOW +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java new file mode 100644 index 00000000..37694bb8 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.booking.repository; + +import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BookingRepository extends JpaRepository { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java new file mode 100644 index 00000000..f688edc5 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java @@ -0,0 +1,5 @@ +package com.eatsfine.eatsfine.domain.booking.service; + +public interface BookingCommandService { + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java new file mode 100644 index 00000000..388e190e --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.booking.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BookingCommandServiceImpl { + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java new file mode 100644 index 00000000..2a163f99 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java @@ -0,0 +1,15 @@ +package com.eatsfine.eatsfine.domain.booking.service; + +import com.eatsfine.eatsfine.domain.booking.dto.response.AvailableTableResponse; +import com.eatsfine.eatsfine.domain.booking.dto.response.BookingDetailResponse; +import com.eatsfine.eatsfine.domain.booking.dto.response.BookingListResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +public interface BookingQueryService { + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java new file mode 100644 index 00000000..ac59d39e --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.booking.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BookingQueryServiceImpl { + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java new file mode 100644 index 00000000..36042bdd --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.store.entity; + +import jakarta.persistence.Entity; + +@Entity +public class Store { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java new file mode 100644 index 00000000..1701288c --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.storetable.entity; + +import jakarta.persistence.Entity; + +@Entity +public class StoreTable { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java new file mode 100644 index 00000000..ad98bb98 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.storetable.repository; + +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StoreTableRepository extends JpaRepository { +} From a8480c430407b10209d05a47275a751052443449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 7 Jan 2026 16:15:15 +0900 Subject: [PATCH 025/169] =?UTF-8?q?[FIX]:=20Entity=EC=97=90=20=EC=8B=9D?= =?UTF-8?q?=EB=B3=84=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/eatsfine/domain/booking/entity/Booking.java | 1 + .../com/eatsfine/eatsfine/domain/store/entity/Store.java | 4 ++++ .../eatsfine/domain/storetable/entity/StoreTable.java | 4 ++++ .../com/eatsfine/eatsfine/domain/user/entity/User.java | 7 +++++++ 4 files changed, 16 insertions(+) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java index 8d144a86..d4b11d7e 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java @@ -39,6 +39,7 @@ public class Booking extends BaseEntity { private Integer partySize; //테이블 분리 허용 여부 + @Builder.Default @Column(name = "is_split_accepted", nullable = false) private boolean isSplitAccepted = false; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index 36042bdd..f907ee6a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -1,7 +1,11 @@ package com.eatsfine.eatsfine.domain.store.entity; import jakarta.persistence.Entity; +import jakarta.persistence.Id; @Entity public class Store { + + @Id + private Long id; } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java index 1701288c..3b7445e2 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java @@ -1,7 +1,11 @@ package com.eatsfine.eatsfine.domain.storetable.entity; import jakarta.persistence.Entity; +import jakarta.persistence.Id; @Entity public class StoreTable { + + @Id + private Long id; } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java b/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java index 32c1bdae..39e5cfab 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java @@ -1,4 +1,11 @@ package com.eatsfine.eatsfine.domain.user.entity; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; + +@Entity public class User { + + @Id + private Long id; } From 4d46bda46e9f0346c66d0478bea38da9fc75a6ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 7 Jan 2026 16:16:22 +0900 Subject: [PATCH 026/169] =?UTF-8?q?[FIX]:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import=EB=AC=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/booking/service/BookingQueryService.java | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java index 2a163f99..b382d97f 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java @@ -1,15 +1,5 @@ package com.eatsfine.eatsfine.domain.booking.service; -import com.eatsfine.eatsfine.domain.booking.dto.response.AvailableTableResponse; -import com.eatsfine.eatsfine.domain.booking.dto.response.BookingDetailResponse; -import com.eatsfine.eatsfine.domain.booking.dto.response.BookingListResponse; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; - public interface BookingQueryService { } From b7648d21f18fffaf08039d0925c3db8954894f95 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Wed, 7 Jan 2026 17:19:10 +0900 Subject: [PATCH 027/169] =?UTF-8?q?[FEAT]:=20BusinessHours=20=EC=9A=94?= =?UTF-8?q?=EC=9D=BC=20enum=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/businesshours/enums/DayOfWeek.java | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/enums/DayOfWeek.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/enums/DayOfWeek.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/enums/DayOfWeek.java new file mode 100644 index 00000000..feb6670a --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/enums/DayOfWeek.java @@ -0,0 +1,5 @@ +package com.eatsfine.eatsfine.domain.businesshours.enums; + +public enum DayOfWeek { + MON, TUE, WED, THU, FRI, SAT, SUN +} From 42946753b94815aac0110e8fbe5350ced7b2e3b7 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Wed, 7 Jan 2026 18:10:46 +0900 Subject: [PATCH 028/169] =?UTF-8?q?[FEAT]:=20BusinessHours=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=B4=88=EA=B8=B0=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../businesshours/entity/BusinessHours.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java new file mode 100644 index 00000000..a01a029b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java @@ -0,0 +1,41 @@ +package com.eatsfine.eatsfine.domain.businesshours.entity; + +import com.eatsfine.eatsfine.domain.businesshours.enums.DayOfWeek; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalTime; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Getter +@Table(name = "business_hours") +public class BusinessHours { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column(name = "store_id", nullable = false) + private long storeId; + + @Column(name = "day_of_week", nullable = false) + @Enumerated(EnumType.STRING) + private DayOfWeek dayOfWeek; + + @Column(name = "open_time", nullable = false) + private LocalTime openTime; + + @Column(name = "close_time", nullable = false) + private LocalTime closeTime; + + @Column(name = "break_start_time") + private LocalTime breakStartTime; + + @Column(name = "break_end_time") + private LocalTime breakEndTime; + + @Column(name = "is_closed", nullable = false) + private boolean isClosed; +} From b83fc842ff9f11255fc095ca92552c4859c10b72 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Wed, 7 Jan 2026 18:17:47 +0900 Subject: [PATCH 029/169] =?UTF-8?q?[FEAT]:=20Region=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/region/entity/Region.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/region/entity/Region.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/region/entity/Region.java b/src/main/java/com/eatsfine/eatsfine/domain/region/entity/Region.java new file mode 100644 index 00000000..916d3b3d --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/region/entity/Region.java @@ -0,0 +1,28 @@ +package com.eatsfine.eatsfine.domain.region.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +@Table(name = "region") +public class Region { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 시도 + @Column(name = "province", nullable = false) + private String province; + + // 시 + @Column(name = "city", nullable = false) + private String city; + + // 구 + @Column(name = "district", nullable = false) + private String district; +} From d6dc73af2e6a23ff3db6cee8f6d46bc50e4edb08 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Wed, 7 Jan 2026 18:42:53 +0900 Subject: [PATCH 030/169] =?UTF-8?q?[FEAT]:=20TableImage=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=B4=88=EA=B8=B0=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/tableimage/entity/TableImage.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java new file mode 100644 index 00000000..b61db6ff --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java @@ -0,0 +1,23 @@ +package com.eatsfine.eatsfine.domain.tableimage.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +@Table(name = "table_image") + +public class TableImage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "table_image_url", nullable = false) + private String tableImageUrl; +} From d5a90b361fde1ee7812c4c35738b52c06aec511f Mon Sep 17 00:00:00 2001 From: twodo0 Date: Thu, 8 Jan 2026 15:36:40 +0900 Subject: [PATCH 031/169] =?UTF-8?q?[REFACTOR]:=20BusinessHours=20isClosed?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=EB=A5=BC=20isHoliday=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/businesshours/entity/BusinessHours.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java index a01a029b..b28f8301 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java @@ -36,6 +36,6 @@ public class BusinessHours { @Column(name = "break_end_time") private LocalTime breakEndTime; - @Column(name = "is_closed", nullable = false) - private boolean isClosed; + @Column(name = "is_holiday", nullable = false) + private boolean isHoliday; } From 4860615199d4527110e2b7050428fc4ca885776a Mon Sep 17 00:00:00 2001 From: twodo0 Date: Thu, 8 Jan 2026 16:38:49 +0900 Subject: [PATCH 032/169] =?UTF-8?q?[REFACTOR]:=20BusinessHours=20Store=20?= =?UTF-8?q?=EC=97=B0=EA=B4=80=EA=B4=80=EA=B3=84=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20BaseEntity=20=EC=83=81=EC=86=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/businesshours/entity/BusinessHours.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java index b28f8301..6eec11cf 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java @@ -1,6 +1,8 @@ package com.eatsfine.eatsfine.domain.businesshours.entity; import com.eatsfine.eatsfine.domain.businesshours.enums.DayOfWeek; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; @@ -12,13 +14,14 @@ @Builder @Getter @Table(name = "business_hours") -public class BusinessHours { +public class BusinessHours extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; - @Column(name = "store_id", nullable = false) - private long storeId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id", nullable = false) + private Store store; @Column(name = "day_of_week", nullable = false) @Enumerated(EnumType.STRING) @@ -36,6 +39,8 @@ public class BusinessHours { @Column(name = "break_end_time") private LocalTime breakEndTime; + // 휴일 여부 (특정 요일 고정 휴무) + @Builder.Default @Column(name = "is_holiday", nullable = false) - private boolean isHoliday; + private boolean isHoliday = false; } From 499b92f919fc911086472cceb209643d5f791ea7 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Thu, 8 Jan 2026 17:30:36 +0900 Subject: [PATCH 033/169] =?UTF-8?q?[FEAT]:=20=EA=B0=80=EA=B2=8C=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EC=83=81=ED=83=9C=20enum=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 --- .../eatsfine/domain/store/enums/StoreApprovalStatus.java | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreApprovalStatus.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreApprovalStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreApprovalStatus.java new file mode 100644 index 00000000..8079c772 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreApprovalStatus.java @@ -0,0 +1,5 @@ +package com.eatsfine.eatsfine.domain.store.enums; + +public enum StoreApprovalStatus { + PENDING, APPROVED, REJECTED +} \ No newline at end of file From b7f43e820b7e8b73f40759496a474674145b3b69 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Thu, 8 Jan 2026 17:31:33 +0900 Subject: [PATCH 034/169] =?UTF-8?q?[FEAT]:=20=EA=B0=80=EA=B2=8C=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20enum=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 --- .../com/eatsfine/eatsfine/domain/store/enums/Category.java | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/enums/Category.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/enums/Category.java b/src/main/java/com/eatsfine/eatsfine/domain/store/enums/Category.java new file mode 100644 index 00000000..57e59a69 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/enums/Category.java @@ -0,0 +1,5 @@ +package com.eatsfine.eatsfine.domain.store.enums; + +public enum Category { + KOREAN, CHINESE, JAPANESE, WESTERN, CAFE +} From 69cff594344a93b7db6b9afbaeaa52399711468b Mon Sep 17 00:00:00 2001 From: twodo0 Date: Thu, 8 Jan 2026 19:57:07 +0900 Subject: [PATCH 035/169] =?UTF-8?q?[FEAT]:=20Store=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/store/entity/Store.java | 66 ++++++++++++++++++- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index f907ee6a..0c49f16d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -1,11 +1,71 @@ package com.eatsfine.eatsfine.domain.store.entity; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; +import com.eatsfine.eatsfine.domain.region.entity.Region; +import com.eatsfine.eatsfine.domain.store.enums.Category; +import com.eatsfine.eatsfine.domain.store.enums.StoreApprovalStatus; +import com.eatsfine.eatsfine.domain.user.entity.User; +import com.eatsfine.eatsfine.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import java.math.BigDecimal; + +@Table(name = "store") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder @Entity -public class Store { +public class Store extends BaseEntity { @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_id", nullable = false) + private User owner; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "region_id", nullable = false) + private Region region; + + @Column(name = "store_name", nullable = false) + private String storeName; + + @Lob + @Column(name = "description", nullable = false) + private String description; + + @Column(name = "phone_number", nullable = false) + private String phoneNumber; + + @Column(name = "address", nullable = false) + private String address; + + @Column(name = "main_image_url", nullable = false) + private String mainImageUrl; + + @Builder.Default + @Column(name = "rating", precision = 2, scale = 1, nullable = false) + private BigDecimal rating = BigDecimal.ZERO; + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false) + private Category category; + + @Column(name = "min_price", nullable = false) + private int minPrice; + + @Column(name = "max_price", nullable = false) + private int maxPrice; + + @Enumerated(EnumType.STRING) + @Column(name = "store_approval_status", nullable = false) + private StoreApprovalStatus approvalStatus; + + @Builder.Default + @Column(name = "booking_interval_minutes", nullable = false) + private int bookingIntervalMinutes = 30; + } From bdd3dcf102754c9ed23c90297d6d7010651ff866 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Thu, 8 Jan 2026 20:09:00 +0900 Subject: [PATCH 036/169] =?UTF-8?q?[REFACTOR]:=20TableImage=20Store=20?= =?UTF-8?q?=EC=97=B0=EA=B4=80=EA=B4=80=EA=B3=84=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20BaseEntity=20=EC=83=81=EC=86=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/tableimage/entity/TableImage.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java index b61db6ff..a86f7f2e 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java @@ -1,5 +1,7 @@ package com.eatsfine.eatsfine.domain.tableimage.entity; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; @@ -10,13 +12,14 @@ @Builder @Table(name = "table_image") -public class TableImage { +public class TableImage extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "store_id", nullable = false) - private Long storeId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id", nullable = false) + private Store store; @Column(name = "table_image_url", nullable = false) private String tableImageUrl; From d7bf7b0cb578af540d7b9ec3bfb1dde4342d8bc6 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Thu, 8 Jan 2026 20:14:10 +0900 Subject: [PATCH 037/169] =?UTF-8?q?[FEAT]:=20StoreRepository=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/store/repository/StoreRepository.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java new file mode 100644 index 00000000..f329a95d --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.store.repository; + +import com.eatsfine.eatsfine.domain.store.entity.Store; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StoreRepository extends JpaRepository { +} From cff7e892858b5c55c40691f8e7d0610964f4d8a7 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Thu, 8 Jan 2026 20:15:26 +0900 Subject: [PATCH 038/169] =?UTF-8?q?[FEAT]:=20TableImageRepository=20?= =?UTF-8?q?=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/tableimage/repository/TableImageRepository.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableimage/repository/TableImageRepository.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/repository/TableImageRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/repository/TableImageRepository.java new file mode 100644 index 00000000..dda63a21 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/repository/TableImageRepository.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.tableimage.repository; + +import com.eatsfine.eatsfine.domain.tableimage.entity.TableImage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TableImageRepository extends JpaRepository { +} From 073a21dca3d1e95f05397e88c5c2efd7a559f07d Mon Sep 17 00:00:00 2001 From: twodo0 Date: Thu, 8 Jan 2026 20:19:45 +0900 Subject: [PATCH 039/169] =?UTF-8?q?[FEAT]:=20BusinessHoursRepository=20?= =?UTF-8?q?=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../businesshours/repository/BusinessHoursRepository.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/repository/BusinessHoursRepository.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/repository/BusinessHoursRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/repository/BusinessHoursRepository.java new file mode 100644 index 00000000..f60a7d49 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/repository/BusinessHoursRepository.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.businesshours.repository; + +import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BusinessHoursRepository extends JpaRepository { +} From 20ca6d2b96fc23b181fad7e08ef201531b5e9d09 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Thu, 8 Jan 2026 20:50:04 +0900 Subject: [PATCH 040/169] =?UTF-8?q?[FEAT]:=20RegionRepository=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/region/repository/RegionRepository.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/region/repository/RegionRepository.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/region/repository/RegionRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/region/repository/RegionRepository.java new file mode 100644 index 00000000..cc772b7e --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/region/repository/RegionRepository.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.region.repository; + +import com.eatsfine.eatsfine.domain.region.entity.Region; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RegionRepository extends JpaRepository { +} From 4e815fac4693754981ff2d123387d0c39a74fff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Thu, 8 Jan 2026 22:34:23 +0900 Subject: [PATCH 041/169] =?UTF-8?q?[CHORE]:=20yml=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 4aaa4354..56548126 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -2,8 +2,24 @@ server: port: 8080 profile: local + spring: config: activate: on-profile: local - + datasource: + url: jdbc:mysql://localhost:3306/eatsfine_local?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul + username: root + password: 0766wjd! + driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: localhost + port: 6379 + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true \ No newline at end of file From ab2e1eab6c305c3648f95b6b7a8384af0c9866db Mon Sep 17 00:00:00 2001 From: twodo0 Date: Thu, 8 Jan 2026 23:50:25 +0900 Subject: [PATCH 042/169] =?UTF-8?q?[REFACTOR]:=20Store=EC=99=80=20Business?= =?UTF-8?q?Hours=20=EC=96=91=EB=B0=A9=ED=96=A5=20=EC=97=B0=EA=B4=80?= =?UTF-8?q?=EA=B4=80=EA=B3=84=20=EB=B0=8F=20=ED=8E=B8=EC=9D=98=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../businesshours/entity/BusinessHours.java | 4 ++++ .../eatsfine/domain/store/entity/Store.java | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java index 6eec11cf..75812bbc 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java @@ -43,4 +43,8 @@ public class BusinessHours extends BaseEntity { @Builder.Default @Column(name = "is_holiday", nullable = false) private boolean isHoliday = false; + + public void assignStore(Store store){ + this.store = store; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index 0c49f16d..a00e4739 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -1,14 +1,18 @@ package com.eatsfine.eatsfine.domain.store.entity; +import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; import com.eatsfine.eatsfine.domain.region.entity.Region; import com.eatsfine.eatsfine.domain.store.enums.Category; import com.eatsfine.eatsfine.domain.store.enums.StoreApprovalStatus; +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.user.entity.User; import com.eatsfine.eatsfine.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; @Table(name = "store") @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -68,4 +72,17 @@ public class Store extends BaseEntity { @Column(name = "booking_interval_minutes", nullable = false) private int bookingIntervalMinutes = 30; + @OneToMany(mappedBy = "store") + private List businessHours = new ArrayList<>(); + + @OneToMany(mappedBy = "store") + private List storeTables = new ArrayList<>(); + + public void addBusinessHours(BusinessHours businessHours) { + this.businessHours.add(businessHours); + businessHours.assignStore(this); + } + + // StoreTable에 대한 연관관계 편의 메서드는 추후 추가 예정 + } From a6d71ef436242a554a9354aad9c8834203abb69e Mon Sep 17 00:00:00 2001 From: twodo0 Date: Fri, 9 Jan 2026 00:07:41 +0900 Subject: [PATCH 043/169] =?UTF-8?q?[FIX]:=20StoreTable=20=EC=97=B0?= =?UTF-8?q?=EA=B4=80=EA=B4=80=EA=B3=84=20=EC=A3=BC=EC=84=9D=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/eatsfine/eatsfine/domain/store/entity/Store.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index a00e4739..2731de36 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -75,8 +75,9 @@ public class Store extends BaseEntity { @OneToMany(mappedBy = "store") private List businessHours = new ArrayList<>(); - @OneToMany(mappedBy = "store") - private List storeTables = new ArrayList<>(); + // 추후 StoreTable 엔티티 개발 완료 시 추가 예정 + //@OneToMany(mappedBy = "store") + //private List storeTables = new ArrayList<>(); public void addBusinessHours(BusinessHours businessHours) { this.businessHours.add(businessHours); From 60f61787ee75f46b43362008686924e33b55374d Mon Sep 17 00:00:00 2001 From: twodo0 Date: Fri, 9 Jan 2026 16:21:41 +0900 Subject: [PATCH 044/169] =?UTF-8?q?[REFACTOR]:=20Store=EC=99=80=20TableIma?= =?UTF-8?q?ge=20=EC=96=91=EB=B0=A9=ED=96=A5=20=EC=97=B0=EA=B4=80=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=EB=B0=8F=20=ED=8E=B8=EC=9D=98=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/store/entity/Store.java | 14 ++++++++++++++ .../domain/tableimage/entity/TableImage.java | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index 2731de36..30fd7a0a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -5,6 +5,7 @@ import com.eatsfine.eatsfine.domain.store.enums.Category; import com.eatsfine.eatsfine.domain.store.enums.StoreApprovalStatus; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.tableimage.entity.TableImage; import com.eatsfine.eatsfine.domain.user.entity.User; import com.eatsfine.eatsfine.global.entity.BaseEntity; import jakarta.persistence.*; @@ -75,6 +76,9 @@ public class Store extends BaseEntity { @OneToMany(mappedBy = "store") private List businessHours = new ArrayList<>(); + @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) + private List tableImages = new ArrayList<>(); + // 추후 StoreTable 엔티티 개발 완료 시 추가 예정 //@OneToMany(mappedBy = "store") //private List storeTables = new ArrayList<>(); @@ -84,6 +88,16 @@ public void addBusinessHours(BusinessHours businessHours) { businessHours.assignStore(this); } + public void addTableImage(TableImage tableImage) { + this.tableImages.add(tableImage); + tableImage.assignStore(this); + } + + public void removeTableImage(TableImage tableImage) { + this.tableImages.remove(tableImage); + tableImage.assignStore(null); + } + // StoreTable에 대한 연관관계 편의 메서드는 추후 추가 예정 } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java index a86f7f2e..1bb000ef 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java @@ -23,4 +23,8 @@ public class TableImage extends BaseEntity { @Column(name = "table_image_url", nullable = false) private String tableImageUrl; + + public void assignStore(Store store) { + this.store = store; + } } From d565be24667f0538649a5512bd3baad5144fdc42 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Fri, 9 Jan 2026 16:27:26 +0900 Subject: [PATCH 045/169] =?UTF-8?q?[REFACTOR]:=20Store-TableImage=20?= =?UTF-8?q?=EC=96=91=EB=B0=A9=ED=96=A5=20=EC=97=B0=EA=B4=80=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=EC=82=AD=EC=A0=9C=20=ED=8E=B8=EC=9D=98=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=83=9D?= =?UTF-8?q?=EB=AA=85=EC=A3=BC=EA=B8=B0=20=EA=B4=80=EB=A6=AC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/eatsfine/eatsfine/domain/store/entity/Store.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index 30fd7a0a..ab60247e 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -73,7 +73,7 @@ public class Store extends BaseEntity { @Column(name = "booking_interval_minutes", nullable = false) private int bookingIntervalMinutes = 30; - @OneToMany(mappedBy = "store") + @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List businessHours = new ArrayList<>(); @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) @@ -88,6 +88,11 @@ public void addBusinessHours(BusinessHours businessHours) { businessHours.assignStore(this); } + public void removeBusinessHours(BusinessHours businessHours) { + this.businessHours.remove(businessHours); + businessHours.assignStore(this); + } + public void addTableImage(TableImage tableImage) { this.tableImages.add(tableImage); tableImage.assignStore(this); From 9b777c4f39adc053e30c181bd0164aabc18e56ab Mon Sep 17 00:00:00 2001 From: twodo0 Date: Sat, 10 Jan 2026 17:49:39 +0900 Subject: [PATCH 046/169] =?UTF-8?q?[REFACTOR]:=20ApiResponse=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=9D=91=EB=8B=B5=EC=9D=84=20BaseErrorCode=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/eatsfine/global/apiPayload/ApiResponse.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/ApiResponse.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/ApiResponse.java index 1a1eb713..2292bc40 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/ApiResponse.java +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/ApiResponse.java @@ -1,6 +1,7 @@ package com.eatsfine.eatsfine.global.apiPayload; import com.eatsfine.eatsfine.global.apiPayload.code.BaseCode; +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; import com.eatsfine.eatsfine.global.apiPayload.code.status.SuccessStatus; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -34,8 +35,11 @@ public static ApiResponse of(BaseCode code, T result){ result); } - public static ApiResponse onFailure(String code, String message, T data) { - return new ApiResponse<>(false, code, message, data); + public static ApiResponse onFailure(BaseErrorCode code, T result) { + return new ApiResponse<>(false, + code.getReasonHttpStatus().getCode(), + code.getReasonHttpStatus().getMessage(), + result); } } From 3a40d701defad29271e961bdd51ab29a68e906e1 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Sun, 11 Jan 2026 01:14:23 +0900 Subject: [PATCH 047/169] =?UTF-8?q?[FEAT]:=20HTTPS=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20NGINX=20Blue-Green=20=EB=AC=B4=EC=A4=91=EB=8B=A8=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EC=A0=84=ED=99=98=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 86294bfe..59405f90 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -151,7 +151,7 @@ jobs: run: | ssh eatsfine-ec2 << 'EOF' set -e - docker exec -i nginx bash -c 'echo "set \$service_url ${{ env.TARGET_UPSTREAM }};" > /etc/nginx/conf.d/service-env.inc && nginx -s reload' + docker exec -i nginx bash -c 'echo "set \$service_url ${{ env.TARGET_UPSTREAM }};" > ~/nginx/service-env.inc && nginx -s reload' EOF - name: 기존 배포 컨테이너 정지 run: | From 0b5b5037b21298a4b533cc1ed8b6632b3031af5e Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Sun, 11 Jan 2026 01:22:24 +0900 Subject: [PATCH 048/169] =?UTF-8?q?[FEAT]:=20HTTPS=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20NGINX=20Blue-Green=20=EB=AC=B4=EC=A4=91=EB=8B=A8=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EC=A0=84=ED=99=98=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 59405f90..84a0e123 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -151,7 +151,8 @@ jobs: run: | ssh eatsfine-ec2 << 'EOF' set -e - docker exec -i nginx bash -c 'echo "set \$service_url ${{ env.TARGET_UPSTREAM }};" > ~/nginx/service-env.inc && nginx -s reload' + echo "set \$service_url ${{ env.TARGET_UPSTREAM }};" > /home/ec2-user/nginx/service-env.inc + docker exec nginx nginx -s reload EOF - name: 기존 배포 컨테이너 정지 run: | From 48125a9ec3c9b36fe11e376e786ef0bc18ed4d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 13:14:31 +0900 Subject: [PATCH 049/169] =?UTF-8?q?[FEAT]=20=EC=98=88=EC=95=BD=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=20=EC=8B=9C=EA=B0=84=EB=8C=80=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=A1=B0=ED=9A=8C=20DTO=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/BookingRequestDTO.java | 21 +++++++++++++++ .../dto/response/BookingResponseDTO.java | 27 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java new file mode 100644 index 00000000..5dd44ea0 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java @@ -0,0 +1,21 @@ +package com.eatsfine.eatsfine.domain.booking.dto.request; + +import java.time.LocalDate; +import java.time.LocalTime; + +public class BookingRequestDTO { + + public record GetAvailableTimeDTO( + Long storeId, + LocalDate date, + Integer partySize + ){} + + public record GetAvailableTableDTO( + Long storeId, + LocalDate date, + LocalTime time, + Integer partySize + ){} + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java new file mode 100644 index 00000000..51199f71 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java @@ -0,0 +1,27 @@ +package com.eatsfine.eatsfine.domain.booking.dto.response; + +import lombok.Builder; + +import java.time.LocalTime; +import java.util.List; + +public class BookingResponseDTO { + + @Builder + public record TimeSlotListDTO( + List availableTimes + ) {} + + @Builder + public record AvailableTableListDTO( + List tables + ) {} + + @Builder + public record TableInfoDTO( + Long tableId, + String tableNumber, + Integer tableSeats, + String tableLocationType + ){} +} From ad456fdd44f90cbf532983b52da849bb1843a86d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 13:47:03 +0900 Subject: [PATCH 050/169] =?UTF-8?q?[FEAT]=20=EC=98=88=EC=95=BD=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=20=EC=8B=9C=EA=B0=84=EB=8C=80,=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=A1=B0=ED=9A=8C=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EA=B0=9C=EB=B0=9C=20=EB=B0=8F=20=EC=8A=A4=EC=9B=A8?= =?UTF-8?q?=EA=B1=B0=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../booking/controller/BookingController.java | 44 +++++++++++++++++++ .../booking/service/BookingQueryService.java | 8 ++++ .../service/BookingQueryServiceImpl.java | 15 ++++++- 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java index 4396400c..392f96a5 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java @@ -1,4 +1,48 @@ package com.eatsfine.eatsfine.domain.booking.controller; +import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; +import com.eatsfine.eatsfine.domain.booking.service.BookingQueryService; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Tag(name = "Booking", description = "예약 관련 API") +@RestController +@RequiredArgsConstructor public class BookingController { + + private final BookingQueryService bookingQueryService; + + @Operation(summary = "1단계: 예약 가능 시간대 조회" + , description = "가게, 날짜, 인원수를 입력받아 예약 가능한 시간 목록 반환") + @GetMapping("/stores/{storeId}/bookings/available-times") + public ApiResponse getAvailableTimes( + @PathVariable Long storeId, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, + @RequestParam Integer partySize) { + + return ApiResponse.onSuccess(bookingQueryService.getAvailableTimeSlots(storeId, date, partySize)); + } + + @Operation(summary = "2단계: 예약 가능 테이블 조회" + , description = "선택한 시간대에 예약 가능한 구체적인 테이블 목록을 반환") + @GetMapping("/stores/{storeId}/bookings/available-tables") + public ApiResponse getAvailableTables( + @PathVariable Long storeId, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, + @RequestParam @DateTimeFormat(pattern = "HH:mm") LocalTime time, + @RequestParam Integer partySize) { + + return ApiResponse.onSuccess(bookingQueryService.getAvailableTables(storeId, date, time, partySize)); + } + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java index b382d97f..46dcc5ee 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java @@ -1,5 +1,13 @@ package com.eatsfine.eatsfine.domain.booking.service; +import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; + +import java.time.LocalDate; +import java.time.LocalTime; + public interface BookingQueryService { + BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, LocalDate date, Integer partySize); + + BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java index ac59d39e..6530d03d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java @@ -1,10 +1,23 @@ package com.eatsfine.eatsfine.domain.booking.service; +import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.time.LocalDate; +import java.time.LocalTime; + @Service @RequiredArgsConstructor -public class BookingQueryServiceImpl { +public class BookingQueryServiceImpl implements BookingQueryService { + + @Override + public BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, LocalDate date, Integer partySize) { + return null; + } + @Override + public BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize) { + return null; + } } From 54d37100b07cde9531dd8c2a22c321c958eea2b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 14:39:45 +0900 Subject: [PATCH 051/169] =?UTF-8?q?[FEAT]=20BookingTable=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../booking/entity/mapping/BookingTable.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/entity/mapping/BookingTable.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/mapping/BookingTable.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/mapping/BookingTable.java new file mode 100644 index 00000000..86ad5055 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/mapping/BookingTable.java @@ -0,0 +1,26 @@ +package com.eatsfine.eatsfine.domain.booking.entity.mapping; + +import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Getter +public class BookingTable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_table_id") + private StoreTable storeTable; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "booking_id") + private Booking booking; +} From a32cd605192321eb3604ca27897701df11a05da9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 14:40:13 +0900 Subject: [PATCH 052/169] =?UTF-8?q?[FIX]=20Booking=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=88=98=EC=A0=95=20(BookingTable=20=EC=97=B0?= =?UTF-8?q?=EA=B4=80=EA=B4=80=EA=B3=84=20=EA=B3=A0=EB=A0=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/eatsfine/domain/booking/entity/Booking.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java index d4b11d7e..5ec34cb1 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java @@ -1,5 +1,6 @@ package com.eatsfine.eatsfine.domain.booking.entity; +import com.eatsfine.eatsfine.domain.booking.entity.mapping.BookingTable; import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; @@ -10,6 +11,8 @@ import java.time.LocalDate; import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -31,9 +34,8 @@ public class Booking extends BaseEntity { @JoinColumn(name = "store_id", nullable = false) private Store store; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "table_id", nullable = false) - private StoreTable table; + @OneToMany(mappedBy = "booking", cascade = CascadeType.ALL) + private List bookingTables = new ArrayList<>(); @Column(name = "party_size", nullable = false) private Integer partySize; From 46eb507fcef08f193a2d48155bd73ce4d835c74f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 14:42:10 +0900 Subject: [PATCH 053/169] =?UTF-8?q?[FEAT]=20BookingRepository-=20findReser?= =?UTF-8?q?vedTableIds=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../booking/repository/BookingRepository.java | 17 ++- .../repository/BookingRepositoryTest.java | 124 ++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepositoryTest.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java index 37694bb8..d29334bb 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java @@ -1,7 +1,22 @@ package com.eatsfine.eatsfine.domain.booking.repository; import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import io.lettuce.core.dynamic.annotation.Param; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; -public interface BookingRepository extends JpaRepository { +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +public interface BookingRepository extends JpaRepository { + + + @Query("Select bt.storeTable.id from BookingTable bt " + + "join bt.booking b " + + "where b.store.id = :storeId " + + "and b.bookingDate = :date " + + "and b.bookingTime = :time " + + "and b.status = 'CONFIRMED'") + List findReservedTableIds(Long storeId, LocalDate date, LocalTime time); } diff --git a/src/test/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepositoryTest.java b/src/test/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepositoryTest.java new file mode 100644 index 00000000..719f7a4a --- /dev/null +++ b/src/test/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepositoryTest.java @@ -0,0 +1,124 @@ +package com.eatsfine.eatsfine.domain.booking.repository; + +import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.booking.entity.mapping.BookingTable; +import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; +import com.eatsfine.eatsfine.domain.region.entity.Region; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.enums.Category; +import com.eatsfine.eatsfine.domain.store.enums.StoreApprovalStatus; +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.user.entity.User; +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.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(BookingRepositoryTest.TestJpaConfig.class) +class BookingRepositoryTest { + + @TestConfiguration + @EnableJpaAuditing + static class TestJpaConfig {} + + @Autowired + private BookingRepository bookingRepository; + + @Autowired + private TestEntityManager em; + + @Test + @DisplayName("특정 날짜와 시간에 예약된 테이블 ID 목록을 정확히 조회한다") + void findReservedTableIds_Success() { + // given: 1. 필수 연관 데이터 생성 (User, Region) + User owner = User.builder() + .build(); + em.persist(owner); + + Region region = Region.builder() + .province("경기도") + .district("마포구") + .city("서울시") + .build(); + em.persist(region); + + // 2. Store 생성 + Store store = Store.builder() + .owner(owner) + .region(region) + .storeName("아웃백") + .address("서울시 강남구") + .phoneNumber("02-123-4567") + .description("맛있는 식당") + .mainImageUrl("https://example.com/image.jpg") + .rating(new BigDecimal("4.5")) + .category(Category.WESTERN) + .minPrice(20000) + .maxPrice(50000) + .approvalStatus(StoreApprovalStatus.APPROVED) + .bookingIntervalMinutes(30) + .build(); + em.persist(store); + + // 3. StoreTable 생성 + StoreTable table1 = StoreTable.builder() + .tableNumber("T1") + .store(store) + .tableSeats(4) + .build(); + StoreTable table2 = StoreTable.builder() + .tableNumber("T2") + .store(store) + .tableSeats(2) + .build(); + em.persist(table1); + em.persist(table2); + + LocalDate date = LocalDate.of(2026, 1, 9); + LocalTime time = LocalTime.of(18, 0); + + // 4. Booking 생성 + Booking booking = Booking.builder() + .store(store) + .bookingDate(date) + .partySize(4) + .user(owner) + .bookingTime(time) + .status(BookingStatus.CONFIRMED) + .build(); + em.persist(booking); + + // 5. 매핑 테이블 생성 + BookingTable bookingTable = BookingTable.builder() + .booking(booking) + .storeTable(table1) + .build(); + em.persist(bookingTable); + + em.flush(); + em.clear(); + + // when + List reservedIds = bookingRepository.findReservedTableIds(store.getId(), date, time); + + // then + assertThat(reservedIds).hasSize(1); + assertThat(reservedIds).containsExactly(table1.getId()); + assertThat(reservedIds).doesNotContain(table2.getId()); + } +} \ No newline at end of file From a90a8a10020d22434fc1948ed5cebbbdbe48d4de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 14:43:03 +0900 Subject: [PATCH 054/169] =?UTF-8?q?[FIX]=20Store=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20businessHours,tableImages=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EC=97=90=20@Builder.Default=20=EC=95=A0=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/eatsfine/eatsfine/domain/store/entity/Store.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index ab60247e..b32ad951 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -73,9 +73,11 @@ public class Store extends BaseEntity { @Column(name = "booking_interval_minutes", nullable = false) private int bookingIntervalMinutes = 30; + @Builder.Default @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List businessHours = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List tableImages = new ArrayList<>(); From 6d541f0d46cbea76fb33acb22fb38b88d8dbd00a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 14:43:36 +0900 Subject: [PATCH 055/169] =?UTF-8?q?[FEAT]=20StoreTable=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EA=B8=B0=EB=B3=B8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/storetable/entity/StoreTable.java | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java index 3b7445e2..0672eddc 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java @@ -1,11 +1,28 @@ package com.eatsfine.eatsfine.domain.storetable.entity; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; @Entity -public class StoreTable { +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class StoreTable extends BaseEntity { @Id + @GeneratedValue private Long id; + + private String tableNumber; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store") + private Store store; + + private Integer tableSeats; + + } From 4afc9995abbd7e5aa92ce5e8ab6073b731330daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 15:04:24 +0900 Subject: [PATCH 056/169] =?UTF-8?q?[FEAT]=20=EC=98=88=EC=95=BD=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=20=EC=8B=9C=EA=B0=84=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C?= =?UTF-8?q?=20'=EB=82=98=EB=88=A0=20=EC=95=89=EA=B8=B0'=20=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20=EC=97=AC=EB=B6=80=20=EB=A1=9C=EC=A7=81=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 --- .../booking/controller/BookingController.java | 17 +++++++++-------- .../booking/dto/request/BookingRequestDTO.java | 8 ++++---- .../booking/service/BookingQueryService.java | 4 ++-- .../service/BookingQueryServiceImpl.java | 4 ++-- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java index 392f96a5..eca50b97 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java @@ -5,12 +5,10 @@ import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.time.LocalDate; import java.time.LocalTime; @@ -18,6 +16,7 @@ @Tag(name = "Booking", description = "예약 관련 API") @RestController @RequiredArgsConstructor +@RequestMapping("/api/vi") public class BookingController { private final BookingQueryService bookingQueryService; @@ -28,9 +27,10 @@ public class BookingController { public ApiResponse getAvailableTimes( @PathVariable Long storeId, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, - @RequestParam Integer partySize) { + @RequestParam Integer partySize, + @RequestParam(defaultValue = "false") Boolean isSplitAccepted) { - return ApiResponse.onSuccess(bookingQueryService.getAvailableTimeSlots(storeId, date, partySize)); + return ApiResponse.onSuccess(bookingQueryService.getAvailableTimeSlots(storeId, date, partySize,isSplitAccepted)); } @Operation(summary = "2단계: 예약 가능 테이블 조회" @@ -40,9 +40,10 @@ public ApiResponse getAvailableTables( @PathVariable Long storeId, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, @RequestParam @DateTimeFormat(pattern = "HH:mm") LocalTime time, - @RequestParam Integer partySize) { + @RequestParam Integer partySize, + @RequestParam(required = false) String seatsType) { - return ApiResponse.onSuccess(bookingQueryService.getAvailableTables(storeId, date, time, partySize)); + return ApiResponse.onSuccess(bookingQueryService.getAvailableTables(storeId, date, time, partySize,seatsType)); } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java index 5dd44ea0..c62ecb93 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java @@ -6,16 +6,16 @@ public class BookingRequestDTO { public record GetAvailableTimeDTO( - Long storeId, LocalDate date, - Integer partySize + Integer partySize, + Boolean isSplitAccepted ){} public record GetAvailableTableDTO( - Long storeId, LocalDate date, LocalTime time, - Integer partySize + Integer partySize, + String seatsType ){} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java index 46dcc5ee..9437b7e4 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java @@ -7,7 +7,7 @@ public interface BookingQueryService { - BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, LocalDate date, Integer partySize); + BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, LocalDate date, Integer partySize,Boolean isSplitAccepted); - BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize); + BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize, String seatsType); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java index 6530d03d..4afcd088 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java @@ -12,12 +12,12 @@ public class BookingQueryServiceImpl implements BookingQueryService { @Override - public BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, LocalDate date, Integer partySize) { + public BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, LocalDate date, Integer partySize, Boolean isSplitAccepted) { return null; } @Override - public BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize) { + public BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize, String seatsType) { return null; } } From 4fbe5a8d12e541505966b23a9dd0b72e405e4c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 15:04:54 +0900 Subject: [PATCH 057/169] =?UTF-8?q?[FEAT]=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=9C=A0=ED=98=95=20enum=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/storetable/enums/SeatsType.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/enums/SeatsType.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/enums/SeatsType.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/enums/SeatsType.java new file mode 100644 index 00000000..eb7ab733 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/enums/SeatsType.java @@ -0,0 +1,12 @@ +package com.eatsfine.eatsfine.domain.storetable.enums; + +public enum SeatsType { + GENERAL("일반석"), + WINDOW("창가석"), + ROOM("룸/프라이빗"), + BAR("바(Bar)석"), + OUTDOOR("야외석"); + + private final String description; + SeatsType(String description) { this.description = description; } +} From 30ae5a35ab2c5a433f8012e7039208e8b2fb20b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 15:57:27 +0900 Subject: [PATCH 058/169] =?UTF-8?q?[FEAT]=20TableLayout=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EA=B8=B0=EB=B3=B8=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20Store,=20StoreTable=20=EC=97=B0=EA=B4=80=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/store/entity/Store.java | 16 ++++++-- .../domain/storetable/entity/StoreTable.java | 10 ++++- .../table_layout/entity/TableLayout.java | 37 +++++++++++++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index b32ad951..0a0cadd9 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -7,11 +7,14 @@ import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.tableimage.entity.TableImage; import com.eatsfine.eatsfine.domain.user.entity.User; +import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; import com.eatsfine.eatsfine.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; import java.math.BigDecimal; +import java.time.DayOfWeek; import java.util.ArrayList; import java.util.List; @@ -81,9 +84,8 @@ public class Store extends BaseEntity { @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List tableImages = new ArrayList<>(); - // 추후 StoreTable 엔티티 개발 완료 시 추가 예정 - //@OneToMany(mappedBy = "store") - //private List storeTables = new ArrayList<>(); + @OneToMany(mappedBy = "store") + private List storeTables = new ArrayList<>(); public void addBusinessHours(BusinessHours businessHours) { this.businessHours.add(businessHours); @@ -105,6 +107,14 @@ public void removeTableImage(TableImage tableImage) { tableImage.assignStore(null); } + // 특정 요일의 영업시간 조회 메서드 + public BusinessHours getBusinessHoursByDay(DayOfWeek dayOfWeek) { + return this.businessHours.stream() + .filter(bh -> bh.getDayOfWeek() == dayOfWeek) + .findFirst() + .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST)); + } + // StoreTable에 대한 연관관계 편의 메서드는 추후 추가 예정 } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java index 0672eddc..612b1d54 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java @@ -1,6 +1,8 @@ package com.eatsfine.eatsfine.domain.storetable.entity; import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.storetable.enums.SeatsType; +import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; import com.eatsfine.eatsfine.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; @@ -19,10 +21,14 @@ public class StoreTable extends BaseEntity { private String tableNumber; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "store") - private Store store; + @JoinColumn(name = "table_layout_id", nullable = false) + private TableLayout tableLayout; // 부모 변경 private Integer tableSeats; + @Enumerated(EnumType.STRING) + @Column(name = "seats_type") + private SeatsType seatsType; + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java new file mode 100644 index 00000000..ba17b7d5 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java @@ -0,0 +1,37 @@ +package com.eatsfine.eatsfine.domain.table_layout.entity; + +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "table_layout") +public class TableLayout extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id", nullable = false) + private Store store; + + private int rows; + private int cols; + + @Column(name = "is_active") + private boolean isActive; + + @OneToMany(mappedBy = "tableLayout") + private List tables = new ArrayList<>(); + +} From e5725e43812bfbbbd5e51f1f05a992d3997f91ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 15:58:50 +0900 Subject: [PATCH 059/169] =?UTF-8?q?[FEAT]=20BusinessHours=EC=9D=98=20Dayof?= =?UTF-8?q?Week=EC=9D=84=20=EC=BB=A4=EC=8A=A4=ED=85=80=20enum=EC=9D=B4=20?= =?UTF-8?q?=EC=95=84=EB=8B=8Cjava.time=20=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/businesshours/entity/BusinessHours.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java index 75812bbc..34f543a6 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java @@ -1,11 +1,11 @@ package com.eatsfine.eatsfine.domain.businesshours.entity; -import com.eatsfine.eatsfine.domain.businesshours.enums.DayOfWeek; import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; +import java.time.DayOfWeek; import java.time.LocalTime; @Entity @@ -24,7 +24,6 @@ public class BusinessHours extends BaseEntity { private Store store; @Column(name = "day_of_week", nullable = false) - @Enumerated(EnumType.STRING) private DayOfWeek dayOfWeek; @Column(name = "open_time", nullable = false) From 7abe68a67b535809af81f5a17ecbecf9d581d487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 16:58:45 +0900 Subject: [PATCH 060/169] =?UTF-8?q?[FIX]=20BusinessHours=EC=9D=98=20DayofW?= =?UTF-8?q?eek=20@Enumerated=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/businesshours/entity/BusinessHours.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java index 34f543a6..6db43f24 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java @@ -23,6 +23,7 @@ public class BusinessHours extends BaseEntity { @JoinColumn(name = "store_id", nullable = false) private Store store; + @Enumerated(EnumType.STRING) @Column(name = "day_of_week", nullable = false) private DayOfWeek dayOfWeek; From 44220932a12e937567e134bed84db6475b810eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 16:59:15 +0900 Subject: [PATCH 061/169] =?UTF-8?q?[FIX]=20rows=20=ED=95=84=EB=93=9C=20db?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=98=A4=EB=A5=98=20->=20lows=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/table_layout/entity/TableLayout.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java index ba17b7d5..3b4956cc 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java @@ -25,7 +25,7 @@ public class TableLayout extends BaseEntity { @JoinColumn(name = "store_id", nullable = false) private Store store; - private int rows; + private int lows; private int cols; @Column(name = "is_active") @@ -34,4 +34,5 @@ public class TableLayout extends BaseEntity { @OneToMany(mappedBy = "tableLayout") private List tables = new ArrayList<>(); + } From ba066f36ec2822513283aca26f5ac0ccb76a50f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 17:00:14 +0900 Subject: [PATCH 062/169] =?UTF-8?q?[FEAT]=20GET=20/api/v1/stores/{storeId}?= =?UTF-8?q?/bookings/available-times=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../booking/controller/BookingController.java | 4 +- .../service/BookingCommandServiceImpl.java | 1 + .../service/BookingQueryServiceImpl.java | 67 ++++++++++++++++++- .../eatsfine/domain/store/entity/Store.java | 8 ++- 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java index eca50b97..1b21f1f1 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java @@ -16,13 +16,13 @@ @Tag(name = "Booking", description = "예약 관련 API") @RestController @RequiredArgsConstructor -@RequestMapping("/api/vi") +@RequestMapping("/api/v1") public class BookingController { private final BookingQueryService bookingQueryService; @Operation(summary = "1단계: 예약 가능 시간대 조회" - , description = "가게, 날짜, 인원수를 입력받아 예약 가능한 시간 목록 반환") + , description = "가게, 날짜, 인원수, 테이블 분리 가능 여부를 입력받아 예약 가능한 시간 목록 반환") @GetMapping("/stores/{storeId}/bookings/available-times") public ApiResponse getAvailableTimes( @PathVariable Long storeId, diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java index 388e190e..eb7b4c92 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java @@ -7,4 +7,5 @@ @RequiredArgsConstructor public class BookingCommandServiceImpl { + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java index 4afcd088..bfee34e3 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java @@ -1,19 +1,84 @@ package com.eatsfine.eatsfine.domain.booking.service; import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; +import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; +import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.time.LocalDate; import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; @Service @RequiredArgsConstructor public class BookingQueryServiceImpl implements BookingQueryService { + private final BookingRepository bookingRepository; + private final StoreRepository storeRepository; + @Override public BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, LocalDate date, Integer partySize, Boolean isSplitAccepted) { - return null; + Store store = storeRepository.findById(storeId) + .orElseThrow(()->new GeneralException(ErrorStatus._BAD_REQUEST)); + BusinessHours hours = store.getBusinessHoursByDay(date.getDayOfWeek()); + + List availableSlots = new ArrayList<>(); + LocalTime currentTime = hours.getOpenTime(); + + while (currentTime.isBefore(hours.getCloseTime())) { + + if (!isDuringBreakTime(hours, currentTime)) { + List reservedTableIds = bookingRepository.findReservedTableIds(storeId, date, currentTime); + + List tableLayouts = store.getTableLayouts(); + TableLayout activeTableLayout = tableLayouts.stream() + .filter(TableLayout::isActive).findFirst() + .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST)); + List activeTables = activeTableLayout.getTables(); + + if (canAccommodate(activeTables, reservedTableIds, partySize, isSplitAccepted)) { + availableSlots.add(currentTime); + } + } + currentTime = currentTime.plusMinutes(store.getBookingIntervalMinutes()); + } + + return new BookingResponseDTO.TimeSlotListDTO(availableSlots); + } + + private boolean canAccommodate(List allTables, List reservedTableIds, Integer partySize, Boolean isSplitAccepted) { + List freeTables = allTables.stream() + .filter(t -> !reservedTableIds.contains(t.getId())) + .toList(); + + // 1.단일 테이블 가능한지 체크 + if(freeTables.stream().anyMatch(t-> t.getTableSeats() >= partySize)) return true; + + // 2. 단일 테이블로 안 될 때, 나눠 앉기 동의 했을 경우 합계로 체크 + + if (isSplitAccepted) { + int totalSeats = freeTables.stream().mapToInt(StoreTable::getTableSeats).sum(); + return totalSeats >= partySize; + } + + return false; + } + + //브레이크 타임 판별 메서드 + private boolean isDuringBreakTime(BusinessHours hours, LocalTime time) { + if (hours.getBreakStartTime() == null || hours.getBreakEndTime() == null) { + return false; + } + return !time.isBefore(hours.getBreakStartTime()) && time.isBefore(hours.getBreakEndTime()); } @Override diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index 0a0cadd9..c0e73d24 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -5,6 +5,7 @@ import com.eatsfine.eatsfine.domain.store.enums.Category; import com.eatsfine.eatsfine.domain.store.enums.StoreApprovalStatus; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; import com.eatsfine.eatsfine.domain.tableimage.entity.TableImage; import com.eatsfine.eatsfine.domain.user.entity.User; import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; @@ -84,8 +85,13 @@ public class Store extends BaseEntity { @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List tableImages = new ArrayList<>(); + // StoreTable이 아닌 TableLayout 엔티티 참조 +// @OneToMany(mappedBy = "store") +// private List storeTables = new ArrayList<>(); + + @Builder.Default @OneToMany(mappedBy = "store") - private List storeTables = new ArrayList<>(); + private List tableLayouts = new ArrayList<>(); public void addBusinessHours(BusinessHours businessHours) { this.businessHours.add(businessHours); From bac7d3cbe03ee940e1a878bfbfc7f0572e6edf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 17:00:29 +0900 Subject: [PATCH 063/169] =?UTF-8?q?[FEAT]=20GeneralException=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/exception/GeneralException.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/global/apiPayload/exception/GeneralException.java diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/exception/GeneralException.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/exception/GeneralException.java new file mode 100644 index 00000000..c9aa0d2f --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/exception/GeneralException.java @@ -0,0 +1,12 @@ +package com.eatsfine.eatsfine.global.apiPayload.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GeneralException extends RuntimeException { + + private final BaseErrorCode code; +} From 97f49efcb3812340124d016897e3b90146b1a7eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 17:01:51 +0900 Subject: [PATCH 064/169] =?UTF-8?q?[FIX]=20=EB=A1=9C=EC=BB=AC=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20https->http?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/eatsfine/eatsfine/global/config/SwaggerConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/SwaggerConfig.java b/src/main/java/com/eatsfine/eatsfine/global/config/SwaggerConfig.java index 1cbeb032..004091c8 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/config/SwaggerConfig.java +++ b/src/main/java/com/eatsfine/eatsfine/global/config/SwaggerConfig.java @@ -24,7 +24,7 @@ public OpenAPI openAPI() { .version("1.0.0")) .servers(List.of( - new Server().url("https://localhost:8080").description("Local") + new Server().url("http://localhost:8080").description("Local") )) .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) From 03153e2907594a5b44e73a616b66341204dc2a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 17:02:11 +0900 Subject: [PATCH 065/169] =?UTF-8?q?[FEAT]=20User=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EA=B8=B0=EB=B3=B8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/eatsfine/domain/user/entity/User.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java b/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java index 39e5cfab..d6279e57 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java @@ -1,11 +1,18 @@ package com.eatsfine.eatsfine.domain.user.entity; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; +import jakarta.persistence.*; +import lombok.*; @Entity +@Getter +// 수정한 부분: access 레벨을 PROTECTED로 설정하여 Hibernate가 접근할 수 있게 합니다. +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "users") public class User { @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; } From d0a576b4b01f08e87f58f99eccaff163159985bb Mon Sep 17 00:00:00 2001 From: twodo0 Date: Sat, 10 Jan 2026 10:25:22 +0900 Subject: [PATCH 066/169] =?UTF-8?q?[FEAT]:=20=EC=8B=9D=EB=8B=B9=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20DTO=20=EB=B0=8F=20Bus?= =?UTF-8?q?inessHours=20=EB=B3=80=ED=99=98=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/BusinessHoursConverter.java | 23 +++++++++++++ .../dto/BusinessHoursResDto.java | 17 ++++++++++ .../domain/store/dto/StoreReqDto.java | 6 ++++ .../domain/store/dto/StoreResDto.java | 32 +++++++++++++++++++ 4 files changed, 78 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java new file mode 100644 index 00000000..59fc7f01 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java @@ -0,0 +1,23 @@ +package com.eatsfine.eatsfine.domain.businesshours.converter; + +import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursResDto; +import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; + +public class BusinessHoursConverter { + + public static BusinessHoursResDto.Summary toSummary(BusinessHours bh) { + // 휴무일 때 + if(bh.isHoliday()) { + return BusinessHoursResDto.Summary.builder() + .day(bh.getDayOfWeek()) + .closed(true) + .build(); + } + // 영업일일 때 + return BusinessHoursResDto.Summary.builder() + .day(bh.getDayOfWeek()) + .openTime(bh.getOpenTime()) + .closeTime(bh.getCloseTime()) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java new file mode 100644 index 00000000..3b3457de --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java @@ -0,0 +1,17 @@ +package com.eatsfine.eatsfine.domain.businesshours.dto; + +import com.eatsfine.eatsfine.domain.businesshours.enums.DayOfWeek; +import lombok.Builder; + +import java.time.LocalTime; + +public class BusinessHoursResDto { + + @Builder + public record Summary( + DayOfWeek day, + LocalTime openTime, + LocalTime closeTime, + Boolean closed // 영업일은 closed = null + ){} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java new file mode 100644 index 00000000..7bace030 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java @@ -0,0 +1,6 @@ +package com.eatsfine.eatsfine.domain.store.dto; + +import lombok.Builder; + +public class StoreReqDto { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java new file mode 100644 index 00000000..d290d43d --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java @@ -0,0 +1,32 @@ +package com.eatsfine.eatsfine.domain.store.dto; + +import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursResDto; +import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import com.eatsfine.eatsfine.domain.businesshours.enums.DayOfWeek; +import com.eatsfine.eatsfine.domain.store.enums.Category; +import lombok.Builder; + +import java.math.BigDecimal; +import java.time.LocalTime; +import java.util.List; + +public class StoreResDto { + + @Builder + public record StoreDetailDto( + Long storeId, + String storeName, + String description, + String address, + String phone, + Category category, + BigDecimal rating, + Long reviewCount, + String mainImage, + List tableImages, + List businessHours, + boolean isOpenNow, + String priceRange + ){} + +} From c22438e19d8985907d941958c70ea5563b495e21 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Sat, 10 Jan 2026 15:14:24 +0900 Subject: [PATCH 067/169] =?UTF-8?q?[FEAT]:=20StoreSuccessStatus,=20StoreEr?= =?UTF-8?q?rorStatus=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/store/status/StoreErrorStatus.java | 40 ++++++++++++++++++ .../store/status/StoreSuccessStatus.java | 41 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java new file mode 100644 index 00000000..f217fdf3 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java @@ -0,0 +1,40 @@ +package com.eatsfine.eatsfine.domain.store.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum StoreErrorStatus implements BaseErrorCode { + + _STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404", "해당하는 가게를 찾을 수 없습니다."), + + _STORE_DETAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE_DETAIL404", "가게 상세 정보를 찾을 수 없습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java new file mode 100644 index 00000000..cb6fc16b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java @@ -0,0 +1,41 @@ +package com.eatsfine.eatsfine.domain.store.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum StoreSuccessStatus implements BaseCode { + + _STORE_FOUND(HttpStatus.OK, "STORE200", "성공적으로 가게를 찾았습니다."), + + _STORE_DETAIL_FOUND(HttpStatus.FOUND, "STORE_DETAIL200", "성공적으로 가게 상세 리뷰를 조회했습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return ReasonDto.builder() + .isSuccess(true) + .message(message) + .code(code) + .build(); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return ReasonDto.builder() + .isSuccess(true) + .httpStatus(httpStatus) + .message(message) + .code(code) + .build(); + } + +} From 8efbbf5d9eff0c9f7fa3675069c407efdec276da Mon Sep 17 00:00:00 2001 From: twodo0 Date: Sat, 10 Jan 2026 15:34:08 +0900 Subject: [PATCH 068/169] =?UTF-8?q?[FEAT]:=20GeneralException(=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20exception)=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/exception/GeneralException.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/global/apiPayload/exception/GeneralException.java diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/exception/GeneralException.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/exception/GeneralException.java new file mode 100644 index 00000000..db5b5189 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/exception/GeneralException.java @@ -0,0 +1,12 @@ +package com.eatsfine.eatsfine.global.apiPayload.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +// 프로젝트 Exception +@Getter +@AllArgsConstructor +public class GeneralException extends RuntimeException { + private final BaseErrorCode code; +} From 207618193366fc1166d7ba12ced6a183613f46a4 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Sat, 10 Jan 2026 15:34:31 +0900 Subject: [PATCH 069/169] =?UTF-8?q?[FEAT]:=20StoreException=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/store/exception/StoreException.java | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/exception/StoreException.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/exception/StoreException.java b/src/main/java/com/eatsfine/eatsfine/domain/store/exception/StoreException.java new file mode 100644 index 00000000..1dc33a31 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/exception/StoreException.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.store.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class StoreException extends GeneralException { + public StoreException(BaseErrorCode code) { + super(code); + } +} From 3601032cfafd0a9e96e4e9cea59973cf6b5297b1 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Sat, 10 Jan 2026 15:38:48 +0900 Subject: [PATCH 070/169] =?UTF-8?q?[FEAT]:=20=EC=8B=9D=EB=8B=B9=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20(Controller/Ser?= =?UTF-8?q?vice)=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store/controller/StoreController.java | 25 +++++++++++ .../service/StoreDetailQueryService.java | 8 ++++ .../service/StoreDetailQueryServiceImpl.java | 44 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryServiceImpl.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java new file mode 100644 index 00000000..25d57b7f --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java @@ -0,0 +1,25 @@ +package com.eatsfine.eatsfine.domain.store.controller; + +import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; +import com.eatsfine.eatsfine.domain.store.service.StoreDetailQueryService; +import com.eatsfine.eatsfine.domain.store.status.StoreSuccessStatus; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/stores") +public class StoreController { + + private final StoreDetailQueryService storeDetailQueryService; + + @GetMapping("/{storeId}") + public ApiResponse getStoreDetail(@PathVariable Long storeId) { + return ApiResponse.of(StoreSuccessStatus._STORE_DETAIL_FOUND, storeDetailQueryService.getStoreDetail(storeId)); + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryService.java new file mode 100644 index 00000000..753a47e4 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryService.java @@ -0,0 +1,8 @@ +package com.eatsfine.eatsfine.domain.store.service; + +import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; + +public interface StoreDetailQueryService { + public StoreResDto.StoreDetailDto getStoreDetail(Long storeId); + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryServiceImpl.java new file mode 100644 index 00000000..3645fade --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryServiceImpl.java @@ -0,0 +1,44 @@ +package com.eatsfine.eatsfine.domain.store.service; + +import com.eatsfine.eatsfine.domain.businesshours.converter.BusinessHoursConverter; +import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Collections; + +@Service +@RequiredArgsConstructor +public class StoreDetailQueryServiceImpl implements StoreDetailQueryService { + + private final StoreRepository storeRepository; + + @Override + public StoreResDto.StoreDetailDto getStoreDetail(Long storeId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + return StoreResDto.StoreDetailDto.builder() + .storeId(store.getId()) + .storeName(store.getStoreName()) + .description(store.getDescription()) + .address(store.getAddress()) + .phone(store.getPhoneNumber()) + .category(store.getCategory()) + .rating(store.getRating()) + .reviewCount(null) // reviewCount는 추후 리뷰 로직 구현 시 추가 예정 + .mainImage(store.getMainImageUrl()) + .tableImages(Collections.emptyList()) // tableImages는 추후 사진 등록 API 구현 시 추가 예정 + .businessHours( + store.getBusinessHours().stream() + .map(BusinessHoursConverter::toSummary) + .toList()) + .isOpenNow(false) // 추후 영업 여부 판단 로직 구현 예정 + .priceRange(null) // 추후 + .build(); + } +} From ba7fa01395f6daeaaf2fc20497b30f3d3c932bd1 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Sat, 10 Jan 2026 20:38:51 +0900 Subject: [PATCH 071/169] =?UTF-8?q?[FEAT]:=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=ED=95=B8=EB=93=A4=EB=9F=AC(GeneralExcepti?= =?UTF-8?q?onAdvice)=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/GeneralExceptionAdvice.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java new file mode 100644 index 00000000..39b76884 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -0,0 +1,19 @@ +package com.eatsfine.eatsfine.global.apiPayload.handler; + +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GeneralExceptionAdvice { + + // 커스텀 예외 처리 + @ExceptionHandler(GeneralException.class) + public ResponseEntity> handleGeneralException(GeneralException e) { + return ResponseEntity.status(e.getCode().getReasonHttpStatus().getHttpStatus()) + .body(ApiResponse.onFailure(e.getCode(), null)); + + } +} From f17b1ed03e1a6d259c97c3591b0bc2ab87557ec1 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Sun, 11 Jan 2026 17:36:52 +0900 Subject: [PATCH 072/169] =?UTF-8?q?[REFACTOR]:=20BusinessHours=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20DTO=20=EB=B0=8F=20=EC=BB=A8=EB=B2=84=ED=84=B0=20?= =?UTF-8?q?=ED=9C=B4=EB=AC=B4=20=EC=B2=98=EB=A6=AC=20=EB=8B=A8=EC=88=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/businesshours/converter/BusinessHoursConverter.java | 1 + .../eatsfine/domain/businesshours/dto/BusinessHoursResDto.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java index 59fc7f01..50b9a46f 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java @@ -18,6 +18,7 @@ public static BusinessHoursResDto.Summary toSummary(BusinessHours bh) { .day(bh.getDayOfWeek()) .openTime(bh.getOpenTime()) .closeTime(bh.getCloseTime()) + .closed(false) .build(); } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java index 3b3457de..994c2055 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java @@ -12,6 +12,6 @@ public record Summary( DayOfWeek day, LocalTime openTime, LocalTime closeTime, - Boolean closed // 영업일은 closed = null + boolean closed // true = 휴무, false = 영업 ){} } From b179f6b0a98f130466d80a3c2cd879b41c5c14ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 19:16:50 +0900 Subject: [PATCH 073/169] =?UTF-8?q?[FEAT]=20=EC=98=88=EC=95=BD=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8C=80=20=EC=84=A4=EC=A0=95=20=ED=9B=84=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=A1=B0=ED=9A=8C=20DTO=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/booking/dto/response/BookingResponseDTO.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java index 51199f71..117c1bac 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java @@ -14,6 +14,8 @@ public record TimeSlotListDTO( @Builder public record AvailableTableListDTO( + int rows, + int cols, List tables ) {} @@ -22,6 +24,10 @@ public record TableInfoDTO( Long tableId, String tableNumber, Integer tableSeats, - String tableLocationType + String seatsType, + int gridX, + int gridY, + int widthSpan, + int heightSpan ){} } From 14435a505237563934522b52defc186fc49ebfe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 19:17:19 +0900 Subject: [PATCH 074/169] =?UTF-8?q?[FEAT]=20StoreTable=EC=97=90=20Table?= =?UTF-8?q?=EC=9D=98=20=EC=9C=84=EC=B9=98=20=ED=95=84=EB=93=9C=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 --- .../eatsfine/domain/storetable/entity/StoreTable.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java index 612b1d54..8bf83dc1 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java @@ -30,5 +30,13 @@ public class StoreTable extends BaseEntity { @Column(name = "seats_type") private SeatsType seatsType; + private int gridX; + + private int gridY; + + private int widthSpan; + + private int heightSpan; + } From af9809289d1e002a073bd61067a7c8f637992e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 19:18:39 +0900 Subject: [PATCH 075/169] =?UTF-8?q?[FEAT]=20=EC=8B=9C=EA=B0=84=EB=8C=80=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=9B=84=20=EC=98=88=EC=95=BD=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=9C=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/BookingQueryServiceImpl.java | 31 +++++++++++++++++-- .../repository/TableLayoutRepository.java | 11 +++++++ 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/table_layout/repository/TableLayoutRepository.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java index bfee34e3..36b68bc7 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java @@ -7,9 +7,11 @@ import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; +import com.eatsfine.eatsfine.domain.table_layout.repository.TableLayoutRepository; import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; +import jakarta.persistence.Table; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -24,6 +26,7 @@ public class BookingQueryServiceImpl implements BookingQueryService { private final BookingRepository bookingRepository; private final StoreRepository storeRepository; + private final TableLayoutRepository tableLayoutRepository; @Override public BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, LocalDate date, Integer partySize, Boolean isSplitAccepted) { @@ -43,6 +46,7 @@ public BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, Lo TableLayout activeTableLayout = tableLayouts.stream() .filter(TableLayout::isActive).findFirst() .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST)); + List activeTables = activeTableLayout.getTables(); if (canAccommodate(activeTables, reservedTableIds, partySize, isSplitAccepted)) { @@ -64,7 +68,6 @@ private boolean canAccommodate(List allTables, List reservedTa if(freeTables.stream().anyMatch(t-> t.getTableSeats() >= partySize)) return true; // 2. 단일 테이블로 안 될 때, 나눠 앉기 동의 했을 경우 합계로 체크 - if (isSplitAccepted) { int totalSeats = freeTables.stream().mapToInt(StoreTable::getTableSeats).sum(); return totalSeats >= partySize; @@ -83,6 +86,30 @@ private boolean isDuringBreakTime(BusinessHours hours, LocalTime time) { @Override public BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize, String seatsType) { - return null; + TableLayout activeTableLayout = tableLayoutRepository.findByStoreIdAndIsActiveTrue(storeId) + .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST)); + List reservedTableIds = bookingRepository.findReservedTableIds(storeId, date, time); + + List availableTables = activeTableLayout.getTables().stream() + .filter(t -> !reservedTableIds.contains(t.getId())) + .filter(t -> t.getTableSeats() >= partySize) + .filter(t -> t.getSeatsType() == null || t.getSeatsType().name().equalsIgnoreCase(seatsType)) + .map(t -> BookingResponseDTO.TableInfoDTO.builder() + .tableId(t.getId()) + .tableNumber(t.getTableNumber()) + .tableSeats(t.getTableSeats()) + .seatsType(t.getSeatsType().name()) + .gridX(t.getGridX()) + .gridY(t.getGridY()) + .widthSpan(t.getWidthSpan()) + .heightSpan(t.getHeightSpan()) + .build()) + .toList(); + + return BookingResponseDTO.AvailableTableListDTO.builder() + .rows(activeTableLayout.getLows()) + .cols(activeTableLayout.getCols()) + .tables(availableTables) + .build(); } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/repository/TableLayoutRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/repository/TableLayoutRepository.java new file mode 100644 index 00000000..cea0265d --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/repository/TableLayoutRepository.java @@ -0,0 +1,11 @@ +package com.eatsfine.eatsfine.domain.table_layout.repository; + +import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TableLayoutRepository extends JpaRepository { + + Optional findByStoreIdAndIsActiveTrue(Long storeId); +} From d3824bd715e0559c9fc76787167e39b39fae52ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 20:30:13 +0900 Subject: [PATCH 076/169] =?UTF-8?q?[FEAT]=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=ED=97=88=EC=9A=A9=20=EC=97=AC=EB=B6=80?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=9D=BC=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../booking/controller/BookingController.java | 7 ++++++- .../booking/service/BookingQueryService.java | 2 +- .../booking/service/BookingQueryServiceImpl.java | 14 +++++++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java index 1b21f1f1..7c6416cb 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java @@ -4,6 +4,8 @@ import com.eatsfine.eatsfine.domain.booking.service.BookingQueryService; import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -39,11 +41,14 @@ public ApiResponse getAvailableTimes( public ApiResponse getAvailableTables( @PathVariable Long storeId, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, + @Parameter(description = "예약 시간", example = "18:00") + @Schema(type = "string", pattern = "HH:mm", example = "18:00") @RequestParam @DateTimeFormat(pattern = "HH:mm") LocalTime time, @RequestParam Integer partySize, + @RequestParam Boolean isSplitAccepted, @RequestParam(required = false) String seatsType) { - return ApiResponse.onSuccess(bookingQueryService.getAvailableTables(storeId, date, time, partySize,seatsType)); + return ApiResponse.onSuccess(bookingQueryService.getAvailableTables(storeId, date, time, partySize,isSplitAccepted,seatsType)); } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java index 9437b7e4..423b8d28 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java @@ -9,5 +9,5 @@ public interface BookingQueryService { BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, LocalDate date, Integer partySize,Boolean isSplitAccepted); - BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize, String seatsType); + BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize,Boolean isSplitAccepted, String seatsType); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java index 36b68bc7..3ffb9ae2 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java @@ -85,15 +85,23 @@ private boolean isDuringBreakTime(BusinessHours hours, LocalTime time) { } @Override - public BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize, String seatsType) { + public BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize,Boolean isSplitAccepted, String seatsType) { TableLayout activeTableLayout = tableLayoutRepository.findByStoreIdAndIsActiveTrue(storeId) .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST)); List reservedTableIds = bookingRepository.findReservedTableIds(storeId, date, time); + List availableTables = activeTableLayout.getTables().stream() .filter(t -> !reservedTableIds.contains(t.getId())) - .filter(t -> t.getTableSeats() >= partySize) - .filter(t -> t.getSeatsType() == null || t.getSeatsType().name().equalsIgnoreCase(seatsType)) + .filter(t -> { + // 사용자가 "분리 허용 안 함(false)"을 선택했다면, 테이블 크기가 인원수보다 커야 함 + if (isSplitAccepted != null && !isSplitAccepted) { + return t.getTableSeats() >= partySize; + } + // 사용자가 "분리 허용(true)"을 선택했다면 모든 빈 테이블 표시 + return true; + }) + .filter(t -> seatsType == null || (t.getSeatsType() != null && t.getSeatsType().name().equalsIgnoreCase(seatsType))) .map(t -> BookingResponseDTO.TableInfoDTO.builder() .tableId(t.getId()) .tableNumber(t.getTableNumber()) From 771651add4ae8a3da88f7671a07d3fd7b6216233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 20:38:08 +0900 Subject: [PATCH 077/169] =?UTF-8?q?[FEAT]=20GET=20/api/v1/stores/{storeId}?= =?UTF-8?q?/bookings/available-tables=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/booking/controller/BookingController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java index 7c6416cb..cddd4e66 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java @@ -41,8 +41,8 @@ public ApiResponse getAvailableTimes( public ApiResponse getAvailableTables( @PathVariable Long storeId, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, - @Parameter(description = "예약 시간", example = "18:00") - @Schema(type = "string", pattern = "HH:mm", example = "18:00") + @Parameter(description = "예약 시간 (HH:mm)", example = "18:00") + @Schema(type = "string", example = "18:00", description = "HH:mm 형식으로 입력하세요.") @RequestParam @DateTimeFormat(pattern = "HH:mm") LocalTime time, @RequestParam Integer partySize, @RequestParam Boolean isSplitAccepted, From 2189d8b7dcad78487f0dd20ce8a82c0d9a769a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 20:57:53 +0900 Subject: [PATCH 078/169] =?UTF-8?q?[CHORE]=20Swagger=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=202.3.0=20->=202.8.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index a7fd0d72..4e82f3fc 100644 --- a/build.gradle +++ b/build.gradle @@ -36,7 +36,7 @@ dependencies { //implementation 'org.springframework.boot:spring-boot-starter-security' //swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.1' } tasks.named('test') { From 3c7685b149536fee00ff08224425696bff8dc6bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 20:58:27 +0900 Subject: [PATCH 079/169] =?UTF-8?q?[FEAT]=20BookingException=20=EB=B0=8F?= =?UTF-8?q?=20ErrorStatus=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/booking/exception/BookingException.java | 10 ++++++++++ .../global/apiPayload/code/status/ErrorStatus.java | 10 +++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/exception/BookingException.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/exception/BookingException.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/exception/BookingException.java new file mode 100644 index 00000000..ddde48dc --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/exception/BookingException.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.booking.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class BookingException extends GeneralException { + public BookingException(BaseErrorCode code) { + super(code); + } +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java index f1538c26..ed29b8ab 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java @@ -12,7 +12,15 @@ public enum ErrorStatus implements BaseErrorCode { _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), - _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."); + _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), + _NOT_FOUND(HttpStatus.NOT_FOUND,"COMMON404","존재하지 않는 요청입니다."), +// 가게 및 예약 관련 에러 + _STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404", "해당 가게를 찾을 수 없습니다."), + _BUSINESS_HOURS_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE4041", "해당 날짜의 영업시간 정보가 없습니다."), + _LAYOUT_NOT_FOUND(HttpStatus.NOT_FOUND, "LAYOUT404", "가게의 활성화된 테이블 레이아웃이 없습니다."), + _BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING404", "예약 정보를 찾을 수 없습니다."), + _INVALID_PARTY_SIZE(HttpStatus.BAD_REQUEST, "BOOKING4001", "인원 설정이 잘못되었습니다."); + private final HttpStatus httpStatus; private final String code; From 3a2acd617b459d6313ac2cf72c9d1ecf7d2ed6d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 20:58:41 +0900 Subject: [PATCH 080/169] =?UTF-8?q?[FEAT]=20=EC=97=90=EB=9F=AC=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/GeneralExceptionAdvice.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java new file mode 100644 index 00000000..0c01904a --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -0,0 +1,51 @@ +package com.eatsfine.eatsfine.global.apiPayload.handler; + +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@Slf4j +@RestControllerAdvice +public class GeneralExceptionAdvice extends ResponseEntityExceptionHandler { + + // 1. 커스텀 예외(GeneralException) 처리 + @ExceptionHandler(value = GeneralException.class) + public ResponseEntity onGeneralException(GeneralException exception, HttpServletRequest request) { + // exception.getCode()는 BaseErrorCode 인터페이스 타입이므로 onFailure에 바로 전달 가능합니다. + return handleExceptionInternal(exception, exception.getCode(), null, request); + } + + // 2. 일반적인 모든 예외 처리 (정의되지 않은 에러) + @ExceptionHandler + public ResponseEntity exception(Exception e, WebRequest request) { + log.error("Unhandled Exception 발생: ", e); + // 시스템 전체 공통 에러인 _INTERNAL_SERVER_ERROR를 사용합니다. + return handleExceptionInternalFalse(e, ErrorStatus._INTERNAL_SERVER_ERROR, HttpHeaders.EMPTY, ErrorStatus._INTERNAL_SERVER_ERROR.getHttpStatus(), request, e.getMessage()); + } + + // 3. 커스텀 예외용 내부 응답 생성 메서드 + private ResponseEntity handleExceptionInternal(Exception e, BaseErrorCode code, HttpHeaders headers, HttpServletRequest request) { + // 정의하신 ApiResponse.onFailure(BaseErrorCode code, T result)를 호출합니다. + ApiResponse body = ApiResponse.onFailure(code, null); + WebRequest webRequest = new ServletWebRequest(request); + return super.handleExceptionInternal(e, body, headers, code.getReasonHttpStatus().getHttpStatus(), webRequest); + } + + // 4. 일반 예외용 내부 응답 생성 메서드 + private ResponseEntity handleExceptionInternalFalse(Exception e, BaseErrorCode code, HttpHeaders headers, HttpStatusCode status, WebRequest request, String errorPoint) { + // 일반 예외의 경우 errorPoint(메시지)를 result에 담아 전달할 수 있습니다. + ApiResponse body = ApiResponse.onFailure(code, errorPoint); + return super.handleExceptionInternal(e, body, headers, status, request); + } +} \ No newline at end of file From 5f75ce1bb5565c56962ad08f900a5e502dc16e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 20:59:45 +0900 Subject: [PATCH 081/169] =?UTF-8?q?[FEAT]=20BookingController=EC=97=90=20B?= =?UTF-8?q?ookingException=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/booking/service/BookingQueryServiceImpl.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java index 3ffb9ae2..a9e7c380 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java @@ -1,6 +1,7 @@ package com.eatsfine.eatsfine.domain.booking.service; import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; +import com.eatsfine.eatsfine.domain.booking.exception.BookingException; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; import com.eatsfine.eatsfine.domain.store.entity.Store; @@ -14,6 +15,7 @@ import jakarta.persistence.Table; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.LocalTime; @@ -29,9 +31,10 @@ public class BookingQueryServiceImpl implements BookingQueryService { private final TableLayoutRepository tableLayoutRepository; @Override + @Transactional(readOnly = true) public BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, LocalDate date, Integer partySize, Boolean isSplitAccepted) { Store store = storeRepository.findById(storeId) - .orElseThrow(()->new GeneralException(ErrorStatus._BAD_REQUEST)); + .orElseThrow(() -> new BookingException(ErrorStatus._STORE_NOT_FOUND)); BusinessHours hours = store.getBusinessHoursByDay(date.getDayOfWeek()); List availableSlots = new ArrayList<>(); @@ -85,9 +88,10 @@ private boolean isDuringBreakTime(BusinessHours hours, LocalTime time) { } @Override + @Transactional(readOnly = true) public BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize,Boolean isSplitAccepted, String seatsType) { TableLayout activeTableLayout = tableLayoutRepository.findByStoreIdAndIsActiveTrue(storeId) - .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST)); + .orElseThrow(() -> new BookingException(ErrorStatus._LAYOUT_NOT_FOUND)); List reservedTableIds = bookingRepository.findReservedTableIds(storeId, date, time); From 60ef2cc33d05ff09117a6ab50c740fa783e01f5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 21:19:31 +0900 Subject: [PATCH 082/169] =?UTF-8?q?=EC=98=88=EC=A0=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/BookingRepositoryTest.java | 124 ------------------ 1 file changed, 124 deletions(-) delete mode 100644 src/test/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepositoryTest.java diff --git a/src/test/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepositoryTest.java b/src/test/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepositoryTest.java deleted file mode 100644 index 719f7a4a..00000000 --- a/src/test/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepositoryTest.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.eatsfine.eatsfine.domain.booking.repository; - -import com.eatsfine.eatsfine.domain.booking.entity.Booking; -import com.eatsfine.eatsfine.domain.booking.entity.mapping.BookingTable; -import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; -import com.eatsfine.eatsfine.domain.region.entity.Region; -import com.eatsfine.eatsfine.domain.store.entity.Store; -import com.eatsfine.eatsfine.domain.store.enums.Category; -import com.eatsfine.eatsfine.domain.store.enums.StoreApprovalStatus; -import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; -import com.eatsfine.eatsfine.domain.user.entity.User; -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.boot.test.autoconfigure.orm.jpa.TestEntityManager; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Import; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; - -@DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Import(BookingRepositoryTest.TestJpaConfig.class) -class BookingRepositoryTest { - - @TestConfiguration - @EnableJpaAuditing - static class TestJpaConfig {} - - @Autowired - private BookingRepository bookingRepository; - - @Autowired - private TestEntityManager em; - - @Test - @DisplayName("특정 날짜와 시간에 예약된 테이블 ID 목록을 정확히 조회한다") - void findReservedTableIds_Success() { - // given: 1. 필수 연관 데이터 생성 (User, Region) - User owner = User.builder() - .build(); - em.persist(owner); - - Region region = Region.builder() - .province("경기도") - .district("마포구") - .city("서울시") - .build(); - em.persist(region); - - // 2. Store 생성 - Store store = Store.builder() - .owner(owner) - .region(region) - .storeName("아웃백") - .address("서울시 강남구") - .phoneNumber("02-123-4567") - .description("맛있는 식당") - .mainImageUrl("https://example.com/image.jpg") - .rating(new BigDecimal("4.5")) - .category(Category.WESTERN) - .minPrice(20000) - .maxPrice(50000) - .approvalStatus(StoreApprovalStatus.APPROVED) - .bookingIntervalMinutes(30) - .build(); - em.persist(store); - - // 3. StoreTable 생성 - StoreTable table1 = StoreTable.builder() - .tableNumber("T1") - .store(store) - .tableSeats(4) - .build(); - StoreTable table2 = StoreTable.builder() - .tableNumber("T2") - .store(store) - .tableSeats(2) - .build(); - em.persist(table1); - em.persist(table2); - - LocalDate date = LocalDate.of(2026, 1, 9); - LocalTime time = LocalTime.of(18, 0); - - // 4. Booking 생성 - Booking booking = Booking.builder() - .store(store) - .bookingDate(date) - .partySize(4) - .user(owner) - .bookingTime(time) - .status(BookingStatus.CONFIRMED) - .build(); - em.persist(booking); - - // 5. 매핑 테이블 생성 - BookingTable bookingTable = BookingTable.builder() - .booking(booking) - .storeTable(table1) - .build(); - em.persist(bookingTable); - - em.flush(); - em.clear(); - - // when - List reservedIds = bookingRepository.findReservedTableIds(store.getId(), date, time); - - // then - assertThat(reservedIds).hasSize(1); - assertThat(reservedIds).containsExactly(table1.getId()); - assertThat(reservedIds).doesNotContain(table2.getId()); - } -} \ No newline at end of file From 11bbf45ac05c3412f040365c7605a684122b46f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sun, 11 Jan 2026 22:28:21 +0900 Subject: [PATCH 083/169] =?UTF-8?q?[REFACTOR]=20:=20BookingErrorStatus?= =?UTF-8?q?=EB=A5=BC=20=EA=B3=B5=ED=86=B5=20ErrorStatus=EC=97=90=EC=84=9C?= =?UTF-8?q?=20Booking=20=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/BookingQueryServiceImpl.java | 7 ++-- .../booking/status/BookingErrorStatus.java | 41 +++++++++++++++++++ .../booking/status/BookingSuccessStatus.java | 41 +++++++++++++++++++ .../apiPayload/code/status/ErrorStatus.java | 9 +--- 4 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingSuccessStatus.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java index a9e7c380..2a3f6f7a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java @@ -3,16 +3,15 @@ import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; import com.eatsfine.eatsfine.domain.booking.exception.BookingException; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; +import com.eatsfine.eatsfine.domain.booking.status.BookingErrorStatus; import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; import com.eatsfine.eatsfine.domain.table_layout.repository.TableLayoutRepository; -import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; -import jakarta.persistence.Table; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,7 +33,7 @@ public class BookingQueryServiceImpl implements BookingQueryService { @Transactional(readOnly = true) public BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, LocalDate date, Integer partySize, Boolean isSplitAccepted) { Store store = storeRepository.findById(storeId) - .orElseThrow(() -> new BookingException(ErrorStatus._STORE_NOT_FOUND)); + .orElseThrow(() -> new BookingException(BookingErrorStatus._STORE_NOT_FOUND)); BusinessHours hours = store.getBusinessHoursByDay(date.getDayOfWeek()); List availableSlots = new ArrayList<>(); @@ -91,7 +90,7 @@ private boolean isDuringBreakTime(BusinessHours hours, LocalTime time) { @Transactional(readOnly = true) public BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize,Boolean isSplitAccepted, String seatsType) { TableLayout activeTableLayout = tableLayoutRepository.findByStoreIdAndIsActiveTrue(storeId) - .orElseThrow(() -> new BookingException(ErrorStatus._LAYOUT_NOT_FOUND)); + .orElseThrow(() -> new BookingException(BookingErrorStatus._LAYOUT_NOT_FOUND)); List reservedTableIds = bookingRepository.findReservedTableIds(storeId, date, time); diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java new file mode 100644 index 00000000..c58e91ae --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java @@ -0,0 +1,41 @@ +package com.eatsfine.eatsfine.domain.booking.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum BookingErrorStatus implements BaseErrorCode { + + _STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404", "해당 가게를 찾을 수 없습니다."), + _BUSINESS_HOURS_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE4041", "해당 날짜의 영업시간 정보가 없습니다."), + _LAYOUT_NOT_FOUND(HttpStatus.NOT_FOUND, "LAYOUT404", "가게의 활성화된 테이블 레이아웃이 없습니다."), + _BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING404", "예약 정보를 찾을 수 없습니다."), + _INVALID_PARTY_SIZE(HttpStatus.BAD_REQUEST, "BOOKING4001", "인원 설정이 잘못되었습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingSuccessStatus.java new file mode 100644 index 00000000..1e6b1e3e --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingSuccessStatus.java @@ -0,0 +1,41 @@ +package com.eatsfine.eatsfine.domain.booking.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum BookingSuccessStatus implements BaseCode { + + _BOOKING_FOUND(HttpStatus.OK, "BOOKING200", "성공적으로 예약을 조회 했습니다."), + + _BOOKING_DETAIL_FOUND(HttpStatus.FOUND, "BOOKING_DETAIL200", "성공적으로 예약 상세 내역을 조회했습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return ReasonDto.builder() + .isSuccess(true) + .message(message) + .code(code) + .build(); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return ReasonDto.builder() + .isSuccess(true) + .httpStatus(httpStatus) + .message(message) + .code(code) + .build(); + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java index ed29b8ab..d0a46c44 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java @@ -13,14 +13,7 @@ public enum ErrorStatus implements BaseErrorCode { _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), - _NOT_FOUND(HttpStatus.NOT_FOUND,"COMMON404","존재하지 않는 요청입니다."), -// 가게 및 예약 관련 에러 - _STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404", "해당 가게를 찾을 수 없습니다."), - _BUSINESS_HOURS_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE4041", "해당 날짜의 영업시간 정보가 없습니다."), - _LAYOUT_NOT_FOUND(HttpStatus.NOT_FOUND, "LAYOUT404", "가게의 활성화된 테이블 레이아웃이 없습니다."), - _BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING404", "예약 정보를 찾을 수 없습니다."), - _INVALID_PARTY_SIZE(HttpStatus.BAD_REQUEST, "BOOKING4001", "인원 설정이 잘못되었습니다."); - + _NOT_FOUND(HttpStatus.NOT_FOUND,"COMMON404","존재하지 않는 요청입니다."); private final HttpStatus httpStatus; private final String code; From 460b82cdc069722f03a1e6f0dcc20d3fec4383b7 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Sun, 11 Jan 2026 22:31:04 +0900 Subject: [PATCH 084/169] =?UTF-8?q?[REFACTOR]:=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/GeneralExceptionAdvice.java | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java deleted file mode 100644 index 39b76884..00000000 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.eatsfine.eatsfine.global.apiPayload.handler; - -import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; -import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -@RestControllerAdvice -public class GeneralExceptionAdvice { - - // 커스텀 예외 처리 - @ExceptionHandler(GeneralException.class) - public ResponseEntity> handleGeneralException(GeneralException e) { - return ResponseEntity.status(e.getCode().getReasonHttpStatus().getHttpStatus()) - .body(ApiResponse.onFailure(e.getCode(), null)); - - } -} From 1a2ef362ee63f77dee28ec8c66ae8d4a9b0b0e25 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Sun, 11 Jan 2026 23:19:23 +0900 Subject: [PATCH 085/169] =?UTF-8?q?[REFACTOR]:=20BusinessHoursResDto.summa?= =?UTF-8?q?ry=20=EC=9D=91=EB=8B=B5=20=EC=8B=9C=EA=B0=84=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7(HH:mm)=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/businesshours/dto/BusinessHoursResDto.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java index 994c2055..51f95d1b 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java @@ -1,6 +1,7 @@ package com.eatsfine.eatsfine.domain.businesshours.dto; import com.eatsfine.eatsfine.domain.businesshours.enums.DayOfWeek; +import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Builder; import java.time.LocalTime; @@ -10,8 +11,13 @@ public class BusinessHoursResDto { @Builder public record Summary( DayOfWeek day, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime openTime, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime closeTime, + boolean closed // true = 휴무, false = 영업 ){} } From e0f0c7c508a253122ee25beb26b13b654eafb464 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Mon, 12 Jan 2026 01:33:00 +0900 Subject: [PATCH 086/169] =?UTF-8?q?[REFACTOR]:=20BusinessHours=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20DTO=20isClosed=EB=A1=9C=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../businesshours/converter/BusinessHoursConverter.java | 4 ++-- .../domain/businesshours/dto/BusinessHoursResDto.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java index 50b9a46f..026ef2d8 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java @@ -10,7 +10,7 @@ public static BusinessHoursResDto.Summary toSummary(BusinessHours bh) { if(bh.isHoliday()) { return BusinessHoursResDto.Summary.builder() .day(bh.getDayOfWeek()) - .closed(true) + .isClosed(true) .build(); } // 영업일일 때 @@ -18,7 +18,7 @@ public static BusinessHoursResDto.Summary toSummary(BusinessHours bh) { .day(bh.getDayOfWeek()) .openTime(bh.getOpenTime()) .closeTime(bh.getCloseTime()) - .closed(false) + .isClosed(false) .build(); } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java index 51f95d1b..98d8d4bd 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java @@ -18,6 +18,6 @@ public record Summary( @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime closeTime, - boolean closed // true = 휴무, false = 영업 + boolean isClosed // true = 휴무, false = 영업 ){} } From e29373e659c7c6306bb1813cb1b795478b47ad2e Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:19:37 +0900 Subject: [PATCH 087/169] =?UTF-8?q?[FIX]:=20NGINX=20Blue-Green=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=A0=84=ED=99=98=20=EB=B0=B0=ED=8F=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 84a0e123..6999edad 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -135,6 +135,12 @@ jobs: docker pull ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest docker compose -f /home/ec2-user/deploy/docker-compose-${{ env.TARGET_UPSTREAM }}.yml up -d + + # 컨테이너 존재 검증 + docker ps --format '{{.Names}}' | grep -q "^${{ env.TARGET_UPSTREAM }}$" + + # 컨테이너 직접 헬스체크 + curl -f http://localhost:${{ env.TARGET_PORT }}/api/v1/deploy/health-check EOF - name: 컨테이너 기동 대기 @@ -158,8 +164,8 @@ jobs: run: | ssh eatsfine-ec2 << 'EOF' set -e - docker stop ${{ env.CURRENT_UPSTREAM }} - docker rm ${{ env.CURRENT_UPSTREAM }} + docker ps --format '{{.Names}}' | grep -q "^${{ env.CURRENT_UPSTREAM }}$" && \ + docker stop ${{ env.CURRENT_UPSTREAM }} && docker rm ${{ env.CURRENT_UPSTREAM }} || true EOF - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 제거 if: always() From 181bba0c88566d307ebf84dbcf3518a63cb1028c Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:37:38 +0900 Subject: [PATCH 088/169] =?UTF-8?q?[FIX]:=20NGINX=20Blue-Green=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=A0=84=ED=99=98=20=EB=B0=B0=ED=8F=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95=20(=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6999edad..3faffe8e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -136,11 +136,6 @@ jobs: docker pull ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest docker compose -f /home/ec2-user/deploy/docker-compose-${{ env.TARGET_UPSTREAM }}.yml up -d - # 컨테이너 존재 검증 - docker ps --format '{{.Names}}' | grep -q "^${{ env.TARGET_UPSTREAM }}$" - - # 컨테이너 직접 헬스체크 - curl -f http://localhost:${{ env.TARGET_PORT }}/api/v1/deploy/health-check EOF - name: 컨테이너 기동 대기 @@ -164,8 +159,8 @@ jobs: run: | ssh eatsfine-ec2 << 'EOF' set -e - docker ps --format '{{.Names}}' | grep -q "^${{ env.CURRENT_UPSTREAM }}$" && \ - docker stop ${{ env.CURRENT_UPSTREAM }} && docker rm ${{ env.CURRENT_UPSTREAM }} || true + docker stop ${{ env.CURRENT_UPSTREAM }} + docker rm ${{ env.CURRENT_UPSTREAM }} EOF - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 제거 if: always() From 5064ccee3922ad41b2b36d81d5fd8b8fa1fb8250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= <83066622+CokaNuri@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:28:45 +0900 Subject: [PATCH 089/169] Update database password in application-local.yml --- src/main/resources/application-local.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 56548126..54ca1a25 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -10,7 +10,7 @@ spring: datasource: url: jdbc:mysql://localhost:3306/eatsfine_local?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul username: root - password: 0766wjd! + password: password driver-class-name: com.mysql.cj.jdbc.Driver data: redis: @@ -22,4 +22,4 @@ spring: show-sql: true properties: hibernate: - format_sql: true \ No newline at end of file + format_sql: true From 7c8fb809029aaee2e19f77f83d23b0011678753c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Mon, 12 Jan 2026 18:29:47 +0900 Subject: [PATCH 090/169] =?UTF-8?q?[FEAT]=20:=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20DTO=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/BookingRequestDTO.java | 9 +++++++ .../dto/response/BookingResponseDTO.java | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java index c62ecb93..9a917a07 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.time.LocalTime; +import java.util.List; public class BookingRequestDTO { @@ -18,4 +19,12 @@ public record GetAvailableTableDTO( String seatsType ){} + public record CreateBookingDTO( + Long storeId, + LocalDate date, + LocalTime time, + Integer partySize, + List tableIds + ){} + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java index 117c1bac..bfb64a70 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java @@ -2,6 +2,8 @@ import lombok.Builder; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.LocalTime; import java.util.List; @@ -30,4 +32,26 @@ public record TableInfoDTO( int widthSpan, int heightSpan ){} + + @Builder + public record CreateBookingResultDTO( + Long bookingId, + // Long paymentId, // 결제 정보 추후 포함 + String status, + String storeName, + LocalDate date, + LocalTime time, + Integer partySize, + Integer totalDeposit, + List tables, + LocalDateTime createdAt // 예약 생성 시간 + ){} + + @Builder + public record BookingResultTableDTO( + Long tableId, + String tableNumber, + Integer tableSeats, + String seatsType + ){} } From 99b5233bc55c0dcfd301eefb58b0dd08aa109e33 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:12:01 +0900 Subject: [PATCH 091/169] =?UTF-8?q?[FIX]:=20NGINX=20Blue-Green=20=EC=A1=B4?= =?UTF-8?q?=EC=9E=AC=20=EC=97=AC=EB=B6=80=20=ED=99=95=EC=9D=B8=20=ED=9B=84?= =?UTF-8?q?=20stop=20=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3faffe8e..dcc1186d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -159,8 +159,12 @@ jobs: run: | ssh eatsfine-ec2 << 'EOF' set -e - docker stop ${{ env.CURRENT_UPSTREAM }} - docker rm ${{ env.CURRENT_UPSTREAM }} + if docker ps -a --format '{{.Names}}' | grep -q "^${{ env.CURRENT_UPSTREAM }}$"; then + docker stop ${{ env.CURRENT_UPSTREAM }} + docker rm ${{ env.CURRENT_UPSTREAM }} + else + echo "컨테이너 ${{ env.CURRENT_UPSTREAM }} 없음 - 스킵" + fi EOF - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 제거 if: always() From 3155f471911ea132e389c0edb45480bc6f51005d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Mon, 12 Jan 2026 19:14:23 +0900 Subject: [PATCH 092/169] =?UTF-8?q?[REFACTOR]=20:=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20DTO,=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../booking/controller/BookingController.java | 42 ++++++++++++------- .../dto/request/BookingRequestDTO.java | 33 ++++++++++----- .../booking/service/BookingQueryService.java | 5 ++- .../service/BookingQueryServiceImpl.java | 21 +++++----- 4 files changed, 64 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java index cddd4e66..b4d16152 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java @@ -1,12 +1,17 @@ package com.eatsfine.eatsfine.domain.booking.controller; +import com.eatsfine.eatsfine.domain.booking.dto.request.BookingRequestDTO; import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; +import com.eatsfine.eatsfine.domain.booking.service.BookingCommandService; import com.eatsfine.eatsfine.domain.booking.service.BookingQueryService; +import com.eatsfine.eatsfine.domain.user.entity.User; +import com.eatsfine.eatsfine.domain.user.repository.UserRepository; import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; @@ -14,6 +19,7 @@ import java.time.LocalDate; import java.time.LocalTime; +import java.util.List; @Tag(name = "Booking", description = "예약 관련 API") @RestController @@ -22,17 +28,18 @@ public class BookingController { private final BookingQueryService bookingQueryService; + private final BookingCommandService bookingCommandService; + private final UserRepository userRepository; @Operation(summary = "1단계: 예약 가능 시간대 조회" , description = "가게, 날짜, 인원수, 테이블 분리 가능 여부를 입력받아 예약 가능한 시간 목록 반환") @GetMapping("/stores/{storeId}/bookings/available-times") public ApiResponse getAvailableTimes( - @PathVariable Long storeId, - @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, - @RequestParam Integer partySize, - @RequestParam(defaultValue = "false") Boolean isSplitAccepted) { + @ModelAttribute @Valid BookingRequestDTO.GetAvailableTimeDTO dto, + @PathVariable Long storeId + ) { - return ApiResponse.onSuccess(bookingQueryService.getAvailableTimeSlots(storeId, date, partySize,isSplitAccepted)); + return ApiResponse.onSuccess(bookingQueryService.getAvailableTimeSlots(storeId, dto)); } @Operation(summary = "2단계: 예약 가능 테이블 조회" @@ -40,15 +47,22 @@ public ApiResponse getAvailableTimes( @GetMapping("/stores/{storeId}/bookings/available-tables") public ApiResponse getAvailableTables( @PathVariable Long storeId, - @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, - @Parameter(description = "예약 시간 (HH:mm)", example = "18:00") - @Schema(type = "string", example = "18:00", description = "HH:mm 형식으로 입력하세요.") - @RequestParam @DateTimeFormat(pattern = "HH:mm") LocalTime time, - @RequestParam Integer partySize, - @RequestParam Boolean isSplitAccepted, - @RequestParam(required = false) String seatsType) { - - return ApiResponse.onSuccess(bookingQueryService.getAvailableTables(storeId, date, time, partySize,isSplitAccepted,seatsType)); + @ModelAttribute @Valid BookingRequestDTO.GetAvailableTableDTO dto + ) { + + return ApiResponse.onSuccess(bookingQueryService.getAvailableTables(storeId, dto)); + } + + @Operation(summary = "예약 생성" , + description = "가게,날짜,시간,인원,테이블 정보를 입력받아 예약을 생성합니다.") + @PostMapping("stores/{storeId}/bookings") + public ApiResponse createBooking( + @PathVariable Long storeId, + @ModelAttribute @Valid BookingRequestDTO.CreateBookingDTO dto + ) { + + User user = userRepository.findById(1L).orElseThrow(); // 임시로 임의의 유저 사용 + return ApiResponse.onSuccess(bookingCommandService.createBooking(user, storeId, dto)); } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java index 9a917a07..b4d530f5 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java @@ -1,5 +1,12 @@ package com.eatsfine.eatsfine.domain.booking.dto.request; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.RequestParam; + import java.time.LocalDate; import java.time.LocalTime; import java.util.List; @@ -7,24 +14,28 @@ public class BookingRequestDTO { public record GetAvailableTimeDTO( - LocalDate date, - Integer partySize, - Boolean isSplitAccepted + @NotNull @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, + @NotNull @Min(1) Integer partySize, + @NotNull Boolean isSplitAccepted ){} public record GetAvailableTableDTO( - LocalDate date, - LocalTime time, - Integer partySize, + @NotNull @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, + @Parameter(description = "예약 시간 (HH:mm)", example = "18:00") + @Schema(type = "string", example = "18:00", description = "HH:mm 형식으로 입력하세요.") + @NotNull @DateTimeFormat(pattern = "HH:mm") LocalTime time, + @NotNull @Min(1) Integer partySize, + @NotNull Boolean isSplitAccepted, String seatsType ){} public record CreateBookingDTO( - Long storeId, - LocalDate date, - LocalTime time, - Integer partySize, - List tableIds + @NotNull @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, + @Parameter(description = "예약 시간 (HH:mm)", example = "18:00") + @Schema(type = "string", example = "18:00", description = "HH:mm 형식으로 입력하세요.") + @NotNull @DateTimeFormat(pattern = "HH:mm") LocalTime time, + @NotNull @Min(1) Integer partySize, + @NotNull List tableIds ){} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java index 423b8d28..cc9df066 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java @@ -1,5 +1,6 @@ package com.eatsfine.eatsfine.domain.booking.service; +import com.eatsfine.eatsfine.domain.booking.dto.request.BookingRequestDTO; import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; import java.time.LocalDate; @@ -7,7 +8,7 @@ public interface BookingQueryService { - BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, LocalDate date, Integer partySize,Boolean isSplitAccepted); + BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, BookingRequestDTO.GetAvailableTimeDTO dto); - BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize,Boolean isSplitAccepted, String seatsType); + BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, BookingRequestDTO.GetAvailableTableDTO dto); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java index 2a3f6f7a..8fe231e6 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java @@ -1,5 +1,6 @@ package com.eatsfine.eatsfine.domain.booking.service; +import com.eatsfine.eatsfine.domain.booking.dto.request.BookingRequestDTO; import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; import com.eatsfine.eatsfine.domain.booking.exception.BookingException; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; @@ -31,10 +32,10 @@ public class BookingQueryServiceImpl implements BookingQueryService { @Override @Transactional(readOnly = true) - public BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, LocalDate date, Integer partySize, Boolean isSplitAccepted) { + public BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, BookingRequestDTO.GetAvailableTimeDTO dto) { Store store = storeRepository.findById(storeId) .orElseThrow(() -> new BookingException(BookingErrorStatus._STORE_NOT_FOUND)); - BusinessHours hours = store.getBusinessHoursByDay(date.getDayOfWeek()); + BusinessHours hours = store.getBusinessHoursByDay(dto.date().getDayOfWeek()); List availableSlots = new ArrayList<>(); LocalTime currentTime = hours.getOpenTime(); @@ -42,7 +43,7 @@ public BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, Lo while (currentTime.isBefore(hours.getCloseTime())) { if (!isDuringBreakTime(hours, currentTime)) { - List reservedTableIds = bookingRepository.findReservedTableIds(storeId, date, currentTime); + List reservedTableIds = bookingRepository.findReservedTableIds(storeId, dto.date(), currentTime); List tableLayouts = store.getTableLayouts(); TableLayout activeTableLayout = tableLayouts.stream() @@ -51,7 +52,7 @@ public BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, Lo List activeTables = activeTableLayout.getTables(); - if (canAccommodate(activeTables, reservedTableIds, partySize, isSplitAccepted)) { + if (canAccommodate(activeTables, reservedTableIds, dto.partySize(), dto.isSplitAccepted())) { availableSlots.add(currentTime); } } @@ -88,28 +89,28 @@ private boolean isDuringBreakTime(BusinessHours hours, LocalTime time) { @Override @Transactional(readOnly = true) - public BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, LocalDate date, LocalTime time, Integer partySize,Boolean isSplitAccepted, String seatsType) { + public BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, BookingRequestDTO.GetAvailableTableDTO dto) { TableLayout activeTableLayout = tableLayoutRepository.findByStoreIdAndIsActiveTrue(storeId) .orElseThrow(() -> new BookingException(BookingErrorStatus._LAYOUT_NOT_FOUND)); - List reservedTableIds = bookingRepository.findReservedTableIds(storeId, date, time); + List reservedTableIds = bookingRepository.findReservedTableIds(storeId, dto.date(), dto.time()); List availableTables = activeTableLayout.getTables().stream() .filter(t -> !reservedTableIds.contains(t.getId())) .filter(t -> { // 사용자가 "분리 허용 안 함(false)"을 선택했다면, 테이블 크기가 인원수보다 커야 함 - if (isSplitAccepted != null && !isSplitAccepted) { - return t.getTableSeats() >= partySize; + if (dto.isSplitAccepted() != null && !dto.isSplitAccepted()) { + return t.getTableSeats() >= dto.partySize(); } // 사용자가 "분리 허용(true)"을 선택했다면 모든 빈 테이블 표시 return true; }) - .filter(t -> seatsType == null || (t.getSeatsType() != null && t.getSeatsType().name().equalsIgnoreCase(seatsType))) + .filter(t -> dto.seatsType() == null || dto.seatsType().isEmpty() || (t.getSeatsType() != null && t.getSeatsType().name().equalsIgnoreCase(dto.seatsType()))) .map(t -> BookingResponseDTO.TableInfoDTO.builder() .tableId(t.getId()) .tableNumber(t.getTableNumber()) .tableSeats(t.getTableSeats()) - .seatsType(t.getSeatsType().name()) + .seatsType(t.getSeatsType() != null ? t.getSeatsType().name() : null) .gridX(t.getGridX()) .gridY(t.getGridY()) .widthSpan(t.getWidthSpan()) From 80cba3f8c9a95fcaa39ba446953af38b41691d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Mon, 12 Jan 2026 21:41:10 +0900 Subject: [PATCH 093/169] =?UTF-8?q?[FEAT]=20:=20BookingErrorStatus=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/booking/status/BookingErrorStatus.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java index c58e91ae..a39a164f 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java @@ -14,7 +14,8 @@ public enum BookingErrorStatus implements BaseErrorCode { _BUSINESS_HOURS_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE4041", "해당 날짜의 영업시간 정보가 없습니다."), _LAYOUT_NOT_FOUND(HttpStatus.NOT_FOUND, "LAYOUT404", "가게의 활성화된 테이블 레이아웃이 없습니다."), _BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING404", "예약 정보를 찾을 수 없습니다."), - _INVALID_PARTY_SIZE(HttpStatus.BAD_REQUEST, "BOOKING4001", "인원 설정이 잘못되었습니다."); + _INVALID_PARTY_SIZE(HttpStatus.BAD_REQUEST, "BOOKING4001", "인원 설정이 잘못되었습니다."), + _ALREADY_RESERVED_TABLE(HttpStatus.CONFLICT, "BOOKING4091", "선택하신 테이블 중 이미 예약된 테이블이 포함되어 있습니다."); private final HttpStatus httpStatus; private final String code; From 3edd7a9e8f55e1de9a60a49e40226ebf38834850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Mon, 12 Jan 2026 21:41:52 +0900 Subject: [PATCH 094/169] =?UTF-8?q?[FEAT]=20:=20CreateBookingDTO=EC=97=90?= =?UTF-8?q?=20isSplitAccepted=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/booking/dto/request/BookingRequestDTO.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java index b4d530f5..de78751e 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java @@ -21,7 +21,6 @@ public record GetAvailableTimeDTO( public record GetAvailableTableDTO( @NotNull @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, - @Parameter(description = "예약 시간 (HH:mm)", example = "18:00") @Schema(type = "string", example = "18:00", description = "HH:mm 형식으로 입력하세요.") @NotNull @DateTimeFormat(pattern = "HH:mm") LocalTime time, @NotNull @Min(1) Integer partySize, @@ -31,11 +30,11 @@ public record GetAvailableTableDTO( public record CreateBookingDTO( @NotNull @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, - @Parameter(description = "예약 시간 (HH:mm)", example = "18:00") @Schema(type = "string", example = "18:00", description = "HH:mm 형식으로 입력하세요.") @NotNull @DateTimeFormat(pattern = "HH:mm") LocalTime time, @NotNull @Min(1) Integer partySize, - @NotNull List tableIds + @NotNull List tableIds, + @NotNull boolean isSplitAccepted ){} } From 529a55251187fa5c91719bb7d4f82bccac5aa1cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Mon, 12 Jan 2026 21:42:19 +0900 Subject: [PATCH 095/169] =?UTF-8?q?[FEAT]=20:=20createdAt=20JPA=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=84=A4=EC=A0=95=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=EC=95=A0=EB=85=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java b/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java index 54ed3395..1f38bf0f 100644 --- a/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java +++ b/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java @@ -4,9 +4,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @ConfigurationPropertiesScan @SpringBootApplication +@EnableJpaAuditing public class EatsfineApplication { public static void main(String[] args) { From 1d0acb7f684025de065c097fa1227d82a4558d8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Mon, 12 Jan 2026 21:43:15 +0900 Subject: [PATCH 096/169] =?UTF-8?q?[FEAT]=20:=20=EB=B9=84=EA=B4=80?= =?UTF-8?q?=EC=A0=81=20=EB=9D=BD=20=EC=A0=81=EC=9A=A9=ED=95=B4=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../storetable/repository/StoreTableRepository.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java index ad98bb98..306997c5 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java @@ -1,7 +1,19 @@ package com.eatsfine.eatsfine.domain.storetable.repository; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface StoreTableRepository extends JpaRepository { + + // 비관적 쓰기 락을 걸어 조회 + // 다른 트랜잭션이 이 테이블들을 수정하거나 동시에 락을 거는 것을 방지 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT st FROM StoreTable st WHERE st.id IN :ids") + List findAllByIdWithLock(@Param("ids") List ids); } From 19e542e930d57b8d981eabbb4f65fbb9f84cdd13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Mon, 12 Jan 2026 21:43:46 +0900 Subject: [PATCH 097/169] =?UTF-8?q?[FIX]=20:=20Post=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=20API=20ModelAttribute->RequestBody=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/booking/controller/BookingController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java index b4d16152..46ed8cd7 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java @@ -58,7 +58,7 @@ public ApiResponse getAvailableTables( @PostMapping("stores/{storeId}/bookings") public ApiResponse createBooking( @PathVariable Long storeId, - @ModelAttribute @Valid BookingRequestDTO.CreateBookingDTO dto + @RequestBody @Valid BookingRequestDTO.CreateBookingDTO dto ) { User user = userRepository.findById(1L).orElseThrow(); // 임시로 임의의 유저 사용 From 471711347ac50f7751dd9f9a90775dab5491e41a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Mon, 12 Jan 2026 21:44:11 +0900 Subject: [PATCH 098/169] =?UTF-8?q?[FIX]=20:=20boolingTables=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EC=97=90=20@Builder.Default=20=EC=95=A0=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/booking/entity/Booking.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java index 5ec34cb1..5039f62c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java @@ -34,7 +34,8 @@ public class Booking extends BaseEntity { @JoinColumn(name = "store_id", nullable = false) private Store store; - @OneToMany(mappedBy = "booking", cascade = CascadeType.ALL) + @Builder.Default + @OneToMany(mappedBy = "booking", cascade = CascadeType.ALL, orphanRemoval = true) private List bookingTables = new ArrayList<>(); @Column(name = "party_size", nullable = false) @@ -57,7 +58,15 @@ public class Booking extends BaseEntity { @Enumerated(EnumType.STRING) private BookingStatus status; - // 결제는 일단 보류 + public void addBookingTable(StoreTable storeTable) { + BookingTable bookingTable = BookingTable.builder() + .booking(this) + .storeTable(storeTable) + .build(); + this.bookingTables.add(bookingTable); + } + + // 결제는 일단 보류 // private PaymentType paymentType; From 0c155adf023a33ac42445b67b6ccbf6147ea49fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Mon, 12 Jan 2026 21:44:51 +0900 Subject: [PATCH 099/169] =?UTF-8?q?[FEAT]=20:=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/BookingCommandService.java | 10 +++ .../service/BookingCommandServiceImpl.java | 86 ++++++++++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java index f688edc5..1c1fd27a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java @@ -1,5 +1,15 @@ package com.eatsfine.eatsfine.domain.booking.service; +import com.eatsfine.eatsfine.domain.booking.dto.request.BookingRequestDTO; +import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; +import com.eatsfine.eatsfine.domain.user.entity.User; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + public interface BookingCommandService { + BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long storeId, BookingRequestDTO.CreateBookingDTO dto); + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java index eb7b4c92..836072bc 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java @@ -1,11 +1,95 @@ package com.eatsfine.eatsfine.domain.booking.service; +import com.eatsfine.eatsfine.domain.booking.dto.request.BookingRequestDTO; +import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; +import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.booking.entity.mapping.BookingTable; +import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; +import com.eatsfine.eatsfine.domain.booking.exception.BookingException; +import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; +import com.eatsfine.eatsfine.domain.booking.status.BookingErrorStatus; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.storetable.repository.StoreTableRepository; +import com.eatsfine.eatsfine.domain.user.entity.User; +import com.eatsfine.eatsfine.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.stream.Stream; @Service @RequiredArgsConstructor -public class BookingCommandServiceImpl { +public class BookingCommandServiceImpl implements BookingCommandService{ + + private final StoreRepository storeRepository; + private final StoreTableRepository storeTableRepository; + private final BookingRepository bookingRepository; + + @Override + @Transactional + public BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long storeId, BookingRequestDTO.CreateBookingDTO dto) { + + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + List selectedTables = storeTableRepository.findAllByIdWithLock(dto.tableIds()); + + //이미 예약된 테이블 있는지 최종 점검 + List reservedTableIds = bookingRepository.findReservedTableIds(storeId, dto.date(), dto.time()); + for (StoreTable storeTable : selectedTables) { + if (reservedTableIds.contains(storeTable.getId())) { + throw new BookingException(BookingErrorStatus._ALREADY_RESERVED_TABLE); + } + } + + int totalDeposit = store.getMinPrice() * dto.partySize(); // 자세한 예약금 로직은 추후 수정 + + + Booking booking = Booking.builder() + + .bookingDate(dto.date()) + .bookingTime(dto.time()) + .partySize(dto.partySize()) + .status(BookingStatus.PENDING) + .store(store) + .user(user) + .isSplitAccepted(dto.isSplitAccepted()) + .build(); + + selectedTables.forEach(booking::addBookingTable); + + Booking savedBooking = bookingRepository.save(booking); + + //BookingResponseDTO.BookingResultTableDTO로 변환 + List resultTableDTOS = savedBooking.getBookingTables().stream() + .map(BookingTable::getStoreTable) + .map(t -> BookingResponseDTO.BookingResultTableDTO.builder() + .tableId(t.getId()) + .tableNumber(t.getTableNumber()) + .tableSeats(t.getTableSeats()) + .seatsType(t.getSeatsType() != null ? t.getSeatsType().name() : null) + .build()) + .toList(); + return BookingResponseDTO.CreateBookingResultDTO.builder() + .bookingId(savedBooking.getId()) + .storeName(store.getStoreName()) + .date(savedBooking.getBookingDate()) + .time(savedBooking.getBookingTime()) + .partySize(savedBooking.getPartySize()) + .status(savedBooking.getStatus().name()) + .totalDeposit(totalDeposit) + .createdAt(savedBooking.getCreatedAt()) + .tables(resultTableDTOS) + .build(); + } } From 10b268218e2f7f0862236aa2771cc85ccbaedf8f Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:17:05 +0900 Subject: [PATCH 100/169] =?UTF-8?q?[FIX]:=20=EB=B0=B0=ED=8F=AC=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=ED=8C=90=EB=8B=A8=20=EB=A1=9C=EC=A7=81=EC=9D=84=20?= =?UTF-8?q?nginx=20upstream=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 91 +++++++++++++++--------------------- 1 file changed, 37 insertions(+), 54 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dcc1186d..23f2e7e6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,46 +48,40 @@ jobs: - name: 도커 이미지 푸시 run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest - - name: 배포 대상 포트/PROFILE 확인 + - name: SSH Key 설정 run: | - # curl 연결 실패 시(서버 꺼짐 등)에도 에러내지 않고 넘어감 (|| true) - response=$(curl -s -w "%{http_code}" "http://${{ secrets.LIVE_SERVER_IP }}/api/v1/deploy/health-check") || true - - echo "Response: $response" - - # 응답이 비어있거나(연결실패) 200이 아니면 - if [ -z "$response" ] || [ "${#response}" -lt 3 ]; then - STATUS="ERROR" - else - STATUS="${response: -3}" - BODY="${response::-3}" - fi - - echo "STATUS=$STATUS" - - if [ "$STATUS" = "200" ]; then - CURRENT_UPSTREAM="$BODY" - else - # 서버가 죽어있거나 응답이 없으면 기본적으로 green을 현재 상태로 보고 blue를 배포 - CURRENT_UPSTREAM="green" - fi - - echo "CURRENT_UPSTREAM=$CURRENT_UPSTREAM" >> $GITHUB_ENV - - if [ "$CURRENT_UPSTREAM" = "blue" ]; then - echo "CURRENT_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV - echo "STOPPED_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV - echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV - echo "TARGET_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV - elif [ "$CURRENT_UPSTREAM" = "green" ]; then - echo "CURRENT_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV - echo "STOPPED_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV - echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV - echo "TARGET_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV - else - echo "error" - exit 1 - fi + mkdir -p ~/.ssh + echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/eatsfine-ec2-key.pem + chmod 600 ~/.ssh/eatsfine-ec2-key.pem + echo "Host eatsfine-ec2" >> ~/.ssh/config + echo " HostName ${{ secrets.LIVE_SERVER_IP }}" >> ~/.ssh/config + echo " User ${{ secrets.EC2_USERNAME }}" >> ~/.ssh/config + echo " IdentityFile ~/.ssh/eatsfine-ec2-key.pem" >> ~/.ssh/config + echo " StrictHostKeyChecking no" >> ~/.ssh/config + + - name: 배포 대상 포트/PROFILE 확인 (nginx 기준) + run: | + ssh eatsfine-ec2 << 'EOF' + if [ -f /home/ec2-user/nginx/service-env.inc ]; then + CURRENT_UPSTREAM=$(awk '{print $3}' /home/ec2-user/nginx/service-env.inc | tr -d ';') + else + CURRENT_UPSTREAM="green" + fi + + echo "CURRENT_UPSTREAM=$CURRENT_UPSTREAM" + + if [ "$CURRENT_UPSTREAM" = "blue" ]; then + echo "CURRENT_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV + echo "STOPPED_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV + echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV + echo "TARGET_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV + else + echo "CURRENT_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV + echo "STOPPED_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV + echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV + echo "TARGET_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV + fi + EOF - name: GitHub Actions 실행자 IP 얻어오기 id: GITHUB_ACTIONS_IP @@ -106,17 +100,8 @@ jobs: --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ --ip-permissions \ 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ - 'IpProtocol=tcp,FromPort=${{ env.STOPPED_PORT }},ToPort=${{ env.STOPPED_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' - - name: SSH Key 설정 - run: | - mkdir -p ~/.ssh - echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/eatsfine-ec2-key.pem - chmod 600 ~/.ssh/eatsfine-ec2-key.pem - echo "Host eatsfine-ec2" >> ~/.ssh/config - echo " HostName ${{ secrets.LIVE_SERVER_IP }}" >> ~/.ssh/config - echo " User ${{ secrets.EC2_USERNAME }}" >> ~/.ssh/config - echo " IdentityFile ~/.ssh/eatsfine-ec2-key.pem" >> ~/.ssh/config - echo " StrictHostKeyChecking no" >> ~/.ssh/config + 'IpProtocol=tcp,FromPort=${{ env.TARGET_PORT }},ToPort=${{ env.TARGET_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' + - name: 도커 이미지 풀링 및 컨테이너 실행 run: | @@ -124,7 +109,7 @@ jobs: set -e CONFIG_DIR=/home/ec2-user/config/eatsfine - DEPLOY_DIR=/home/ec2-user/deploy + mkdir -p $CONFIG_DIR # 필요한 프로필 파일을 서버로 복사합니다. if [ "${{ env.TARGET_UPSTREAM }}" = "blue" ]; then @@ -162,8 +147,6 @@ jobs: if docker ps -a --format '{{.Names}}' | grep -q "^${{ env.CURRENT_UPSTREAM }}$"; then docker stop ${{ env.CURRENT_UPSTREAM }} docker rm ${{ env.CURRENT_UPSTREAM }} - else - echo "컨테이너 ${{ env.CURRENT_UPSTREAM }} 없음 - 스킵" fi EOF - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 제거 @@ -173,4 +156,4 @@ jobs: --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ --ip-permissions \ 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ - 'IpProtocol=tcp,FromPort=${{env.STOPPED_PORT}},ToPort=${{env.STOPPED_PORT}},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ No newline at end of file + 'IpProtocol=tcp,FromPort=${{env.TARGET_PORT}},ToPort=${{env.TARGET_PORT}},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ No newline at end of file From dd596c0dbd0144c0f8524d5e383ca0de342ae566 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 13 Jan 2026 00:17:01 +0900 Subject: [PATCH 101/169] =?UTF-8?q?[FIX]:=20deploy.yml=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=95=BD=EA=B0=84=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 44 +++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 23f2e7e6..caf0e685 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,6 +48,24 @@ jobs: - name: 도커 이미지 푸시 run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest + - name: GitHub Actions 실행자 IP 얻어오기 + id: GITHUB_ACTIONS_IP + uses: haythem/public-ip@v1.3 + + - name: AWS CLI 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: GitHub Actions - SSH 접근 허용 + run: | + aws ec2 authorize-security-group-ingress \ + --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ + --ip-permissions \ + 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' + - name: SSH Key 설정 run: | mkdir -p ~/.ssh @@ -68,39 +86,23 @@ jobs: CURRENT_UPSTREAM="green" fi - echo "CURRENT_UPSTREAM=$CURRENT_UPSTREAM" - if [ "$CURRENT_UPSTREAM" = "blue" ]; then - echo "CURRENT_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV - echo "STOPPED_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV + echo "CURRENT_UPSTREAM=blue" >> $GITHUB_ENV echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV echo "TARGET_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV else - echo "CURRENT_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV - echo "STOPPED_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV + echo "CURRENT_UPSTREAM=green" >> $GITHUB_ENV echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV echo "TARGET_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV fi EOF - - - name: GitHub Actions 실행자 IP 얻어오기 - id: GITHUB_ACTIONS_IP - uses: haythem/public-ip@v1.3 - - - name: AWS CLI 설정 - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ secrets.AWS_REGION }} - - - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 부여 + + - name: GitHub Actions - TARGET 포트 접근 허용 run: | aws ec2 authorize-security-group-ingress \ --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ --ip-permissions \ - 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ - 'IpProtocol=tcp,FromPort=${{ env.TARGET_PORT }},ToPort=${{ env.TARGET_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' + 'IpProtocol=tcp,FromPort=${{ env.TARGET_PORT }},ToPort=${{ env.TARGET_PORT }},IpRanges=[{CidrIp=${{ steps.gh-ip.outputs.ipv4 }}/32}]' - name: 도커 이미지 풀링 및 컨테이너 실행 From ed49ff0cdcc53c8a30eeb8e565afe99aebab6faf Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 13 Jan 2026 00:30:16 +0900 Subject: [PATCH 102/169] =?UTF-8?q?[FIX]:=20=EA=B8=B0=EC=A1=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B5=EA=B5=AC=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 125 +++++++++++++++++++++++------------ 1 file changed, 84 insertions(+), 41 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index caf0e685..f6d18c85 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -33,7 +33,7 @@ jobs: shell: bash - name: 프로젝트 빌드 - run: | + run: | ./gradlew build - name: DockerHub 로그인 @@ -48,6 +48,56 @@ jobs: - name: 도커 이미지 푸시 run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest + # ================= DEBUG START ================= + - name: 배포 대상 포트/PROFILE 확인 (DEBUG) + run: | + set +e + echo "===== [DEBUG] deploy target check =====" + echo "curl -I 결과:" + curl -I "http://${{ secrets.LIVE_SERVER_IP }}/api/v1/deploy/health-check" || true + + response=$(curl -s -w "%{http_code}" "http://${{ secrets.LIVE_SERVER_IP }}/api/v1/deploy/health-check") || true + echo "Response(raw): [$response]" + echo "Response(length): ${#response}" + + if [ -z "$response" ] || [ "${#response}" -lt 3 ]; then + STATUS="ERROR" + BODY="" + else + STATUS="${response: -3}" + BODY="${response::-3}" + fi + + echo "STATUS=$STATUS" + echo "BODY=[$BODY]" + + if [ "$STATUS" = "200" ]; then + CURRENT_UPSTREAM="$BODY" + else + CURRENT_UPSTREAM="green" + fi + + echo "CURRENT_UPSTREAM=$CURRENT_UPSTREAM" >> $GITHUB_ENV + + if [ "$CURRENT_UPSTREAM" = "blue" ]; then + echo "CURRENT_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV + echo "STOPPED_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV + echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV + echo "TARGET_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV + elif [ "$CURRENT_UPSTREAM" = "green" ]; then + echo "CURRENT_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV + echo "STOPPED_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV + echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV + echo "TARGET_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV + else + echo "error" + exit 1 + fi + + echo "===== [DEBUG] env decided =====" + echo "CURRENT_UPSTREAM=$CURRENT_UPSTREAM" + # ================= DEBUG END ================= + - name: GitHub Actions 실행자 IP 얻어오기 id: GITHUB_ACTIONS_IP uses: haythem/public-ip@v1.3 @@ -59,12 +109,20 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }} - - name: GitHub Actions - SSH 접근 허용 + - name: [DEBUG] ENV 확인 + run: | + echo "CURRENT_UPSTREAM=${{ env.CURRENT_UPSTREAM }}" + echo "TARGET_UPSTREAM=${{ env.TARGET_UPSTREAM }}" + echo "TARGET_PORT=${{ env.TARGET_PORT }}" + echo "STOPPED_PORT=${{ env.STOPPED_PORT }}" + + - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 부여 run: | aws ec2 authorize-security-group-ingress \ --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ --ip-permissions \ - 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' + 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ + 'IpProtocol=tcp,FromPort=${{ env.STOPPED_PORT }},ToPort=${{ env.STOPPED_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' - name: SSH Key 설정 run: | @@ -77,52 +135,31 @@ jobs: echo " IdentityFile ~/.ssh/eatsfine-ec2-key.pem" >> ~/.ssh/config echo " StrictHostKeyChecking no" >> ~/.ssh/config - - name: 배포 대상 포트/PROFILE 확인 (nginx 기준) + - name: 도커 이미지 풀링 및 컨테이너 실행 (DEBUG) run: | ssh eatsfine-ec2 << 'EOF' - if [ -f /home/ec2-user/nginx/service-env.inc ]; then - CURRENT_UPSTREAM=$(awk '{print $3}' /home/ec2-user/nginx/service-env.inc | tr -d ';') - else - CURRENT_UPSTREAM="green" - fi - - if [ "$CURRENT_UPSTREAM" = "blue" ]; then - echo "CURRENT_UPSTREAM=blue" >> $GITHUB_ENV - echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV - echo "TARGET_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV - else - echo "CURRENT_UPSTREAM=green" >> $GITHUB_ENV - echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV - echo "TARGET_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV - fi - EOF - - - name: GitHub Actions - TARGET 포트 접근 허용 - run: | - aws ec2 authorize-security-group-ingress \ - --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ - --ip-permissions \ - 'IpProtocol=tcp,FromPort=${{ env.TARGET_PORT }},ToPort=${{ env.TARGET_PORT }},IpRanges=[{CidrIp=${{ steps.gh-ip.outputs.ipv4 }}/32}]' - + set +e + echo "===== [DEBUG] docker before =====" + docker ps -a - - name: 도커 이미지 풀링 및 컨테이너 실행 - run: | - ssh eatsfine-ec2 << 'EOF' - set -e - CONFIG_DIR=/home/ec2-user/config/eatsfine mkdir -p $CONFIG_DIR - - # 필요한 프로필 파일을 서버로 복사합니다. + if [ "${{ env.TARGET_UPSTREAM }}" = "blue" ]; then echo "${{ secrets.APPLICATION_BLUE_YML }}" | base64 --decode > ${CONFIG_DIR}/application-blue.yml else echo "${{ secrets.APPLICATION_GREEN_YML }}" | base64 --decode > ${CONFIG_DIR}/application-green.yml fi - + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest docker compose -f /home/ec2-user/deploy/docker-compose-${{ env.TARGET_UPSTREAM }}.yml up -d - + echo "compose exit code: $?" + + echo "===== [DEBUG] docker after =====" + docker ps -a + + echo "===== [DEBUG] target logs =====" + docker logs --tail 200 ${{ env.TARGET_UPSTREAM }} || true EOF - name: 컨테이너 기동 대기 @@ -135,22 +172,28 @@ jobs: max-attempts: 10 retry-delay: 10s - - name: Nginx 의 대상 서버를 새로 실행한 컨테이너쪽으로 전환 + - name: Nginx 의 대상 서버를 새로 실행한 컨테이너쪽으로 전환 (DEBUG) run: | ssh eatsfine-ec2 << 'EOF' - set -e + echo "===== [DEBUG] nginx before =====" + cat /home/ec2-user/nginx/service-env.inc || true echo "set \$service_url ${{ env.TARGET_UPSTREAM }};" > /home/ec2-user/nginx/service-env.inc + cat /home/ec2-user/nginx/service-env.inc + docker exec nginx nginx -t docker exec nginx nginx -s reload EOF + - name: 기존 배포 컨테이너 정지 run: | ssh eatsfine-ec2 << 'EOF' - set -e if docker ps -a --format '{{.Names}}' | grep -q "^${{ env.CURRENT_UPSTREAM }}$"; then docker stop ${{ env.CURRENT_UPSTREAM }} docker rm ${{ env.CURRENT_UPSTREAM }} + else + echo "컨테이너 ${{ env.CURRENT_UPSTREAM }} 없음 - 스킵" fi EOF + - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 제거 if: always() run: | @@ -158,4 +201,4 @@ jobs: --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ --ip-permissions \ 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ - 'IpProtocol=tcp,FromPort=${{env.TARGET_PORT}},ToPort=${{env.TARGET_PORT}},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ No newline at end of file + 'IpProtocol=tcp,FromPort=${{env.STOPPED_PORT}},ToPort=${{env.STOPPED_PORT}},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' From 1839046763935c8714b654a01af4bd16720f2e24 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 13 Jan 2026 00:39:14 +0900 Subject: [PATCH 103/169] =?UTF-8?q?[FIX]:=20=EA=B8=B0=EC=A1=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 81 +++++++++++------------------------- 1 file changed, 25 insertions(+), 56 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f6d18c85..5e6d522e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,37 +48,32 @@ jobs: - name: 도커 이미지 푸시 run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest - # ================= DEBUG START ================= - - name: 배포 대상 포트/PROFILE 확인 (DEBUG) + - name: 배포 대상 포트/PROFILE 확인 run: | - set +e - echo "===== [DEBUG] deploy target check =====" - echo "curl -I 결과:" - curl -I "http://${{ secrets.LIVE_SERVER_IP }}/api/v1/deploy/health-check" || true - + # curl 연결 실패 시(서버 꺼짐 등)에도 에러내지 않고 넘어감 (|| true) response=$(curl -s -w "%{http_code}" "http://${{ secrets.LIVE_SERVER_IP }}/api/v1/deploy/health-check") || true - echo "Response(raw): [$response]" - echo "Response(length): ${#response}" - + + echo "Response: $response" + + # 응답이 비어있거나(연결실패) 200이 아니면 if [ -z "$response" ] || [ "${#response}" -lt 3 ]; then STATUS="ERROR" - BODY="" else STATUS="${response: -3}" BODY="${response::-3}" fi - + echo "STATUS=$STATUS" - echo "BODY=[$BODY]" - + if [ "$STATUS" = "200" ]; then CURRENT_UPSTREAM="$BODY" else + # 서버가 죽어있거나 응답이 없으면 기본적으로 green을 현재 상태로 보고 blue를 배포 CURRENT_UPSTREAM="green" fi - + echo "CURRENT_UPSTREAM=$CURRENT_UPSTREAM" >> $GITHUB_ENV - + if [ "$CURRENT_UPSTREAM" = "blue" ]; then echo "CURRENT_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV echo "STOPPED_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV @@ -94,10 +89,6 @@ jobs: exit 1 fi - echo "===== [DEBUG] env decided =====" - echo "CURRENT_UPSTREAM=$CURRENT_UPSTREAM" - # ================= DEBUG END ================= - - name: GitHub Actions 실행자 IP 얻어오기 id: GITHUB_ACTIONS_IP uses: haythem/public-ip@v1.3 @@ -109,13 +100,6 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }} - - name: [DEBUG] ENV 확인 - run: | - echo "CURRENT_UPSTREAM=${{ env.CURRENT_UPSTREAM }}" - echo "TARGET_UPSTREAM=${{ env.TARGET_UPSTREAM }}" - echo "TARGET_PORT=${{ env.TARGET_PORT }}" - echo "STOPPED_PORT=${{ env.STOPPED_PORT }}" - - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 부여 run: | aws ec2 authorize-security-group-ingress \ @@ -123,7 +107,6 @@ jobs: --ip-permissions \ 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ 'IpProtocol=tcp,FromPort=${{ env.STOPPED_PORT }},ToPort=${{ env.STOPPED_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' - - name: SSH Key 설정 run: | mkdir -p ~/.ssh @@ -135,31 +118,24 @@ jobs: echo " IdentityFile ~/.ssh/eatsfine-ec2-key.pem" >> ~/.ssh/config echo " StrictHostKeyChecking no" >> ~/.ssh/config - - name: 도커 이미지 풀링 및 컨테이너 실행 (DEBUG) + - name: 도커 이미지 풀링 및 컨테이너 실행 run: | ssh eatsfine-ec2 << 'EOF' - set +e - echo "===== [DEBUG] docker before =====" - docker ps -a - + set -e + CONFIG_DIR=/home/ec2-user/config/eatsfine - mkdir -p $CONFIG_DIR - + DEPLOY_DIR=/home/ec2-user/deploy + + # 필요한 프로필 파일을 서버로 복사합니다. if [ "${{ env.TARGET_UPSTREAM }}" = "blue" ]; then echo "${{ secrets.APPLICATION_BLUE_YML }}" | base64 --decode > ${CONFIG_DIR}/application-blue.yml else echo "${{ secrets.APPLICATION_GREEN_YML }}" | base64 --decode > ${CONFIG_DIR}/application-green.yml fi - + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest docker compose -f /home/ec2-user/deploy/docker-compose-${{ env.TARGET_UPSTREAM }}.yml up -d - echo "compose exit code: $?" - - echo "===== [DEBUG] docker after =====" - docker ps -a - - echo "===== [DEBUG] target logs =====" - docker logs --tail 200 ${{ env.TARGET_UPSTREAM }} || true + EOF - name: 컨테이너 기동 대기 @@ -172,28 +148,21 @@ jobs: max-attempts: 10 retry-delay: 10s - - name: Nginx 의 대상 서버를 새로 실행한 컨테이너쪽으로 전환 (DEBUG) + - name: Nginx 의 대상 서버를 새로 실행한 컨테이너쪽으로 전환 run: | ssh eatsfine-ec2 << 'EOF' - echo "===== [DEBUG] nginx before =====" - cat /home/ec2-user/nginx/service-env.inc || true + set -e echo "set \$service_url ${{ env.TARGET_UPSTREAM }};" > /home/ec2-user/nginx/service-env.inc - cat /home/ec2-user/nginx/service-env.inc - docker exec nginx nginx -t docker exec nginx nginx -s reload EOF - - name: 기존 배포 컨테이너 정지 run: | ssh eatsfine-ec2 << 'EOF' - if docker ps -a --format '{{.Names}}' | grep -q "^${{ env.CURRENT_UPSTREAM }}$"; then - docker stop ${{ env.CURRENT_UPSTREAM }} - docker rm ${{ env.CURRENT_UPSTREAM }} - else - echo "컨테이너 ${{ env.CURRENT_UPSTREAM }} 없음 - 스킵" - fi + set -e + # 컨테이너가 없어도 에러내지 않고 넘어감 (|| true) + docker stop ${{ env.CURRENT_UPSTREAM }} || true + docker rm ${{ env.CURRENT_UPSTREAM }} || true EOF - - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 제거 if: always() run: | From ae36c102eb0dda348d8558b21d576ea7b90825d1 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 13 Jan 2026 00:51:57 +0900 Subject: [PATCH 104/169] =?UTF-8?q?[FIX]:=20=EB=B0=B0=ED=8F=AC=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20Nginx=20=EB=A6=AC=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B2=BD=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5e6d522e..507b38a3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -152,8 +152,8 @@ jobs: run: | ssh eatsfine-ec2 << 'EOF' set -e - echo "set \$service_url ${{ env.TARGET_UPSTREAM }};" > /home/ec2-user/nginx/service-env.inc - docker exec nginx nginx -s reload + # 컨테이너 내부의 파일에 직접 쓰기 (sh 사용, 경로 이슈 해결) + docker exec -i nginx sh -c 'echo "set \$service_url ${{ env.TARGET_UPSTREAM }};" > /etc/nginx/conf.d/service-env.inc && nginx -s reload' EOF - name: 기존 배포 컨테이너 정지 run: | From 5333ee752abdd8632292debd857f0bf928c55840 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 13 Jan 2026 01:22:59 +0900 Subject: [PATCH 105/169] =?UTF-8?q?[FIX]:=20HTTPS=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B9=A8=EC=A7=80=EB=8D=98=20blue-green?= =?UTF-8?q?=20=EB=B0=B0=ED=8F=AC=20=ED=8C=90=EB=8B=A8=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 58 +++++++++++------------------------- 1 file changed, 17 insertions(+), 41 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 507b38a3..11ca6fb8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,46 +48,22 @@ jobs: - name: 도커 이미지 푸시 run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest - - name: 배포 대상 포트/PROFILE 확인 + - name: 배포 대상 판단 (nginx 기준) run: | - # curl 연결 실패 시(서버 꺼짐 등)에도 에러내지 않고 넘어감 (|| true) - response=$(curl -s -w "%{http_code}" "http://${{ secrets.LIVE_SERVER_IP }}/api/v1/deploy/health-check") || true - - echo "Response: $response" - - # 응답이 비어있거나(연결실패) 200이 아니면 - if [ -z "$response" ] || [ "${#response}" -lt 3 ]; then - STATUS="ERROR" - else - STATUS="${response: -3}" - BODY="${response::-3}" - fi - - echo "STATUS=$STATUS" - - if [ "$STATUS" = "200" ]; then - CURRENT_UPSTREAM="$BODY" - else - # 서버가 죽어있거나 응답이 없으면 기본적으로 green을 현재 상태로 보고 blue를 배포 - CURRENT_UPSTREAM="green" - fi - - echo "CURRENT_UPSTREAM=$CURRENT_UPSTREAM" >> $GITHUB_ENV - - if [ "$CURRENT_UPSTREAM" = "blue" ]; then - echo "CURRENT_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV - echo "STOPPED_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV - echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV - echo "TARGET_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV - elif [ "$CURRENT_UPSTREAM" = "green" ]; then - echo "CURRENT_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV - echo "STOPPED_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV - echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV - echo "TARGET_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV - else - echo "error" - exit 1 - fi + ssh eatsfine-ec2 << 'EOF' + set -e + CURRENT_UPSTREAM=$(awk '{print $3}' /home/ec2-user/nginx/service-env.inc | tr -d ';') + + echo "CURRENT_UPSTREAM=$CURRENT_UPSTREAM" >> $GITHUB_ENV + + if [ "$CURRENT_UPSTREAM" = "blue" ]; then + echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV + echo "TARGET_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV + else + echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV + echo "TARGET_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV + fi + EOF - name: GitHub Actions 실행자 IP 얻어오기 id: GITHUB_ACTIONS_IP @@ -106,7 +82,7 @@ jobs: --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ --ip-permissions \ 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ - 'IpProtocol=tcp,FromPort=${{ env.STOPPED_PORT }},ToPort=${{ env.STOPPED_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' + 'IpProtocol=tcp,FromPort=${{ env.TARGET_PORT }},ToPort=${{ env.TARGET_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' - name: SSH Key 설정 run: | mkdir -p ~/.ssh @@ -170,4 +146,4 @@ jobs: --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ --ip-permissions \ 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ - 'IpProtocol=tcp,FromPort=${{env.STOPPED_PORT}},ToPort=${{env.STOPPED_PORT}},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' + 'IpProtocol=tcp,FromPort=${{env.TARGET_PORT}},ToPort=${{env.TARGET_PORT}},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' From 73d45fa22ce69836b84f6715db71755ba4381759 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 13 Jan 2026 01:30:50 +0900 Subject: [PATCH 106/169] =?UTF-8?q?[FIX]:=20=EB=A1=9C=EC=A7=81=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 11ca6fb8..50f47322 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,6 +48,17 @@ jobs: - name: 도커 이미지 푸시 run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest + - name: SSH Key 설정 + run: | + mkdir -p ~/.ssh + echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/eatsfine-ec2-key.pem + chmod 600 ~/.ssh/eatsfine-ec2-key.pem + echo "Host eatsfine-ec2" >> ~/.ssh/config + echo " HostName ${{ secrets.LIVE_SERVER_IP }}" >> ~/.ssh/config + echo " User ${{ secrets.EC2_USERNAME }}" >> ~/.ssh/config + echo " IdentityFile ~/.ssh/eatsfine-ec2-key.pem" >> ~/.ssh/config + echo " StrictHostKeyChecking no" >> ~/.ssh/config + - name: 배포 대상 판단 (nginx 기준) run: | ssh eatsfine-ec2 << 'EOF' @@ -83,16 +94,7 @@ jobs: --ip-permissions \ 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ 'IpProtocol=tcp,FromPort=${{ env.TARGET_PORT }},ToPort=${{ env.TARGET_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' - - name: SSH Key 설정 - run: | - mkdir -p ~/.ssh - echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/eatsfine-ec2-key.pem - chmod 600 ~/.ssh/eatsfine-ec2-key.pem - echo "Host eatsfine-ec2" >> ~/.ssh/config - echo " HostName ${{ secrets.LIVE_SERVER_IP }}" >> ~/.ssh/config - echo " User ${{ secrets.EC2_USERNAME }}" >> ~/.ssh/config - echo " IdentityFile ~/.ssh/eatsfine-ec2-key.pem" >> ~/.ssh/config - echo " StrictHostKeyChecking no" >> ~/.ssh/config + - name: 도커 이미지 풀링 및 컨테이너 실행 run: | From b73fe1306335ecdd4f3f10cdeef69a60239476a8 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 13 Jan 2026 01:44:03 +0900 Subject: [PATCH 107/169] =?UTF-8?q?[FIX]:=20=EB=A1=9C=EC=A7=81=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 50f47322..17979ea5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,6 +48,24 @@ jobs: - name: 도커 이미지 푸시 run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest + - name: GitHub Actions 실행자 IP 얻어오기 + id: GITHUB_ACTIONS_IP + uses: haythem/public-ip@v1.3 + + - name: AWS CLI 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: GitHub Actions - SSH 포트 임시 오픈 + run: | + aws ec2 authorize-security-group-ingress \ + --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ + --ip-permissions \ + 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' + - name: SSH Key 설정 run: | mkdir -p ~/.ssh @@ -76,25 +94,14 @@ jobs: fi EOF - - name: GitHub Actions 실행자 IP 얻어오기 - id: GITHUB_ACTIONS_IP - uses: haythem/public-ip@v1.3 - - - name: AWS CLI 설정 - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ secrets.AWS_REGION }} - - - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 부여 + - name: GitHub Actions - TARGET 컨테이너 포트 오픈 run: | aws ec2 authorize-security-group-ingress \ --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ --ip-permissions \ - 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ 'IpProtocol=tcp,FromPort=${{ env.TARGET_PORT }},ToPort=${{ env.TARGET_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' - + + - name: 도커 이미지 풀링 및 컨테이너 실행 run: | From 50fe3bb6dbc6e799d08af326a03d52b9f1699e23 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 13 Jan 2026 01:52:26 +0900 Subject: [PATCH 108/169] =?UTF-8?q?[FIX]:=20=EC=BD=94=EB=93=9C=20=EC=95=BD?= =?UTF-8?q?=EA=B0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 17979ea5..9f0a08a3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -79,20 +79,20 @@ jobs: - name: 배포 대상 판단 (nginx 기준) run: | - ssh eatsfine-ec2 << 'EOF' - set -e - CURRENT_UPSTREAM=$(awk '{print $3}' /home/ec2-user/nginx/service-env.inc | tr -d ';') + RESULT=$(ssh eatsfine-ec2 << 'EOF' + awk '{print $3}' /home/ec2-user/nginx/service-env.inc | tr -d ';' + EOF + ) - echo "CURRENT_UPSTREAM=$CURRENT_UPSTREAM" >> $GITHUB_ENV + echo "CURRENT_UPSTREAM=$RESULT" >> $GITHUB_ENV - if [ "$CURRENT_UPSTREAM" = "blue" ]; then + if [ "$RESULT" = "blue" ]; then echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV echo "TARGET_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV else echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV echo "TARGET_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV fi - EOF - name: GitHub Actions - TARGET 컨테이너 포트 오픈 run: | From c63b64eae6ee2c409e17082286573cec4ac7fd9c Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 13 Jan 2026 01:57:36 +0900 Subject: [PATCH 109/169] =?UTF-8?q?[FIX]:=20=EC=BD=94=EB=93=9C=20=EC=95=BD?= =?UTF-8?q?=EA=B0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9f0a08a3..c29fdb48 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -79,7 +79,7 @@ jobs: - name: 배포 대상 판단 (nginx 기준) run: | - RESULT=$(ssh eatsfine-ec2 << 'EOF' + RESULT=$(ssh -T eatsfine-ec2 << 'EOF' | tail -n 1 awk '{print $3}' /home/ec2-user/nginx/service-env.inc | tr -d ';' EOF ) From 964c100142f774d3dc3db87b1979a356b8d8b87a Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 13 Jan 2026 02:14:33 +0900 Subject: [PATCH 110/169] =?UTF-8?q?[FIX]:=20=EC=BD=94=EB=93=9C=20=EC=95=BD?= =?UTF-8?q?=EA=B0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c29fdb48..0ee5a155 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -144,9 +144,14 @@ jobs: run: | ssh eatsfine-ec2 << 'EOF' set -e - # 컨테이너가 없어도 에러내지 않고 넘어감 (|| true) - docker stop ${{ env.CURRENT_UPSTREAM }} || true - docker rm ${{ env.CURRENT_UPSTREAM }} || true + for C in blue green; do + if docker ps -a --format '{{.Names}}' | grep -q "^$C$"; then + if [ "$C" != "${{ env.TARGET_UPSTREAM }}" ]; then + docker stop "$C" || true + docker rm "$C" || true + fi + fi + done EOF - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 제거 if: always() From d406aede2f40f2019db5657da445c5c0c85406f6 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 13 Jan 2026 02:54:01 +0900 Subject: [PATCH 111/169] =?UTF-8?q?[FIX]:=20=EC=BD=94=EB=93=9C=20=EC=95=BD?= =?UTF-8?q?=EA=B0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0ee5a155..89c3b277 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -79,20 +79,25 @@ jobs: - name: 배포 대상 판단 (nginx 기준) run: | - RESULT=$(ssh -T eatsfine-ec2 << 'EOF' | tail -n 1 - awk '{print $3}' /home/ec2-user/nginx/service-env.inc | tr -d ';' + CURRENT=$(ssh -T eatsfine-ec2 << 'EOF' + if docker ps --format '{{.Names}}' | grep -q '^blue$'; then + echo blue + else + echo green + fi EOF ) + + + echo "CURRENT_UPSTREAM=$CURRENT" >> $GITHUB_ENV - echo "CURRENT_UPSTREAM=$RESULT" >> $GITHUB_ENV - - if [ "$RESULT" = "blue" ]; then - echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV - echo "TARGET_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV - else - echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV - echo "TARGET_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV - fi + if [ "$CURRENT" = "blue" ]; then + echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV + echo "TARGET_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV + else + echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV + echo "TARGET_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV + fi - name: GitHub Actions - TARGET 컨테이너 포트 오픈 run: | @@ -120,7 +125,8 @@ jobs: docker pull ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest docker compose -f /home/ec2-user/deploy/docker-compose-${{ env.TARGET_UPSTREAM }}.yml up -d - + + docker ps EOF - name: 컨테이너 기동 대기 From 0cc15af1364cb6f7e8f95e3f76fd288c3b6b4c38 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 13 Jan 2026 02:58:36 +0900 Subject: [PATCH 112/169] =?UTF-8?q?[FIX]:=20=EC=BD=94=EB=93=9C=20=EC=95=BD?= =?UTF-8?q?=EA=B0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 89c3b277..9f53e4b4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -79,7 +79,7 @@ jobs: - name: 배포 대상 판단 (nginx 기준) run: | - CURRENT=$(ssh -T eatsfine-ec2 << 'EOF' + CURRENT=$(ssh -T eatsfine-ec2 << 'EOF' | tail -n 1 if docker ps --format '{{.Names}}' | grep -q '^blue$'; then echo blue else From 034d59a512390d5f5c08f4d3c4679d0a29de256c Mon Sep 17 00:00:00 2001 From: twodo0 Date: Wed, 14 Jan 2026 11:04:03 +0900 Subject: [PATCH 113/169] =?UTF-8?q?[BUILD]:=20QueryDSL=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80=20(.gitignore,=20build.gradle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ build.gradle | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/.gitignore b/.gitignore index a07b6aaa..cff6a590 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ src/main/resources/application.yml ### Gradle 설정 파일 제외 build.gradle + +# QueryDSL generated sources +/build/generated/ \ No newline at end of file diff --git a/build.gradle b/build.gradle index 4e82f3fc..87169b1c 100644 --- a/build.gradle +++ b/build.gradle @@ -37,7 +37,31 @@ dependencies { //swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.1' + + // Bean Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // QueryDSL (기본, Jakarta) + implementation "com.querydsl:querydsl-jpa:5.1.0:jakarta" + annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" +} +// --- QueryDSL --- +def generated = 'build/generated/sources/annotationProcessor/java/main' + +sourceSets { + main.java.srcDirs += [generated] +} + +tasks.withType(JavaCompile).configureEach { + options.generatedSourceOutputDirectory = file(generated) +} + +clean { + delete file(generated) } +/// --- QueryDSL tasks.named('test') { useJUnitPlatform() From 30128b5ccfc0d6c8a95815acbf6d00501c2e1f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 14 Jan 2026 15:13:51 +0900 Subject: [PATCH 114/169] =?UTF-8?q?[CHORE]=20:=20DB=5FPASSWORD=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=EB=A1=9C=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ src/main/resources/application-local.yml | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index cff6a590..fcdd2719 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,8 @@ out/ ### secret properties files ### /src/main/resources/application-blue.yml /src/main/resources/application-green.yml +/src/main/resources/application-local.yml + ### Application Config (개인 설정, 보안 정보) ### src/main/resources/application.yml diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 56548126..78dffa82 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -10,7 +10,7 @@ spring: datasource: url: jdbc:mysql://localhost:3306/eatsfine_local?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul username: root - password: 0766wjd! + password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver data: redis: @@ -22,4 +22,4 @@ spring: show-sql: true properties: hibernate: - format_sql: true \ No newline at end of file + format_sql: true From 49605dbe954ee1bdaf32ba4dcefadc9c5361eedd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 14 Jan 2026 15:14:58 +0900 Subject: [PATCH 115/169] =?UTF-8?q?[FEAT]=20:=20UserRepository=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/user/repository/UserRepository.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/repository/UserRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/user/repository/UserRepository.java index 6caff489..48f65767 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/user/repository/UserRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/repository/UserRepository.java @@ -1,4 +1,7 @@ package com.eatsfine.eatsfine.domain.user.repository; -public interface UserRepository { +import com.eatsfine.eatsfine.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { } From c50801d3cce4d7fefbf5afdc57bbb69d993dd975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 14 Jan 2026 15:41:28 +0900 Subject: [PATCH 116/169] =?UTF-8?q?[FEAT]=20:=20BookingErrorStatus=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/booking/status/BookingErrorStatus.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java index a39a164f..b5690932 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java @@ -15,7 +15,10 @@ public enum BookingErrorStatus implements BaseErrorCode { _LAYOUT_NOT_FOUND(HttpStatus.NOT_FOUND, "LAYOUT404", "가게의 활성화된 테이블 레이아웃이 없습니다."), _BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING404", "예약 정보를 찾을 수 없습니다."), _INVALID_PARTY_SIZE(HttpStatus.BAD_REQUEST, "BOOKING4001", "인원 설정이 잘못되었습니다."), - _ALREADY_RESERVED_TABLE(HttpStatus.CONFLICT, "BOOKING4091", "선택하신 테이블 중 이미 예약된 테이블이 포함되어 있습니다."); + _ALREADY_RESERVED_TABLE(HttpStatus.CONFLICT, "BOOKING4091", "선택하신 테이블 중 이미 예약된 테이블이 포함되어 있습니다."), + _ALREADY_CONFIRMED(HttpStatus.BAD_REQUEST,"BOOKING4002", "이미 확정된 예약입니다."), + _PAYMENT_AMOUNT_MISMATCH(HttpStatus.BAD_REQUEST, "BOOKING4003", "결제 금액이 일치하지 않습니다."); + private final HttpStatus httpStatus; private final String code; From cd5cc8bd054b6406ac1c861b56f722cf322596cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 14 Jan 2026 15:41:45 +0900 Subject: [PATCH 117/169] =?UTF-8?q?[FEAT]=20:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EA=B4=80=EB=A0=A8=20DTO=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/booking/dto/request/BookingRequestDTO.java | 7 +++++++ .../domain/booking/dto/response/BookingResponseDTO.java | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java index de78751e..0212258d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.RequestParam; @@ -37,4 +38,10 @@ public record CreateBookingDTO( @NotNull boolean isSplitAccepted ){} + public record PaymentConfirmDTO( + @NotNull Long bookingId, + @NotBlank String paymentKey, //결제 고유 키 + @NotNull Integer amount //실제 결제 금액 + ){} + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java index bfb64a70..ad1bf084 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java @@ -54,4 +54,13 @@ public record BookingResultTableDTO( Integer tableSeats, String seatsType ){} + + + @Builder + public record ConfirmPaymentResultDTO( + Long bookingId, + String status, // CONFIRMED + String paymentKey, // PG사 결제 키 + Integer amount // 최종 결제 금액 + ){} } From 4a261c99b41d1ada441bb5ec890625c6ba5a363d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 14 Jan 2026 17:01:46 +0900 Subject: [PATCH 118/169] =?UTF-8?q?[FEAT]=20:=20StoreErrorStatus=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/store/status/StoreErrorStatus.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java index f217fdf3..b52dda24 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java @@ -13,7 +13,8 @@ public enum StoreErrorStatus implements BaseErrorCode { _STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404", "해당하는 가게를 찾을 수 없습니다."), _STORE_DETAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE_DETAIL404", "가게 상세 정보를 찾을 수 없습니다."), - ; + _STORE_NOT_OPEN_ON_DAY(HttpStatus.NOT_FOUND,"STORE4041" , "해당 영업시간 정보를 찾을 수 없습니다."),; + private final HttpStatus httpStatus; private final String code; From 988ffd734c04cca37a34cf8a39e2244fcf2fc94d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 14 Jan 2026 17:03:21 +0900 Subject: [PATCH 119/169] =?UTF-8?q?[REFACTOR]=20:=20Swagger=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=9A=A9=20@ParameterObject=20=EC=95=A0?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../booking/controller/BookingController.java | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java index 46ed8cd7..9738b140 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java @@ -8,18 +8,13 @@ import com.eatsfine.eatsfine.domain.user.repository.UserRepository; import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.springframework.format.annotation.DateTimeFormat; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.web.bind.annotation.*; -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; + @Tag(name = "Booking", description = "예약 관련 API") @RestController @@ -35,7 +30,7 @@ public class BookingController { , description = "가게, 날짜, 인원수, 테이블 분리 가능 여부를 입력받아 예약 가능한 시간 목록 반환") @GetMapping("/stores/{storeId}/bookings/available-times") public ApiResponse getAvailableTimes( - @ModelAttribute @Valid BookingRequestDTO.GetAvailableTimeDTO dto, + @ParameterObject @ModelAttribute @Valid BookingRequestDTO.GetAvailableTimeDTO dto, @PathVariable Long storeId ) { @@ -46,7 +41,7 @@ public ApiResponse getAvailableTimes( , description = "선택한 시간대에 예약 가능한 구체적인 테이블 목록을 반환") @GetMapping("/stores/{storeId}/bookings/available-tables") public ApiResponse getAvailableTables( - @PathVariable Long storeId, + @ParameterObject @PathVariable Long storeId, @ModelAttribute @Valid BookingRequestDTO.GetAvailableTableDTO dto ) { @@ -58,11 +53,21 @@ public ApiResponse getAvailableTables( @PostMapping("stores/{storeId}/bookings") public ApiResponse createBooking( @PathVariable Long storeId, - @RequestBody @Valid BookingRequestDTO.CreateBookingDTO dto + @ParameterObject @RequestBody @Valid BookingRequestDTO.CreateBookingDTO dto ) { User user = userRepository.findById(1L).orElseThrow(); // 임시로 임의의 유저 사용 return ApiResponse.onSuccess(bookingCommandService.createBooking(user, storeId, dto)); } + @Operation(summary = "결제 완료 처리", + description = "결제 완료 후 결제 정보를 입력받아 예약 상태를 업데이트합니다.") + @PostMapping("/bookings/payments-confirm") + public ApiResponse confirmPayment( + @RequestBody @Valid BookingRequestDTO.PaymentConfirmDTO dto + ) { + + return ApiResponse.onSuccess(bookingCommandService.confirmPayment(dto)); + } + } From aca5023f31b6d1e95ec5a7f2ef12ba9084ebdc59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 14 Jan 2026 17:04:46 +0900 Subject: [PATCH 120/169] =?UTF-8?q?[REFACTOR]=20:=20=EC=98=81=EC=97=85?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=A0=95=EB=B3=B4=20=EC=97=86=EC=9D=84=20?= =?UTF-8?q?=EC=8B=9C=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../booking/service/BookingQueryServiceImpl.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java index 8fe231e6..62192a5d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java @@ -8,16 +8,15 @@ import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; import com.eatsfine.eatsfine.domain.table_layout.repository.TableLayoutRepository; -import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; -import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDate; + import java.time.LocalTime; import java.util.ArrayList; import java.util.List; @@ -35,8 +34,13 @@ public class BookingQueryServiceImpl implements BookingQueryService { public BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, BookingRequestDTO.GetAvailableTimeDTO dto) { Store store = storeRepository.findById(storeId) .orElseThrow(() -> new BookingException(BookingErrorStatus._STORE_NOT_FOUND)); + BusinessHours hours = store.getBusinessHoursByDay(dto.date().getDayOfWeek()); + if (hours == null) { + throw new BookingException(StoreErrorStatus._STORE_NOT_OPEN_ON_DAY); + } + List availableSlots = new ArrayList<>(); LocalTime currentTime = hours.getOpenTime(); @@ -48,7 +52,7 @@ public BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, Bo List tableLayouts = store.getTableLayouts(); TableLayout activeTableLayout = tableLayouts.stream() .filter(TableLayout::isActive).findFirst() - .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST)); + .orElseThrow(() -> new BookingException(BookingErrorStatus._LAYOUT_NOT_FOUND)); List activeTables = activeTableLayout.getTables(); @@ -75,7 +79,6 @@ private boolean canAccommodate(List allTables, List reservedTa int totalSeats = freeTables.stream().mapToInt(StoreTable::getTableSeats).sum(); return totalSeats >= partySize; } - return false; } From 824aa85e3dd5f2afe51efce11c0ef30ac1001687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 14 Jan 2026 17:05:58 +0900 Subject: [PATCH 121/169] =?UTF-8?q?[FEAT]=20:=20Booking=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=EC=97=90=20depositAmount=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20confirm=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/booking/entity/Booking.java | 6 ++-- .../service/BookingCommandService.java | 2 ++ .../service/BookingCommandServiceImpl.java | 31 ++++++++++++++++++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java index 5039f62c..7b9f6f90 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java @@ -66,8 +66,10 @@ public void addBookingTable(StoreTable storeTable) { this.bookingTables.add(bookingTable); } - // 결제는 일단 보류 - // private PaymentType paymentType; + private Integer depositAmount; + public void confirm() { + this.status = BookingStatus.CONFIRMED; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java index 1c1fd27a..9432df71 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java @@ -3,6 +3,7 @@ import com.eatsfine.eatsfine.domain.booking.dto.request.BookingRequestDTO; import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; import com.eatsfine.eatsfine.domain.user.entity.User; +import jakarta.validation.Valid; import java.time.LocalDate; import java.time.LocalTime; @@ -12,4 +13,5 @@ public interface BookingCommandService { BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long storeId, BookingRequestDTO.CreateBookingDTO dto); + BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(BookingRequestDTO.PaymentConfirmDTO dto); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java index 836072bc..a68af86b 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java @@ -54,7 +54,7 @@ public BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long s Booking booking = Booking.builder() - + .depositAmount(totalDeposit) .bookingDate(dto.date()) .bookingTime(dto.time()) .partySize(dto.partySize()) @@ -92,4 +92,33 @@ public BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long s .tables(resultTableDTOS) .build(); } + + @Override + @Transactional + public BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(BookingRequestDTO.PaymentConfirmDTO dto) { + + Booking booking = bookingRepository.findById(dto.bookingId()) + .orElseThrow(() -> new BookingException(BookingErrorStatus._BOOKING_NOT_FOUND)); + + //이미 예약이 확정됐는지 최종 확인 + if(booking.getStatus() == BookingStatus.CONFIRMED) { + throw new BookingException(BookingErrorStatus._ALREADY_CONFIRMED); + } + + // 예약 생성 시 설정된 예약금액과 결제 완료된 금액이 일치하는지 확인 + if(!booking.getDepositAmount().equals(dto.amount())) { + throw new BookingException(BookingErrorStatus._PAYMENT_AMOUNT_MISMATCH); + } + + //예약 상태 확정으로 변경 + booking.confirm(); + + + return BookingResponseDTO.ConfirmPaymentResultDTO.builder() + .bookingId(booking.getId()) + .status(booking.getStatus().name()) + .paymentKey(dto.paymentKey()) // 추후 변경 + .amount(booking.getDepositAmount()) + .build(); + } } From e0f5e633a747e4b22eff7ed4d80279bd8af9c16b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 14 Jan 2026 17:06:35 +0900 Subject: [PATCH 122/169] =?UTF-8?q?[FIX]=20:=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EA=B0=80=20PENDING=20=ED=98=B9=EC=9D=80=20CO?= =?UTF-8?q?NFIRMED=EC=9D=B8=20=ED=85=8C=EC=9D=B4=EB=B8=94=EC=9D=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/booking/repository/BookingRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java index d29334bb..a0f4c5e3 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java @@ -1,7 +1,7 @@ package com.eatsfine.eatsfine.domain.booking.repository; import com.eatsfine.eatsfine.domain.booking.entity.Booking; -import io.lettuce.core.dynamic.annotation.Param; +import org.springframework.data.repository.query.Param; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -17,6 +17,6 @@ public interface BookingRepository extends JpaRepository { "where b.store.id = :storeId " + "and b.bookingDate = :date " + "and b.bookingTime = :time " + - "and b.status = 'CONFIRMED'") + "and b.status IN ('CONFIRMED', 'PENDING')") List findReservedTableIds(Long storeId, LocalDate date, LocalTime time); } From a3e0385268bc09a77adb6edcf1f86f6a62a2b02b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 14 Jan 2026 17:17:56 +0900 Subject: [PATCH 123/169] =?UTF-8?q?[FEAT]=20:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=B2=98=EB=A6=AC=20API=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/booking/controller/BookingController.java | 11 ++++++----- .../domain/booking/dto/request/BookingRequestDTO.java | 1 - .../domain/booking/service/BookingCommandService.java | 2 +- .../booking/service/BookingCommandServiceImpl.java | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java index 9738b140..bc91aa8d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java @@ -41,8 +41,8 @@ public ApiResponse getAvailableTimes( , description = "선택한 시간대에 예약 가능한 구체적인 테이블 목록을 반환") @GetMapping("/stores/{storeId}/bookings/available-tables") public ApiResponse getAvailableTables( - @ParameterObject @PathVariable Long storeId, - @ModelAttribute @Valid BookingRequestDTO.GetAvailableTableDTO dto + @PathVariable Long storeId, + @ParameterObject @ModelAttribute @Valid BookingRequestDTO.GetAvailableTableDTO dto ) { return ApiResponse.onSuccess(bookingQueryService.getAvailableTables(storeId, dto)); @@ -53,7 +53,7 @@ public ApiResponse getAvailableTables( @PostMapping("stores/{storeId}/bookings") public ApiResponse createBooking( @PathVariable Long storeId, - @ParameterObject @RequestBody @Valid BookingRequestDTO.CreateBookingDTO dto + @RequestBody @Valid BookingRequestDTO.CreateBookingDTO dto ) { User user = userRepository.findById(1L).orElseThrow(); // 임시로 임의의 유저 사용 @@ -62,12 +62,13 @@ public ApiResponse createBooking( @Operation(summary = "결제 완료 처리", description = "결제 완료 후 결제 정보를 입력받아 예약 상태를 업데이트합니다.") - @PostMapping("/bookings/payments-confirm") + @PatchMapping("/bookings/{bookingId}/payments-confirm") public ApiResponse confirmPayment( + @PathVariable Long bookingId, @RequestBody @Valid BookingRequestDTO.PaymentConfirmDTO dto ) { - return ApiResponse.onSuccess(bookingCommandService.confirmPayment(dto)); + return ApiResponse.onSuccess(bookingCommandService.confirmPayment(bookingId,dto)); } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java index 0212258d..6616d469 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java @@ -39,7 +39,6 @@ public record CreateBookingDTO( ){} public record PaymentConfirmDTO( - @NotNull Long bookingId, @NotBlank String paymentKey, //결제 고유 키 @NotNull Integer amount //실제 결제 금액 ){} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java index 9432df71..5eacc66b 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java @@ -13,5 +13,5 @@ public interface BookingCommandService { BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long storeId, BookingRequestDTO.CreateBookingDTO dto); - BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(BookingRequestDTO.PaymentConfirmDTO dto); + BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(Long BookingId, BookingRequestDTO.PaymentConfirmDTO dto); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java index a68af86b..e41b4371 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java @@ -95,9 +95,9 @@ public BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long s @Override @Transactional - public BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(BookingRequestDTO.PaymentConfirmDTO dto) { + public BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(Long bookingId, BookingRequestDTO.PaymentConfirmDTO dto) { - Booking booking = bookingRepository.findById(dto.bookingId()) + Booking booking = bookingRepository.findById(bookingId) .orElseThrow(() -> new BookingException(BookingErrorStatus._BOOKING_NOT_FOUND)); //이미 예약이 확정됐는지 최종 확인 @@ -117,7 +117,7 @@ public BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(BookingRequestD return BookingResponseDTO.ConfirmPaymentResultDTO.builder() .bookingId(booking.getId()) .status(booking.getStatus().name()) - .paymentKey(dto.paymentKey()) // 추후 변경 + .paymentKey(dto.paymentKey()) .amount(booking.getDepositAmount()) .build(); } From ab20a8e3ffdc4c2077e924b76483f96750a736aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Wed, 14 Jan 2026 18:05:46 +0900 Subject: [PATCH 124/169] =?UTF-8?q?[FIX]=20:=20JpaAuditing=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/eatsfine/eatsfine/EatsfineApplication.java | 2 +- .../eatsfine/eatsfine/global/config/JpaAuditConfig.java | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/global/config/JpaAuditConfig.java diff --git a/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java b/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java index 1f38bf0f..be1ae707 100644 --- a/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java +++ b/src/main/java/com/eatsfine/eatsfine/EatsfineApplication.java @@ -8,7 +8,7 @@ @ConfigurationPropertiesScan @SpringBootApplication -@EnableJpaAuditing +//@EnableJpaAuditing public class EatsfineApplication { public static void main(String[] args) { diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/JpaAuditConfig.java b/src/main/java/com/eatsfine/eatsfine/global/config/JpaAuditConfig.java new file mode 100644 index 00000000..c8e07b1d --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/config/JpaAuditConfig.java @@ -0,0 +1,9 @@ +package com.eatsfine.eatsfine.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditConfig { +} \ No newline at end of file From 0c3ace436b5236911986057a48f138c2944c7321 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Wed, 14 Jan 2026 20:55:32 +0900 Subject: [PATCH 125/169] =?UTF-8?q?[FEAT]=20Swagger=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/global/config/SwaggerConfig.java | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/SwaggerConfig.java b/src/main/java/com/eatsfine/eatsfine/global/config/SwaggerConfig.java index 004091c8..db0f57ea 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/config/SwaggerConfig.java +++ b/src/main/java/com/eatsfine/eatsfine/global/config/SwaggerConfig.java @@ -1,4 +1,5 @@ package com.eatsfine.eatsfine.global.config; + import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; @@ -13,30 +14,28 @@ @Configuration public class SwaggerConfig { - @Bean - public OpenAPI openAPI() { - final String securitySchemeName = "JWT"; + @Bean + public OpenAPI openAPI() { + final String securitySchemeName = "JWT"; - return new OpenAPI() - .info(new Info() - .title("Eatsfine API 명세서") - .description("Eatsfine 프로젝트의 Swagger 문서입니다.") - .version("1.0.0")) + return new OpenAPI() + .info(new Info() + .title("Eatsfine API 명세서") + .description("Eatsfine 프로젝트의 Swagger 문서입니다.") + .version("1.0.0")) - .servers(List.of( - new Server().url("http://localhost:8080").description("Local") - )) + .servers(List.of( + new Server().url("https://eatsfine.co.kr").description("Production"), + new Server().url("http://localhost:8080").description("Local"))) - .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) - .components(new Components() - .addSecuritySchemes(securitySchemeName, - new SecurityScheme() - .name("Authorization") - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT") - .in(SecurityScheme.In.HEADER) - ) - ); - } + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components() + .addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .name("Authorization") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER))); + } } \ No newline at end of file From fa36abc8b9a15dc136ee2c62380908a704f502c5 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:40:40 +0900 Subject: [PATCH 126/169] =?UTF-8?q?[FEAT]:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20Boo?= =?UTF-8?q?king=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/booking/entity/Booking.java | 8 ++- .../payment/controller/PaymentController.java | 30 ++++++++ .../dto/request/PaymentRequestDTO.java | 14 ++++ .../dto/response/PaymentResponseDTO.java | 20 ++++++ .../domain/payment/entity/Payment.java | 72 +++++++++++++++++++ .../domain/payment/enums/PaymentMethod.java | 15 ++++ .../domain/payment/enums/PaymentProvider.java | 13 ++++ .../domain/payment/enums/PaymentStatus.java | 15 ++++ .../domain/payment/enums/PaymentType.java | 13 ++++ .../payment/repository/PaymentRepository.java | 7 ++ .../payment/service/PaymentService.java | 67 +++++++++++++++++ .../apiPayload/code/status/ErrorStatus.java | 5 +- src/main/resources/application-local.yml | 8 +-- 13 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentProvider.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentType.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java index 7b9f6f90..386d7644 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java @@ -2,6 +2,7 @@ import com.eatsfine.eatsfine.domain.booking.entity.mapping.BookingTable; import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; +import com.eatsfine.eatsfine.domain.payment.entity.Payment; import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.user.entity.User; @@ -38,10 +39,14 @@ public class Booking extends BaseEntity { @OneToMany(mappedBy = "booking", cascade = CascadeType.ALL, orphanRemoval = true) private List bookingTables = new ArrayList<>(); + @Builder.Default + @OneToMany(mappedBy = "booking", cascade = CascadeType.ALL) // 결제 내역은 중요하므로 orphanRemoval은 신중하게, 우선 제외 + private List payments = new ArrayList<>(); + @Column(name = "party_size", nullable = false) private Integer partySize; - //테이블 분리 허용 여부 + // 테이블 분리 허용 여부 @Builder.Default @Column(name = "is_split_accepted", nullable = false) private boolean isSplitAccepted = false; @@ -54,7 +59,6 @@ public class Booking extends BaseEntity { @Column(name = "booking_time", nullable = false) private LocalTime bookingTime; - @Enumerated(EnumType.STRING) private BookingStatus status; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java new file mode 100644 index 00000000..d3284402 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java @@ -0,0 +1,30 @@ +package com.eatsfine.eatsfine.domain.payment.controller; + +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; +import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; +import com.eatsfine.eatsfine.domain.payment.service.PaymentService; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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; + +@Tag(name = "Payment", description = "결제 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/payments") +public class PaymentController { + + private final PaymentService paymentService; + + @Operation(summary = "결제 요청", description = "예약 ID와 결제 제공자를 받아 결제를 요청합니다.") + @PostMapping("/request") + public ApiResponse requestPayment( + @RequestBody @Valid PaymentRequestDTO.RequestPaymentDTO dto) { + return ApiResponse.onSuccess(paymentService.requestPayment(dto)); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java new file mode 100644 index 00000000..199e4f0d --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java @@ -0,0 +1,14 @@ +package com.eatsfine.eatsfine.domain.payment.dto.request; + +import com.eatsfine.eatsfine.domain.payment.enums.PaymentProvider; +import jakarta.validation.constraints.NotNull; + +public class PaymentRequestDTO { + + public record RequestPaymentDTO( + @NotNull Long bookingId, + @NotNull PaymentProvider provider, + @NotNull String successUrl, + @NotNull String failUrl) { + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java new file mode 100644 index 00000000..5f4fc62b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java @@ -0,0 +1,20 @@ +package com.eatsfine.eatsfine.domain.payment.dto.response; + +import com.eatsfine.eatsfine.domain.payment.enums.PaymentMethod; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; + +import java.time.LocalDateTime; + +public class PaymentResponseDTO { + + public record PaymentRequestResultDTO( + Long paymentId, + Long bookingId, + PaymentMethod paymentMethod, + String tid, + Integer amount, + PaymentStatus paymentStatus, + String nextRedirectUrl, + LocalDateTime requestedAt) { + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java new file mode 100644 index 00000000..8e2f8f8a --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -0,0 +1,72 @@ +package com.eatsfine.eatsfine.domain.payment.entity; + +import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentMethod; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentProvider; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentType; +import com.eatsfine.eatsfine.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "payment") +public class Payment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "payment_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "booking_id", nullable = false) + private Booking booking; + + @Column(name = "order_id", nullable = false) + private String orderId; + + @Column(name = "amount", nullable = false) + private Integer amount; + + @Column(name = "payment_key") + private String paymentKey; + + @Enumerated(EnumType.STRING) + @Column(name = "payment_provider", nullable = false) + private PaymentProvider paymentProvider; + + @Enumerated(EnumType.STRING) + @Column(name = "payment_method") + private PaymentMethod paymentMethod; + + @Enumerated(EnumType.STRING) + @Column(name = "payment_status", nullable = false) + private PaymentStatus paymentStatus; + + @Column(name = "requested_at", nullable = false) + private LocalDateTime requestedAt; + + @Column(name = "approved_at") + private LocalDateTime approvedAt; + + @Enumerated(EnumType.STRING) + @Column(name = "payment_type", nullable = false) + private PaymentType paymentType; + + public void setPaymentKey(String paymentKey) { + this.paymentKey = paymentKey; + } + + public void completePayment(LocalDateTime approvedAt, PaymentMethod method, String paymentKey) { + this.paymentStatus = PaymentStatus.COMPLETED; + this.approvedAt = approvedAt; + this.paymentMethod = method; + this.paymentKey = paymentKey; + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java new file mode 100644 index 00000000..07f47343 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java @@ -0,0 +1,15 @@ +package com.eatsfine.eatsfine.domain.payment.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PaymentMethod { + CARD("카드"), + VIRTUAL_ACCOUNT("가상계좌"), + SIMPLE_PAYMENT("간편결제"), + PHONE("휴대폰"); + + private final String description; +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentProvider.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentProvider.java new file mode 100644 index 00000000..7782c614 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentProvider.java @@ -0,0 +1,13 @@ +package com.eatsfine.eatsfine.domain.payment.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PaymentProvider { + KAKAOPAY("카카오페이"), + TOSS("토스"); + + private final String description; +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentStatus.java new file mode 100644 index 00000000..a2a1ae3b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentStatus.java @@ -0,0 +1,15 @@ +package com.eatsfine.eatsfine.domain.payment.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PaymentStatus { + PENDING("결제 대기"), + COMPLETED("결제 완료"), + FAILED("결제 실패"), + REFUNDED("환불 완료"); + + private final String description; +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentType.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentType.java new file mode 100644 index 00000000..38afc12f --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentType.java @@ -0,0 +1,13 @@ +package com.eatsfine.eatsfine.domain.payment.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PaymentType { + DEPOSIT("예약금"), + REFUND("환불"); + + private final String description; +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java new file mode 100644 index 00000000..3c2c17ce --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.payment.repository; + +import com.eatsfine.eatsfine.domain.payment.entity.Payment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PaymentRepository extends JpaRepository { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java new file mode 100644 index 00000000..b8ec2f27 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -0,0 +1,67 @@ +package com.eatsfine.eatsfine.domain.payment.service; + +import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; +import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; +import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; +import com.eatsfine.eatsfine.domain.payment.entity.Payment; + +import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentType; +import com.eatsfine.eatsfine.domain.payment.repository.PaymentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final BookingRepository bookingRepository; + + @Transactional + public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestDTO.RequestPaymentDTO dto) { + Booking booking = bookingRepository.findById(dto.bookingId()) + .orElseThrow(() -> new IllegalArgumentException("Booking not found")); + + // 주문 ID 생성 + String orderId = UUID.randomUUID().toString(); + + // 예약금 검증 + if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { + throw new GeneralException(ErrorStatus.PAYMENT_INVALID_DEPOSIT); + } + + Payment payment = Payment.builder() + .booking(booking) + .orderId(orderId) + .amount(booking.getDepositAmount()) + .paymentProvider(dto.provider()) + .paymentStatus(PaymentStatus.PENDING) + .paymentType(PaymentType.DEPOSIT) + .requestedAt(LocalDateTime.now()) + .build(); + + Payment savedPayment = paymentRepository.save(payment); + + // 외부 결제 제공자 응답 모의 처리 + String tid = "T" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); + String nextRedirectUrl = "https://mock.api.kakaopay.com/online/v1/payment/ready/" + tid; + + return new PaymentResponseDTO.PaymentRequestResultDTO( + savedPayment.getId(), + booking.getId(), + savedPayment.getPaymentMethod(), + tid, + savedPayment.getAmount(), + savedPayment.getPaymentStatus(), + nextRedirectUrl, + savedPayment.getRequestedAt()); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java index d0a46c44..fc94377e 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java @@ -13,7 +13,10 @@ public enum ErrorStatus implements BaseErrorCode { _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), - _NOT_FOUND(HttpStatus.NOT_FOUND,"COMMON404","존재하지 않는 요청입니다."); + _NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404", "존재하지 않는 요청입니다."), + + // 예약금 관련 에러 + PAYMENT_INVALID_DEPOSIT(HttpStatus.BAD_REQUEST, "PAYMENT4001", "예약금이 유효하지 않습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 78dffa82..ef35d7cf 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -8,14 +8,14 @@ spring: activate: on-profile: local datasource: - url: jdbc:mysql://localhost:3306/eatsfine_local?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul - username: root + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul + username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver data: redis: - host: localhost - port: 6379 + host: ${REDIS_HOST} + port: ${REDIS_PORT} jpa: hibernate: ddl-auto: update From 8213aaed3c14d4e0044173cd2c804ef76b24dffb Mon Sep 17 00:00:00 2001 From: twodo0 Date: Mon, 12 Jan 2026 09:34:08 +0900 Subject: [PATCH 127/169] =?UTF-8?q?[FEAT]:=20=EA=B0=80=EA=B2=8C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EB=B0=8F=20Region=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../region/exception/RegionException.java | 10 ++++++++++ .../domain/store/dto/StoreReqDto.java | 19 +++++++++++++++++++ .../domain/store/dto/StoreResDto.java | 8 ++++++++ 3 files changed, 37 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/region/exception/RegionException.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/region/exception/RegionException.java b/src/main/java/com/eatsfine/eatsfine/domain/region/exception/RegionException.java new file mode 100644 index 00000000..00636c22 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/region/exception/RegionException.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.region.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class RegionException extends GeneralException { + public RegionException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java index 7bace030..19d64f8b 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java @@ -1,6 +1,25 @@ package com.eatsfine.eatsfine.domain.store.dto; +import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursReqDto; +import com.eatsfine.eatsfine.domain.store.enums.Category; import lombok.Builder; +import java.util.List; + public class StoreReqDto { + + @Builder + public record StoreCreateDto( + String storeName, + String businessNumber, + String description, + Long regionId, + String address, + String phoneNumber, + Category category, + int bookingIntervalMinutes, + List businessHours, + int minPrice, + int maxPrice + ){} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java index d290d43d..3d9795b0 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java @@ -4,6 +4,7 @@ import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; import com.eatsfine.eatsfine.domain.businesshours.enums.DayOfWeek; import com.eatsfine.eatsfine.domain.store.enums.Category; +import com.eatsfine.eatsfine.domain.store.enums.StoreApprovalStatus; import lombok.Builder; import java.math.BigDecimal; @@ -12,6 +13,13 @@ public class StoreResDto { + @Builder + public record StoreCreateDto( + Long storeId, + StoreApprovalStatus status + ){} + + @Builder public record StoreDetailDto( Long storeId, From d7859029090b6e3c5bd5116f64f13358cd184e31 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Mon, 12 Jan 2026 10:49:30 +0900 Subject: [PATCH 128/169] =?UTF-8?q?[REFACTOR]:=20Store=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=B4=88=EA=B8=B0=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/eatsfine/eatsfine/domain/store/entity/Store.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index c0e73d24..05ff3b10 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -32,7 +32,7 @@ public class Store extends BaseEntity { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "owner_id", nullable = false) + @JoinColumn(name = "owner_id") // 임시 nullable 허용 (User 도메인 머지 후 owner 처리 예정) private User owner; @ManyToOne(fetch = FetchType.LAZY) @@ -42,6 +42,9 @@ public class Store extends BaseEntity { @Column(name = "store_name", nullable = false) private String storeName; + @Column(name = "business_number", nullable = false) + private String businessNumber; + @Lob @Column(name = "description", nullable = false) private String description; @@ -52,7 +55,7 @@ public class Store extends BaseEntity { @Column(name = "address", nullable = false) private String address; - @Column(name = "main_image_url", nullable = false) + @Column(name = "main_image_url") private String mainImageUrl; @Builder.Default @@ -100,7 +103,7 @@ public void addBusinessHours(BusinessHours businessHours) { public void removeBusinessHours(BusinessHours businessHours) { this.businessHours.remove(businessHours); - businessHours.assignStore(this); + businessHours.assignStore(null); } public void addTableImage(TableImage tableImage) { From 4d018d9853cad8043dc6c10ae0a7dd678b1e44c2 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Mon, 12 Jan 2026 11:07:45 +0900 Subject: [PATCH 129/169] =?UTF-8?q?[FEAT]:=20StoreConverter=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 --- .../store/converter/StoreConverter.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java new file mode 100644 index 00000000..82cb5ac3 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java @@ -0,0 +1,39 @@ +package com.eatsfine.eatsfine.domain.store.converter; + +import com.eatsfine.eatsfine.domain.businesshours.converter.BusinessHoursConverter; +import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; +import com.eatsfine.eatsfine.domain.store.entity.Store; + +import java.util.Collections; + +public class StoreConverter { + + public static StoreResDto.StoreCreateDto toCreateDto(Store store) { + return StoreResDto.StoreCreateDto.builder() + .storeId(store.getId()) + .status(store.getApprovalStatus()) + .build(); + } + + public static StoreResDto.StoreDetailDto toDetailDto(Store store) { + return StoreResDto.StoreDetailDto.builder() + .storeId(store.getId()) + .storeName(store.getStoreName()) + .description(store.getDescription()) + .address(store.getAddress()) + .phone(store.getPhoneNumber()) + .category(store.getCategory()) + .rating(store.getRating()) + .reviewCount(null) // reviewCount는 추후 리뷰 로직 구현 시 추가 예정 + .mainImage(store.getMainImageUrl()) + .tableImages(Collections.emptyList()) // tableImages는 추후 사진 등록 API 구현 시 추가 예정 + .businessHours( + store.getBusinessHours().stream() + .map(BusinessHoursConverter::toSummary) + .toList()) + .isOpenNow(false) // 추후 영업 여부 판단 로직 구현 예정 + .priceRange(null) // 추후 + .build(); + } + } +} From d7ef215bcac1fbe4c972853b5289d78c747167e5 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Mon, 12 Jan 2026 11:56:01 +0900 Subject: [PATCH 130/169] =?UTF-8?q?[FEAT]:=20Store/Region=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=20=EC=83=81=ED=83=9C=20=EC=BD=94=EB=93=9C=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 --- .../region/status/RegionErrorStatus.java | 39 ++++++++++++++++++ .../region/status/RegionSuccessStatus.java | 40 +++++++++++++++++++ .../store/status/StoreSuccessStatus.java | 3 ++ 3 files changed, 82 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/region/status/RegionErrorStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/region/status/RegionSuccessStatus.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/region/status/RegionErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/region/status/RegionErrorStatus.java new file mode 100644 index 00000000..36766aed --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/region/status/RegionErrorStatus.java @@ -0,0 +1,39 @@ +package com.eatsfine.eatsfine.domain.region.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum RegionErrorStatus implements BaseErrorCode { + + _REGION_NOT_FOUND(HttpStatus.NOT_FOUND, "REGION404", "해당 주소를 찾을 수 없습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + + @Override + public ErrorReasonDto getReason() { + return com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/region/status/RegionSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/region/status/RegionSuccessStatus.java new file mode 100644 index 00000000..7a935b78 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/region/status/RegionSuccessStatus.java @@ -0,0 +1,40 @@ +package com.eatsfine.eatsfine.domain.region.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum RegionSuccessStatus implements BaseErrorCode { + + _REGION_FOUND(HttpStatus.OK, "REGION200", "성공적으로 주소를 찾았습니다."), + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + + @Override + public ErrorReasonDto getReason() { + return com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java index cb6fc16b..720a0573 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java @@ -13,8 +13,11 @@ public enum StoreSuccessStatus implements BaseCode { _STORE_FOUND(HttpStatus.OK, "STORE200", "성공적으로 가게를 찾았습니다."), _STORE_DETAIL_FOUND(HttpStatus.FOUND, "STORE_DETAIL200", "성공적으로 가게 상세 리뷰를 조회했습니다."), + + _STORE_CREATED(HttpStatus.CREATED, "STORE201", "성공적으로 가게를 등록했습니다.") ; + private final HttpStatus httpStatus; private final String code; private final String message; From d8cd5e16c1e6d506fc497bdb1b5f74421ed164f9 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Mon, 12 Jan 2026 11:58:27 +0900 Subject: [PATCH 131/169] =?UTF-8?q?[FEAT]:=20=EA=B0=80=EA=B2=8C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EB=B0=8F=20=EC=98=81=EC=97=85=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=9A=94=EC=B2=AD=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/BusinessHoursConverter.java | 12 ++++ .../dto/BusinessHoursReqDto.java | 17 ++++++ .../store/controller/StoreController.java | 17 ++++-- .../store/converter/StoreConverter.java | 3 +- .../domain/store/dto/StoreReqDto.java | 4 +- .../domain/store/dto/StoreResDto.java | 4 +- .../eatsfine/domain/store/entity/Store.java | 6 -- .../store/service/StoreCommandService.java | 8 +++ .../service/StoreCommandServiceImpl.java | 56 +++++++++++++++++++ .../service/StoreDetailQueryServiceImpl.java | 20 +------ 10 files changed, 111 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java index 026ef2d8..ff7d01bf 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java @@ -1,10 +1,22 @@ package com.eatsfine.eatsfine.domain.businesshours.converter; +import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursReqDto; import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursResDto; import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; public class BusinessHoursConverter { + public static BusinessHours toEntity(BusinessHoursReqDto.Summary dto) { + return BusinessHours.builder() + .dayOfWeek(dto.dayOfWeek()) + .openTime(dto.openTime()) + .closeTime(dto.closeTime()) + .isHoliday(dto.isClosed()) // 특정 요일 고정 휴무 + .build(); + } + + + public static BusinessHoursResDto.Summary toSummary(BusinessHours bh) { // 휴무일 때 if(bh.isHoliday()) { diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java new file mode 100644 index 00000000..42ec0bbc --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java @@ -0,0 +1,17 @@ +package com.eatsfine.eatsfine.domain.businesshours.dto; + +import com.eatsfine.eatsfine.domain.businesshours.enums.DayOfWeek; +import lombok.Builder; + +import java.time.LocalTime; + +public class BusinessHoursReqDto { + + @Builder + public record Summary( + DayOfWeek dayOfWeek, + LocalTime openTime, + LocalTime closeTime, + boolean isClosed + ){} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java index 25d57b7f..0cd73f3a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java @@ -1,22 +1,31 @@ package com.eatsfine.eatsfine.domain.store.controller; +import com.eatsfine.eatsfine.domain.store.dto.StoreReqDto; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; +import com.eatsfine.eatsfine.domain.store.service.StoreCommandService; import com.eatsfine.eatsfine.domain.store.service.StoreDetailQueryService; import com.eatsfine.eatsfine.domain.store.status.StoreSuccessStatus; import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor @RequestMapping("/stores") public class StoreController { + private final StoreCommandService storeCommandService; private final StoreDetailQueryService storeDetailQueryService; + // 식당 등록 + @PostMapping + public ApiResponse createStore( + @RequestBody StoreReqDto.StoreCreateDto dto + ) { + return ApiResponse.of(StoreSuccessStatus._STORE_CREATED, storeCommandService.createStore(dto)); + } + + // 상세조회 @GetMapping("/{storeId}") public ApiResponse getStoreDetail(@PathVariable Long storeId) { return ApiResponse.of(StoreSuccessStatus._STORE_DETAIL_FOUND, storeDetailQueryService.getStoreDetail(storeId)); diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java index 82cb5ac3..5b79095d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java @@ -32,8 +32,7 @@ public static StoreResDto.StoreDetailDto toDetailDto(Store store) { .map(BusinessHoursConverter::toSummary) .toList()) .isOpenNow(false) // 추후 영업 여부 판단 로직 구현 예정 - .priceRange(null) // 추후 .build(); } } -} + diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java index 19d64f8b..c81d28f2 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java @@ -18,8 +18,6 @@ public record StoreCreateDto( String phoneNumber, Category category, int bookingIntervalMinutes, - List businessHours, - int minPrice, - int maxPrice + List businessHours ){} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java index 3d9795b0..7ef8e41d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java @@ -19,7 +19,6 @@ public record StoreCreateDto( StoreApprovalStatus status ){} - @Builder public record StoreDetailDto( Long storeId, @@ -33,8 +32,7 @@ public record StoreDetailDto( String mainImage, List tableImages, List businessHours, - boolean isOpenNow, - String priceRange + boolean isOpenNow ){} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index 05ff3b10..fed1487d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -66,12 +66,6 @@ public class Store extends BaseEntity { @Column(name = "category", nullable = false) private Category category; - @Column(name = "min_price", nullable = false) - private int minPrice; - - @Column(name = "max_price", nullable = false) - private int maxPrice; - @Enumerated(EnumType.STRING) @Column(name = "store_approval_status", nullable = false) private StoreApprovalStatus approvalStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java new file mode 100644 index 00000000..f79fe42f --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java @@ -0,0 +1,8 @@ +package com.eatsfine.eatsfine.domain.store.service; + +import com.eatsfine.eatsfine.domain.store.dto.StoreReqDto; +import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; + +public interface StoreCommandService { + StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto storeCreateDto); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java new file mode 100644 index 00000000..37691439 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java @@ -0,0 +1,56 @@ +package com.eatsfine.eatsfine.domain.store.service; + +import com.eatsfine.eatsfine.domain.businesshours.converter.BusinessHoursConverter; +import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import com.eatsfine.eatsfine.domain.region.entity.Region; +import com.eatsfine.eatsfine.domain.region.repository.RegionRepository; +import com.eatsfine.eatsfine.domain.region.status.RegionErrorStatus; +import com.eatsfine.eatsfine.domain.store.converter.StoreConverter; +import com.eatsfine.eatsfine.domain.store.dto.StoreReqDto; +import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.enums.StoreApprovalStatus; +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@Transactional +@RequiredArgsConstructor +public class StoreCommandServiceImpl implements StoreCommandService { + + private final StoreRepository storeRepository; + private final RegionRepository regionRepository; + + @Override + public StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto dto) { + Region region = regionRepository.findById(dto.regionId()) + .orElseThrow(() -> new StoreException(RegionErrorStatus._REGION_NOT_FOUND)); + + Store store = Store.builder() + .owner(null) // User 도메인 머지 후 owner 처리 예정 + .storeName(dto.storeName()) + .businessNumber(dto.businessNumber()) + .description(dto.description()) + .address(dto.address()) + .mainImageUrl(null) // 별도 API로 구현 + .region(region) + .phoneNumber(dto.phoneNumber()) + .category(dto.category()) + .approvalStatus(StoreApprovalStatus.PENDING) + .bookingIntervalMinutes(dto.bookingIntervalMinutes()) + .build(); + + dto.businessHours().forEach(bhDto -> { + BusinessHours businessHours = BusinessHoursConverter.toEntity(bhDto); + store.addBusinessHours(businessHours); + }); + + Store savedStore = storeRepository.save(store); + + return StoreConverter.toCreateDto(savedStore); + } + +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryServiceImpl.java index 3645fade..e1d7d513 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryServiceImpl.java @@ -1,6 +1,7 @@ package com.eatsfine.eatsfine.domain.store.service; import com.eatsfine.eatsfine.domain.businesshours.converter.BusinessHoursConverter; +import com.eatsfine.eatsfine.domain.store.converter.StoreConverter; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.store.exception.StoreException; @@ -22,23 +23,6 @@ public StoreResDto.StoreDetailDto getStoreDetail(Long storeId) { Store store = storeRepository.findById(storeId) .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); - return StoreResDto.StoreDetailDto.builder() - .storeId(store.getId()) - .storeName(store.getStoreName()) - .description(store.getDescription()) - .address(store.getAddress()) - .phone(store.getPhoneNumber()) - .category(store.getCategory()) - .rating(store.getRating()) - .reviewCount(null) // reviewCount는 추후 리뷰 로직 구현 시 추가 예정 - .mainImage(store.getMainImageUrl()) - .tableImages(Collections.emptyList()) // tableImages는 추후 사진 등록 API 구현 시 추가 예정 - .businessHours( - store.getBusinessHours().stream() - .map(BusinessHoursConverter::toSummary) - .toList()) - .isOpenNow(false) // 추후 영업 여부 판단 로직 구현 예정 - .priceRange(null) // 추후 - .build(); + return StoreConverter.toDetailDto(store); } } From 70ae0d601157ee3f7f42e82448fb3beb67a1387e Mon Sep 17 00:00:00 2001 From: twodo0 Date: Mon, 12 Jan 2026 17:23:38 +0900 Subject: [PATCH 132/169] =?UTF-8?q?[FEAT]:=20=EC=98=81=EC=97=85=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/BusinessHoursException.java | 10 +++ .../status/BusinessHoursErrorStatus.java | 44 ++++++++++++ .../validator/BusinessHoursValidator.java | 67 +++++++++++++++++++ .../service/StoreCommandServiceImpl.java | 4 ++ 4 files changed, 125 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/exception/BusinessHoursException.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursErrorStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/exception/BusinessHoursException.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/exception/BusinessHoursException.java new file mode 100644 index 00000000..63f869f7 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/exception/BusinessHoursException.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.businesshours.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class BusinessHoursException extends GeneralException { + public BusinessHoursException(BaseErrorCode code){ + super(code); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursErrorStatus.java new file mode 100644 index 00000000..e9a9310a --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursErrorStatus.java @@ -0,0 +1,44 @@ +package com.eatsfine.eatsfine.domain.businesshours.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum BusinessHoursErrorStatus implements BaseErrorCode { + + _DUPLICATE_DAY_OF_WEEK(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS_400_1", "요일이 중복되었습니다."), + _BUSINESS_HOURS_NOT_COMPLETE(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS_400_2", "영업일은 7일 모두 입력되어야 합니다."), + _INVALID_BUSINESS_TIME(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS_400_3", "영업 시작 시간은 마감 시간보다 빨라야 합니다."), + _INVALID_OPEN_DAY(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS_400_4", "영업일에는 영업시간 및 마감 시간이 존재해야 합니다."), + _INVALID_CLOSED_DAY(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS_400_5", "휴무일에는 영업시간이 존재할 수 없습니다."), + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + + @Override + public ErrorReasonDto getReason() { + return com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java new file mode 100644 index 00000000..9120f349 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java @@ -0,0 +1,67 @@ +package com.eatsfine.eatsfine.domain.businesshours.validator; + +import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursReqDto; +import com.eatsfine.eatsfine.domain.businesshours.enums.DayOfWeek; +import com.eatsfine.eatsfine.domain.businesshours.exception.BusinessHoursException; +import com.eatsfine.eatsfine.domain.businesshours.status.BusinessHoursErrorStatus; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class BusinessHoursValidator { + public static void validate(List dto) { + + validateComplete(dto); + validateDuplicateDayOfWeek(dto); + validateOpenDay(dto); + validateClosedDay(dto); + validateOpenCloseTime(dto); + + + } + + // 7일 모두 입력 여부 검증 + private static void validateComplete(List dto) { + if(dto.size() != 7){ + throw new BusinessHoursException(BusinessHoursErrorStatus._BUSINESS_HOURS_NOT_COMPLETE); + } + } + + // open < close 검증 + private static void validateOpenCloseTime(List dto) { + for(BusinessHoursReqDto.Summary s: dto) { + if(s.openTime().isAfter(s.closeTime())) { + throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_BUSINESS_TIME); + } + } + } + + // 요일 중복 검증 + private static void validateDuplicateDayOfWeek(List dto) { + Set set = new HashSet<>(); + for(BusinessHoursReqDto.Summary s: dto) { + if(!set.add(s.dayOfWeek())) { + throw new BusinessHoursException(BusinessHoursErrorStatus._DUPLICATE_DAY_OF_WEEK); + } + } + } + + // 휴무인데 영업시간이 들어갔는지 + private static void validateClosedDay(List dto) { + for(BusinessHoursReqDto.Summary s: dto) { + if(s.isClosed() && (s.openTime() != null || s.closeTime() != null)) { + throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_CLOSED_DAY); + } + } + } + + // 영업일인데 영업시간이 비버있는지 + private static void validateOpenDay(List dto) { + for(BusinessHoursReqDto.Summary s: dto) { + if(!s.isClosed() && (s.openTime() == null || s.closeTime() == null)) { + throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_OPEN_DAY); + } + } + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java index 37691439..b68d7d49 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java @@ -2,6 +2,7 @@ import com.eatsfine.eatsfine.domain.businesshours.converter.BusinessHoursConverter; import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import com.eatsfine.eatsfine.domain.businesshours.validator.BusinessHoursValidator; import com.eatsfine.eatsfine.domain.region.entity.Region; import com.eatsfine.eatsfine.domain.region.repository.RegionRepository; import com.eatsfine.eatsfine.domain.region.status.RegionErrorStatus; @@ -29,6 +30,9 @@ public StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto dto) { Region region = regionRepository.findById(dto.regionId()) .orElseThrow(() -> new StoreException(RegionErrorStatus._REGION_NOT_FOUND)); + // 영업시간 정상 여부 검증 + BusinessHoursValidator.validate(dto.businessHours()); + Store store = Store.builder() .owner(null) // User 도메인 머지 후 owner 처리 예정 .storeName(dto.storeName()) From 19229b0edecd0790806fea3e121dee777526d5d4 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Mon, 12 Jan 2026 18:35:37 +0900 Subject: [PATCH 133/169] =?UTF-8?q?[REFACTOR]:=20=EC=98=81=EC=97=85?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EB=B0=8F=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../businesshours/dto/BusinessHoursReqDto.java | 2 +- .../domain/businesshours/enums/DayOfWeek.java | 5 ----- .../validator/BusinessHoursValidator.java | 2 +- .../domain/store/converter/StoreConverter.java | 4 ++-- .../eatsfine/domain/store/dto/StoreResDto.java | 15 ++++++++++----- 5 files changed, 14 insertions(+), 14 deletions(-) delete mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/enums/DayOfWeek.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java index 42ec0bbc..f4a2b7ee 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java @@ -1,8 +1,8 @@ package com.eatsfine.eatsfine.domain.businesshours.dto; -import com.eatsfine.eatsfine.domain.businesshours.enums.DayOfWeek; import lombok.Builder; +import java.time.DayOfWeek; import java.time.LocalTime; public class BusinessHoursReqDto { diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/enums/DayOfWeek.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/enums/DayOfWeek.java deleted file mode 100644 index feb6670a..00000000 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/enums/DayOfWeek.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.eatsfine.eatsfine.domain.businesshours.enums; - -public enum DayOfWeek { - MON, TUE, WED, THU, FRI, SAT, SUN -} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java index 9120f349..41cd62b5 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java @@ -1,10 +1,10 @@ package com.eatsfine.eatsfine.domain.businesshours.validator; import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursReqDto; -import com.eatsfine.eatsfine.domain.businesshours.enums.DayOfWeek; import com.eatsfine.eatsfine.domain.businesshours.exception.BusinessHoursException; import com.eatsfine.eatsfine.domain.businesshours.status.BusinessHoursErrorStatus; +import java.time.DayOfWeek; import java.util.HashSet; import java.util.List; import java.util.Set; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java index 5b79095d..dadedaf2 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java @@ -25,8 +25,8 @@ public static StoreResDto.StoreDetailDto toDetailDto(Store store) { .category(store.getCategory()) .rating(store.getRating()) .reviewCount(null) // reviewCount는 추후 리뷰 로직 구현 시 추가 예정 - .mainImage(store.getMainImageUrl()) - .tableImages(Collections.emptyList()) // tableImages는 추후 사진 등록 API 구현 시 추가 예정 + .mainImageUrl(store.getMainImageUrl()) + .tableImageUrls(Collections.emptyList()) // tableImages는 추후 사진 등록 API 구현 시 추가 예정 .businessHours( store.getBusinessHours().stream() .map(BusinessHoursConverter::toSummary) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java index 7ef8e41d..3f813571 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java @@ -1,24 +1,23 @@ package com.eatsfine.eatsfine.domain.store.dto; import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursResDto; -import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; -import com.eatsfine.eatsfine.domain.businesshours.enums.DayOfWeek; import com.eatsfine.eatsfine.domain.store.enums.Category; import com.eatsfine.eatsfine.domain.store.enums.StoreApprovalStatus; import lombok.Builder; import java.math.BigDecimal; -import java.time.LocalTime; import java.util.List; public class StoreResDto { + // 가게 등록 응답 @Builder public record StoreCreateDto( Long storeId, StoreApprovalStatus status ){} + // 가게 상세 조회 응답 @Builder public record StoreDetailDto( Long storeId, @@ -29,10 +28,16 @@ public record StoreDetailDto( Category category, BigDecimal rating, Long reviewCount, - String mainImage, - List tableImages, + String mainImageUrl, + List tableImageUrls, List businessHours, boolean isOpenNow ){} + // 가게 대표 이미지 등록 응답 + @Builder + public record uploadMainImageResDto( + String mainImageUrl + ) {} + } From ba65145c72d72e339c885c40bb66818d1c7f4ace Mon Sep 17 00:00:00 2001 From: twodo0 Date: Tue, 13 Jan 2026 07:13:02 +0900 Subject: [PATCH 134/169] =?UTF-8?q?[FEAT]:=20=EA=B0=80=EA=B2=8C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=B3=B8=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/store/dto/StoreResDto.java | 26 +++++++++++++++++++ .../dto/projection/StoreSearchResult.java | 8 ++++++ .../eatsfine/domain/store/entity/Store.java | 6 +++++ .../domain/store/enums/StoreSortType.java | 5 ++++ 4 files changed, 45 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/dto/projection/StoreSearchResult.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreSortType.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java index 3f813571..13d7316f 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java @@ -17,6 +17,32 @@ public record StoreCreateDto( StoreApprovalStatus status ){} + @Builder + public record StoreSearchDto( + Long storeId, + String name, + String address, + Category category, + BigDecimal rating, + Integer reviewCount, // 리뷰 도메인이 존재하지 않아 null 허용 + double distance, + String mainImageUrl, + boolean isOpenNow + ){} + + @Builder + public record PaginationDto( + int currentPage, + int totalPages, + long totalCount + ){} + + @Builder + public record StoreSearchResDto( + List stores, + PaginationDto pagination + ){} + // 가게 상세 조회 응답 @Builder public record StoreDetailDto( diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/projection/StoreSearchResult.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/projection/StoreSearchResult.java new file mode 100644 index 00000000..c134473d --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/projection/StoreSearchResult.java @@ -0,0 +1,8 @@ +package com.eatsfine.eatsfine.domain.store.dto.projection; + +import com.eatsfine.eatsfine.domain.store.entity.Store; + +public record StoreSearchResult( + Store store, + Double distance +) {} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index fed1487d..a26b378e 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -39,6 +39,12 @@ public class Store extends BaseEntity { @JoinColumn(name = "region_id", nullable = false) private Region region; + @Column(nullable = false) + private double latitude; + + @Column(nullable = false) + private double longitude; + @Column(name = "store_name", nullable = false) private String storeName; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreSortType.java b/src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreSortType.java new file mode 100644 index 00000000..9762a730 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreSortType.java @@ -0,0 +1,5 @@ +package com.eatsfine.eatsfine.domain.store.enums; + +public enum StoreSortType { + DISTANCE, RATING, REVIEW_COUNT +} From a2eaf706b152f04f1459088ab9072596bc46066d Mon Sep 17 00:00:00 2001 From: twodo0 Date: Tue, 13 Jan 2026 09:46:51 +0900 Subject: [PATCH 135/169] =?UTF-8?q?[FEAT]:=20QueryDSL=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EA=B0=80=EA=B2=8C=20=EA=B2=80=EC=83=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store/controller/StoreController.java | 23 ++++++- .../store/converter/StoreConverter.java | 14 ++++ .../store/repository/StoreRepository.java | 48 ++++++++++++- .../service/StoreDetailQueryService.java | 8 --- .../service/StoreDetailQueryServiceImpl.java | 28 -------- .../store/service/StoreQueryService.java | 20 ++++++ .../store/service/StoreQueryServiceImpl.java | 69 +++++++++++++++++++ .../store/status/StoreSuccessStatus.java | 2 + 8 files changed, 172 insertions(+), 40 deletions(-) delete mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryService.java delete mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryServiceImpl.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java index 0cd73f3a..88c4f4e0 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java @@ -2,8 +2,10 @@ import com.eatsfine.eatsfine.domain.store.dto.StoreReqDto; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; +import com.eatsfine.eatsfine.domain.store.enums.Category; +import com.eatsfine.eatsfine.domain.store.enums.StoreSortType; import com.eatsfine.eatsfine.domain.store.service.StoreCommandService; -import com.eatsfine.eatsfine.domain.store.service.StoreDetailQueryService; +import com.eatsfine.eatsfine.domain.store.service.StoreQueryService; import com.eatsfine.eatsfine.domain.store.status.StoreSuccessStatus; import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; import lombok.RequiredArgsConstructor; @@ -15,7 +17,7 @@ public class StoreController { private final StoreCommandService storeCommandService; - private final StoreDetailQueryService storeDetailQueryService; + private final StoreQueryService storeQueryService; // 식당 등록 @PostMapping @@ -25,10 +27,25 @@ public ApiResponse createStore( return ApiResponse.of(StoreSuccessStatus._STORE_CREATED, storeCommandService.createStore(dto)); } + // 식당 검색 + @GetMapping("/search") + public ApiResponse searchStore( + @RequestParam double lat, + @RequestParam double lng, + @RequestParam(required = false, defaultValue = "5") Double radius, + @RequestParam(required = false) Category category, + @RequestParam(required = false, defaultValue = "DISTANCE") StoreSortType sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int limit + ) { + return ApiResponse.of(StoreSuccessStatus._STORE_SEARCH_SUCCESS, + storeQueryService.search(lat, lng, radius, category, sort, page, limit)); + } + // 상세조회 @GetMapping("/{storeId}") public ApiResponse getStoreDetail(@PathVariable Long storeId) { - return ApiResponse.of(StoreSuccessStatus._STORE_DETAIL_FOUND, storeDetailQueryService.getStoreDetail(storeId)); + return ApiResponse.of(StoreSuccessStatus._STORE_DETAIL_FOUND, storeQueryService.getStoreDetail(storeId)); } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java index dadedaf2..46d636b1 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java @@ -15,6 +15,20 @@ public static StoreResDto.StoreCreateDto toCreateDto(Store store) { .build(); } + public static StoreResDto.StoreSearchDto toSearchDto(Store store, Double distance) { + return StoreResDto.StoreSearchDto.builder() + .storeId(store.getId()) + .name(store.getStoreName()) + .address(store.getAddress()) + .category(store.getCategory()) + .rating(store.getRating()) + .reviewCount(null) // 리뷰 도메인 구현 이후 추가 예정 + .distance(distance != null ? distance : 0) + .mainImageUrl(store.getMainImageUrl()) + .isOpenNow(false) // 영업 상태 체크 로직 구현 후 추가 예정 + .build(); + } + public static StoreResDto.StoreDetailDto toDetailDto(Store store) { return StoreResDto.StoreDetailDto.builder() .storeId(store.getId()) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java index f329a95d..6c5fceca 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java @@ -1,7 +1,53 @@ package com.eatsfine.eatsfine.domain.store.repository; +import com.eatsfine.eatsfine.domain.store.dto.projection.StoreSearchResult; import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.enums.Category; +import com.eatsfine.eatsfine.domain.store.enums.StoreSortType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface StoreRepository extends JpaRepository { -} + // JPQL에서는 SELECT alias(distance)를 WHERE/HAVING 절에서 재사용할 수 없어 + // 동일한 거리 계산식을 WHERE, ORDER BY 절에 중복 작성함 + // → 추후 QueryDSL 도입 시 계산식 분리 및 가독성 개선 예정 + @Query(""" + SELECT new com.eatsfine.eatsfine.domain.store.dto.projection.StoreSearchResult( + s, + CAST((6371 * acos( + cos(radians(:lat)) * cos(radians(s.latitude)) + * cos(radians(s.longitude) - radians(:lng)) + + sin(radians(:lat)) * sin(radians(s.latitude)) + )) AS double) + ) + FROM Store s + WHERE (:category IS NULL OR s.category = :category) + AND ( + 6371 * acos( + cos(radians(:lat)) * cos(radians(s.latitude)) + * cos(radians(s.longitude) - radians(:lng)) + + sin(radians(:lat)) * sin(radians(s.latitude)) + ) + ) <= :radius + ORDER BY + CASE WHEN :sort = 'DISTANCE' THEN + (6371 * acos( + cos(radians(:lat)) * cos(radians(s.latitude)) + * cos(radians(s.longitude) - radians(:lng)) + + sin(radians(:lat)) * sin(radians(s.latitude)) + )) + END ASC, + CASE WHEN :sort = 'RATING' THEN s.rating END DESC + """) + Page searchStores( + @Param("lat") Double lat, + @Param("lng") Double lng, + @Param("radius") Double radius, + @Param("category") Category category, + @Param("sort") StoreSortType sort, + Pageable pageable + ); +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryService.java deleted file mode 100644 index 753a47e4..00000000 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryService.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.eatsfine.eatsfine.domain.store.service; - -import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; - -public interface StoreDetailQueryService { - public StoreResDto.StoreDetailDto getStoreDetail(Long storeId); - -} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryServiceImpl.java deleted file mode 100644 index e1d7d513..00000000 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreDetailQueryServiceImpl.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.eatsfine.eatsfine.domain.store.service; - -import com.eatsfine.eatsfine.domain.businesshours.converter.BusinessHoursConverter; -import com.eatsfine.eatsfine.domain.store.converter.StoreConverter; -import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; -import com.eatsfine.eatsfine.domain.store.entity.Store; -import com.eatsfine.eatsfine.domain.store.exception.StoreException; -import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; -import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.Collections; - -@Service -@RequiredArgsConstructor -public class StoreDetailQueryServiceImpl implements StoreDetailQueryService { - - private final StoreRepository storeRepository; - - @Override - public StoreResDto.StoreDetailDto getStoreDetail(Long storeId) { - Store store = storeRepository.findById(storeId) - .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); - - return StoreConverter.toDetailDto(store); - } -} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java new file mode 100644 index 00000000..6cfa2770 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java @@ -0,0 +1,20 @@ +package com.eatsfine.eatsfine.domain.store.service; + +import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; +import com.eatsfine.eatsfine.domain.store.enums.Category; +import com.eatsfine.eatsfine.domain.store.enums.StoreSortType; + +public interface StoreQueryService { + StoreResDto.StoreSearchResDto search( + double lat, + double lng, + Double radius, + Category category, + StoreSortType sort, + int page, + int limit + ); + + StoreResDto.StoreDetailDto getStoreDetail(Long storeId); + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java new file mode 100644 index 00000000..2f9541d9 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java @@ -0,0 +1,69 @@ +package com.eatsfine.eatsfine.domain.store.service; + +import com.eatsfine.eatsfine.domain.store.converter.StoreConverter; +import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; +import com.eatsfine.eatsfine.domain.store.dto.projection.StoreSearchResult; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.enums.Category; +import com.eatsfine.eatsfine.domain.store.enums.StoreSortType; +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class StoreQueryServiceImpl implements StoreQueryService { + + private final StoreRepository storeRepository; + + @Override + public StoreResDto.StoreSearchResDto search( + double lat, + double lng, + Double radius, + Category category, + StoreSortType sort, + int page, + int limit + ) { + Pageable pageable = PageRequest.of(page - 1, limit); + + Page resultPage = storeRepository.searchStores( + lat, lng, radius, category, sort, pageable + ); + + List stores = + resultPage.getContent().stream() + .map(row -> StoreConverter.toSearchDto( + row.store(), + row.distance() + )) + .toList(); + + return StoreResDto.StoreSearchResDto.builder() + .stores(stores) + .pagination( + StoreResDto.PaginationDto.builder() + .currentPage(page) + .totalPages(resultPage.getTotalPages()) + .totalCount(resultPage.getTotalElements()) + .build() + ) + .build(); + } + + @Override + public StoreResDto.StoreDetailDto getStoreDetail(Long storeId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + return StoreConverter.toDetailDto(store); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java index 720a0573..067ef69f 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java @@ -12,6 +12,8 @@ public enum StoreSuccessStatus implements BaseCode { _STORE_FOUND(HttpStatus.OK, "STORE200", "성공적으로 가게를 찾았습니다."), + _STORE_SEARCH_SUCCESS(HttpStatus.OK, "STORE2002", "성공적으로 가게를 검색했습니다."), + _STORE_DETAIL_FOUND(HttpStatus.FOUND, "STORE_DETAIL200", "성공적으로 가게 상세 리뷰를 조회했습니다."), _STORE_CREATED(HttpStatus.CREATED, "STORE201", "성공적으로 가게를 등록했습니다.") From b3248b43bda0cbad6241087c3520c7b531a1fda4 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Tue, 13 Jan 2026 15:28:17 +0900 Subject: [PATCH 136/169] =?UTF-8?q?[FEAT]:=20=EC=8B=A4=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=20=EC=98=81=EC=97=85=20=EC=97=AC=EB=B6=80=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/BusinessHoursReqDto.java | 9 +++++ .../validator/BusinessHoursValidator.java | 6 ++- .../store/controller/StoreController.java | 31 +++++++++++++-- .../store/converter/StoreConverter.java | 10 ++--- .../domain/store/dto/StoreReqDto.java | 19 ++++++++++ .../eatsfine/domain/store/entity/Store.java | 9 +++++ .../store/service/StoreQueryService.java | 5 +++ .../store/service/StoreQueryServiceImpl.java | 38 ++++++++++++++++++- 8 files changed, 114 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java index f4a2b7ee..be14695a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java @@ -1,5 +1,7 @@ package com.eatsfine.eatsfine.domain.businesshours.dto; +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.NotNull; import lombok.Builder; import java.time.DayOfWeek; @@ -9,9 +11,16 @@ public class BusinessHoursReqDto { @Builder public record Summary( + + @NotNull(message = "요일은 필수입니다.") DayOfWeek dayOfWeek, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime openTime, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime closeTime, + boolean isClosed ){} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java index 41cd62b5..f173851d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java @@ -31,8 +31,10 @@ private static void validateComplete(List dto) { // open < close 검증 private static void validateOpenCloseTime(List dto) { for(BusinessHoursReqDto.Summary s: dto) { - if(s.openTime().isAfter(s.closeTime())) { - throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_BUSINESS_TIME); + if(!s.isClosed()){ + if(s.openTime().isAfter(s.closeTime())) { + throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_BUSINESS_TIME); + } } } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java index 88c4f4e0..35b35fb4 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java @@ -8,9 +8,14 @@ import com.eatsfine.eatsfine.domain.store.service.StoreQueryService; import com.eatsfine.eatsfine.domain.store.status.StoreSuccessStatus; import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +@Tag(name = "Store", description = "식당 조회 및 관리 API") @RestController @RequiredArgsConstructor @RequestMapping("/stores") @@ -19,21 +24,36 @@ public class StoreController { private final StoreCommandService storeCommandService; private final StoreQueryService storeQueryService; - // 식당 등록 + @Operation( + summary = "식당 등록", + description = "사장 회원이 새로운 식당을 등록합니다. 등록 후 승인 상태는 PENDING입니다." + ) @PostMapping public ApiResponse createStore( - @RequestBody StoreReqDto.StoreCreateDto dto + @Valid @RequestBody StoreReqDto.StoreCreateDto dto ) { return ApiResponse.of(StoreSuccessStatus._STORE_CREATED, storeCommandService.createStore(dto)); } - // 식당 검색 + @Operation( + summary = "식당 검색", + description = "위치 기반으로 반경 내 식당을 검색합니다." + ) @GetMapping("/search") public ApiResponse searchStore( + @Parameter(description = "위도", example = "37.5665") @RequestParam double lat, + + @Parameter(description = "경도", example = "127.9740") @RequestParam double lng, + + @Parameter(description = "검색 반경 (km)", example = "1.0") @RequestParam(required = false, defaultValue = "5") Double radius, + + @Parameter(description = "카테고리") @RequestParam(required = false) Category category, + + @Parameter(description = "정렬 기준", example = "DISTANCE") @RequestParam(required = false, defaultValue = "DISTANCE") StoreSortType sort, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int limit @@ -42,7 +62,10 @@ public ApiResponse searchStore( storeQueryService.search(lat, lng, radius, category, sort, page, limit)); } - // 상세조회 + @Operation( + summary = "식당 상세 조회", + description = "식당 ID로 상세 정보를 조회합니다." + ) @GetMapping("/{storeId}") public ApiResponse getStoreDetail(@PathVariable Long storeId) { return ApiResponse.of(StoreSuccessStatus._STORE_DETAIL_FOUND, storeQueryService.getStoreDetail(storeId)); diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java index 46d636b1..8873a084 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java @@ -15,7 +15,7 @@ public static StoreResDto.StoreCreateDto toCreateDto(Store store) { .build(); } - public static StoreResDto.StoreSearchDto toSearchDto(Store store, Double distance) { + public static StoreResDto.StoreSearchDto toSearchDto(Store store, Double distance, boolean isOpenNow) { return StoreResDto.StoreSearchDto.builder() .storeId(store.getId()) .name(store.getStoreName()) @@ -23,13 +23,13 @@ public static StoreResDto.StoreSearchDto toSearchDto(Store store, Double distanc .category(store.getCategory()) .rating(store.getRating()) .reviewCount(null) // 리뷰 도메인 구현 이후 추가 예정 - .distance(distance != null ? distance : 0) + .distance(distance) .mainImageUrl(store.getMainImageUrl()) - .isOpenNow(false) // 영업 상태 체크 로직 구현 후 추가 예정 + .isOpenNow(isOpenNow) .build(); } - public static StoreResDto.StoreDetailDto toDetailDto(Store store) { + public static StoreResDto.StoreDetailDto toDetailDto(Store store, boolean isOpenNow) { return StoreResDto.StoreDetailDto.builder() .storeId(store.getId()) .storeName(store.getStoreName()) @@ -45,7 +45,7 @@ public static StoreResDto.StoreDetailDto toDetailDto(Store store) { store.getBusinessHours().stream() .map(BusinessHoursConverter::toSummary) .toList()) - .isOpenNow(false) // 추후 영업 여부 판단 로직 구현 예정 + .isOpenNow(isOpenNow) // 추후 영업 여부 판단 로직 구현 예정 .build(); } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java index c81d28f2..a15978ae 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java @@ -2,6 +2,9 @@ import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursReqDto; import com.eatsfine.eatsfine.domain.store.enums.Category; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Builder; import java.util.List; @@ -10,14 +13,30 @@ public class StoreReqDto { @Builder public record StoreCreateDto( + + @NotBlank(message = "가게명은 필수입니다.") String storeName, + + @NotBlank(message = "사업자번호는 필수입니다.") String businessNumber, + String description, + + @NotNull(message = "지역은 필수입니다.") Long regionId, + + @NotBlank(message = "주소는 필수입니다.") String address, + + @NotBlank(message = "전화번호는 필수입니다.") String phoneNumber, + + @NotNull(message = "카테고리는 필수입니다.") Category category, + int bookingIntervalMinutes, + + @Valid List businessHours ){} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index a26b378e..f8700364 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -18,6 +18,7 @@ import java.time.DayOfWeek; import java.util.ArrayList; import java.util.List; +import java.util.Optional; @Table(name = "store") @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -124,6 +125,14 @@ public BusinessHours getBusinessHoursByDay(DayOfWeek dayOfWeek) { .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST)); } + // 특정 요일의 영업시간 조회 메서드 (Optional) + // -> 검색 로직에서는 결과들 중 하나가 영업시간이 비어있어도 나머지는 보여줘야 함 + public Optional findBusinessHoursByDay(DayOfWeek dayOfWeek) { + return this.businessHours.stream() + .filter(bh -> bh.getDayOfWeek() == dayOfWeek) + .findFirst(); + } + // StoreTable에 대한 연관관계 편의 메서드는 추후 추가 예정 } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java index 6cfa2770..b2f171df 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java @@ -1,9 +1,12 @@ package com.eatsfine.eatsfine.domain.store.service; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; +import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.store.enums.Category; import com.eatsfine.eatsfine.domain.store.enums.StoreSortType; +import java.time.LocalDateTime; + public interface StoreQueryService { StoreResDto.StoreSearchResDto search( double lat, @@ -17,4 +20,6 @@ StoreResDto.StoreSearchResDto search( StoreResDto.StoreDetailDto getStoreDetail(Long storeId); + boolean isOpenNow(Store store, LocalDateTime now); + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java index 2f9541d9..f0a64317 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java @@ -1,5 +1,6 @@ package com.eatsfine.eatsfine.domain.store.service; +import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; import com.eatsfine.eatsfine.domain.store.converter.StoreConverter; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; import com.eatsfine.eatsfine.domain.store.dto.projection.StoreSearchResult; @@ -15,6 +16,9 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; @Service @@ -23,6 +27,7 @@ public class StoreQueryServiceImpl implements StoreQueryService { private final StoreRepository storeRepository; + // 식당 검색 @Override public StoreResDto.StoreSearchResDto search( double lat, @@ -39,11 +44,14 @@ public StoreResDto.StoreSearchResDto search( lat, lng, radius, category, sort, pageable ); + LocalDateTime now = LocalDateTime.now(); + List stores = resultPage.getContent().stream() .map(row -> StoreConverter.toSearchDto( row.store(), - row.distance() + row.distance(), + isOpenNow(row.store(), now) )) .toList(); @@ -59,11 +67,37 @@ public StoreResDto.StoreSearchResDto search( .build(); } + // 식당 상세 조회 @Override public StoreResDto.StoreDetailDto getStoreDetail(Long storeId) { Store store = storeRepository.findById(storeId) .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); - return StoreConverter.toDetailDto(store); + return StoreConverter.toDetailDto(store, isOpenNow(store, LocalDateTime.now())); + } + + // 현재 영업 여부 계산 (실시간 계산) + @Override + public boolean isOpenNow(Store store, LocalDateTime now) { + DayOfWeek dayOfWeek = now.getDayOfWeek(); + LocalTime time = now.toLocalTime(); + + BusinessHours bh = store.getBusinessHoursByDay(dayOfWeek); + + if(bh.isHoliday()) { + return false; + } + + if((bh.getBreakStartTime() != null && bh.getBreakEndTime() != null)) { + if(!time.isBefore(bh.getBreakStartTime()) && time.isBefore(bh.getBreakEndTime())) { // start <= time < end 에 쉼 + return false; + } + } + + if(time.isBefore(bh.getOpenTime()) || !time.isBefore(bh.getCloseTime())) { // open <= time < end 일때만 true + return false; + } + + return true; } } From b16474c494577203b958391b4ad7445a1da7d6a9 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Wed, 14 Jan 2026 20:47:39 +0900 Subject: [PATCH 137/169] =?UTF-8?q?[FEAT]:=20=EA=B0=80=EA=B2=8C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=97=90=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89,=20=EC=A7=80=EC=97=AD=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/region/entity/Region.java | 6 +- .../store/condition/StoreSearchCondition.java | 38 +++++ .../store/controller/StoreController.java | 30 +--- .../eatsfine/domain/store/entity/Store.java | 3 +- .../domain/store/enums/StoreSortType.java | 3 +- .../store/repository/StoreRepository.java | 49 +----- .../repository/StoreRepositoryCustom.java | 21 +++ .../store/repository/StoreRepositoryImpl.java | 147 ++++++++++++++++++ .../store/service/StoreQueryService.java | 7 +- .../store/service/StoreQueryServiceImpl.java | 36 ++--- .../global/config/QueryDslConfig.java | 21 +++ 11 files changed, 259 insertions(+), 102 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/condition/StoreSearchCondition.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryCustom.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryImpl.java create mode 100644 src/main/java/com/eatsfine/eatsfine/global/config/QueryDslConfig.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/region/entity/Region.java b/src/main/java/com/eatsfine/eatsfine/domain/region/entity/Region.java index 916d3b3d..b54e3f08 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/region/entity/Region.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/region/entity/Region.java @@ -14,15 +14,15 @@ public class Region { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - // 시도 + // 시/도 (예: 서울특별시, 경기도) @Column(name = "province", nullable = false) private String province; - // 시 + // 시/군/구 (예: 강남구, 성남시, 가평군) @Column(name = "city", nullable = false) private String city; - // 구 + // 구/읍/면/동 (예: 분당구, 진접읍, 역삼동 ..) @Column(name = "district", nullable = false) private String district; } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/condition/StoreSearchCondition.java b/src/main/java/com/eatsfine/eatsfine/domain/store/condition/StoreSearchCondition.java new file mode 100644 index 00000000..2d75358e --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/condition/StoreSearchCondition.java @@ -0,0 +1,38 @@ +package com.eatsfine.eatsfine.domain.store.condition; + +import com.eatsfine.eatsfine.domain.store.enums.Category; +import com.eatsfine.eatsfine.domain.store.enums.StoreSortType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class StoreSearchCondition { + @Schema(description = "위도", example = "37.5665") + @NotNull(message = "위도값은 필수입니다.") + Double lat; + + @Schema(description = "경도", example = "127.9740") + @NotNull(message = "경도값은 필수입니다.") + Double lng; + + @Schema(description = "키워드", example = "강남구") + String keyword; + + @Schema(description = "카테고리") + Category category; + + @Schema(description = "시/도 (예: 서울특별시, 경기도)") + String province; + + @Schema(description = "시/군/구 (예: 강남구, 성남시, 가평군)") + String city; + + @Schema(description = "구/읍/면/동 (예: 분당구, 진접읍, 역삼동 ..)") + String district; + + @Schema(description = "정렬 기준", example = "DISTANCE") + StoreSortType sort = StoreSortType.DISTANCE; +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java index 35b35fb4..10a5ad7c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java @@ -1,24 +1,23 @@ package com.eatsfine.eatsfine.domain.store.controller; +import com.eatsfine.eatsfine.domain.store.condition.StoreSearchCondition; import com.eatsfine.eatsfine.domain.store.dto.StoreReqDto; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; -import com.eatsfine.eatsfine.domain.store.enums.Category; -import com.eatsfine.eatsfine.domain.store.enums.StoreSortType; import com.eatsfine.eatsfine.domain.store.service.StoreCommandService; import com.eatsfine.eatsfine.domain.store.service.StoreQueryService; import com.eatsfine.eatsfine.domain.store.status.StoreSuccessStatus; import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.web.bind.annotation.*; @Tag(name = "Store", description = "식당 조회 및 관리 API") @RestController @RequiredArgsConstructor -@RequestMapping("/stores") +@RequestMapping("/api/v1") public class StoreController { private final StoreCommandService storeCommandService; @@ -28,7 +27,7 @@ public class StoreController { summary = "식당 등록", description = "사장 회원이 새로운 식당을 등록합니다. 등록 후 승인 상태는 PENDING입니다." ) - @PostMapping + @PostMapping("/stores") public ApiResponse createStore( @Valid @RequestBody StoreReqDto.StoreCreateDto dto ) { @@ -39,34 +38,21 @@ public ApiResponse createStore( summary = "식당 검색", description = "위치 기반으로 반경 내 식당을 검색합니다." ) - @GetMapping("/search") + @GetMapping("/stores/search") public ApiResponse searchStore( - @Parameter(description = "위도", example = "37.5665") - @RequestParam double lat, - - @Parameter(description = "경도", example = "127.9740") - @RequestParam double lng, - - @Parameter(description = "검색 반경 (km)", example = "1.0") - @RequestParam(required = false, defaultValue = "5") Double radius, - - @Parameter(description = "카테고리") - @RequestParam(required = false) Category category, - - @Parameter(description = "정렬 기준", example = "DISTANCE") - @RequestParam(required = false, defaultValue = "DISTANCE") StoreSortType sort, + @Valid @ParameterObject @ModelAttribute StoreSearchCondition cond, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int limit ) { return ApiResponse.of(StoreSuccessStatus._STORE_SEARCH_SUCCESS, - storeQueryService.search(lat, lng, radius, category, sort, page, limit)); + storeQueryService.search(cond, page, limit)); } @Operation( summary = "식당 상세 조회", description = "식당 ID로 상세 정보를 조회합니다." ) - @GetMapping("/{storeId}") + @GetMapping("/stores/{storeId}") public ApiResponse getStoreDetail(@PathVariable Long storeId) { return ApiResponse.of(StoreSuccessStatus._STORE_DETAIL_FOUND, storeQueryService.getStoreDetail(storeId)); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index f8700364..12456115 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -52,8 +52,7 @@ public class Store extends BaseEntity { @Column(name = "business_number", nullable = false) private String businessNumber; - @Lob - @Column(name = "description", nullable = false) + @Column(name = "description", length = 1000, nullable = false) private String description; @Column(name = "phone_number", nullable = false) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreSortType.java b/src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreSortType.java index 9762a730..c9852a9c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreSortType.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreSortType.java @@ -1,5 +1,6 @@ package com.eatsfine.eatsfine.domain.store.enums; +// REVIEW_COUNT는 추후 리뷰 도메인 구현 시 추가 public enum StoreSortType { - DISTANCE, RATING, REVIEW_COUNT + DISTANCE, RATING } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java index 6c5fceca..7c72fd36 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java @@ -1,53 +1,8 @@ package com.eatsfine.eatsfine.domain.store.repository; -import com.eatsfine.eatsfine.domain.store.dto.projection.StoreSearchResult; import com.eatsfine.eatsfine.domain.store.entity.Store; -import com.eatsfine.eatsfine.domain.store.enums.Category; -import com.eatsfine.eatsfine.domain.store.enums.StoreSortType; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -public interface StoreRepository extends JpaRepository { - // JPQL에서는 SELECT alias(distance)를 WHERE/HAVING 절에서 재사용할 수 없어 - // 동일한 거리 계산식을 WHERE, ORDER BY 절에 중복 작성함 - // → 추후 QueryDSL 도입 시 계산식 분리 및 가독성 개선 예정 - @Query(""" - SELECT new com.eatsfine.eatsfine.domain.store.dto.projection.StoreSearchResult( - s, - CAST((6371 * acos( - cos(radians(:lat)) * cos(radians(s.latitude)) - * cos(radians(s.longitude) - radians(:lng)) - + sin(radians(:lat)) * sin(radians(s.latitude)) - )) AS double) - ) - FROM Store s - WHERE (:category IS NULL OR s.category = :category) - AND ( - 6371 * acos( - cos(radians(:lat)) * cos(radians(s.latitude)) - * cos(radians(s.longitude) - radians(:lng)) - + sin(radians(:lat)) * sin(radians(s.latitude)) - ) - ) <= :radius - ORDER BY - CASE WHEN :sort = 'DISTANCE' THEN - (6371 * acos( - cos(radians(:lat)) * cos(radians(s.latitude)) - * cos(radians(s.longitude) - radians(:lng)) - + sin(radians(:lat)) * sin(radians(s.latitude)) - )) - END ASC, - CASE WHEN :sort = 'RATING' THEN s.rating END DESC - """) - Page searchStores( - @Param("lat") Double lat, - @Param("lng") Double lng, - @Param("radius") Double radius, - @Param("category") Category category, - @Param("sort") StoreSortType sort, - Pageable pageable - ); + +public interface StoreRepository extends JpaRepository, StoreRepositoryCustom { } \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryCustom.java b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryCustom.java new file mode 100644 index 00000000..4b26cf38 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryCustom.java @@ -0,0 +1,21 @@ +package com.eatsfine.eatsfine.domain.store.repository; + +import com.eatsfine.eatsfine.domain.store.dto.projection.StoreSearchResult; +import com.eatsfine.eatsfine.domain.store.enums.Category; +import com.eatsfine.eatsfine.domain.store.enums.StoreSortType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface StoreRepositoryCustom { + Page searchStores( + Double lat, + Double lng, + String keyword, + Category category, + StoreSortType sort, + String province, + String city, + String district, + Pageable pageable + ); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryImpl.java new file mode 100644 index 00000000..e4b162b5 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryImpl.java @@ -0,0 +1,147 @@ +package com.eatsfine.eatsfine.domain.store.repository; + +import com.eatsfine.eatsfine.domain.region.entity.QRegion; +import com.eatsfine.eatsfine.domain.store.dto.projection.StoreSearchResult; +import com.eatsfine.eatsfine.domain.store.entity.QStore; +import com.eatsfine.eatsfine.domain.store.enums.Category; +import com.eatsfine.eatsfine.domain.store.enums.StoreSortType; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; + + +@Repository +@RequiredArgsConstructor +public class StoreRepositoryImpl implements StoreRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page searchStores( + Double lat, + Double lng, + String keyword, + Category category, + StoreSortType sort, + String province, + String city, + String district, + Pageable pageable + ) { + QStore store = QStore.store; + QRegion region = QRegion.region; + + // 거리 계산 + NumberExpression distanceExpression = calculateDistance(lat, lng, store); + + // 동적 조건 생성 + BooleanBuilder whereClause = new BooleanBuilder(); + + // 키워드 필터 + if (keyword != null && !keyword.isBlank()) { + whereClause.and(keywordContains(store, keyword)); + } + + // 카테고리 필터 + if (category != null) { + whereClause.and(store.category.eq(category)); + } + + // 시/도 필터 + if (province != null) { + whereClause.and(region.province.eq(province)); + } + + // 시도 필터 + if (city != null) { + whereClause.and(region.city.eq(city)); + } + + // 구 필터 + if (district != null) { + whereClause.and(region.district.eq(district)); + } + + // 정렬 조건 생성 + OrderSpecifier orderSpecifier = createOrderSpecifier(sort, store, distanceExpression); + + // 메인 쿼리 실행 + List results = queryFactory + .select(Projections.constructor( + StoreSearchResult.class, + store, + distanceExpression + )) + .from(store) + .join(store.region, region) + .where(whereClause) + .orderBy(orderSpecifier) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // 전체 개수 조회 + Long total = queryFactory + .select(store.count()) + .from(store) + .join(store.region, region) + .where(whereClause) + .fetchOne(); + + return new PageImpl<>(results, pageable, total != null ? total : 0); + } + + // 두 지점 간 거리 계산 (km) + private NumberExpression calculateDistance(Double lat, Double lng, QStore store) { + // 위도/경도를 라디안으로 변환 + NumberExpression latRad = Expressions.numberTemplate( + Double.class, "radians({0})", lat + ); + NumberExpression lngRad = Expressions.numberTemplate( + Double.class, "radians({0})", lng + ); + NumberExpression storeLatRad = Expressions.numberTemplate( + Double.class, "radians({0})", store.latitude + ); + NumberExpression storeLngRad = Expressions.numberTemplate( + Double.class, "radians({0})", store.longitude + ); + + return Expressions.numberTemplate( + Double.class, + "6371 * acos(cos({0}) * cos({1}) * cos({2} - {3}) + sin({0}) * sin({1}))", + latRad, storeLatRad, storeLngRad, lngRad + ); + + } + + // 정렬 조건 생성 + private OrderSpecifier createOrderSpecifier( + StoreSortType sort, + QStore store, + NumberExpression distanceExpression + ) { + return switch (sort) { + case DISTANCE -> distanceExpression.asc(); + case RATING -> store.rating.desc(); + }; + } + + // 키워드 찾기 메서드 + private BooleanExpression keywordContains(QStore store, String keyword) { + return store.storeName.containsIgnoreCase(keyword) + .or(store.description.containsIgnoreCase(keyword) + .or(store.address.containsIgnoreCase(keyword))); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java index b2f171df..8171953d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java @@ -1,5 +1,6 @@ package com.eatsfine.eatsfine.domain.store.service; +import com.eatsfine.eatsfine.domain.store.condition.StoreSearchCondition; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.store.enums.Category; @@ -9,11 +10,7 @@ public interface StoreQueryService { StoreResDto.StoreSearchResDto search( - double lat, - double lng, - Double radius, - Category category, - StoreSortType sort, + StoreSearchCondition cond, int page, int limit ); diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java index f0a64317..7db92558 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java @@ -1,6 +1,6 @@ package com.eatsfine.eatsfine.domain.store.service; -import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import com.eatsfine.eatsfine.domain.store.condition.StoreSearchCondition; import com.eatsfine.eatsfine.domain.store.converter.StoreConverter; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; import com.eatsfine.eatsfine.domain.store.dto.projection.StoreSearchResult; @@ -30,18 +30,15 @@ public class StoreQueryServiceImpl implements StoreQueryService { // 식당 검색 @Override public StoreResDto.StoreSearchResDto search( - double lat, - double lng, - Double radius, - Category category, - StoreSortType sort, + StoreSearchCondition cond, int page, int limit ) { Pageable pageable = PageRequest.of(page - 1, limit); Page resultPage = storeRepository.searchStores( - lat, lng, radius, category, sort, pageable + cond.getLat(), cond.getLng(), cond.getKeyword(), cond.getCategory(), cond.getSort(), + cond.getProvince(), cond.getCity(), cond.getDistrict(), pageable ); LocalDateTime now = LocalDateTime.now(); @@ -82,22 +79,17 @@ public boolean isOpenNow(Store store, LocalDateTime now) { DayOfWeek dayOfWeek = now.getDayOfWeek(); LocalTime time = now.toLocalTime(); - BusinessHours bh = store.getBusinessHoursByDay(dayOfWeek); + return store.findBusinessHoursByDay(dayOfWeek) + .map(bh -> { + if (bh.isHoliday()) return false; - if(bh.isHoliday()) { - return false; - } + if ((bh.getBreakStartTime() != null && bh.getBreakEndTime() != null)) { + if (!time.isBefore(bh.getBreakStartTime()) && (time.isBefore(bh.getBreakEndTime()))) { + return false; // start <= time < end 에 쉼 + } + } + return (!time.isBefore(bh.getOpenTime()) && time.isBefore(bh.getCloseTime())); - if((bh.getBreakStartTime() != null && bh.getBreakEndTime() != null)) { - if(!time.isBefore(bh.getBreakStartTime()) && time.isBefore(bh.getBreakEndTime())) { // start <= time < end 에 쉼 - return false; - } - } - - if(time.isBefore(bh.getOpenTime()) || !time.isBefore(bh.getCloseTime())) { // open <= time < end 일때만 true - return false; - } - - return true; + }).orElse(false); // 현재 요일에 해당하는 영업시간 없으면 닫힘처리 } } diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/QueryDslConfig.java b/src/main/java/com/eatsfine/eatsfine/global/config/QueryDslConfig.java new file mode 100644 index 00000000..36826d0d --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/config/QueryDslConfig.java @@ -0,0 +1,21 @@ +package com.eatsfine.eatsfine.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} From 75cceb2f6afff11c5090418517e360d0ecd23b6d Mon Sep 17 00:00:00 2001 From: twodo0 Date: Thu, 15 Jan 2026 13:32:22 +0900 Subject: [PATCH 138/169] =?UTF-8?q?[FEAT]:=20=EA=B0=80=EA=B2=8C=EB=B3=84?= =?UTF-8?q?=20=EC=98=88=EC=95=BD=EA=B8=88=20=EB=A1=9C=EC=A7=81=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 --- .../businesshours/entity/BusinessHours.java | 4 ++-- .../store/controller/StoreController.java | 2 +- .../store/converter/StoreConverter.java | 3 ++- .../domain/store/dto/StoreReqDto.java | 7 +++++++ .../domain/store/dto/StoreResDto.java | 5 ++--- .../eatsfine/domain/store/entity/Store.java | 21 +++++++++++++------ .../domain/store/enums/DepositRate.java | 21 +++++++++++++++++++ .../store/enums/StoreApprovalStatus.java | 5 ----- .../service/StoreCommandServiceImpl.java | 4 ++-- 9 files changed, 52 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/enums/DepositRate.java delete mode 100644 src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreApprovalStatus.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java index 6db43f24..61883ae3 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java @@ -27,10 +27,10 @@ public class BusinessHours extends BaseEntity { @Column(name = "day_of_week", nullable = false) private DayOfWeek dayOfWeek; - @Column(name = "open_time", nullable = false) + @Column(name = "open_time") private LocalTime openTime; - @Column(name = "close_time", nullable = false) + @Column(name = "close_time") private LocalTime closeTime; @Column(name = "break_start_time") diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java index 10a5ad7c..c207c4b5 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java @@ -25,7 +25,7 @@ public class StoreController { @Operation( summary = "식당 등록", - description = "사장 회원이 새로운 식당을 등록합니다. 등록 후 승인 상태는 PENDING입니다." + description = "사장 회원이 새로운 식당을 등록합니다" ) @PostMapping("/stores") public ApiResponse createStore( diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java index 8873a084..759aac6c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java @@ -4,6 +4,7 @@ import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; import com.eatsfine.eatsfine.domain.store.entity.Store; +import java.math.BigDecimal; import java.util.Collections; public class StoreConverter { @@ -11,7 +12,6 @@ public class StoreConverter { public static StoreResDto.StoreCreateDto toCreateDto(Store store) { return StoreResDto.StoreCreateDto.builder() .storeId(store.getId()) - .status(store.getApprovalStatus()) .build(); } @@ -41,6 +41,7 @@ public static StoreResDto.StoreDetailDto toDetailDto(Store store, boolean isOpen .reviewCount(null) // reviewCount는 추후 리뷰 로직 구현 시 추가 예정 .mainImageUrl(store.getMainImageUrl()) .tableImageUrls(Collections.emptyList()) // tableImages는 추후 사진 등록 API 구현 시 추가 예정 + .depositAmount(store.calculateDepositAmount()) .businessHours( store.getBusinessHours().stream() .map(BusinessHoursConverter::toSummary) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java index a15978ae..3e7ff19b 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java @@ -2,6 +2,7 @@ import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursReqDto; import com.eatsfine.eatsfine.domain.store.enums.Category; +import com.eatsfine.eatsfine.domain.store.enums.DepositRate; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -34,6 +35,12 @@ public record StoreCreateDto( @NotNull(message = "카테고리는 필수입니다.") Category category, + @NotNull(message = "최소 메뉴 가격은 필수입니다.") + int minPrice, + + @NotNull(message = "예약금 비율은 필수입니다.") + DepositRate depositRate, + int bookingIntervalMinutes, @Valid diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java index 13d7316f..7cd66f6c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java @@ -2,7 +2,6 @@ import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursResDto; import com.eatsfine.eatsfine.domain.store.enums.Category; -import com.eatsfine.eatsfine.domain.store.enums.StoreApprovalStatus; import lombok.Builder; import java.math.BigDecimal; @@ -13,8 +12,7 @@ public class StoreResDto { // 가게 등록 응답 @Builder public record StoreCreateDto( - Long storeId, - StoreApprovalStatus status + Long storeId ){} @Builder @@ -54,6 +52,7 @@ public record StoreDetailDto( Category category, BigDecimal rating, Long reviewCount, + BigDecimal depositAmount, String mainImageUrl, List tableImageUrls, List businessHours, diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index 12456115..3717efdc 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -3,8 +3,7 @@ import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; import com.eatsfine.eatsfine.domain.region.entity.Region; import com.eatsfine.eatsfine.domain.store.enums.Category; -import com.eatsfine.eatsfine.domain.store.enums.StoreApprovalStatus; -import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.store.enums.DepositRate; import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; import com.eatsfine.eatsfine.domain.tableimage.entity.TableImage; import com.eatsfine.eatsfine.domain.user.entity.User; @@ -15,6 +14,7 @@ import lombok.*; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.DayOfWeek; import java.util.ArrayList; import java.util.List; @@ -72,14 +72,17 @@ public class Store extends BaseEntity { @Column(name = "category", nullable = false) private Category category; - @Enumerated(EnumType.STRING) - @Column(name = "store_approval_status", nullable = false) - private StoreApprovalStatus approvalStatus; - @Builder.Default @Column(name = "booking_interval_minutes", nullable = false) private int bookingIntervalMinutes = 30; + @Column(name = "min_price", nullable = false) + private int minPrice; + + @Enumerated(EnumType.STRING) + @Column(name = "deposit_rate", nullable = false) + private DepositRate depositRate; + @Builder.Default @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List businessHours = new ArrayList<>(); @@ -132,6 +135,12 @@ public Optional findBusinessHoursByDay(DayOfWeek dayOfWeek) { .findFirst(); } + public BigDecimal calculateDepositAmount() { + return BigDecimal.valueOf(minPrice) + .multiply(BigDecimal.valueOf(depositRate.getPercent())) + .divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN); + } + // StoreTable에 대한 연관관계 편의 메서드는 추후 추가 예정 } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/enums/DepositRate.java b/src/main/java/com/eatsfine/eatsfine/domain/store/enums/DepositRate.java new file mode 100644 index 00000000..ffc38bb9 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/enums/DepositRate.java @@ -0,0 +1,21 @@ +package com.eatsfine.eatsfine.domain.store.enums; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Schema( + description = "예약금 비율", + allowableValues = {"10%", "20%", "30%", "40%", "50%"} +) +@Getter +@RequiredArgsConstructor +public enum DepositRate { + TEN(10), + TWENTY(20), + THIRTY(30), + FORTY(40), + FIFTY(50); + + private final int percent; +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreApprovalStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreApprovalStatus.java deleted file mode 100644 index 8079c772..00000000 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/enums/StoreApprovalStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.eatsfine.eatsfine.domain.store.enums; - -public enum StoreApprovalStatus { - PENDING, APPROVED, REJECTED -} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java index b68d7d49..f843fb67 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java @@ -10,7 +10,6 @@ import com.eatsfine.eatsfine.domain.store.dto.StoreReqDto; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; import com.eatsfine.eatsfine.domain.store.entity.Store; -import com.eatsfine.eatsfine.domain.store.enums.StoreApprovalStatus; import com.eatsfine.eatsfine.domain.store.exception.StoreException; import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; import jakarta.transaction.Transactional; @@ -43,8 +42,9 @@ public StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto dto) { .region(region) .phoneNumber(dto.phoneNumber()) .category(dto.category()) - .approvalStatus(StoreApprovalStatus.PENDING) .bookingIntervalMinutes(dto.bookingIntervalMinutes()) + .minPrice(dto.minPrice()) + .depositRate(dto.depositRate()) .build(); dto.businessHours().forEach(bhDto -> { From a8600d3406269c6c440584f799da78fd92cd8d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A4=80=EA=B7=9C?= <144649271+sonjunkyu@users.noreply.github.com> Date: Fri, 16 Jan 2026 18:58:59 +0900 Subject: [PATCH 139/169] =?UTF-8?q?[FEAT]:=20=EA=B0=80=EA=B2=8C=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94(StoreTable)=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StoreTable: Soft Delete 적용, 좌석 인원(min/max) 필드 세분화 - TableLayout: Soft Delete 적용, 배치도 활성/비활성 로직, Cascade 옵션 적용 - TableBlock: 예약 차단 시간 관리를 위한 엔티티 설계 - SlotStatus: 예약 시간대 상태 관리를 위한 Enum 및 DTO 구조 설계 - Common: DB 스키마 정합성을 위한 @Column 제약조건 명시 --- .../domain/storetable/entity/StoreTable.java | 35 +++++++++++++++++-- .../table_layout/entity/TableLayout.java | 20 +++++++++-- .../domain/tableblock/entity/TableBlock.java | 35 +++++++++++++++++++ .../domain/tableblock/enums/SlotStatus.java | 8 +++++ 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableblock/entity/TableBlock.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableblock/enums/SlotStatus.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java index 8bf83dc1..7b16850e 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java @@ -6,37 +6,68 @@ import com.eatsfine.eatsfine.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.math.BigDecimal; +import java.time.LocalDateTime; @Entity @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Getter +@SQLDelete(sql = "UPDATE store_table SET is_deleted = true WHERE id = ?") +@SQLRestriction("is_deleted = false") +@Table(name = "store_table") public class StoreTable extends BaseEntity { @Id - @GeneratedValue + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(name = "table_number", nullable = false, length = 30) private String tableNumber; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "table_layout_id", nullable = false) private TableLayout tableLayout; // 부모 변경 - private Integer tableSeats; + private Integer tableSeats; // 추후 삭제 예정 + + @Column(name = "min_seat_count", nullable = false) + private int minSeatCount; + + @Column(name = "max_seat_count", nullable = false) + private int maxSeatCount; @Enumerated(EnumType.STRING) @Column(name = "seats_type") private SeatsType seatsType; + @Column(name = "grid_x", nullable = false) private int gridX; + @Column(name = "grid_y", nullable = false) private int gridY; + @Column(name = "width_span", nullable = false) private int widthSpan; + @Column(name = "height_span", nullable = false) private int heightSpan; + @Builder.Default + @Column(name = "rating", precision = 2, scale = 1, nullable = false) + private BigDecimal rating = BigDecimal.ZERO; + + @Column(name = "table_image_url") + private String tableImageUrl; + + @Builder.Default + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java index 3b4956cc..7231dcea 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java @@ -5,7 +5,10 @@ import com.eatsfine.eatsfine.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -14,6 +17,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder +@SQLDelete(sql = "UPDATE table_layout SET is_deleted = true WHERE id = ?") +@SQLRestriction("is_deleted = false") @Table(name = "table_layout") public class TableLayout extends BaseEntity { @@ -25,14 +30,23 @@ public class TableLayout extends BaseEntity { @JoinColumn(name = "store_id", nullable = false) private Store store; + @Column(name = "grid_rows", nullable = false) private int lows; + + @Column(name = "grid_cols", nullable = false) private int cols; - @Column(name = "is_active") + @Column(name = "is_active", nullable = false) private boolean isActive; - @OneToMany(mappedBy = "tableLayout") - private List tables = new ArrayList<>(); + @Column(name = "is_deleted", nullable = false) + @Builder.Default + private boolean isDeleted = false; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @OneToMany(mappedBy = "tableLayout", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) + private List tables = new ArrayList<>(); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/entity/TableBlock.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/entity/TableBlock.java new file mode 100644 index 00000000..f38ab2f8 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/entity/TableBlock.java @@ -0,0 +1,35 @@ +package com.eatsfine.eatsfine.domain.tableblock.entity; + +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "table_block") +public class TableBlock extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_table_id", nullable = false) + private StoreTable storeTable; + + @Column(name = "target_date", nullable = false) + private LocalDate targetDate; + + @Column(name = "start_time", nullable = false) + private LocalTime startTime; + + @Column(name = "end_time", nullable = false) + private LocalTime endTime; +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/enums/SlotStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/enums/SlotStatus.java new file mode 100644 index 00000000..9b35d668 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/enums/SlotStatus.java @@ -0,0 +1,8 @@ +package com.eatsfine.eatsfine.domain.tableblock.enums; + +public enum SlotStatus { + AVAILABLE, + BOOKED, + BLOCKED, + BREAK_TIME +} From dc981b6e6a68abab3a46cf124fd87c4d6659f4e8 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:38:21 +0900 Subject: [PATCH 140/169] =?UTF-8?q?[FIX]:=20=EA=B0=84=ED=8E=B8=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=EB=A7=8C=20=EB=86=94=EB=91=90=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/payment/enums/PaymentMethod.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java index 07f47343..62f47c77 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java @@ -6,10 +6,7 @@ @Getter @AllArgsConstructor public enum PaymentMethod { - CARD("카드"), - VIRTUAL_ACCOUNT("가상계좌"), - SIMPLE_PAYMENT("간편결제"), - PHONE("휴대폰"); + SIMPLE_PAYMENT("간편결제"); private final String description; } From 171348c31e3af3a30ab7e33e53de783371b71524 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Sun, 18 Jan 2026 00:39:17 +0900 Subject: [PATCH 141/169] =?UTF-8?q?[FEAT]:=20=ED=86=A0=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EB=A8=BC=EC=B8=A0=20=EA=B2=B0=EC=A0=9C=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- .../payment/controller/PaymentController.java | 10 +- .../dto/request/PaymentConfirmDTO.java | 11 ++ .../dto/request/PaymentRequestDTO.java | 6 +- .../dto/response/PaymentResponseDTO.java | 5 +- .../dto/response/TossPaymentResponse.java | 29 ++++ .../domain/payment/entity/Payment.java | 6 +- .../payment/repository/PaymentRepository.java | 3 + .../payment/service/PaymentService.java | 131 ++++++++++++------ .../apiPayload/code/status/ErrorStatus.java | 7 +- .../global/config/TossPaymentConfig.java | 26 ++++ src/main/resources/application-local.yml | 4 + 12 files changed, 185 insertions(+), 58 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java create mode 100644 src/main/java/com/eatsfine/eatsfine/global/config/TossPaymentConfig.java diff --git a/.gitignore b/.gitignore index fcdd2719..a0b80340 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,7 @@ src/main/resources/application.yml build.gradle # QueryDSL generated sources -/build/generated/ \ No newline at end of file +/build/generated/ + +src/main/resources/static/payment-test.html +src/main/resources/static/payment-success.html diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java index d3284402..dc3d4a16 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java @@ -1,5 +1,6 @@ package com.eatsfine.eatsfine.domain.payment.controller; +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentConfirmDTO; import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; import com.eatsfine.eatsfine.domain.payment.service.PaymentService; @@ -21,10 +22,17 @@ public class PaymentController { private final PaymentService paymentService; - @Operation(summary = "결제 요청", description = "예약 ID와 결제 제공자를 받아 결제를 요청합니다.") + @Operation(summary = "결제 요청", description = "예약 ID를 받아 주문 ID를 생성하고 결제 정보를 초기화합니다.") @PostMapping("/request") public ApiResponse requestPayment( @RequestBody @Valid PaymentRequestDTO.RequestPaymentDTO dto) { return ApiResponse.onSuccess(paymentService.requestPayment(dto)); } + + @Operation(summary = "결제 승인", description = "토스페이먼츠 결제 승인을 요청합니다.") + @PostMapping("/confirm") + public ApiResponse confirmPayment( + @RequestBody @Valid PaymentConfirmDTO dto) { + return ApiResponse.onSuccess(paymentService.confirmPayment(dto)); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java new file mode 100644 index 00000000..863c4a69 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java @@ -0,0 +1,11 @@ +package com.eatsfine.eatsfine.domain.payment.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record PaymentConfirmDTO( + @NotNull String paymentKey, + @NotNull String orderId, + @NotNull Integer amount) { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java index 199e4f0d..a7dc7884 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java @@ -1,14 +1,10 @@ package com.eatsfine.eatsfine.domain.payment.dto.request; -import com.eatsfine.eatsfine.domain.payment.enums.PaymentProvider; import jakarta.validation.constraints.NotNull; public class PaymentRequestDTO { public record RequestPaymentDTO( - @NotNull Long bookingId, - @NotNull PaymentProvider provider, - @NotNull String successUrl, - @NotNull String failUrl) { + @NotNull Long bookingId) { } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java index 5f4fc62b..8f100eda 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java @@ -10,11 +10,8 @@ public class PaymentResponseDTO { public record PaymentRequestResultDTO( Long paymentId, Long bookingId, - PaymentMethod paymentMethod, - String tid, + String orderId, Integer amount, - PaymentStatus paymentStatus, - String nextRedirectUrl, LocalDateTime requestedAt) { } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java new file mode 100644 index 00000000..5e41d6f5 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java @@ -0,0 +1,29 @@ +package com.eatsfine.eatsfine.domain.payment.dto.response; + +import java.time.OffsetDateTime; + +public record TossPaymentResponse( + String paymentKey, + String type, + String orderId, + String orderName, + String mId, + String currency, + String method, + Integer totalAmount, + Integer balanceAmount, + String status, + OffsetDateTime requestedAt, + OffsetDateTime approvedAt, + Boolean useEscrow, + String lastTransactionKey, + Integer suppliedAmount, + Integer vat, + EasyPay easyPay) { + + public record EasyPay( + String provider, + Integer amount, + Integer discountAmount) { + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java index 8e2f8f8a..ae339a7c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -38,7 +38,7 @@ public class Payment extends BaseEntity { private String paymentKey; @Enumerated(EnumType.STRING) - @Column(name = "payment_provider", nullable = false) + @Column(name = "payment_provider") private PaymentProvider paymentProvider; @Enumerated(EnumType.STRING) @@ -63,10 +63,12 @@ public void setPaymentKey(String paymentKey) { this.paymentKey = paymentKey; } - public void completePayment(LocalDateTime approvedAt, PaymentMethod method, String paymentKey) { + public void completePayment(LocalDateTime approvedAt, PaymentMethod method, String paymentKey, + PaymentProvider provider) { this.paymentStatus = PaymentStatus.COMPLETED; this.approvedAt = approvedAt; this.paymentMethod = method; this.paymentKey = paymentKey; + this.paymentProvider = provider; } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java index 3c2c17ce..39567acf 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java @@ -3,5 +3,8 @@ import com.eatsfine.eatsfine.domain.payment.entity.Payment; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface PaymentRepository extends JpaRepository { + Optional findByOrderId(String orderId); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java index b8ec2f27..4414afe2 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -2,18 +2,22 @@ import com.eatsfine.eatsfine.domain.booking.entity.Booking; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; -import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; -import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentConfirmDTO; import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; +import com.eatsfine.eatsfine.domain.payment.dto.response.TossPaymentResponse; import com.eatsfine.eatsfine.domain.payment.entity.Payment; - +import com.eatsfine.eatsfine.domain.payment.enums.PaymentMethod; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentProvider; import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; import com.eatsfine.eatsfine.domain.payment.enums.PaymentType; import com.eatsfine.eatsfine.domain.payment.repository.PaymentRepository; +import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClient; import java.time.LocalDateTime; import java.util.UUID; @@ -22,46 +26,85 @@ @RequiredArgsConstructor public class PaymentService { - private final PaymentRepository paymentRepository; - private final BookingRepository bookingRepository; - - @Transactional - public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestDTO.RequestPaymentDTO dto) { - Booking booking = bookingRepository.findById(dto.bookingId()) - .orElseThrow(() -> new IllegalArgumentException("Booking not found")); - - // 주문 ID 생성 - String orderId = UUID.randomUUID().toString(); - - // 예약금 검증 - if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { - throw new GeneralException(ErrorStatus.PAYMENT_INVALID_DEPOSIT); - } - - Payment payment = Payment.builder() - .booking(booking) - .orderId(orderId) - .amount(booking.getDepositAmount()) - .paymentProvider(dto.provider()) - .paymentStatus(PaymentStatus.PENDING) - .paymentType(PaymentType.DEPOSIT) - .requestedAt(LocalDateTime.now()) - .build(); - - Payment savedPayment = paymentRepository.save(payment); - - // 외부 결제 제공자 응답 모의 처리 - String tid = "T" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); - String nextRedirectUrl = "https://mock.api.kakaopay.com/online/v1/payment/ready/" + tid; - - return new PaymentResponseDTO.PaymentRequestResultDTO( - savedPayment.getId(), - booking.getId(), - savedPayment.getPaymentMethod(), - tid, - savedPayment.getAmount(), - savedPayment.getPaymentStatus(), - nextRedirectUrl, - savedPayment.getRequestedAt()); + private final PaymentRepository paymentRepository; + private final BookingRepository bookingRepository; + private final RestClient tossPaymentClient; + + @Transactional + public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestDTO.RequestPaymentDTO dto) { + Booking booking = bookingRepository.findById(dto.bookingId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.BOOKING_NOT_FOUND)); + + // 주문 ID 생성 + String orderId = UUID.randomUUID().toString(); + + // 예약금 검증 + if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { + throw new GeneralException(ErrorStatus.PAYMENT_INVALID_DEPOSIT); } + + Payment payment = Payment.builder() + .booking(booking) + .orderId(orderId) + .amount(booking.getDepositAmount()) + .paymentStatus(PaymentStatus.PENDING) + .paymentType(PaymentType.DEPOSIT) + .requestedAt(LocalDateTime.now()) + .build(); + + Payment savedPayment = paymentRepository.save(payment); + + return new PaymentResponseDTO.PaymentRequestResultDTO( + savedPayment.getId(), + booking.getId(), + savedPayment.getOrderId(), + savedPayment.getAmount(), + savedPayment.getRequestedAt()); + } + + @Transactional + public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmDTO dto) { + Payment payment = paymentRepository.findByOrderId(dto.orderId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.PAYMENT_NOT_FOUND)); + + if (!payment.getAmount().equals(dto.amount())) { + throw new GeneralException(ErrorStatus.PAYMENT_INVALID_AMOUNT); + } + + // 토스 API 호출 + TossPaymentResponse response = tossPaymentClient.post() + .uri("/v1/payments/confirm") + .body(dto) + .retrieve() + .body(TossPaymentResponse.class); + + if (response == null || !"DONE".equals(response.status())) { + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + + // Provider 파싱 + PaymentProvider provider = null; + if (response.easyPay() != null) { + String providerCode = response.easyPay().provider(); + if ("토스페이".equals(providerCode)) { + provider = PaymentProvider.TOSS; + } else if ("카카오페이".equals(providerCode)) { + provider = PaymentProvider.KAKAOPAY; + } + } + + payment.completePayment( + response.approvedAt() != null ? response.approvedAt().toLocalDateTime() : LocalDateTime.now(), + PaymentMethod.SIMPLE_PAYMENT, + response.paymentKey(), + provider + ); + + return new PaymentResponseDTO.PaymentRequestResultDTO( + payment.getId(), + payment.getBooking().getId(), + payment.getOrderId(), + payment.getAmount(), + payment.getRequestedAt()); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java index fc94377e..f10d8c86 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java @@ -16,7 +16,12 @@ public enum ErrorStatus implements BaseErrorCode { _NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404", "존재하지 않는 요청입니다."), // 예약금 관련 에러 - PAYMENT_INVALID_DEPOSIT(HttpStatus.BAD_REQUEST, "PAYMENT4001", "예약금이 유효하지 않습니다."); + PAYMENT_INVALID_DEPOSIT(HttpStatus.BAD_REQUEST, "PAYMENT4001", "예약금이 유효하지 않습니다."), + PAYMENT_INVALID_AMOUNT(HttpStatus.BAD_REQUEST, "PAYMENT4002", "결제 금액이 일치하지 않습니다."), + PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT4003", "결제 정보를 찾을 수 없습니다."), + + // 예약 관련 에러 + BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING4001", "예약을 찾을 수 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/TossPaymentConfig.java b/src/main/java/com/eatsfine/eatsfine/global/config/TossPaymentConfig.java new file mode 100644 index 00000000..d210a393 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/config/TossPaymentConfig.java @@ -0,0 +1,26 @@ +package com.eatsfine.eatsfine.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +import java.util.Base64; + +@Configuration +public class TossPaymentConfig { + + @Value("${payment.toss.widget-secret-key}") + private String widgetSecretKey; + + @Bean + public RestClient tossPaymentClient() { + String encodedSecretKey = Base64.getEncoder().encodeToString((widgetSecretKey + ":").getBytes()); + + return RestClient.builder() + .baseUrl("https://api.tosspayments.com") + .defaultHeader("Authorization", "Basic " + encodedSecretKey) + .defaultHeader("Content-Type", "application/json") + .build(); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ef35d7cf..6feee07c 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -23,3 +23,7 @@ spring: properties: hibernate: format_sql: true + +payment: + toss: + widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 From 19a42d2594e7027ffdfd1b2503adff2bb15d6a7c Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Sun, 18 Jan 2026 01:14:13 +0900 Subject: [PATCH 142/169] =?UTF-8?q?[FEAT]:=20=EB=8F=85=EB=A6=BD=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=EC=9D=84=20=EC=9C=84=ED=95=9C=20H2=20DB=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=ED=95=B4?= =?UTF-8?q?=EC=84=9C=20=EB=B9=8C=EB=93=9C=20=EC=8B=A4=ED=8C=A8=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index ce96af2e..920ac33d 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,5 +1,18 @@ spring: - application: - name: Eatsfine - profiles: - active: test \ No newline at end of file + datasource: + url: jdbc:h2:mem:testdb;MODE=MySQL + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + +payment: + toss: + widget-secret-key: test_sk_sample_key_for_testing \ No newline at end of file From 106291468188a86ee6e11271636efa36da6cd5ab Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:50:19 +0900 Subject: [PATCH 143/169] =?UTF-8?q?[FIX]:=20username=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 920ac33d..c005f2ef 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -2,7 +2,7 @@ spring: datasource: url: jdbc:h2:mem:testdb;MODE=MySQL driver-class-name: org.h2.Driver - username: sa + username: password: jpa: hibernate: From 3108c2a4ac7ba1a28ae890b83741ea631044efce Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:47:27 +0900 Subject: [PATCH 144/169] Feature/payment (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [FIX]: 간편결제만 놔두기 * [FEAT]: 토스페이먼츠 결제위젯 연동 및 승인 프로세스 구현 * [FEAT]: 독립적인 테스트 환경 구성을 위한 H2 DB 및 설정 추가해서 빌드 실패 해결 * [FIX]: username 삭제 --- .gitignore | 5 +- .../payment/controller/PaymentController.java | 10 +- .../dto/request/PaymentConfirmDTO.java | 11 ++ .../dto/request/PaymentRequestDTO.java | 6 +- .../dto/response/PaymentResponseDTO.java | 5 +- .../dto/response/TossPaymentResponse.java | 29 ++++ .../domain/payment/entity/Payment.java | 6 +- .../domain/payment/enums/PaymentMethod.java | 5 +- .../payment/repository/PaymentRepository.java | 3 + .../payment/service/PaymentService.java | 131 ++++++++++++------ .../apiPayload/code/status/ErrorStatus.java | 7 +- .../global/config/TossPaymentConfig.java | 26 ++++ src/main/resources/application-local.yml | 4 + src/test/resources/application.yml | 21 ++- 14 files changed, 203 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java create mode 100644 src/main/java/com/eatsfine/eatsfine/global/config/TossPaymentConfig.java diff --git a/.gitignore b/.gitignore index fcdd2719..a0b80340 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,7 @@ src/main/resources/application.yml build.gradle # QueryDSL generated sources -/build/generated/ \ No newline at end of file +/build/generated/ + +src/main/resources/static/payment-test.html +src/main/resources/static/payment-success.html diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java index d3284402..dc3d4a16 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java @@ -1,5 +1,6 @@ package com.eatsfine.eatsfine.domain.payment.controller; +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentConfirmDTO; import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; import com.eatsfine.eatsfine.domain.payment.service.PaymentService; @@ -21,10 +22,17 @@ public class PaymentController { private final PaymentService paymentService; - @Operation(summary = "결제 요청", description = "예약 ID와 결제 제공자를 받아 결제를 요청합니다.") + @Operation(summary = "결제 요청", description = "예약 ID를 받아 주문 ID를 생성하고 결제 정보를 초기화합니다.") @PostMapping("/request") public ApiResponse requestPayment( @RequestBody @Valid PaymentRequestDTO.RequestPaymentDTO dto) { return ApiResponse.onSuccess(paymentService.requestPayment(dto)); } + + @Operation(summary = "결제 승인", description = "토스페이먼츠 결제 승인을 요청합니다.") + @PostMapping("/confirm") + public ApiResponse confirmPayment( + @RequestBody @Valid PaymentConfirmDTO dto) { + return ApiResponse.onSuccess(paymentService.confirmPayment(dto)); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java new file mode 100644 index 00000000..863c4a69 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java @@ -0,0 +1,11 @@ +package com.eatsfine.eatsfine.domain.payment.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record PaymentConfirmDTO( + @NotNull String paymentKey, + @NotNull String orderId, + @NotNull Integer amount) { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java index 199e4f0d..a7dc7884 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java @@ -1,14 +1,10 @@ package com.eatsfine.eatsfine.domain.payment.dto.request; -import com.eatsfine.eatsfine.domain.payment.enums.PaymentProvider; import jakarta.validation.constraints.NotNull; public class PaymentRequestDTO { public record RequestPaymentDTO( - @NotNull Long bookingId, - @NotNull PaymentProvider provider, - @NotNull String successUrl, - @NotNull String failUrl) { + @NotNull Long bookingId) { } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java index 5f4fc62b..8f100eda 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java @@ -10,11 +10,8 @@ public class PaymentResponseDTO { public record PaymentRequestResultDTO( Long paymentId, Long bookingId, - PaymentMethod paymentMethod, - String tid, + String orderId, Integer amount, - PaymentStatus paymentStatus, - String nextRedirectUrl, LocalDateTime requestedAt) { } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java new file mode 100644 index 00000000..5e41d6f5 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java @@ -0,0 +1,29 @@ +package com.eatsfine.eatsfine.domain.payment.dto.response; + +import java.time.OffsetDateTime; + +public record TossPaymentResponse( + String paymentKey, + String type, + String orderId, + String orderName, + String mId, + String currency, + String method, + Integer totalAmount, + Integer balanceAmount, + String status, + OffsetDateTime requestedAt, + OffsetDateTime approvedAt, + Boolean useEscrow, + String lastTransactionKey, + Integer suppliedAmount, + Integer vat, + EasyPay easyPay) { + + public record EasyPay( + String provider, + Integer amount, + Integer discountAmount) { + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java index 8e2f8f8a..ae339a7c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -38,7 +38,7 @@ public class Payment extends BaseEntity { private String paymentKey; @Enumerated(EnumType.STRING) - @Column(name = "payment_provider", nullable = false) + @Column(name = "payment_provider") private PaymentProvider paymentProvider; @Enumerated(EnumType.STRING) @@ -63,10 +63,12 @@ public void setPaymentKey(String paymentKey) { this.paymentKey = paymentKey; } - public void completePayment(LocalDateTime approvedAt, PaymentMethod method, String paymentKey) { + public void completePayment(LocalDateTime approvedAt, PaymentMethod method, String paymentKey, + PaymentProvider provider) { this.paymentStatus = PaymentStatus.COMPLETED; this.approvedAt = approvedAt; this.paymentMethod = method; this.paymentKey = paymentKey; + this.paymentProvider = provider; } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java index 07f47343..62f47c77 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/enums/PaymentMethod.java @@ -6,10 +6,7 @@ @Getter @AllArgsConstructor public enum PaymentMethod { - CARD("카드"), - VIRTUAL_ACCOUNT("가상계좌"), - SIMPLE_PAYMENT("간편결제"), - PHONE("휴대폰"); + SIMPLE_PAYMENT("간편결제"); private final String description; } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java index 3c2c17ce..39567acf 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java @@ -3,5 +3,8 @@ import com.eatsfine.eatsfine.domain.payment.entity.Payment; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface PaymentRepository extends JpaRepository { + Optional findByOrderId(String orderId); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java index b8ec2f27..4414afe2 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -2,18 +2,22 @@ import com.eatsfine.eatsfine.domain.booking.entity.Booking; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; -import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; -import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentConfirmDTO; import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; +import com.eatsfine.eatsfine.domain.payment.dto.response.TossPaymentResponse; import com.eatsfine.eatsfine.domain.payment.entity.Payment; - +import com.eatsfine.eatsfine.domain.payment.enums.PaymentMethod; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentProvider; import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; import com.eatsfine.eatsfine.domain.payment.enums.PaymentType; import com.eatsfine.eatsfine.domain.payment.repository.PaymentRepository; +import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClient; import java.time.LocalDateTime; import java.util.UUID; @@ -22,46 +26,85 @@ @RequiredArgsConstructor public class PaymentService { - private final PaymentRepository paymentRepository; - private final BookingRepository bookingRepository; - - @Transactional - public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestDTO.RequestPaymentDTO dto) { - Booking booking = bookingRepository.findById(dto.bookingId()) - .orElseThrow(() -> new IllegalArgumentException("Booking not found")); - - // 주문 ID 생성 - String orderId = UUID.randomUUID().toString(); - - // 예약금 검증 - if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { - throw new GeneralException(ErrorStatus.PAYMENT_INVALID_DEPOSIT); - } - - Payment payment = Payment.builder() - .booking(booking) - .orderId(orderId) - .amount(booking.getDepositAmount()) - .paymentProvider(dto.provider()) - .paymentStatus(PaymentStatus.PENDING) - .paymentType(PaymentType.DEPOSIT) - .requestedAt(LocalDateTime.now()) - .build(); - - Payment savedPayment = paymentRepository.save(payment); - - // 외부 결제 제공자 응답 모의 처리 - String tid = "T" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); - String nextRedirectUrl = "https://mock.api.kakaopay.com/online/v1/payment/ready/" + tid; - - return new PaymentResponseDTO.PaymentRequestResultDTO( - savedPayment.getId(), - booking.getId(), - savedPayment.getPaymentMethod(), - tid, - savedPayment.getAmount(), - savedPayment.getPaymentStatus(), - nextRedirectUrl, - savedPayment.getRequestedAt()); + private final PaymentRepository paymentRepository; + private final BookingRepository bookingRepository; + private final RestClient tossPaymentClient; + + @Transactional + public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestDTO.RequestPaymentDTO dto) { + Booking booking = bookingRepository.findById(dto.bookingId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.BOOKING_NOT_FOUND)); + + // 주문 ID 생성 + String orderId = UUID.randomUUID().toString(); + + // 예약금 검증 + if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { + throw new GeneralException(ErrorStatus.PAYMENT_INVALID_DEPOSIT); } + + Payment payment = Payment.builder() + .booking(booking) + .orderId(orderId) + .amount(booking.getDepositAmount()) + .paymentStatus(PaymentStatus.PENDING) + .paymentType(PaymentType.DEPOSIT) + .requestedAt(LocalDateTime.now()) + .build(); + + Payment savedPayment = paymentRepository.save(payment); + + return new PaymentResponseDTO.PaymentRequestResultDTO( + savedPayment.getId(), + booking.getId(), + savedPayment.getOrderId(), + savedPayment.getAmount(), + savedPayment.getRequestedAt()); + } + + @Transactional + public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmDTO dto) { + Payment payment = paymentRepository.findByOrderId(dto.orderId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.PAYMENT_NOT_FOUND)); + + if (!payment.getAmount().equals(dto.amount())) { + throw new GeneralException(ErrorStatus.PAYMENT_INVALID_AMOUNT); + } + + // 토스 API 호출 + TossPaymentResponse response = tossPaymentClient.post() + .uri("/v1/payments/confirm") + .body(dto) + .retrieve() + .body(TossPaymentResponse.class); + + if (response == null || !"DONE".equals(response.status())) { + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + + // Provider 파싱 + PaymentProvider provider = null; + if (response.easyPay() != null) { + String providerCode = response.easyPay().provider(); + if ("토스페이".equals(providerCode)) { + provider = PaymentProvider.TOSS; + } else if ("카카오페이".equals(providerCode)) { + provider = PaymentProvider.KAKAOPAY; + } + } + + payment.completePayment( + response.approvedAt() != null ? response.approvedAt().toLocalDateTime() : LocalDateTime.now(), + PaymentMethod.SIMPLE_PAYMENT, + response.paymentKey(), + provider + ); + + return new PaymentResponseDTO.PaymentRequestResultDTO( + payment.getId(), + payment.getBooking().getId(), + payment.getOrderId(), + payment.getAmount(), + payment.getRequestedAt()); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java index fc94377e..f10d8c86 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java @@ -16,7 +16,12 @@ public enum ErrorStatus implements BaseErrorCode { _NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404", "존재하지 않는 요청입니다."), // 예약금 관련 에러 - PAYMENT_INVALID_DEPOSIT(HttpStatus.BAD_REQUEST, "PAYMENT4001", "예약금이 유효하지 않습니다."); + PAYMENT_INVALID_DEPOSIT(HttpStatus.BAD_REQUEST, "PAYMENT4001", "예약금이 유효하지 않습니다."), + PAYMENT_INVALID_AMOUNT(HttpStatus.BAD_REQUEST, "PAYMENT4002", "결제 금액이 일치하지 않습니다."), + PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT4003", "결제 정보를 찾을 수 없습니다."), + + // 예약 관련 에러 + BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING4001", "예약을 찾을 수 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/TossPaymentConfig.java b/src/main/java/com/eatsfine/eatsfine/global/config/TossPaymentConfig.java new file mode 100644 index 00000000..d210a393 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/config/TossPaymentConfig.java @@ -0,0 +1,26 @@ +package com.eatsfine.eatsfine.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +import java.util.Base64; + +@Configuration +public class TossPaymentConfig { + + @Value("${payment.toss.widget-secret-key}") + private String widgetSecretKey; + + @Bean + public RestClient tossPaymentClient() { + String encodedSecretKey = Base64.getEncoder().encodeToString((widgetSecretKey + ":").getBytes()); + + return RestClient.builder() + .baseUrl("https://api.tosspayments.com") + .defaultHeader("Authorization", "Basic " + encodedSecretKey) + .defaultHeader("Content-Type", "application/json") + .build(); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ef35d7cf..6feee07c 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -23,3 +23,7 @@ spring: properties: hibernate: format_sql: true + +payment: + toss: + widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index ce96af2e..c005f2ef 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,5 +1,18 @@ spring: - application: - name: Eatsfine - profiles: - active: test \ No newline at end of file + datasource: + url: jdbc:h2:mem:testdb;MODE=MySQL + driver-class-name: org.h2.Driver + username: + password: + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + +payment: + toss: + widget-secret-key: test_sk_sample_key_for_testing \ No newline at end of file From 9499e14b4e3582d120b666ee82ef496981ab7e62 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Mon, 19 Jan 2026 21:37:35 +0900 Subject: [PATCH 145/169] =?UTF-8?q?[FEAT]:=20=EC=8B=9D=EB=8B=B9=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=A0=95=EB=B3=B4,=20=EC=98=81=EC=97=85?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=88=98=EC=A0=95,=20=EB=B8=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=ED=81=AC=ED=83=80=EC=9E=84=20=EC=84=A4=EC=A0=95=20API?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=20=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [FEAT]: 가게 기본 정보 수정 DTO 추가 * [FEAT]: StoreSuccessStatus에 가게 기본 정보 수정 성공 응답 추가 * [FEAT]: Store 엔티티에 가게 기본 정보 수정 메서드 추가 * [FEAT]: 가게 기본 정보 수정 로직 개발 * [FEAT]: StoreController에 가게 기본 정보 수정 API 추가 * [FEAT]: 영업시간 검증 메서드(생성용/수정용) 분리 * [FEAT]: 영업시간 수정 DTO 추가 * [FEAT]: 응답 컨버터 및 상태코드 추가 * [FEAT]: 영업 시간 수정 로직 개발 * [REFACTOR]: 영업시간 변경 응답 7일 모두 내려주도록 수정 및 필드명 변경 * [FEAT]: 브레이크타임 설정 DTO 및 응답코드 추가 * [FEAT]: 요청된 브레이크타임 유효성 검증 로직 개발 * [FEAT]: 브레이크타임 설정 로직 구현 * [FEAT]: 상세조회 응답에 브레이크타임 추가 --- .../controller/BusinessHoursController.java | 51 ++++++++++++++ .../converter/BusinessHoursConverter.java | 29 +++++++- .../dto/BusinessHoursReqDto.java | 22 +++++- .../dto/BusinessHoursResDto.java | 20 ++++++ .../businesshours/entity/BusinessHours.java | 17 ++++- .../service/BusinessHoursCommandService.java | 17 +++++ .../BusinessHoursCommandServiceImpl.java | 68 +++++++++++++++++++ .../status/BusinessHoursErrorStatus.java | 14 ++-- .../status/BusinessHoursSuccessStatus.java | 40 +++++++++++ .../validator/BreakTimeValidator.java | 29 ++++++++ .../validator/BusinessHoursValidator.java | 11 ++- .../store/controller/StoreController.java | 14 ++++ .../store/converter/StoreConverter.java | 16 +++++ .../domain/store/dto/StoreReqDto.java | 26 +++++++ .../domain/store/dto/StoreResDto.java | 16 +++++ .../eatsfine/domain/store/entity/Store.java | 46 ++++++++++++- .../store/service/StoreCommandService.java | 1 + .../service/StoreCommandServiceImpl.java | 35 +++++++++- .../store/service/StoreQueryServiceImpl.java | 2 +- .../store/status/StoreSuccessStatus.java | 6 +- 20 files changed, 459 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/controller/BusinessHoursController.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursCommandService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursCommandServiceImpl.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursSuccessStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BreakTimeValidator.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/controller/BusinessHoursController.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/controller/BusinessHoursController.java new file mode 100644 index 00000000..3c0aa8fb --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/controller/BusinessHoursController.java @@ -0,0 +1,51 @@ +package com.eatsfine.eatsfine.domain.businesshours.controller; + +import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursReqDto; +import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursResDto; +import com.eatsfine.eatsfine.domain.businesshours.service.BusinessHoursCommandService; +import com.eatsfine.eatsfine.domain.businesshours.status.BusinessHoursSuccessStatus; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "BusinessHours", description = "영업시간 관련 API") +@RequestMapping("/api/v1") +@RestController +@RequiredArgsConstructor +public class BusinessHoursController { + + private final BusinessHoursCommandService businessHoursCommandService; + + @Operation( + summary = "가게 영업시간 수정", + description = "가게의 영업시간을 수정합니다." + ) + @PatchMapping("/stores/{storeId}/business-hours") + public ApiResponse updateBusinessHours( + @PathVariable Long storeId, + @RequestBody BusinessHoursReqDto.UpdateBusinessHoursDto dto + ){ + return ApiResponse.of( + BusinessHoursSuccessStatus._UPDATE_BUSINESS_HOURS_SUCCESS, + businessHoursCommandService.updateBusinessHours(storeId, dto) + ); + } + + @Operation( + summary = "브레이크타임 설정", + description = "가게의 브레이크타임을 설정합니다." + ) + @PatchMapping("/stores/{storeId}/break-time") + public ApiResponse updateBreakTime( + @PathVariable Long storeId, + @RequestBody BusinessHoursReqDto.UpdateBreakTimeDto dto + ){ + return ApiResponse.of( + BusinessHoursSuccessStatus._UPDATE_BREAKTIME_SUCCESS, + businessHoursCommandService.updateBreakTime(storeId, dto) + ); + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java index ff7d01bf..e74b3374 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/converter/BusinessHoursConverter.java @@ -4,14 +4,16 @@ import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursResDto; import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import java.util.List; + public class BusinessHoursConverter { public static BusinessHours toEntity(BusinessHoursReqDto.Summary dto) { return BusinessHours.builder() - .dayOfWeek(dto.dayOfWeek()) + .dayOfWeek(dto.day()) .openTime(dto.openTime()) .closeTime(dto.closeTime()) - .isHoliday(dto.isClosed()) // 특정 요일 고정 휴무 + .isClosed(dto.isClosed()) // 특정 요일 고정 휴무 .build(); } @@ -19,9 +21,11 @@ public static BusinessHours toEntity(BusinessHoursReqDto.Summary dto) { public static BusinessHoursResDto.Summary toSummary(BusinessHours bh) { // 휴무일 때 - if(bh.isHoliday()) { + if(bh.isClosed()) { return BusinessHoursResDto.Summary.builder() .day(bh.getDayOfWeek()) + .openTime(null) + .closeTime(null) .isClosed(true) .build(); } @@ -33,4 +37,23 @@ public static BusinessHoursResDto.Summary toSummary(BusinessHours bh) { .isClosed(false) .build(); } + + public static BusinessHoursResDto.UpdateBusinessHoursDto toUpdateBusinessHoursDto(Long storeId, List updatedBusinessHours) { + return BusinessHoursResDto.UpdateBusinessHoursDto.builder() + .storeId(storeId) + .updatedBusinessHours( + updatedBusinessHours.stream().map( + BusinessHoursConverter::toSummary + ).toList() + ) + .build(); + } + + public static BusinessHoursResDto.UpdateBreakTimeDto toUpdateBreakTimeDto(Long storeId, BusinessHoursReqDto.UpdateBreakTimeDto dto) { + return BusinessHoursResDto.UpdateBreakTimeDto.builder() + .storeId(storeId) + .breakStartTime(dto.breakStartTime()) + .breakEndTime(dto.breakEndTime()) + .build(); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java index be14695a..10db26c9 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursReqDto.java @@ -1,11 +1,13 @@ package com.eatsfine.eatsfine.domain.businesshours.dto; import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Builder; import java.time.DayOfWeek; import java.time.LocalTime; +import java.util.List; public class BusinessHoursReqDto { @@ -13,7 +15,7 @@ public class BusinessHoursReqDto { public record Summary( @NotNull(message = "요일은 필수입니다.") - DayOfWeek dayOfWeek, + DayOfWeek day, @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime openTime, @@ -23,4 +25,22 @@ public record Summary( boolean isClosed ){} + + @Builder + public record UpdateBusinessHoursDto( + @Valid + List businessHours + ){} + + @Builder + public record UpdateBreakTimeDto( + + @NotNull(message = "브레이크타임 시작 시간은 필수입니다.") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") + LocalTime breakStartTime, + + @NotNull(message = "브레이크타임 종료 시간은 필수입니다.") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") + LocalTime breakEndTime + ){} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java index bae21008..d7674866 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/dto/BusinessHoursResDto.java @@ -5,6 +5,7 @@ import java.time.DayOfWeek; import java.time.LocalTime; +import java.util.List; public class BusinessHoursResDto { @@ -20,4 +21,23 @@ public record Summary( boolean isClosed // true = 휴무, false = 영업 ){} + + // 영업시간 수정 응답 + @Builder + public record UpdateBusinessHoursDto( + Long storeId, + List updatedBusinessHours + ){} + + // 브레이크타임 설정 응답 + @Builder + public record UpdateBreakTimeDto( + Long storeId, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") + LocalTime breakStartTime, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") + LocalTime breakEndTime + ){} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java index 61883ae3..b9a332fb 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java @@ -41,10 +41,23 @@ public class BusinessHours extends BaseEntity { // 휴일 여부 (특정 요일 고정 휴무) @Builder.Default - @Column(name = "is_holiday", nullable = false) - private boolean isHoliday = false; + @Column(name = "is_closed", nullable = false) + private boolean isClosed = false; public void assignStore(Store store){ this.store = store; } + + // 영업시간 변경 + public void update(LocalTime open, LocalTime close, boolean isClosed){ + this.openTime = open; + this.closeTime = close; + this.isClosed = isClosed; + } + + // 브레이크타임 변경 + public void updateBreakTime(LocalTime breakStart, LocalTime breakEnd){ + this.breakStartTime = breakStart; + this.breakEndTime = breakEnd; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursCommandService.java new file mode 100644 index 00000000..e7526242 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursCommandService.java @@ -0,0 +1,17 @@ +package com.eatsfine.eatsfine.domain.businesshours.service; + +import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursReqDto; +import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursResDto; + +public interface BusinessHoursCommandService { + BusinessHoursResDto.UpdateBusinessHoursDto updateBusinessHours( + Long storeId, + BusinessHoursReqDto.UpdateBusinessHoursDto updateBusinessHoursDto + ); + + BusinessHoursResDto.UpdateBreakTimeDto updateBreakTime( + Long storeId, + BusinessHoursReqDto.UpdateBreakTimeDto dto + ); + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursCommandServiceImpl.java new file mode 100644 index 00000000..d5416dd8 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/service/BusinessHoursCommandServiceImpl.java @@ -0,0 +1,68 @@ +package com.eatsfine.eatsfine.domain.businesshours.service; + +import com.eatsfine.eatsfine.domain.businesshours.converter.BusinessHoursConverter; +import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursReqDto; +import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursResDto; +import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import com.eatsfine.eatsfine.domain.businesshours.validator.BreakTimeValidator; +import com.eatsfine.eatsfine.domain.businesshours.validator.BusinessHoursValidator; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class BusinessHoursCommandServiceImpl implements BusinessHoursCommandService { + + private final StoreRepository storeRepository; + + @Override + public BusinessHoursResDto.UpdateBusinessHoursDto updateBusinessHours( + Long storeId, + BusinessHoursReqDto.UpdateBusinessHoursDto dto + ) { + // 영업시간 검증 + BusinessHoursValidator.validateForUpdate(dto.businessHours()); + + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + dto.businessHours().forEach(s -> { + store.updateBusinessHours( + s.day(), + s.openTime(), + s.closeTime(), + s.isClosed() + ); + }); + + return BusinessHoursConverter.toUpdateBusinessHoursDto(storeId, store.getBusinessHours()); + } + + @Override + public BusinessHoursResDto.UpdateBreakTimeDto updateBreakTime( + Long storeId, + BusinessHoursReqDto.UpdateBreakTimeDto dto + ) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + for(BusinessHours bh : store.getBusinessHours()) { + if(bh.isClosed()) continue; + BreakTimeValidator.validateBreakTime(bh.getOpenTime(), bh.getCloseTime(), dto.breakStartTime(), dto.breakEndTime()); + } + + store.getBusinessHours().forEach(s -> { + if(!s.isClosed()) { + s.updateBreakTime(dto.breakStartTime(), dto.breakEndTime()); + } + }); + + return BusinessHoursConverter.toUpdateBreakTimeDto(storeId, dto); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursErrorStatus.java index e9a9310a..59e13312 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursErrorStatus.java @@ -10,14 +10,18 @@ @RequiredArgsConstructor public enum BusinessHoursErrorStatus implements BaseErrorCode { - _DUPLICATE_DAY_OF_WEEK(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS_400_1", "요일이 중복되었습니다."), - _BUSINESS_HOURS_NOT_COMPLETE(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS_400_2", "영업일은 7일 모두 입력되어야 합니다."), - _INVALID_BUSINESS_TIME(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS_400_3", "영업 시작 시간은 마감 시간보다 빨라야 합니다."), - _INVALID_OPEN_DAY(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS_400_4", "영업일에는 영업시간 및 마감 시간이 존재해야 합니다."), - _INVALID_CLOSED_DAY(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS_400_5", "휴무일에는 영업시간이 존재할 수 없습니다."), + _DUPLICATE_DAY_OF_WEEK(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS4001", "요일이 중복되었습니다."), + _BUSINESS_HOURS_NOT_COMPLETE(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS4002", "영업일은 7일 모두 입력되어야 합니다."), + _INVALID_BUSINESS_TIME(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS4003", "영업 시작 시간은 마감 시간보다 빨라야 합니다."), + _INVALID_OPEN_DAY(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS4004", "영업일에는 영업시간 및 마감 시간이 존재해야 합니다."), + _INVALID_CLOSED_DAY(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS4005", "휴무일에는 영업시간이 존재할 수 없습니다."), + _BUSINESS_HOURS_DAY_NOT_FOUND(HttpStatus.NOT_FOUND, "BUSINESS_HOURS404", "해당 요일이 존재하지 않습니다."), + _INVALID_BREAK_TIME(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS4006", "브레이크타임 시작 시간은 종료 시간보다 빨라야 합니다."), + _BREAK_TIME_OUT_OF_BUSINESS_HOURS(HttpStatus.BAD_REQUEST, "BUSINESS_HOURS4007", "브레이크타임은 영업시간 내에만 설정할 수 있습니다."), ; + private final HttpStatus httpStatus; private final String code; private final String message; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursSuccessStatus.java new file mode 100644 index 00000000..b0a12e45 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/status/BusinessHoursSuccessStatus.java @@ -0,0 +1,40 @@ +package com.eatsfine.eatsfine.domain.businesshours.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ReasonDto; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum BusinessHoursSuccessStatus implements BaseCode { + + _UPDATE_BUSINESS_HOURS_SUCCESS(HttpStatus.OK, "BUSINESS_HOURS200", "영업시간이 성공적으로 수정되었습니다."), + _UPDATE_BREAKTIME_SUCCESS(HttpStatus.OK, "BUSINESS_HOURS2001", "브레이크타임이 성공적으로 설정되었습니다.") + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + + @Override + public ReasonDto getReason() { + return ReasonDto.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return ReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BreakTimeValidator.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BreakTimeValidator.java new file mode 100644 index 00000000..a8766dd3 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BreakTimeValidator.java @@ -0,0 +1,29 @@ +package com.eatsfine.eatsfine.domain.businesshours.validator; + +import com.eatsfine.eatsfine.domain.businesshours.exception.BusinessHoursException; +import com.eatsfine.eatsfine.domain.businesshours.status.BusinessHoursErrorStatus; + +import java.time.LocalTime; + +public class BreakTimeValidator { + + public static void validateBreakTime(LocalTime openTime, LocalTime closeTime, LocalTime breakStartTime, LocalTime breakEndTime) { + + // 휴무일은 검증 대상이 아님 + if(openTime == null || closeTime == null) { + return; + } + + // start < end + if(!breakEndTime.isAfter(breakStartTime)) { + throw new BusinessHoursException(BusinessHoursErrorStatus._INVALID_BREAK_TIME); + } + + // 브레이크타임이 영업시간 내에 존재 + if(breakStartTime.isBefore(openTime) || breakEndTime.isAfter(closeTime)) { + throw new BusinessHoursException(BusinessHoursErrorStatus._BREAK_TIME_OUT_OF_BUSINESS_HOURS); + } + + + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java index f173851d..6313b7e9 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/validator/BusinessHoursValidator.java @@ -10,15 +10,20 @@ import java.util.Set; public class BusinessHoursValidator { - public static void validate(List dto) { + public static void validateForCreate(List dto) { validateComplete(dto); validateDuplicateDayOfWeek(dto); validateOpenDay(dto); validateClosedDay(dto); validateOpenCloseTime(dto); + } - + public static void validateForUpdate(List dto) { + validateDuplicateDayOfWeek(dto); + validateOpenDay(dto); + validateClosedDay(dto); + validateOpenCloseTime(dto); } // 7일 모두 입력 여부 검증 @@ -43,7 +48,7 @@ private static void validateOpenCloseTime(List dto) private static void validateDuplicateDayOfWeek(List dto) { Set set = new HashSet<>(); for(BusinessHoursReqDto.Summary s: dto) { - if(!set.add(s.dayOfWeek())) { + if(!set.add(s.day())) { throw new BusinessHoursException(BusinessHoursErrorStatus._DUPLICATE_DAY_OF_WEEK); } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java index c207c4b5..6527f479 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java @@ -57,4 +57,18 @@ public ApiResponse getStoreDetail(@PathVariable Long return ApiResponse.of(StoreSuccessStatus._STORE_DETAIL_FOUND, storeQueryService.getStoreDetail(storeId)); } + @Operation( + summary = "가게 기본 정보 수정", + description = "가게 기본 정보(영업시간, 브레이크타임 제외)를 수정합니다. " + + "영업시간, 브레이크타임, 이미지는 별도 엔티티/컬렉션이므로 개별 API로 분리" + ) + @PatchMapping("/stores/{storeId}") + public ApiResponse updateStoreBasicInfo( + @PathVariable Long storeId, + @Valid @RequestBody StoreReqDto.StoreUpdateDto dto + ) { + return ApiResponse.of(StoreSuccessStatus._STORE_UPDATE_SUCCESS, storeCommandService.updateBasicInfo(storeId, dto)); + } + + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java index 759aac6c..11f7ac4a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java @@ -1,11 +1,13 @@ package com.eatsfine.eatsfine.domain.store.converter; import com.eatsfine.eatsfine.domain.businesshours.converter.BusinessHoursConverter; +import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; import com.eatsfine.eatsfine.domain.store.entity.Store; import java.math.BigDecimal; import java.util.Collections; +import java.util.List; public class StoreConverter { @@ -30,6 +32,11 @@ public static StoreResDto.StoreSearchDto toSearchDto(Store store, Double distanc } public static StoreResDto.StoreDetailDto toDetailDto(Store store, boolean isOpenNow) { + BusinessHours anyOpenDay = store.getBusinessHours().stream() + .filter(bh -> !bh.isClosed()) + .findFirst() + .orElse(null); + return StoreResDto.StoreDetailDto.builder() .storeId(store.getId()) .storeName(store.getStoreName()) @@ -46,8 +53,17 @@ public static StoreResDto.StoreDetailDto toDetailDto(Store store, boolean isOpen store.getBusinessHours().stream() .map(BusinessHoursConverter::toSummary) .toList()) + .breakStartTime(anyOpenDay != null ? anyOpenDay.getBreakStartTime() : null) + .breakEndTime(anyOpenDay != null ? anyOpenDay.getBreakEndTime() : null) .isOpenNow(isOpenNow) // 추후 영업 여부 판단 로직 구현 예정 .build(); } + + public static StoreResDto.StoreUpdateDto toUpdateDto(Long storeId, List updatedFields) { + return StoreResDto.StoreUpdateDto.builder() + .storeId(storeId) + .updatedFields(updatedFields) + .build(); } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java index 3e7ff19b..73301164 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java @@ -6,6 +6,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import lombok.Builder; import java.util.List; @@ -29,6 +30,10 @@ public record StoreCreateDto( @NotBlank(message = "주소는 필수입니다.") String address, + @Pattern( + regexp = "^0\\d{1,2}-\\d{3,4}-\\d{4}$", + message = "전화번호 형식이 올바르지 않습니다." + ) @NotBlank(message = "전화번호는 필수입니다.") String phoneNumber, @@ -46,4 +51,25 @@ public record StoreCreateDto( @Valid List businessHours ){} + + @Builder + public record StoreUpdateDto( + String storeName, + + String description, + + @Pattern( + regexp = "^0\\d{1,2}-\\d{3,4}-\\d{4}$", + message = "전화번호 형식이 올바르지 않습니다. (예: 02-123-4567, 010-1234-5678)" + ) + String phoneNumber, + + Category category, + + Integer minPrice, + + DepositRate depositRate, + + Integer bookingIntervalMinutes + ){} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java index 7cd66f6c..a674eaa3 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java @@ -2,9 +2,11 @@ import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursResDto; import com.eatsfine.eatsfine.domain.store.enums.Category; +import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Builder; import java.math.BigDecimal; +import java.time.LocalTime; import java.util.List; public class StoreResDto { @@ -56,6 +58,13 @@ public record StoreDetailDto( String mainImageUrl, List tableImageUrls, List businessHours, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") + LocalTime breakStartTime, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") + LocalTime breakEndTime, + boolean isOpenNow ){} @@ -65,4 +74,11 @@ public record uploadMainImageResDto( String mainImageUrl ) {} + // 식당 수정 응답 + @Builder + public record StoreUpdateDto( + Long storeId, + List updatedFields + ){}; + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index 3717efdc..9f8dc340 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -1,7 +1,10 @@ package com.eatsfine.eatsfine.domain.store.entity; import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import com.eatsfine.eatsfine.domain.businesshours.exception.BusinessHoursException; +import com.eatsfine.eatsfine.domain.businesshours.status.BusinessHoursErrorStatus; import com.eatsfine.eatsfine.domain.region.entity.Region; +import com.eatsfine.eatsfine.domain.store.dto.StoreReqDto; import com.eatsfine.eatsfine.domain.store.enums.Category; import com.eatsfine.eatsfine.domain.store.enums.DepositRate; import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; @@ -16,6 +19,7 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.time.DayOfWeek; +import java.time.LocalTime; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -92,8 +96,6 @@ public class Store extends BaseEntity { private List tableImages = new ArrayList<>(); // StoreTable이 아닌 TableLayout 엔티티 참조 -// @OneToMany(mappedBy = "store") -// private List storeTables = new ArrayList<>(); @Builder.Default @OneToMany(mappedBy = "store") @@ -109,6 +111,15 @@ public void removeBusinessHours(BusinessHours businessHours) { businessHours.assignStore(null); } + // 영업시간 변경 + public void updateBusinessHours(DayOfWeek dayOfWeek, LocalTime open, LocalTime close, boolean isClosed) { + BusinessHours businessHours = this.businessHours.stream() + .filter(bh -> bh.getDayOfWeek() == dayOfWeek) + .findFirst() + .orElseThrow(() -> new BusinessHoursException(BusinessHoursErrorStatus._BUSINESS_HOURS_DAY_NOT_FOUND)); + + businessHours.update(open, close, isClosed); + } public void addTableImage(TableImage tableImage) { this.tableImages.add(tableImage); tableImage.assignStore(this); @@ -143,4 +154,35 @@ public BigDecimal calculateDepositAmount() { // StoreTable에 대한 연관관계 편의 메서드는 추후 추가 예정 + // 가게 기본 정보 변경 메서드 + public void updateBasicInfo(StoreReqDto.StoreUpdateDto dto) { + if(dto.storeName() != null) { + this.storeName = dto.storeName(); + } + + if(dto.description() != null) { + this.description = dto.description(); + } + + if(dto.phoneNumber() != null) { + this.phoneNumber = dto.phoneNumber(); + } + + if(dto.category() != null) { + this.category = dto.category(); + } + + if(dto.minPrice() != null) { + this.minPrice = dto.minPrice(); + } + + if(dto.depositRate() != null) { + this.depositRate = dto.depositRate(); + } + + if(dto.bookingIntervalMinutes() != null) { + this.bookingIntervalMinutes = dto.bookingIntervalMinutes(); + } + } + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java index f79fe42f..65ab0172 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java @@ -5,4 +5,5 @@ public interface StoreCommandService { StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto storeCreateDto); + StoreResDto.StoreUpdateDto updateBasicInfo(Long storeId, StoreReqDto.StoreUpdateDto storeUpdateDto); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java index f843fb67..982f0bee 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java @@ -12,9 +12,13 @@ import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.store.exception.StoreException; import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; -import jakarta.transaction.Transactional; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; @Service @Transactional @@ -30,7 +34,7 @@ public StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto dto) { .orElseThrow(() -> new StoreException(RegionErrorStatus._REGION_NOT_FOUND)); // 영업시간 정상 여부 검증 - BusinessHoursValidator.validate(dto.businessHours()); + BusinessHoursValidator.validateForCreate(dto.businessHours()); Store store = Store.builder() .owner(null) // User 도메인 머지 후 owner 처리 예정 @@ -57,4 +61,31 @@ public StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto dto) { return StoreConverter.toCreateDto(savedStore); } + // 가게 기본 정보 수정 (필드) + @Override + public StoreResDto.StoreUpdateDto updateBasicInfo(Long storeId, StoreReqDto.StoreUpdateDto dto) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + store.updateBasicInfo(dto); + List updatedFields = extractUpdatedFields(dto); + + return StoreConverter.toUpdateDto(storeId, updatedFields); + } + + // 수정된 필드 목록 + public List extractUpdatedFields(StoreReqDto.StoreUpdateDto dto) { + List updated = new ArrayList<>(); + + if(dto.storeName() != null) updated.add("storeName"); + if(dto.description() != null) updated.add("description"); + if(dto.phoneNumber() != null) updated.add("phoneNumber"); + if(dto.category() != null) updated.add("category"); + if(dto.minPrice() != null) updated.add("minPrice"); + if(dto.depositRate() != null) updated.add("depositRate"); + if(dto.bookingIntervalMinutes() != null) updated.add("bookingIntervalMinutes"); + + return updated; + } + } \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java index 7db92558..2c93816f 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java @@ -81,7 +81,7 @@ public boolean isOpenNow(Store store, LocalDateTime now) { return store.findBusinessHoursByDay(dayOfWeek) .map(bh -> { - if (bh.isHoliday()) return false; + if (bh.isClosed()) return false; if ((bh.getBreakStartTime() != null && bh.getBreakEndTime() != null)) { if (!time.isBefore(bh.getBreakStartTime()) && (time.isBefore(bh.getBreakEndTime()))) { diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java index 067ef69f..088f0935 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java @@ -14,9 +14,11 @@ public enum StoreSuccessStatus implements BaseCode { _STORE_SEARCH_SUCCESS(HttpStatus.OK, "STORE2002", "성공적으로 가게를 검색했습니다."), - _STORE_DETAIL_FOUND(HttpStatus.FOUND, "STORE_DETAIL200", "성공적으로 가게 상세 리뷰를 조회했습니다."), + _STORE_DETAIL_FOUND(HttpStatus.OK, "STORE2003", "성공적으로 가게 상세 리뷰를 조회했습니다."), - _STORE_CREATED(HttpStatus.CREATED, "STORE201", "성공적으로 가게를 등록했습니다.") + _STORE_CREATED(HttpStatus.CREATED, "STORE201", "성공적으로 가게를 등록했습니다."), + + _STORE_UPDATE_SUCCESS(HttpStatus.OK, "STORE2004", "성공적으로 가게 기본 정보를 수정했습니다.") ; From 08eebc6fb4d28c894e99224f358f3b78cf8faabc Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:01:50 +0900 Subject: [PATCH 146/169] =?UTF-8?q?[FEAT]:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EA=B2=B0=EA=B3=BC(=EC=84=B1=EA=B3=B5/?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8)=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payment/entity/Payment.java | 4 ++ .../payment/service/PaymentService.java | 46 +++++++++++++------ 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java index ae339a7c..f52933a8 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -71,4 +71,8 @@ public void completePayment(LocalDateTime approvedAt, PaymentMethod method, Stri this.paymentKey = paymentKey; this.paymentProvider = provider; } + + public void failPayment() { + this.paymentStatus = PaymentStatus.FAILED; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java index 4414afe2..d94b7b50 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -12,9 +12,12 @@ import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; import com.eatsfine.eatsfine.domain.payment.enums.PaymentType; import com.eatsfine.eatsfine.domain.payment.repository.PaymentRepository; +import com.eatsfine.eatsfine.domain.payment.exception.PaymentException; +import com.eatsfine.eatsfine.domain.payment.status.PaymentErrorStatus; import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestClient; @@ -22,6 +25,7 @@ import java.time.LocalDateTime; import java.util.UUID; +@Slf4j @Service @RequiredArgsConstructor public class PaymentService { @@ -33,14 +37,14 @@ public class PaymentService { @Transactional public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestDTO.RequestPaymentDTO dto) { Booking booking = bookingRepository.findById(dto.bookingId()) - .orElseThrow(() -> new GeneralException(ErrorStatus.BOOKING_NOT_FOUND)); + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._BOOKING_NOT_FOUND)); // 주문 ID 생성 String orderId = UUID.randomUUID().toString(); // 예약금 검증 if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { - throw new GeneralException(ErrorStatus.PAYMENT_INVALID_DEPOSIT); + throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_DEPOSIT); } Payment payment = Payment.builder() @@ -62,27 +66,37 @@ public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestD savedPayment.getRequestedAt()); } - @Transactional + @Transactional(noRollbackFor = GeneralException.class) public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmDTO dto) { Payment payment = paymentRepository.findByOrderId(dto.orderId()) - .orElseThrow(() -> new GeneralException(ErrorStatus.PAYMENT_NOT_FOUND)); + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); if (!payment.getAmount().equals(dto.amount())) { - throw new GeneralException(ErrorStatus.PAYMENT_INVALID_AMOUNT); + payment.failPayment(); + throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_AMOUNT); } - // 토스 API 호출 - TossPaymentResponse response = tossPaymentClient.post() - .uri("/v1/payments/confirm") - .body(dto) - .retrieve() - .body(TossPaymentResponse.class); - - if (response == null || !"DONE".equals(response.status())) { - throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + // 토스 API 호출 + TossPaymentResponse response; + try { + response = tossPaymentClient.post() + .uri("/v1/payments/confirm") + .body(dto) + .retrieve() + .body(TossPaymentResponse.class); + + if (response == null || !"DONE".equals(response.status())) { + log.error("Toss Payment Confirmation Failed: Status is not DONE"); + payment.failPayment(); + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } catch (Exception e) { + log.error("Toss Payment API Error", e); + payment.failPayment(); + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); } - // Provider 파싱 + // Provider 파싱 PaymentProvider provider = null; if (response.easyPay() != null) { String providerCode = response.easyPay().provider(); @@ -100,6 +114,8 @@ public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmD provider ); + log.info("Payment confirmed for OrderID: {}", dto.orderId()); + return new PaymentResponseDTO.PaymentRequestResultDTO( payment.getId(), payment.getBooking().getId(), From c9d6a8ce06c89b1d65b28c895cda69b27117d4e5 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:02:22 +0900 Subject: [PATCH 147/169] =?UTF-8?q?[REFACTOR]:=20Payment=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/exception/PaymentException.java | 11 +++++ .../payment/status/PaymentErrorStatus.java | 40 +++++++++++++++++++ .../apiPayload/code/status/ErrorStatus.java | 10 +---- 3 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/exception/PaymentException.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/status/PaymentErrorStatus.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/exception/PaymentException.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/exception/PaymentException.java new file mode 100644 index 00000000..c6d2580c --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/exception/PaymentException.java @@ -0,0 +1,11 @@ +package com.eatsfine.eatsfine.domain.payment.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class PaymentException extends GeneralException { + + public PaymentException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/status/PaymentErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/status/PaymentErrorStatus.java new file mode 100644 index 00000000..e6dddaa8 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/status/PaymentErrorStatus.java @@ -0,0 +1,40 @@ +package com.eatsfine.eatsfine.domain.payment.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum PaymentErrorStatus implements BaseErrorCode { + + _PAYMENT_INVALID_DEPOSIT(HttpStatus.BAD_REQUEST, "PAYMENT4001", "예약금이 유효하지 않습니다."), + _PAYMENT_INVALID_AMOUNT(HttpStatus.BAD_REQUEST, "PAYMENT4002", "결제 금액이 일치하지 않습니다."), + _PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT4003", "결제 정보를 찾을 수 없습니다."), + _BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING4001", "예약을 찾을 수 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java index f10d8c86..2d63881e 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java @@ -13,15 +13,7 @@ public enum ErrorStatus implements BaseErrorCode { _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), - _NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404", "존재하지 않는 요청입니다."), - - // 예약금 관련 에러 - PAYMENT_INVALID_DEPOSIT(HttpStatus.BAD_REQUEST, "PAYMENT4001", "예약금이 유효하지 않습니다."), - PAYMENT_INVALID_AMOUNT(HttpStatus.BAD_REQUEST, "PAYMENT4002", "결제 금액이 일치하지 않습니다."), - PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT4003", "결제 정보를 찾을 수 없습니다."), - - // 예약 관련 에러 - BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING4001", "예약을 찾을 수 없습니다."); + _NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404", "존재하지 않는 요청입니다."); private final HttpStatus httpStatus; private final String code; From 35be75bda2e1b44f7c6210c3e70dc2f7cc297934 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:32:58 +0900 Subject: [PATCH 148/169] =?UTF-8?q?[FEAT]:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C(=ED=99=98=EB=B6=88)=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/controller/PaymentController.java | 9 + .../dto/request/PaymentRequestDTO.java | 12 +- .../dto/response/PaymentResponseDTO.java | 8 + .../domain/payment/entity/Payment.java | 4 + .../payment/repository/PaymentRepository.java | 1 + .../payment/service/PaymentService.java | 175 +++++++++++------- 6 files changed, 135 insertions(+), 74 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java index dc3d4a16..e5d26ed2 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java @@ -13,6 +13,7 @@ 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.PathVariable; @Tag(name = "Payment", description = "결제 관련 API") @RestController @@ -35,4 +36,12 @@ public ApiResponse confirmPayment( @RequestBody @Valid PaymentConfirmDTO dto) { return ApiResponse.onSuccess(paymentService.confirmPayment(dto)); } + + @Operation(summary = "결제 취소", description = "결제 키를 받아 결제를 취소합니다.") + @PostMapping("/{paymentKey}/cancel") + public ApiResponse cancelPayment( + @PathVariable String paymentKey, + @RequestBody @Valid PaymentRequestDTO.CancelPaymentDTO dto) { + return ApiResponse.onSuccess(paymentService.cancelPayment(paymentKey, dto)); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java index a7dc7884..12929ef0 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentRequestDTO.java @@ -4,7 +4,13 @@ public class PaymentRequestDTO { - public record RequestPaymentDTO( - @NotNull Long bookingId) { - } + public record RequestPaymentDTO( + @NotNull Long bookingId) { + } + + public record CancelPaymentDTO( + @NotNull String cancelReason) { + + } + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java index 8f100eda..f919352f 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java @@ -14,4 +14,12 @@ public record PaymentRequestResultDTO( Integer amount, LocalDateTime requestedAt) { } + + public record CancelPaymentResultDTO( + Long paymentId, + String orderId, + String paymentKey, + String status, + LocalDateTime canceledAt) { + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java index f52933a8..59bf7886 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -75,4 +75,8 @@ public void completePayment(LocalDateTime approvedAt, PaymentMethod method, Stri public void failPayment() { this.paymentStatus = PaymentStatus.FAILED; } + + public void cancelPayment() { + this.paymentStatus = PaymentStatus.REFUNDED; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java index 39567acf..e98709a5 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java @@ -7,4 +7,5 @@ public interface PaymentRepository extends JpaRepository { Optional findByOrderId(String orderId); + Optional findByPaymentKey(String paymentKey); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java index d94b7b50..983de435 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -30,97 +30,130 @@ @RequiredArgsConstructor public class PaymentService { - private final PaymentRepository paymentRepository; - private final BookingRepository bookingRepository; - private final RestClient tossPaymentClient; - - @Transactional - public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestDTO.RequestPaymentDTO dto) { - Booking booking = bookingRepository.findById(dto.bookingId()) - .orElseThrow(() -> new PaymentException(PaymentErrorStatus._BOOKING_NOT_FOUND)); - - // 주문 ID 생성 - String orderId = UUID.randomUUID().toString(); - - // 예약금 검증 - if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { - throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_DEPOSIT); + private final PaymentRepository paymentRepository; + private final BookingRepository bookingRepository; + private final RestClient tossPaymentClient; + + @Transactional + public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestDTO.RequestPaymentDTO dto) { + Booking booking = bookingRepository.findById(dto.bookingId()) + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._BOOKING_NOT_FOUND)); + + // 주문 ID 생성 + String orderId = UUID.randomUUID().toString(); + + // 예약금 검증 + if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { + throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_DEPOSIT); + } + + Payment payment = Payment.builder() + .booking(booking) + .orderId(orderId) + .amount(booking.getDepositAmount()) + .paymentStatus(PaymentStatus.PENDING) + .paymentType(PaymentType.DEPOSIT) + .requestedAt(LocalDateTime.now()) + .build(); + + Payment savedPayment = paymentRepository.save(payment); + + return new PaymentResponseDTO.PaymentRequestResultDTO( + savedPayment.getId(), + booking.getId(), + savedPayment.getOrderId(), + savedPayment.getAmount(), + savedPayment.getRequestedAt()); } - Payment payment = Payment.builder() - .booking(booking) - .orderId(orderId) - .amount(booking.getDepositAmount()) - .paymentStatus(PaymentStatus.PENDING) - .paymentType(PaymentType.DEPOSIT) - .requestedAt(LocalDateTime.now()) - .build(); - - Payment savedPayment = paymentRepository.save(payment); - - return new PaymentResponseDTO.PaymentRequestResultDTO( - savedPayment.getId(), - booking.getId(), - savedPayment.getOrderId(), - savedPayment.getAmount(), - savedPayment.getRequestedAt()); - } - + @Transactional(noRollbackFor = GeneralException.class) + public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmDTO dto) { + Payment payment = paymentRepository.findByOrderId(dto.orderId()) + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); + + if (!payment.getAmount().equals(dto.amount())) { + payment.failPayment(); + throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_AMOUNT); + } + + // 토스 API 호출 + TossPaymentResponse response; + try { + response = tossPaymentClient.post() + .uri("/v1/payments/confirm") + .body(dto) + .retrieve() + .body(TossPaymentResponse.class); + + if (response == null || !"DONE".equals(response.status())) { + log.error("Toss Payment Confirmation Failed: Status is not DONE"); + payment.failPayment(); + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } catch (Exception e) { + log.error("Toss Payment API Error", e); + payment.failPayment(); + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + + // Provider 파싱 + PaymentProvider provider = null; + if (response.easyPay() != null) { + String providerCode = response.easyPay().provider(); + if ("토스페이".equals(providerCode)) { + provider = PaymentProvider.TOSS; + } else if ("카카오페이".equals(providerCode)) { + provider = PaymentProvider.KAKAOPAY; + } + } + + payment.completePayment( + response.approvedAt() != null ? response.approvedAt().toLocalDateTime() + : LocalDateTime.now(), + PaymentMethod.SIMPLE_PAYMENT, + response.paymentKey(), + provider); + + log.info("Payment confirmed for OrderID: {}", dto.orderId()); + + return new PaymentResponseDTO.PaymentRequestResultDTO( + payment.getId(), + payment.getBooking().getId(), + payment.getOrderId(), + payment.getAmount(), + payment.getRequestedAt()); + } @Transactional(noRollbackFor = GeneralException.class) - public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmDTO dto) { - Payment payment = paymentRepository.findByOrderId(dto.orderId()) + public PaymentResponseDTO.CancelPaymentResultDTO cancelPayment(String paymentKey, PaymentRequestDTO.CancelPaymentDTO dto) { + Payment payment = paymentRepository.findByPaymentKey(paymentKey) .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); - if (!payment.getAmount().equals(dto.amount())) { - payment.failPayment(); - throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_AMOUNT); - } - - // 토스 API 호출 + // 토스 결제 취소 API 호출 TossPaymentResponse response; try { response = tossPaymentClient.post() - .uri("/v1/payments/confirm") + .uri("/v1/payments/" + paymentKey + "/cancel") .body(dto) .retrieve() .body(TossPaymentResponse.class); - if (response == null || !"DONE".equals(response.status())) { - log.error("Toss Payment Confirmation Failed: Status is not DONE"); - payment.failPayment(); + if (response == null || !"CANCELED".equals(response.status())) { + log.error("Toss Payment Cancel Failed: {}", response); throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); } } catch (Exception e) { - log.error("Toss Payment API Error", e); - payment.failPayment(); + log.error("Toss Payment Cancel API Error", e); throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); } - // Provider 파싱 - PaymentProvider provider = null; - if (response.easyPay() != null) { - String providerCode = response.easyPay().provider(); - if ("토스페이".equals(providerCode)) { - provider = PaymentProvider.TOSS; - } else if ("카카오페이".equals(providerCode)) { - provider = PaymentProvider.KAKAOPAY; - } - } + payment.cancelPayment(); - payment.completePayment( - response.approvedAt() != null ? response.approvedAt().toLocalDateTime() : LocalDateTime.now(), - PaymentMethod.SIMPLE_PAYMENT, - response.paymentKey(), - provider - ); - - log.info("Payment confirmed for OrderID: {}", dto.orderId()); - - return new PaymentResponseDTO.PaymentRequestResultDTO( + return new PaymentResponseDTO.CancelPaymentResultDTO( payment.getId(), - payment.getBooking().getId(), payment.getOrderId(), - payment.getAmount(), - payment.getRequestedAt()); + payment.getPaymentKey(), + payment.getPaymentStatus().name(), + LocalDateTime.now() + ); } } From 3db6f18f64c5b591577064de8ecea0f1f96a41c0 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:28:23 +0900 Subject: [PATCH 149/169] =?UTF-8?q?[FEAT]:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/controller/PaymentController.java | 20 +++ .../dto/response/PaymentResponseDTO.java | 39 +++++ .../dto/response/TossPaymentResponse.java | 7 +- .../domain/payment/entity/Payment.java | 6 +- .../payment/repository/PaymentRepository.java | 8 + .../payment/service/PaymentService.java | 141 ++++++++++++++---- 6 files changed, 186 insertions(+), 35 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java index e5d26ed2..83c10d6a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java @@ -14,6 +14,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.GetMapping; @Tag(name = "Payment", description = "결제 관련 API") @RestController @@ -44,4 +46,22 @@ public ApiResponse cancelPayment( @RequestBody @Valid PaymentRequestDTO.CancelPaymentDTO dto) { return ApiResponse.onSuccess(paymentService.cancelPayment(paymentKey, dto)); } + + @Operation(summary = "결제 내역 조회", description = "로그인한 사용자의 결제 내역을 조회합니다.") + @GetMapping + public ApiResponse getPaymentList( + @RequestParam(name = "userId", required = false, defaultValue = "1") Long userId, + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "10") Integer limit, + @RequestParam(name = "status", required = false) String status) { + // TODO: userId는 추후 Security Context에서 가져오도록 수정 + return ApiResponse.onSuccess(paymentService.getPaymentList(userId, page, limit, status)); + } + + @Operation(summary = "결제 상세 조회", description = "특정 결제 건의 상세 내역을 조회합니다.") + @GetMapping("/{paymentId}") + public ApiResponse getPaymentDetail( + @PathVariable Long paymentId) { + return ApiResponse.onSuccess(paymentService.getPaymentDetail(paymentId)); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java index f919352f..9a006481 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java @@ -4,6 +4,7 @@ import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; import java.time.LocalDateTime; +import java.util.List; public class PaymentResponseDTO { @@ -22,4 +23,42 @@ public record CancelPaymentResultDTO( String status, LocalDateTime canceledAt) { } + + public record PaymentHistoryResultDTO( + Long paymentId, + Long bookingId, + String restaurantName, + Integer amount, + String paymentType, + String paymentMethod, + String paymentProvider, + String status, + LocalDateTime approvedAt) { + } + + public record PaymentListResponseDTO( + List payments, + PaginationDTO pagination) { + } + + public record PaginationDTO( + Integer currentPage, + Integer totalPages, + Long totalCount) { + } + + public record PaymentDetailResultDTO( + Long paymentId, + Long bookingId, + String restaurantName, + String paymentMethod, + String paymentProvider, + Integer amount, + String paymentType, + String status, + LocalDateTime requestedAt, + LocalDateTime approvedAt, + String receiptUrl, + String refundInfo) { + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java index 5e41d6f5..bc6fc4ec 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/TossPaymentResponse.java @@ -19,11 +19,16 @@ public record TossPaymentResponse( String lastTransactionKey, Integer suppliedAmount, Integer vat, - EasyPay easyPay) { + EasyPay easyPay, + Receipt receipt) { public record EasyPay( String provider, Integer amount, Integer discountAmount) { } + + public record Receipt( + String url) { + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java index 59bf7886..ebf44154 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -59,17 +59,21 @@ public class Payment extends BaseEntity { @Column(name = "payment_type", nullable = false) private PaymentType paymentType; + @Column(name = "receipt_url") + private String receiptUrl; + public void setPaymentKey(String paymentKey) { this.paymentKey = paymentKey; } public void completePayment(LocalDateTime approvedAt, PaymentMethod method, String paymentKey, - PaymentProvider provider) { + PaymentProvider provider, String receiptUrl) { this.paymentStatus = PaymentStatus.COMPLETED; this.approvedAt = approvedAt; this.paymentMethod = method; this.paymentKey = paymentKey; this.paymentProvider = provider; + this.receiptUrl = receiptUrl; } public void failPayment() { diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java index e98709a5..91cf70b6 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java @@ -1,11 +1,19 @@ package com.eatsfine.eatsfine.domain.payment.repository; import com.eatsfine.eatsfine.domain.payment.entity.Payment; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface PaymentRepository extends JpaRepository { Optional findByOrderId(String orderId); + Optional findByPaymentKey(String paymentKey); + + Page findAllByBooking_User_Id(Long userId, Pageable pageable); + + Page findAllByBooking_User_IdAndPaymentStatus(Long userId, PaymentStatus status, Pageable pageable); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java index 983de435..8d4c57d0 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -18,12 +18,16 @@ import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestClient; import java.time.LocalDateTime; import java.util.UUID; +import java.util.List; +import java.util.stream.Collectors; @Slf4j @Service @@ -112,7 +116,8 @@ public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmD : LocalDateTime.now(), PaymentMethod.SIMPLE_PAYMENT, response.paymentKey(), - provider); + provider, + response.receipt() != null ? response.receipt().url() : null); log.info("Payment confirmed for OrderID: {}", dto.orderId()); @@ -123,37 +128,107 @@ public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmD payment.getAmount(), payment.getRequestedAt()); } - @Transactional(noRollbackFor = GeneralException.class) - public PaymentResponseDTO.CancelPaymentResultDTO cancelPayment(String paymentKey, PaymentRequestDTO.CancelPaymentDTO dto) { - Payment payment = paymentRepository.findByPaymentKey(paymentKey) - .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); - - // 토스 결제 취소 API 호출 - TossPaymentResponse response; - try { - response = tossPaymentClient.post() - .uri("/v1/payments/" + paymentKey + "/cancel") - .body(dto) - .retrieve() - .body(TossPaymentResponse.class); - - if (response == null || !"CANCELED".equals(response.status())) { - log.error("Toss Payment Cancel Failed: {}", response); - throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); - } - } catch (Exception e) { - log.error("Toss Payment Cancel API Error", e); - throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + + @Transactional(noRollbackFor = GeneralException.class) + public PaymentResponseDTO.CancelPaymentResultDTO cancelPayment(String paymentKey, + PaymentRequestDTO.CancelPaymentDTO dto) { + Payment payment = paymentRepository.findByPaymentKey(paymentKey) + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); + + // 토스 결제 취소 API 호출 + TossPaymentResponse response; + try { + response = tossPaymentClient.post() + .uri("/v1/payments/" + paymentKey + "/cancel") + .body(dto) + .retrieve() + .body(TossPaymentResponse.class); + + if (response == null || !"CANCELED".equals(response.status())) { + log.error("Toss Payment Cancel Failed: {}", response); + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } catch (Exception e) { + log.error("Toss Payment Cancel API Error", e); + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + + payment.cancelPayment(); + + return new PaymentResponseDTO.CancelPaymentResultDTO( + payment.getId(), + payment.getOrderId(), + payment.getPaymentKey(), + payment.getPaymentStatus().name(), + LocalDateTime.now()); } - payment.cancelPayment(); - - return new PaymentResponseDTO.CancelPaymentResultDTO( - payment.getId(), - payment.getOrderId(), - payment.getPaymentKey(), - payment.getPaymentStatus().name(), - LocalDateTime.now() - ); - } -} + @Transactional(readOnly = true) + public PaymentResponseDTO.PaymentListResponseDTO getPaymentList(Long userId, Integer page, Integer limit, + String status) { + // limit 기본값 처리 (만약 null이면 10) + int size = (limit != null) ? limit : 10; + // page 기본값 처리 (만약 null이면 1, 0보다 작으면 1로 보정). Spring Data는 0-based index이므로 -1 + int pageNumber = (page != null && page > 0) ? page - 1 : 0; + + Pageable pageable = org.springframework.data.domain.PageRequest.of(pageNumber, size); + + Page paymentPage; + if (status != null && !status.isEmpty()) { + PaymentStatus paymentStatus; + try { + paymentStatus = PaymentStatus.valueOf(status.toUpperCase()); + } catch (IllegalArgumentException e) { + // 유효하지 않은 status가 들어오면 BadRequest 예외 발생 + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + paymentPage = paymentRepository.findAllByBooking_User_IdAndPaymentStatus(userId, paymentStatus, + pageable); + } else { + paymentPage = paymentRepository.findAllByBooking_User_Id(userId, pageable); + } + + List payments = paymentPage.getContent().stream() + .map(payment -> new PaymentResponseDTO.PaymentHistoryResultDTO( + payment.getId(), + payment.getBooking().getId(), + payment.getBooking().getStore().getStoreName(), + payment.getAmount(), + payment.getPaymentType().name(), + payment.getPaymentMethod() != null ? payment.getPaymentMethod().name() + : null, + payment.getPaymentProvider() != null ? payment.getPaymentProvider().name() + : null, + payment.getPaymentStatus().name(), + payment.getApprovedAt())) + .collect(Collectors.toList()); + + PaymentResponseDTO.PaginationDTO pagination = new PaymentResponseDTO.PaginationDTO( + paymentPage.getNumber() + 1, // 0-based -> 1-based + paymentPage.getTotalPages(), + paymentPage.getTotalElements()); + + return new PaymentResponseDTO.PaymentListResponseDTO(payments, pagination); + } + + @Transactional(readOnly = true) + public PaymentResponseDTO.PaymentDetailResultDTO getPaymentDetail(Long paymentId) { + Payment payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); + + return new PaymentResponseDTO.PaymentDetailResultDTO( + payment.getId(), + payment.getBooking().getId(), + payment.getBooking().getStore().getStoreName(), + payment.getPaymentMethod() != null ? payment.getPaymentMethod().name() : null, + payment.getPaymentProvider() != null ? payment.getPaymentProvider().name() : null, + payment.getAmount(), + payment.getPaymentType().name(), + payment.getPaymentStatus().name(), + payment.getRequestedAt(), + payment.getApprovedAt(), + payment.getReceiptUrl(), + null // 환불 상세 정보는 현재 null 처리 + ); + } +} \ No newline at end of file From 732cbb8bb4fe5de7167aa533a4f5d5d0e2964038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A4=80=EA=B7=9C?= <144649271+sonjunkyu@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:35:44 +0900 Subject: [PATCH 150/169] =?UTF-8?q?[FEAT]:=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=EB=8F=84=20=EC=83=9D=EC=84=B1,=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [REFACTOR]: TableLayout SQLDelete 쿼리문 수정 및 Builder.Default 추가 * [FEAT]: TableLayoutException 세팅 * [FEAT]: TableLayout 성공/실패 응답코드 추가 * [FEAT]: Controller에 배치도 생성 API 추가 * [FEAT]: 배치도 DTO, Converter 추가 * [FEAT]: 테이블 배치도 생성 로직 개발 * [REFACTOR]: StoreTable SQLDelete 쿼리문 수정 * [REFACTOR]: TableLayout - StoreTable 연관관계 Cascade.REMOVE 추가 * [FEAT]: 테이블 배치도 조회 API 추가 * [FEAT]: 테이블 배치도 조회 로직 개발 * [REFACTOR]: 테이블 배치도 조회 응답 DTO, Converter에 reviewCount 필드 추가 --- .../domain/storetable/entity/StoreTable.java | 2 +- .../controller/TableLayoutController.java | 39 ++++++++++++++ .../controller/TableLayoutControllerDocs.java | 53 +++++++++++++++++++ .../converter/TableLayoutConverter.java | 42 +++++++++++++++ .../dto/req/TableLayoutReqDto.java | 21 ++++++++ .../dto/res/TableLayoutResDto.java | 36 +++++++++++++ .../table_layout/entity/TableLayout.java | 5 +- .../exception/TableLayoutException.java | 10 ++++ .../status/TableLayoutErrorStatus.java | 40 ++++++++++++++ .../status/TableLayoutSuccessStatus.java | 42 +++++++++++++++ .../service/TableLayoutCommandService.java | 8 +++ .../TableLayoutCommandServiceImpl.java | 50 +++++++++++++++++ .../service/TableLayoutQueryService.java | 7 +++ .../service/TableLayoutQueryServiceImpl.java | 31 +++++++++++ 14 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutController.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutControllerDocs.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/table_layout/converter/TableLayoutConverter.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/table_layout/dto/req/TableLayoutReqDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/table_layout/dto/res/TableLayoutResDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/TableLayoutException.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutErrorStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutSuccessStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandServiceImpl.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutQueryService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutQueryServiceImpl.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java index 7b16850e..bec6bc7d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java @@ -17,7 +17,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Getter -@SQLDelete(sql = "UPDATE store_table SET is_deleted = true WHERE id = ?") +@SQLDelete(sql = "UPDATE store_table SET is_deleted = true, deleted_at = CURRENT_TIMESTAMP WHERE id = ?") @SQLRestriction("is_deleted = false") @Table(name = "store_table") public class StoreTable extends BaseEntity { diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutController.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutController.java new file mode 100644 index 00000000..b96960d1 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutController.java @@ -0,0 +1,39 @@ +package com.eatsfine.eatsfine.domain.table_layout.controller; + +import com.eatsfine.eatsfine.domain.table_layout.dto.req.TableLayoutReqDto; +import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; +import com.eatsfine.eatsfine.domain.table_layout.exception.status.TableLayoutSuccessStatus; +import com.eatsfine.eatsfine.domain.table_layout.service.TableLayoutCommandService; +import com.eatsfine.eatsfine.domain.table_layout.service.TableLayoutQueryService; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "TableLayout", description = "테이블 배치도 조회 및 관리 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class TableLayoutController implements TableLayoutControllerDocs{ + private final TableLayoutCommandService tableLayoutCommandService; + private final TableLayoutQueryService tableLayoutQueryService; + + @PostMapping("stores/{storeId}/layouts") + public ApiResponse createLayout( + @PathVariable Long storeId, + @RequestBody TableLayoutReqDto.LayoutCreateDto dto + ) { + return ApiResponse.of(TableLayoutSuccessStatus._LAYOUT_CREATED, tableLayoutCommandService.createLayout(storeId, dto)); + } + + @GetMapping("stores/{storeId}/layouts") + public ApiResponse getActiveLayout(@PathVariable Long storeId) { + TableLayoutResDto.LayoutDetailDto result = tableLayoutQueryService.getActiveLayout(storeId); + + if (result == null) { + return ApiResponse.of(TableLayoutSuccessStatus._LAYOUT_NO_CONTENT, null); + } + + return ApiResponse.of(TableLayoutSuccessStatus._LAYOUT_FOUND, result); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutControllerDocs.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutControllerDocs.java new file mode 100644 index 00000000..4a845671 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/controller/TableLayoutControllerDocs.java @@ -0,0 +1,53 @@ +package com.eatsfine.eatsfine.domain.table_layout.controller; + +import com.eatsfine.eatsfine.domain.table_layout.dto.req.TableLayoutReqDto; +import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; + +public interface TableLayoutControllerDocs { + @Operation( + summary = "테이블 배치도 생성", + description = """ + 사장 회원이 가게의 테이블 배치도를 생성합니다. + + - 그리드 크기는 1x1 ~ 10x10 범위 내에서 설정 가능합니다. + - 가게당 활성 배치도는 1개만 존재하며, 새 배치도 생성 시 기존 배치도는 자동으로 비활성화됩니다. + - 생성된 배치도는 빈 상태로 생성되며, 이후 테이블 추가 API를 통해 테이블을 배치할 수 있습니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "배치도 생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 (그리드 크기 범위 초과 등)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "가게를 찾을 수 없음") + }) + ApiResponse createLayout( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + @RequestBody @Valid TableLayoutReqDto.LayoutCreateDto dto + ); + + @Operation( + summary = "테이블 배치도 조회", + description = """ + 가게의 활성화된 테이블 배치도를 조회합니다. + + - isActive = true인 배치도만 조회됩니다. + - 배치된 테이블 목록도 함께 반환됩니다. (삭제된 테이블은 제외) + - 활성 배치도가 없는 경우 204 응답을 반환합니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "배치도 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "조회는 성공했지만 가게 배치도가 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "가게를 찾을 수 없음") + }) + ApiResponse getActiveLayout( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId + ); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/converter/TableLayoutConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/converter/TableLayoutConverter.java new file mode 100644 index 00000000..1dc63043 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/converter/TableLayoutConverter.java @@ -0,0 +1,42 @@ +package com.eatsfine.eatsfine.domain.table_layout.converter; + +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; +import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; + +public class TableLayoutConverter { + // TableLayout Entity를 생성 응답 DTO로 변환 + public static TableLayoutResDto.LayoutDetailDto toLayoutDetailDto(TableLayout layout) { + return TableLayoutResDto.LayoutDetailDto.builder() + .layoutId(layout.getId()) + .totalTableCount(layout.getTables().size()) + .gridInfo( + TableLayoutResDto.GridInfo.builder() + .gridCol(layout.getCols()) + .gridRow(layout.getLows()) + .build() + ) + .tables( + layout.getTables().stream() + .map(TableLayoutConverter::toTableInfo) + .toList() + ) + .build(); + } + + // StoreTable Entity를 TableInfo DTO로 변환 + private static TableLayoutResDto.TableInfo toTableInfo(StoreTable table) { + return TableLayoutResDto.TableInfo.builder() + .tableId(table.getId()) + .tableNumber(table.getTableNumber()) + .seatsType(table.getSeatsType()) + .minSeatCount(table.getMinSeatCount()) + .maxSeatCount(table.getMaxSeatCount()) + .reviewCount(0) // 추후 리뷰 로직 구현 시 추가 + .gridX(table.getGridX()) + .gridY(table.getGridY()) + .widthSpan(table.getWidthSpan()) + .heightSpan(table.getHeightSpan()) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/dto/req/TableLayoutReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/dto/req/TableLayoutReqDto.java new file mode 100644 index 00000000..1acda582 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/dto/req/TableLayoutReqDto.java @@ -0,0 +1,21 @@ +package com.eatsfine.eatsfine.domain.table_layout.dto.req; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +public class TableLayoutReqDto { + @Builder + public record LayoutCreateDto( + @NotNull(message = "Column 크기는 필수입니다.") + @Min(value = 1, message = "가로 크기는 최소 1이어야 합니다.") + @Max(value = 10, message = "가로 크기는 최대 10이어야 합니다.") + Integer gridCol, + + @NotNull(message = "Row 크기는 필수입니다.") + @Min(value = 1, message = "세로 크기는 최소 1이어야 합니다.") + @Max(value = 10, message = "세로 크기는 최대 10이어야 합니다.") + Integer gridRow + ) {} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/dto/res/TableLayoutResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/dto/res/TableLayoutResDto.java new file mode 100644 index 00000000..1e1a4a09 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/dto/res/TableLayoutResDto.java @@ -0,0 +1,36 @@ +package com.eatsfine.eatsfine.domain.table_layout.dto.res; + +import com.eatsfine.eatsfine.domain.storetable.enums.SeatsType; +import lombok.Builder; + +import java.util.List; + +public class TableLayoutResDto { + @Builder + public record LayoutDetailDto( + Long layoutId, + Integer totalTableCount, + GridInfo gridInfo, + List tables + ) {} + + @Builder + public record GridInfo( + Integer gridCol, + Integer gridRow + ) {} + + @Builder + public record TableInfo( + Long tableId, + String tableNumber, + SeatsType seatsType, + Integer minSeatCount, + Integer maxSeatCount, + Integer reviewCount, + Integer gridX, + Integer gridY, + Integer widthSpan, + Integer heightSpan + ) {} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java index 7231dcea..4d45ea43 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java @@ -17,7 +17,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -@SQLDelete(sql = "UPDATE table_layout SET is_deleted = true WHERE id = ?") +@SQLDelete(sql = "UPDATE table_layout SET is_deleted = true, is_active = false, deleted_at = CURRENT_TIMESTAMP WHERE id = ?") @SQLRestriction("is_deleted = false") @Table(name = "table_layout") public class TableLayout extends BaseEntity { @@ -46,7 +46,8 @@ public class TableLayout extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; - @OneToMany(mappedBy = "tableLayout", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) + @Builder.Default + @OneToMany(mappedBy = "tableLayout", cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REMOVE}) private List tables = new ArrayList<>(); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/TableLayoutException.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/TableLayoutException.java new file mode 100644 index 00000000..ee893b50 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/TableLayoutException.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.table_layout.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class TableLayoutException extends GeneralException { + public TableLayoutException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutErrorStatus.java new file mode 100644 index 00000000..c0d02e4c --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutErrorStatus.java @@ -0,0 +1,40 @@ +package com.eatsfine.eatsfine.domain.table_layout.exception.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum TableLayoutErrorStatus implements BaseErrorCode { + + _LAYOUT_NOT_FOUND(HttpStatus.NOT_FOUND, "LAYOUT404", "배치도를 찾을 수 없습니다."), + + _LAYOUT_FORBIDDEN(HttpStatus.FORBIDDEN, "LAYOUT403", "해당 가게의 소유자만 접근 가능합니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutSuccessStatus.java new file mode 100644 index 00000000..beb03ef0 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/exception/status/TableLayoutSuccessStatus.java @@ -0,0 +1,42 @@ +package com.eatsfine.eatsfine.domain.table_layout.exception.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum TableLayoutSuccessStatus implements BaseCode { + + _LAYOUT_CREATED(HttpStatus.CREATED, "LAYOUT201", "성공적으로 배치도를 생성했습니다."), + + _LAYOUT_FOUND(HttpStatus.OK, "LAYOUT200", "성공적으로 배치도를 조회했습니다."), + + _LAYOUT_NO_CONTENT(HttpStatus.NO_CONTENT, "LAYOUT204", "조회된 배치도가 없습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return ReasonDto.builder() + .isSuccess(true) + .message(message) + .code(code) + .build(); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return ReasonDto.builder() + .isSuccess(true) + .httpStatus(httpStatus) + .message(message) + .code(code) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandService.java new file mode 100644 index 00000000..3e7840b4 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandService.java @@ -0,0 +1,8 @@ +package com.eatsfine.eatsfine.domain.table_layout.service; + +import com.eatsfine.eatsfine.domain.table_layout.dto.req.TableLayoutReqDto; +import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; + +public interface TableLayoutCommandService { + TableLayoutResDto.LayoutDetailDto createLayout(Long storeId, TableLayoutReqDto.LayoutCreateDto dto); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandServiceImpl.java new file mode 100644 index 00000000..3eeb906e --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutCommandServiceImpl.java @@ -0,0 +1,50 @@ +package com.eatsfine.eatsfine.domain.table_layout.service; + +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import com.eatsfine.eatsfine.domain.table_layout.converter.TableLayoutConverter; +import com.eatsfine.eatsfine.domain.table_layout.dto.req.TableLayoutReqDto; +import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; +import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; +import com.eatsfine.eatsfine.domain.table_layout.repository.TableLayoutRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class TableLayoutCommandServiceImpl implements TableLayoutCommandService { + private final StoreRepository storeRepository; + private final TableLayoutRepository tableLayoutRepository; + + // 테이블 배치도 생성 + @Override + public TableLayoutResDto.LayoutDetailDto createLayout(Long storeId, TableLayoutReqDto.LayoutCreateDto dto) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + deactivateExistingLayout(store); + + // 새 배치도 생성 + TableLayout newLayout = TableLayout.builder() + .store(store) + .lows(dto.gridRow()) + .cols(dto.gridCol()) + .isActive(true) + .isDeleted(false) + .build(); + + TableLayout savedLayout = tableLayoutRepository.save(newLayout); + + return TableLayoutConverter.toLayoutDetailDto(savedLayout); + } + + // 기존 테이블 배치도 비활성화 + private void deactivateExistingLayout(Store store) { + tableLayoutRepository.findByStoreIdAndIsActiveTrue(store.getId()) + .ifPresent(tableLayoutRepository::delete); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutQueryService.java new file mode 100644 index 00000000..ac834759 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutQueryService.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.table_layout.service; + +import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; + +public interface TableLayoutQueryService { + TableLayoutResDto.LayoutDetailDto getActiveLayout(Long storeId); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutQueryServiceImpl.java new file mode 100644 index 00000000..b5ab0c68 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/service/TableLayoutQueryServiceImpl.java @@ -0,0 +1,31 @@ +package com.eatsfine.eatsfine.domain.table_layout.service; + +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import com.eatsfine.eatsfine.domain.table_layout.converter.TableLayoutConverter; +import com.eatsfine.eatsfine.domain.table_layout.dto.res.TableLayoutResDto; +import com.eatsfine.eatsfine.domain.table_layout.repository.TableLayoutRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TableLayoutQueryServiceImpl implements TableLayoutQueryService { + private final StoreRepository storeRepository; + private final TableLayoutRepository tableLayoutRepository; + + // 테이블 배치도 조회 + @Override + public TableLayoutResDto.LayoutDetailDto getActiveLayout(Long storeId) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + // 배치도가 없을 시 null 반환 + return tableLayoutRepository.findByStoreIdAndIsActiveTrue(storeId) + .map(TableLayoutConverter::toLayoutDetailDto) + .orElse(null); + } +} From 1ec6d26379521c96ff1d0b1988b7bb275279cf3c Mon Sep 17 00:00:00 2001 From: twodo0 Date: Wed, 21 Jan 2026 14:26:32 +0900 Subject: [PATCH 151/169] =?UTF-8?q?[FEAT]:=20=EA=B0=80=EA=B2=8C=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80,=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80(S3)=20=EC=97=85=EB=A1=9C=EB=93=9C/?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C/=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [REFACTOR]: url -> key로 변수명 변경 및 imageOrder 필드 추가 * [BUILD]: AWS S3 관련 의존성 추가 * [FEAT]: S3Config, S3Service 추가 * [FEAT]: 이미지 업로드 관련 예외 및 에러 상태코드 추가 * [FEAT]: 가게 메인 이미지 등록 응답 DTO 및 성공 상태코드 추가 * [FEAT]: 가게 메인 이미지 등록 로직 구현 * [FEAT]: 가게 매인 이미지 조회 응답 DTO 및 성공 상태코드 추가 * [FEAT]: 가게 메인 이미지 조회 로직 구현 * [FEAT]: 테이블 이미지 등록 응답 DTO 및 성공 상태코드 추가 * [FEAT]: 가게 테이블 이미지(TableImage) 등록 로직 구현 * [FEAT]: 가게 테이블 이미지(TableImage) 조회 로직 구현 * [REFACTOR]: 테이블 이미지 등록 및 조회 성공 코드 수정 * [FEAT]: 테이블 이미지 삭제 응답 DTO 및 성공 상태코드 추가 * [FEAT]: 가게 테이블 이미지(TableImage) 삭제 로직 구현 * [CHORE]: application.yml에 AWS S3 설정 추가 * [FIX]: StoreSuccessStatus 충돌 해결 * [FIX]: 에러 상태코드 중복 수정 * [CHORE]: application-test.yml에 aws s3 설정 추가 * [FIX]: S3 설정 위치 수정 * [REFACTOR]: AWS S3 설정값 환경변수 처리 --- build.gradle | 6 ++ .../image/exception/ImageException.java | 11 +++ .../domain/image/status/ImageErrorStatus.java | 41 ++++++++++ .../store/controller/StoreController.java | 28 +++++++ .../store/converter/StoreConverter.java | 20 ++++- .../domain/store/dto/StoreResDto.java | 9 ++- .../eatsfine/domain/store/entity/Store.java | 9 ++- .../store/service/StoreCommandService.java | 2 + .../service/StoreCommandServiceImpl.java | 44 +++++++++-- .../store/service/StoreQueryService.java | 2 + .../store/service/StoreQueryServiceImpl.java | 15 +++- .../store/status/StoreSuccessStatus.java | 6 +- .../controller/TableImageController.java | 68 +++++++++++++++++ .../converter/TableImageConverter.java | 29 +++++++ .../tableimage/dto/TableImageResDto.java | 26 +++++++ .../domain/tableimage/entity/TableImage.java | 5 +- .../repository/TableImageRepository.java | 16 ++++ .../service/TableImageCommandService.java | 13 ++++ .../service/TableImageCommandServiceImpl.java | 74 ++++++++++++++++++ .../service/TableImageQueryService.java | 7 ++ .../service/TableImageQueryServiceImpl.java | 41 ++++++++++ .../status/TableImageSuccessStatus.java | 46 +++++++++++ .../eatsfine/global/config/S3Config.java | 17 +++++ .../eatsfine/global/s3/S3Service.java | 76 +++++++++++++++++++ src/main/resources/application.yml | 7 ++ src/test/resources/application-test.yml | 2 +- src/test/resources/application.yml | 9 ++- 27 files changed, 609 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/image/exception/ImageException.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableimage/controller/TableImageController.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableimage/converter/TableImageConverter.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableimage/dto/TableImageResDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageCommandService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageCommandServiceImpl.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageQueryService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageQueryServiceImpl.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableimage/status/TableImageSuccessStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/global/config/S3Config.java create mode 100644 src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java diff --git a/build.gradle b/build.gradle index 87169b1c..c9b5cfd7 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,12 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" annotationProcessor "jakarta.persistence:jakarta.persistence-api" annotationProcessor "jakarta.annotation:jakarta.annotation-api" + + // amazon + implementation platform('software.amazon.awssdk:bom:2.25.17') + + implementation 'software.amazon.awssdk:s3' + implementation 'software.amazon.awssdk:auth' } // --- QueryDSL --- def generated = 'build/generated/sources/annotationProcessor/java/main' diff --git a/src/main/java/com/eatsfine/eatsfine/domain/image/exception/ImageException.java b/src/main/java/com/eatsfine/eatsfine/domain/image/exception/ImageException.java new file mode 100644 index 00000000..3f704010 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/image/exception/ImageException.java @@ -0,0 +1,11 @@ +package com.eatsfine.eatsfine.domain.image.exception; + + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class ImageException extends GeneralException { + public ImageException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java new file mode 100644 index 00000000..f2008156 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java @@ -0,0 +1,41 @@ +package com.eatsfine.eatsfine.domain.image.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ImageErrorStatus implements BaseErrorCode { + EMPTY_FILE(HttpStatus.BAD_REQUEST, "IMAGE4001", "업로드할 파일이 비어 있습니다."), + INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "IMAGE4002", "지원하지 않는 파일 형식입니다."), + S3_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "IMAGE5001", "이미지 업로드에 실패했습니다."), + _IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당하는 이미지가 존재하지 않습니다.") + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(true) + .message(message) + .code(code) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(true) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java index 6527f479..aab3686a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java @@ -12,7 +12,9 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @Tag(name = "Store", description = "식당 조회 및 관리 API") @RestController @@ -71,4 +73,30 @@ public ApiResponse updateStoreBasicInfo( } + @Operation( + summary = "식당 대표 이미지 등록", + description = "식당의 대표 이미지를 등록합니다." + ) + @PostMapping( + value = "/stores/{storeId}/main-image", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) + public ApiResponse uploadMainImage( + @RequestPart("mainImage")MultipartFile mainImage, + @PathVariable Long storeId + ){ + return ApiResponse.of(StoreSuccessStatus._STORE_MAIN_IMAGE_UPLOAD_SUCCESS, storeCommandService.uploadMainImage(storeId, mainImage)); + } + + @Operation( + summary = "식당 대표 이미지 조회", + description = "식당의 대표 이미지를 조회합니다." + ) + @GetMapping("/stores/{storeId}/main-image") + public ApiResponse getMainImage( + @PathVariable Long storeId + ) { + return ApiResponse.of(StoreSuccessStatus._STORE_MAIN_IMAGE_GET_SUCCESS, storeQueryService.getMainImage(storeId)); + } + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java index 11f7ac4a..962c97aa 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java @@ -4,8 +4,8 @@ import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; import com.eatsfine.eatsfine.domain.store.entity.Store; +import org.springframework.web.multipart.MultipartFile; -import java.math.BigDecimal; import java.util.Collections; import java.util.List; @@ -26,7 +26,7 @@ public static StoreResDto.StoreSearchDto toSearchDto(Store store, Double distanc .rating(store.getRating()) .reviewCount(null) // 리뷰 도메인 구현 이후 추가 예정 .distance(distance) - .mainImageUrl(store.getMainImageUrl()) + .mainImageUrl(store.getMainImageKey()) .isOpenNow(isOpenNow) .build(); } @@ -46,7 +46,7 @@ public static StoreResDto.StoreDetailDto toDetailDto(Store store, boolean isOpen .category(store.getCategory()) .rating(store.getRating()) .reviewCount(null) // reviewCount는 추후 리뷰 로직 구현 시 추가 예정 - .mainImageUrl(store.getMainImageUrl()) + .mainImageUrl(store.getMainImageKey()) .tableImageUrls(Collections.emptyList()) // tableImages는 추후 사진 등록 API 구현 시 추가 예정 .depositAmount(store.calculateDepositAmount()) .businessHours( @@ -65,5 +65,19 @@ public static StoreResDto.StoreUpdateDto toUpdateDto(Long storeId, List .updatedFields(updatedFields) .build(); } + + public static StoreResDto.UploadMainImageDto toUploadMainImageDto(Long storeId, String mainImageUrl) { + return StoreResDto.UploadMainImageDto.builder() + .storeId(storeId) + .mainImageUrl(mainImageUrl) + .build(); + } + + public static StoreResDto.GetMainImageDto toGetMainImageDto(Long storeId, String key) { + return StoreResDto.GetMainImageDto.builder() + .storeId(storeId) + .mainImageUrl(key) + .build(); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java index a674eaa3..c9ee44a2 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java @@ -70,7 +70,8 @@ public record StoreDetailDto( // 가게 대표 이미지 등록 응답 @Builder - public record uploadMainImageResDto( + public record UploadMainImageDto( + Long storeId, String mainImageUrl ) {} @@ -80,5 +81,11 @@ public record StoreUpdateDto( Long storeId, List updatedFields ){}; + // 가게 대표 이미지 조회 응답 + @Builder + public record GetMainImageDto( + Long storeId, + String mainImageUrl + ) {} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index 9f8dc340..fcd2bb26 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -66,7 +66,7 @@ public class Store extends BaseEntity { private String address; @Column(name = "main_image_url") - private String mainImageUrl; + private String mainImageKey; @Builder.Default @Column(name = "rating", precision = 2, scale = 1, nullable = false) @@ -95,7 +95,6 @@ public class Store extends BaseEntity { @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List tableImages = new ArrayList<>(); - // StoreTable이 아닌 TableLayout 엔티티 참조 @Builder.Default @OneToMany(mappedBy = "store") @@ -130,6 +129,11 @@ public void removeTableImage(TableImage tableImage) { tableImage.assignStore(null); } + // 가게 메인 이미지 등록 + public void updateMainImageKey(String mainImageKey) { + this.mainImageKey = mainImageKey; + } + // 특정 요일의 영업시간 조회 메서드 public BusinessHours getBusinessHoursByDay(DayOfWeek dayOfWeek) { return this.businessHours.stream() @@ -146,6 +150,7 @@ public Optional findBusinessHoursByDay(DayOfWeek dayOfWeek) { .findFirst(); } + // 예약금 계산 메서드 public BigDecimal calculateDepositAmount() { return BigDecimal.valueOf(minPrice) .multiply(BigDecimal.valueOf(depositRate.getPercent())) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java index 65ab0172..33d39189 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java @@ -2,8 +2,10 @@ import com.eatsfine.eatsfine.domain.store.dto.StoreReqDto; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; +import org.springframework.web.multipart.MultipartFile; public interface StoreCommandService { StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto storeCreateDto); StoreResDto.StoreUpdateDto updateBasicInfo(Long storeId, StoreReqDto.StoreUpdateDto storeUpdateDto); + StoreResDto.UploadMainImageDto uploadMainImage(Long storeId, MultipartFile file); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java index 982f0bee..7ceae863 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java @@ -3,6 +3,8 @@ import com.eatsfine.eatsfine.domain.businesshours.converter.BusinessHoursConverter; import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; import com.eatsfine.eatsfine.domain.businesshours.validator.BusinessHoursValidator; +import com.eatsfine.eatsfine.domain.image.exception.ImageException; +import com.eatsfine.eatsfine.domain.image.status.ImageErrorStatus; import com.eatsfine.eatsfine.domain.region.entity.Region; import com.eatsfine.eatsfine.domain.region.repository.RegionRepository; import com.eatsfine.eatsfine.domain.region.status.RegionErrorStatus; @@ -19,6 +21,8 @@ import java.util.ArrayList; import java.util.List; +import com.eatsfine.eatsfine.global.s3.S3Service; +import org.springframework.web.multipart.MultipartFile; @Service @Transactional @@ -27,7 +31,9 @@ public class StoreCommandServiceImpl implements StoreCommandService { private final StoreRepository storeRepository; private final RegionRepository regionRepository; + private final S3Service s3Service; + // 가게 등록 @Override public StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto dto) { Region region = regionRepository.findById(dto.regionId()) @@ -42,7 +48,7 @@ public StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto dto) { .businessNumber(dto.businessNumber()) .description(dto.description()) .address(dto.address()) - .mainImageUrl(null) // 별도 API로 구현 + .mainImageKey(null) // 별도 API로 구현 .region(region) .phoneNumber(dto.phoneNumber()) .category(dto.category()) @@ -77,15 +83,37 @@ public StoreResDto.StoreUpdateDto updateBasicInfo(Long storeId, StoreReqDto.Stor public List extractUpdatedFields(StoreReqDto.StoreUpdateDto dto) { List updated = new ArrayList<>(); - if(dto.storeName() != null) updated.add("storeName"); - if(dto.description() != null) updated.add("description"); - if(dto.phoneNumber() != null) updated.add("phoneNumber"); - if(dto.category() != null) updated.add("category"); - if(dto.minPrice() != null) updated.add("minPrice"); - if(dto.depositRate() != null) updated.add("depositRate"); - if(dto.bookingIntervalMinutes() != null) updated.add("bookingIntervalMinutes"); + if (dto.storeName() != null) updated.add("storeName"); + if (dto.description() != null) updated.add("description"); + if (dto.phoneNumber() != null) updated.add("phoneNumber"); + if (dto.category() != null) updated.add("category"); + if (dto.minPrice() != null) updated.add("minPrice"); + if (dto.depositRate() != null) updated.add("depositRate"); + if (dto.bookingIntervalMinutes() != null) updated.add("bookingIntervalMinutes"); return updated; } + // 가게 메인 이미지 등록 + @Override + public StoreResDto.UploadMainImageDto uploadMainImage(Long storeId, MultipartFile file) { + Store store = storeRepository.findById(storeId).orElseThrow( + () -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND) + ); + + if(file.isEmpty()) { + throw new ImageException(ImageErrorStatus.EMPTY_FILE); + } + + if(store.getMainImageKey() != null) { + s3Service.deleteByKey(store.getMainImageKey()); + } + + String key = s3Service.upload(file, "stores/" + storeId + "/main"); + store.updateMainImageKey(key); + + String mainImageUrl = s3Service.toUrl(store.getMainImageKey()); + + return StoreConverter.toUploadMainImageDto(store.getId(), mainImageUrl); + } } \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java index 8171953d..513f7cf2 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java @@ -17,6 +17,8 @@ StoreResDto.StoreSearchResDto search( StoreResDto.StoreDetailDto getStoreDetail(Long storeId); + StoreResDto.GetMainImageDto getMainImage(Long storeId); + boolean isOpenNow(Store store, LocalDateTime now); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java index 2c93816f..fd06161a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java @@ -5,16 +5,16 @@ import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; import com.eatsfine.eatsfine.domain.store.dto.projection.StoreSearchResult; import com.eatsfine.eatsfine.domain.store.entity.Store; -import com.eatsfine.eatsfine.domain.store.enums.Category; -import com.eatsfine.eatsfine.domain.store.enums.StoreSortType; import com.eatsfine.eatsfine.domain.store.exception.StoreException; import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import com.eatsfine.eatsfine.global.s3.S3Service; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.DayOfWeek; import java.time.LocalDateTime; @@ -23,9 +23,11 @@ @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class StoreQueryServiceImpl implements StoreQueryService { private final StoreRepository storeRepository; + private final S3Service s3Service; // 식당 검색 @Override @@ -73,6 +75,15 @@ public StoreResDto.StoreDetailDto getStoreDetail(Long storeId) { return StoreConverter.toDetailDto(store, isOpenNow(store, LocalDateTime.now())); } + // 식당 대표 이미지 조회 + @Override + public StoreResDto.GetMainImageDto getMainImage(Long storeId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + return StoreConverter.toGetMainImageDto(storeId, s3Service.toUrl(store.getMainImageKey())); + } + // 현재 영업 여부 계산 (실시간 계산) @Override public boolean isOpenNow(Store store, LocalDateTime now) { diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java index 088f0935..b581c921 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java @@ -18,7 +18,11 @@ public enum StoreSuccessStatus implements BaseCode { _STORE_CREATED(HttpStatus.CREATED, "STORE201", "성공적으로 가게를 등록했습니다."), - _STORE_UPDATE_SUCCESS(HttpStatus.OK, "STORE2004", "성공적으로 가게 기본 정보를 수정했습니다.") + _STORE_UPDATE_SUCCESS(HttpStatus.OK, "STORE2004", "성공적으로 가게 기본 정보를 수정했습니다."), + + _STORE_MAIN_IMAGE_UPLOAD_SUCCESS(HttpStatus.OK, "STORE2005", "성공적으로 가게 대표 이미지를 업로드했습니다."), + + _STORE_MAIN_IMAGE_GET_SUCCESS(HttpStatus.OK, "STORE2005", "성공적으로 가게 대표 이미지를 조회했습니다.") ; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/controller/TableImageController.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/controller/TableImageController.java new file mode 100644 index 00000000..7ff69f6c --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/controller/TableImageController.java @@ -0,0 +1,68 @@ +package com.eatsfine.eatsfine.domain.tableimage.controller; + +import com.eatsfine.eatsfine.domain.tableimage.dto.TableImageResDto; +import com.eatsfine.eatsfine.domain.tableimage.service.TableImageCommandService; +import com.eatsfine.eatsfine.domain.tableimage.service.TableImageQueryService; +import com.eatsfine.eatsfine.domain.tableimage.status.TableImageSuccessStatus; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Tag(name = "TableImage", description = "테이블 이미지 조회 및 관리 API") +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class TableImageController { + + private final TableImageCommandService tableImageCommandService; + private final TableImageQueryService tableImageQueryService; + + @Operation( + summary = "식당 테이블 이미지 등록", + description = "식당 테이블 이미지들을 등록합니다." + ) + @PostMapping( + value = "/stores/{storeId}/table-images", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) + ApiResponse uploadTableImage( + @RequestPart("file") List files, + @PathVariable Long storeId + ) { + return ApiResponse.of( + TableImageSuccessStatus._STORE_TABLE_IMAGE_UPLOAD_SUCCESS, + tableImageCommandService.uploadTableImage(storeId, files) + ); + } + + @Operation( + summary = "식당 테이블 이미지 조회", + description = "식당 테이블 이미지들을 조회합니다." + ) + @GetMapping("/stores/{storeId}/table-images") + ApiResponse getTableImage( + @PathVariable Long storeId + ) { + return ApiResponse.of(TableImageSuccessStatus._STORE_TABLE_IMAGE_GET_SUCCESS, tableImageQueryService.getTableImage(storeId)); + } + + @Operation( + summary = "식당 테이블 이미지 삭제", + description = "식당 테이블 이미지를 삭제합니다." + ) + @DeleteMapping("/stores/{storeId}/table-images") + ApiResponse deleteTableImage( + @PathVariable Long storeId, + @RequestBody List tableImageIds + ) { + return ApiResponse.of(TableImageSuccessStatus._STORE_TABLE_IMAGE_DELETE_SUCCESS, tableImageCommandService.deleteTableImage(storeId, tableImageIds)); + } + + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/converter/TableImageConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/converter/TableImageConverter.java new file mode 100644 index 00000000..9daf0ae3 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/converter/TableImageConverter.java @@ -0,0 +1,29 @@ +package com.eatsfine.eatsfine.domain.tableimage.converter; + +import com.eatsfine.eatsfine.domain.tableimage.dto.TableImageResDto; + +import java.util.List; + +public class TableImageConverter { + + public static TableImageResDto.UploadTableImageDto toUploadTableImageDto(Long storeId, List tableImages) { + return TableImageResDto.UploadTableImageDto.builder() + .storeId(storeId) + .tableImageUrls(tableImages) + .build(); + } + + public static TableImageResDto.GetTableImageDto toGetTableImageDto(Long storeId, List tableImages) { + return TableImageResDto.GetTableImageDto.builder() + .storeId(storeId) + .tableImageUrls(tableImages) + .build(); + } + + public static TableImageResDto.DeleteTableImageDto toDeleteTableImageDto(Long storeId, List removedTableImages) { + return TableImageResDto.DeleteTableImageDto.builder() + .storeId(storeId) + .deletedTableImageIds(removedTableImages) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/dto/TableImageResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/dto/TableImageResDto.java new file mode 100644 index 00000000..baea00db --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/dto/TableImageResDto.java @@ -0,0 +1,26 @@ +package com.eatsfine.eatsfine.domain.tableimage.dto; + +import lombok.Builder; + +import java.util.List; + +public class TableImageResDto { + + @Builder + public record UploadTableImageDto( + Long storeId, + List tableImageUrls + ){} + + @Builder + public record GetTableImageDto( + Long storeId, + List tableImageUrls + ){} + + @Builder + public record DeleteTableImageDto( + Long storeId, + List deletedTableImageIds + ){} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java index 1bb000ef..d13b2370 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java @@ -22,7 +22,10 @@ public class TableImage extends BaseEntity { private Store store; @Column(name = "table_image_url", nullable = false) - private String tableImageUrl; + private String tableImageKey; + + @Column(name = "image_order", nullable = false) + private int imageOrder; public void assignStore(Store store) { this.store = store; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/repository/TableImageRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/repository/TableImageRepository.java index dda63a21..414733be 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/repository/TableImageRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/repository/TableImageRepository.java @@ -1,7 +1,23 @@ package com.eatsfine.eatsfine.domain.tableimage.repository; +import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.tableimage.entity.TableImage; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; public interface TableImageRepository extends JpaRepository { + + @Query(""" + select coalesce(max(ti.imageOrder), 0) + from TableImage ti + where ti.store.id = :storeId +""") + int findMaxOrderByStoreId(Long storeId); + + List findAllByStoreOrderByImageOrder(Store store); + + Optional findByIdAndStore(Long id, Store store); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageCommandService.java new file mode 100644 index 00000000..be27464b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageCommandService.java @@ -0,0 +1,13 @@ +package com.eatsfine.eatsfine.domain.tableimage.service; + +import com.eatsfine.eatsfine.domain.tableimage.dto.TableImageResDto; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public interface TableImageCommandService { + + TableImageResDto.UploadTableImageDto uploadTableImage(Long storeId, List files); + + TableImageResDto.DeleteTableImageDto deleteTableImage(Long storeId, List tableImageIds); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageCommandServiceImpl.java new file mode 100644 index 00000000..bc72c8ba --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageCommandServiceImpl.java @@ -0,0 +1,74 @@ +package com.eatsfine.eatsfine.domain.tableimage.service; + +import com.eatsfine.eatsfine.domain.image.exception.ImageException; +import com.eatsfine.eatsfine.domain.image.status.ImageErrorStatus; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import com.eatsfine.eatsfine.domain.tableimage.converter.TableImageConverter; +import com.eatsfine.eatsfine.domain.tableimage.dto.TableImageResDto; +import com.eatsfine.eatsfine.domain.tableimage.entity.TableImage; +import com.eatsfine.eatsfine.domain.tableimage.repository.TableImageRepository; +import com.eatsfine.eatsfine.global.s3.S3Service; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.awt.*; +import java.util.ArrayList; +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +public class TableImageCommandServiceImpl implements TableImageCommandService { + + private final StoreRepository storeRepository; + private final TableImageRepository tableImageRepository; + private final S3Service s3Service; + + // 가게 테이블 이미지 등록 + public TableImageResDto.UploadTableImageDto uploadTableImage(Long storeId, List files) { + + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + if(files == null || files.isEmpty() || files.stream().allMatch(MultipartFile::isEmpty)) { + throw new ImageException(ImageErrorStatus.EMPTY_FILE); + } + + int imageOrder = tableImageRepository.findMaxOrderByStoreId(storeId) + 1; + List tableImages = new ArrayList<>(); + + for (MultipartFile file : files) { + String key = s3Service.upload(file, "stores/" + storeId + "/tables"); + TableImage tableImage = TableImage.builder() + .tableImageKey(key) + .imageOrder(imageOrder++) + .build(); + store.addTableImage(tableImage); + tableImages.add(s3Service.toUrl(key)); + } + return TableImageConverter.toUploadTableImageDto(storeId, tableImages); + } + + @Override + public TableImageResDto.DeleteTableImageDto deleteTableImage(Long storeId, List tableImageIds) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + List tableImages = tableImageIds.stream() + .map(id -> tableImageRepository.findByIdAndStore(id, store) + .orElseThrow(() -> new ImageException(ImageErrorStatus._IMAGE_NOT_FOUND))) + .toList(); + + for (TableImage tableImage : tableImages) { + s3Service.deleteByKey(tableImage.getTableImageKey()); + store.removeTableImage(tableImage); + } + + return TableImageConverter.toDeleteTableImageDto(storeId, tableImageIds); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageQueryService.java new file mode 100644 index 00000000..52a0602e --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageQueryService.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.tableimage.service; + +import com.eatsfine.eatsfine.domain.tableimage.dto.TableImageResDto; + +public interface TableImageQueryService { + TableImageResDto.GetTableImageDto getTableImage(Long storeId); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageQueryServiceImpl.java new file mode 100644 index 00000000..a2c19d9b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageQueryServiceImpl.java @@ -0,0 +1,41 @@ +package com.eatsfine.eatsfine.domain.tableimage.service; + +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import com.eatsfine.eatsfine.domain.tableimage.converter.TableImageConverter; +import com.eatsfine.eatsfine.domain.tableimage.dto.TableImageResDto; +import com.eatsfine.eatsfine.domain.tableimage.entity.TableImage; +import com.eatsfine.eatsfine.domain.tableimage.repository.TableImageRepository; +import com.eatsfine.eatsfine.global.s3.S3Service; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class TableImageQueryServiceImpl implements TableImageQueryService { + + private final StoreRepository storeRepository; + private final TableImageRepository tableImageRepository; + private final S3Service s3Service; + + @Override + public TableImageResDto.GetTableImageDto getTableImage(Long storeId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + List tableImages = tableImageRepository.findAllByStoreOrderByImageOrder(store); + + List tableImageUrls = tableImages.stream() + .map(ti-> s3Service.toUrl(ti.getTableImageKey())) + .toList(); + + return TableImageConverter.toGetTableImageDto(storeId, tableImageUrls); + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/status/TableImageSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/status/TableImageSuccessStatus.java new file mode 100644 index 00000000..058f30bb --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/status/TableImageSuccessStatus.java @@ -0,0 +1,46 @@ +package com.eatsfine.eatsfine.domain.tableimage.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum TableImageSuccessStatus implements BaseCode { + + _STORE_TABLE_IMAGE_UPLOAD_SUCCESS(HttpStatus.OK, "TABLE_IMAGE200", "성공적으로 가게 테이블 이미지를 업로드했습니다."), + + _STORE_TABLE_IMAGE_GET_SUCCESS(HttpStatus.OK, "TABLE_IMAGE2001", "성공적으로 가게 테이블 이미지를 조회했습니다."), + + _STORE_TABLE_IMAGE_DELETE_SUCCESS(HttpStatus.OK, "TABLE_IMAGE2002", "성공적으로 가게 테이블 이미지를 삭제했습니다.") + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return ReasonDto.builder() + .isSuccess(true) + .message(message) + .code(code) + .build(); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return ReasonDto.builder() + .isSuccess(true) + .httpStatus(httpStatus) + .message(message) + .code(code) + .build(); + } + + } + + diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/S3Config.java b/src/main/java/com/eatsfine/eatsfine/global/config/S3Config.java new file mode 100644 index 00000000..b8c5c145 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/config/S3Config.java @@ -0,0 +1,17 @@ +package com.eatsfine.eatsfine.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class S3Config { + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.AP_NORTHEAST_2) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java b/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java new file mode 100644 index 00000000..774fc367 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java @@ -0,0 +1,76 @@ +package com.eatsfine.eatsfine.global.s3; + +import com.eatsfine.eatsfine.domain.image.exception.ImageException; +import com.eatsfine.eatsfine.domain.image.status.ImageErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class S3Service { + + private final S3Client s3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${cloud.aws.s3.base-url}") + private String baseUrl; + + public String upload(MultipartFile file, String directory) { + String key = generateKey(file, directory); + + try { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(request, + RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + return key; + } catch (IOException e) { + throw new ImageException(ImageErrorStatus.S3_UPLOAD_FAILED); + } + } + + public void deleteByKey(String key) { + if (key == null || key.isBlank()) return; + + s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(bucket) + .key(key) + .build()); + } + + public String toUrl(String key) { + if (key == null || key.isBlank()) return null; + return baseUrl + "/" + key; + } + + private String generateKey(MultipartFile file, String directory) { + if(directory == null || directory.isBlank()) { + throw new IllegalArgumentException("S3 디렉토리는 비어있을 수 없습니다."); + } + String extension = extractExtension(file.getOriginalFilename()); + return directory + "/" + UUID.randomUUID() + extension; + } + + private String extractExtension(String filename) { + if (filename == null || !filename.contains(".")) { + throw new ImageException(ImageErrorStatus.INVALID_FILE_TYPE); + } + return filename.substring(filename.lastIndexOf(".")); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b6cae831..2338f34c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,3 +3,10 @@ spring: name: Eatsfine profiles: active: local + +cloud: + aws: + region: ${AWS_REGION} + s3: + bucket: ${AWS_S3_BUCKET} + base-url: ${AWS_S3_BASE_URL} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 32c915a2..addc3347 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -5,4 +5,4 @@ server: spring: config: activate: - on-profile: test + on-profile: test \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index c005f2ef..e65dfae3 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -15,4 +15,11 @@ spring: payment: toss: - widget-secret-key: test_sk_sample_key_for_testing \ No newline at end of file + widget-secret-key: test_sk_sample_key_for_testing + +cloud: + aws: + region: test-region + s3: + bucket: test-bucket + base-url: https://test-bucket.s3.test-region.amazonaws.com \ No newline at end of file From 7905e850fb3a59afcb9f3705a2fee178397d4e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A4=80=EA=B7=9C?= <144649271+sonjunkyu@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:16:38 +0900 Subject: [PATCH 152/169] =?UTF-8?q?[FEAT]:=20=EA=B0=80=EA=B2=8C=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=9D=EC=84=B1=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [FEAT]: StoreTableException 세팅 * [FEAT]: 테이블 생성 성공/실패 응답코드 추가 * [FEAT]: StoreTableController 가게 테이블 생성 API 및 명세 추가 * [FEAT]: 테이블 생성 DTO, Converter 추가 * [FEAT]: 테이블 생성 검증 로직 추가 * [FEAT]: 테이블 생성 검증 서비스 로직 추가 --- .../controller/StoreTableController.java | 26 ++++++ .../controller/StoreTableControllerDocs.java | 36 +++++++ .../converter/StoreTableConverter.java | 24 +++++ .../storetable/dto/req/StoreTableReqDto.java | 33 +++++++ .../storetable/dto/res/StoreTableResDto.java | 24 +++++ .../exception/StoreTableException.java | 10 ++ .../status/StoreTableErrorStatus.java | 41 ++++++++ .../status/StoreTableSuccessStatus.java | 39 ++++++++ .../service/StoreTableCommandService.java | 8 ++ .../service/StoreTableCommandServiceImpl.java | 93 +++++++++++++++++++ .../validator/StoreTableValidator.java | 63 +++++++++++++ 11 files changed, 397 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/req/StoreTableReqDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/StoreTableException.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/validator/StoreTableValidator.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java new file mode 100644 index 00000000..25b86e72 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java @@ -0,0 +1,26 @@ +package com.eatsfine.eatsfine.domain.storetable.controller; + +import com.eatsfine.eatsfine.domain.storetable.dto.req.StoreTableReqDto; +import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; +import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableSuccessStatus; +import com.eatsfine.eatsfine.domain.storetable.service.StoreTableCommandService; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "StoreTable", description = "가게 테이블 관리 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class StoreTableController implements StoreTableControllerDocs { + private final StoreTableCommandService storeTableCommandService; + + @PostMapping("/stores/{storeId}/tables") + public ApiResponse createTable( + @PathVariable Long storeId, + @RequestBody StoreTableReqDto.TableCreateDto dto + ) { + return ApiResponse.of(StoreTableSuccessStatus._TABLE_CREATED, storeTableCommandService.createTable(storeId, dto)); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java new file mode 100644 index 00000000..8e1f72b6 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java @@ -0,0 +1,36 @@ +package com.eatsfine.eatsfine.domain.storetable.controller; + +import com.eatsfine.eatsfine.domain.storetable.dto.req.StoreTableReqDto; +import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.RequestBody; + +public interface StoreTableControllerDocs { + + @Operation( + summary = "테이블 생성", + description = """ + 배치도에 새 테이블을 추가합니다. + + - 테이블 번호는 자동으로 순차 생성됩니다. (1번 테이블, 2번 테이블, ...) + - 좌표와 크기는 배치도 그리드 범위 내에 있어야 합니다. + - 다른 테이블과 겹치지 않아야 합니다. + - 최소 인원은 최대 인원보다 작거나 같아야 합니다. + - 활성화된 배치도에만 테이블을 추가할 수 있습니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "테이블 생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 (좌표 범위 초과, 테이블 겹침 등)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "가게 또는 배치도를 찾을 수 없음") + }) + ApiResponse createTable( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + @RequestBody @Valid StoreTableReqDto.TableCreateDto dto + ); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java new file mode 100644 index 00000000..eb211f8b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java @@ -0,0 +1,24 @@ +package com.eatsfine.eatsfine.domain.storetable.converter; + +import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; + +public class StoreTableConverter { + // StoreTable Entity를 생성 응답 DTO로 변환 + public static StoreTableResDto.TableCreateDto toTableCreateDto(StoreTable table) { + return StoreTableResDto.TableCreateDto.builder() + .tableId(table.getId()) + .tableNumber(table.getTableNumber()) + .gridX(table.getGridX()) + .gridY(table.getGridY()) + .widthSpan(table.getWidthSpan()) + .heightSpan(table.getHeightSpan()) + .minSeatCount(table.getMinSeatCount()) + .maxSeatCount(table.getMaxSeatCount()) + .seatsType(table.getSeatsType()) + .rating(table.getRating()) + .reviewCount(0) // 리뷰 기능 미구현으로 0 반환 + .tableImageUrl(table.getTableImageUrl()) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/req/StoreTableReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/req/StoreTableReqDto.java new file mode 100644 index 00000000..559366da --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/req/StoreTableReqDto.java @@ -0,0 +1,33 @@ +package com.eatsfine.eatsfine.domain.storetable.dto.req; + +import com.eatsfine.eatsfine.domain.storetable.enums.SeatsType; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public class StoreTableReqDto { + public record TableCreateDto( + @NotNull(message = "X 좌표는 필수입니다.") + @Min(value = 0, message = "X 좌표는 0 이상이어야 합니다.") + Integer gridX, + + @NotNull(message = "Y 좌표는 필수입니다.") + @Min(value = 0, message = "Y 좌표는 0 이상이어야 합니다.") + Integer gridY, + + @NotNull(message = "최소 인원은 필수입니다.") + @Min(value = 1, message = "최소 인원은 1명 이상이어야 합니다.") + @Max(value = 20, message = "최소 인원은 20명 이하여야 합니다.") + Integer minSeatCount, + + @NotNull(message = "최대 인원은 필수입니다.") + @Min(value = 1, message = "최대 인원은 1명 이상이어야 합니다.") + @Max(value = 20, message = "최대 인원은 20명 이하여야 합니다.") + Integer maxSeatCount, + + @NotNull(message = "테이블 유형은 필수입니다.") + SeatsType seatsType, + + String tableImageUrl + ) {} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java new file mode 100644 index 00000000..b4290c93 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java @@ -0,0 +1,24 @@ +package com.eatsfine.eatsfine.domain.storetable.dto.res; + +import com.eatsfine.eatsfine.domain.storetable.enums.SeatsType; +import lombok.Builder; + +import java.math.BigDecimal; + +public class StoreTableResDto { + @Builder + public record TableCreateDto( + Long tableId, + String tableNumber, + Integer gridX, + Integer gridY, + Integer widthSpan, + Integer heightSpan, + Integer minSeatCount, + Integer maxSeatCount, + SeatsType seatsType, + BigDecimal rating, + Integer reviewCount, + String tableImageUrl + ) {} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/StoreTableException.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/StoreTableException.java new file mode 100644 index 00000000..ce07af04 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/StoreTableException.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.storetable.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class StoreTableException extends GeneralException { + public StoreTableException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java new file mode 100644 index 00000000..3fcdafa4 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java @@ -0,0 +1,41 @@ +package com.eatsfine.eatsfine.domain.storetable.exception.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum StoreTableErrorStatus implements BaseErrorCode { + + _TABLE_NOT_FOUND(HttpStatus.NOT_FOUND, "TABLE404", "테이블을 찾을 수 없습니다."), + _TABLE_INVALID_SEAT_RANGE(HttpStatus.BAD_REQUEST, "TABLE400", "최소 인원은 최대 인원보다 작거나 같아야 합니다."), + _TABLE_POSITION_OUT_OF_BOUNDS(HttpStatus.BAD_REQUEST, "TABLE401", "테이블 위치가 배치도 그리드 범위를 벗어났습니다."), + _TABLE_POSITION_OVERLAPS(HttpStatus.BAD_REQUEST, "TABLE402", "해당 위치에 이미 다른 테이블이 존재합니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(false) + .message(message) + .code(code) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .isSuccess(false) + .httpStatus(httpStatus) + .message(message) + .code(code) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java new file mode 100644 index 00000000..118f5484 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java @@ -0,0 +1,39 @@ +package com.eatsfine.eatsfine.domain.storetable.exception.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum StoreTableSuccessStatus implements BaseCode { + + _TABLE_CREATED(HttpStatus.CREATED, "TABLE201", "성공적으로 테이블을 생성했습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return ReasonDto.builder() + .isSuccess(true) + .message(message) + .code(code) + .build(); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return ReasonDto.builder() + .isSuccess(true) + .httpStatus(httpStatus) + .message(message) + .code(code) + .build(); + } +} + diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java new file mode 100644 index 00000000..58370247 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java @@ -0,0 +1,8 @@ +package com.eatsfine.eatsfine.domain.storetable.service; + +import com.eatsfine.eatsfine.domain.storetable.dto.req.StoreTableReqDto; +import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; + +public interface StoreTableCommandService { + StoreTableResDto.TableCreateDto createTable(Long storeId, StoreTableReqDto.TableCreateDto dto); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java new file mode 100644 index 00000000..8d95557b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java @@ -0,0 +1,93 @@ +package com.eatsfine.eatsfine.domain.storetable.service; + +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import com.eatsfine.eatsfine.domain.storetable.converter.StoreTableConverter; +import com.eatsfine.eatsfine.domain.storetable.dto.req.StoreTableReqDto; +import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.storetable.repository.StoreTableRepository; +import com.eatsfine.eatsfine.domain.storetable.validator.StoreTableValidator; +import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; +import com.eatsfine.eatsfine.domain.table_layout.exception.TableLayoutException; +import com.eatsfine.eatsfine.domain.table_layout.exception.status.TableLayoutErrorStatus; +import com.eatsfine.eatsfine.domain.table_layout.repository.TableLayoutRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class StoreTableCommandServiceImpl implements StoreTableCommandService { + private final StoreRepository storeRepository; + private final TableLayoutRepository tableLayoutRepository; + private final StoreTableRepository storeTableRepository; + + // 테이블 생성 + @Override + public StoreTableResDto.TableCreateDto createTable(Long storeId, StoreTableReqDto.TableCreateDto dto) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + TableLayout layout = tableLayoutRepository.findByStoreIdAndIsActiveTrue(storeId) + .orElseThrow(() -> new TableLayoutException(TableLayoutErrorStatus._LAYOUT_NOT_FOUND)); + + // 좌석 범위 검증 + StoreTableValidator.validateSeatRange(dto.minSeatCount(), dto.maxSeatCount()); + + // 테이블이 그리드 범위 내인지 검증 (테이블 생성 시 크기는 1x1 크기로 고정) + StoreTableValidator.validateGridBounds(dto.gridX(), dto.gridY(), 1, 1, layout); + + // 테이블 겹침 검증 + StoreTableValidator.validateNoOverlap(dto.gridX(), dto.gridY(), 1, 1, layout.getTables()); + + // 테이블 번호 자동 생성 + String tableNumber = generateTableNumber(layout); + + // 테이블 생성 + StoreTable newTable = StoreTable.builder() + .tableNumber(tableNumber) + .tableLayout(layout) + .gridX(dto.gridX()) + .gridY(dto.gridY()) + .widthSpan(1) + .heightSpan(1) + .minSeatCount(dto.minSeatCount()) + .maxSeatCount(dto.maxSeatCount()) + .seatsType(dto.seatsType()) + .rating(BigDecimal.ZERO) + .tableImageUrl(dto.tableImageUrl()) + .isDeleted(false) + .build(); + + StoreTable savedTable = storeTableRepository.save(newTable); + + return StoreTableConverter.toTableCreateDto(savedTable); + } + + private String generateTableNumber(TableLayout layout) { + List tables = layout.getTables(); + + if (tables.isEmpty()) { + return "1번 테이블"; + } + + // 기존 테이블 번호 중 최대값 찾기 + int maxNumber = tables.stream() + .map(StoreTable::getTableNumber) + .filter(number -> number.matches("\\d+번 테이블")) + .map(number -> { + String numPart = number.replace("번 테이블", ""); + return Integer.parseInt(numPart); + }) + .max(Integer::compareTo) + .orElse(0); + + return String.format("%d번 테이블", maxNumber + 1); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/validator/StoreTableValidator.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/validator/StoreTableValidator.java new file mode 100644 index 00000000..0d3a018a --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/validator/StoreTableValidator.java @@ -0,0 +1,63 @@ +package com.eatsfine.eatsfine.domain.storetable.validator; + +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.storetable.exception.StoreTableException; +import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableErrorStatus; +import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; + +import java.util.List; + +public class StoreTableValidator { + + private StoreTableValidator() { + // 인스턴스화 방지 + } + + // 좌석 범위 검증 (최소 좌석 수가 최대 좌석 수보다 클 수 없음) + public static void validateSeatRange(int minSeatCount, int maxSeatCount) { + if (minSeatCount > maxSeatCount) { + throw new StoreTableException(StoreTableErrorStatus._TABLE_INVALID_SEAT_RANGE); + } + } + + // 테이블 전체(시작점 + 크기)가 그리드 범위 내에 있는지 검증 + public static void validateGridBounds(int gridX, int gridY, int widthSpan, int heightSpan, TableLayout layout) { + // 테이블의 끝점 계산 (0-based이므로 -1) + int endX = gridX + widthSpan - 1; + int endY = gridY + heightSpan - 1; + + // 시작점이 음수이거나, 끝점이 그리드 범위를 벗어나면 예외 + if (gridX < 0 || gridY < 0 || endX >= layout.getCols() || endY >= layout.getLows()) { + throw new StoreTableException(StoreTableErrorStatus._TABLE_POSITION_OUT_OF_BOUNDS); + } + } + + // 새로 추가할 테이블이 기존 테이블과 겹치지 않는지 확인 + public static void validateNoOverlap(int gridX, int gridY, int widthSpan, int heightSpan, + List existingTables) { + for (StoreTable existing : existingTables) { + if (isOverlapping(gridX, gridY, widthSpan, heightSpan, existing)) { + throw new StoreTableException(StoreTableErrorStatus._TABLE_POSITION_OVERLAPS); + } + } + } + + // 직사각형 겹침 판정 알고리즘 + private static boolean isOverlapping(int newX, int newY, int newWidth, int newHeight, StoreTable existing) { + // 새 테이블의 범위 + int newX2 = newX + newWidth - 1; + int newY2 = newY + newHeight - 1; + + // 기존 테이블의 범위 + int existX1 = existing.getGridX(); + int existY1 = existing.getGridY(); + int existX2 = existing.getGridX() + existing.getWidthSpan() - 1; + int existY2 = existing.getGridY() + existing.getHeightSpan() - 1; + + // 겹치는 조건: x축도 겹치고 y축도 겹침 + boolean xOverlap = (newX <= existX2) && (newX2 >= existX1); + boolean yOverlap = (newY <= existY2) && (newY2 >= existY1); + + return xOverlap && yOverlap; + } +} From e8178081a155432d58f6d7e7578b49e725e27a9e Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:53:01 +0900 Subject: [PATCH 153/169] =?UTF-8?q?[FIX]:=20=EB=AC=B4=EC=A4=91=EB=8B=A8=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EA=B5=AC=EC=A1=B0=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=EC=97=90=EC=84=9C=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 123 +++-------------------------------- 1 file changed, 10 insertions(+), 113 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9f53e4b4..2d127643 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,122 +48,19 @@ jobs: - name: 도커 이미지 푸시 run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest - - name: GitHub Actions 실행자 IP 얻어오기 - id: GITHUB_ACTIONS_IP - uses: haythem/public-ip@v1.3 - - - name: AWS CLI 설정 - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ secrets.AWS_REGION }} - - - name: GitHub Actions - SSH 포트 임시 오픈 - run: | - aws ec2 authorize-security-group-ingress \ - --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ - --ip-permissions \ - 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' - - - name: SSH Key 설정 - run: | - mkdir -p ~/.ssh - echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/eatsfine-ec2-key.pem - chmod 600 ~/.ssh/eatsfine-ec2-key.pem - echo "Host eatsfine-ec2" >> ~/.ssh/config - echo " HostName ${{ secrets.LIVE_SERVER_IP }}" >> ~/.ssh/config - echo " User ${{ secrets.EC2_USERNAME }}" >> ~/.ssh/config - echo " IdentityFile ~/.ssh/eatsfine-ec2-key.pem" >> ~/.ssh/config - echo " StrictHostKeyChecking no" >> ~/.ssh/config - - - name: 배포 대상 판단 (nginx 기준) - run: | - CURRENT=$(ssh -T eatsfine-ec2 << 'EOF' | tail -n 1 - if docker ps --format '{{.Names}}' | grep -q '^blue$'; then - echo blue - else - echo green - fi - EOF - ) - - - echo "CURRENT_UPSTREAM=$CURRENT" >> $GITHUB_ENV - - if [ "$CURRENT" = "blue" ]; then - echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV - echo "TARGET_PORT=${{ secrets.GREEN_PORT }}" >> $GITHUB_ENV - else - echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV - echo "TARGET_PORT=${{ secrets.BLUE_PORT }}" >> $GITHUB_ENV - fi - - - name: GitHub Actions - TARGET 컨테이너 포트 오픈 + - name: EC2 배포 run: | - aws ec2 authorize-security-group-ingress \ - --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ - --ip-permissions \ - 'IpProtocol=tcp,FromPort=${{ env.TARGET_PORT }},ToPort=${{ env.TARGET_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' - - - - - name: 도커 이미지 풀링 및 컨테이너 실행 - run: | - ssh eatsfine-ec2 << 'EOF' + ssh -o StrictHostKeyChecking=no \ + -i <(echo "${{ secrets.EC2_SSH_KEY }}") \ + ${{ secrets.EC2_USERNAME }}@${{ secrets.LIVE_SERVER_IP }} << EOF + set -e + cd /home/ec2-user/deploy - CONFIG_DIR=/home/ec2-user/config/eatsfine - DEPLOY_DIR=/home/ec2-user/deploy + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest - # 필요한 프로필 파일을 서버로 복사합니다. - if [ "${{ env.TARGET_UPSTREAM }}" = "blue" ]; then - echo "${{ secrets.APPLICATION_BLUE_YML }}" | base64 --decode > ${CONFIG_DIR}/application-blue.yml - else - echo "${{ secrets.APPLICATION_GREEN_YML }}" | base64 --decode > ${CONFIG_DIR}/application-green.yml - fi + docker compose down + docker compose up -d - docker pull ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest - docker compose -f /home/ec2-user/deploy/docker-compose-${{ env.TARGET_UPSTREAM }}.yml up -d - docker ps - EOF - - - name: 컨테이너 기동 대기 - run: sleep 10 - - - name: 새로 실행한 서버 컨테이너 헬스 체크 - uses: jtalk/url-health-check-action@v3 - with: - url: http://${{ secrets.LIVE_SERVER_IP }}:${{ env.TARGET_PORT }}/api/v1/deploy/health-check - max-attempts: 10 - retry-delay: 10s - - - name: Nginx 의 대상 서버를 새로 실행한 컨테이너쪽으로 전환 - run: | - ssh eatsfine-ec2 << 'EOF' - set -e - # 컨테이너 내부의 파일에 직접 쓰기 (sh 사용, 경로 이슈 해결) - docker exec -i nginx sh -c 'echo "set \$service_url ${{ env.TARGET_UPSTREAM }};" > /etc/nginx/conf.d/service-env.inc && nginx -s reload' - EOF - - name: 기존 배포 컨테이너 정지 - run: | - ssh eatsfine-ec2 << 'EOF' - set -e - for C in blue green; do - if docker ps -a --format '{{.Names}}' | grep -q "^$C$"; then - if [ "$C" != "${{ env.TARGET_UPSTREAM }}" ]; then - docker stop "$C" || true - docker rm "$C" || true - fi - fi - done - EOF - - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 제거 - if: always() - run: | - aws ec2 revoke-security-group-ingress \ - --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ - --ip-permissions \ - 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ - 'IpProtocol=tcp,FromPort=${{env.TARGET_PORT}},ToPort=${{env.TARGET_PORT}},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' + EOF \ No newline at end of file From f09b36dad97473c1ecec154affea4b839ed71b12 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:09:54 +0900 Subject: [PATCH 154/169] =?UTF-8?q?[FIX]:=20EC2=20SSH=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20GitHub?= =?UTF-8?q?=20Actions=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2d127643..79f60cb4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,9 +50,14 @@ jobs: - name: EC2 배포 run: | + + mkdir -p ~/.ssh + echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/eatsfine-ec2-key.pem + chmod 600 ~/.ssh/eatsfine-ec2-key.pem + ssh -o StrictHostKeyChecking=no \ -i <(echo "${{ secrets.EC2_SSH_KEY }}") \ - ${{ secrets.EC2_USERNAME }}@${{ secrets.LIVE_SERVER_IP }} << EOF + ${{ secrets.EC2_USERNAME }}@${{ secrets.LIVE_SERVER_IP }} << 'EOF' set -e cd /home/ec2-user/deploy From e57580fed235da5819120a76df1b053157328a97 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:31:00 +0900 Subject: [PATCH 155/169] =?UTF-8?q?[FIX]:=20GitHub=20Actions=20SSH=20?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EC=9E=84=EC=8B=9C=20=EC=98=A4=ED=94=88/?= =?UTF-8?q?=ED=9A=8C=EC=88=98=20=EB=A1=9C=EC=A7=81=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 44 +++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 79f60cb4..ccd1ff91 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,17 +48,38 @@ jobs: - name: 도커 이미지 푸시 run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest - - name: EC2 배포 + - name: GitHub Actions 실행자 IP 얻어오기 + id: GITHUB_ACTIONS_IP + uses: haythem/public-ip@v1.3 + + - name: AWS CLI 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: GitHub Actions - SSH 포트 임시 오픈 + run: | + aws ec2 authorize-security-group-ingress \ + --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ + --ip-permissions \ + 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' + + - name: SSH Key 설정 run: | - mkdir -p ~/.ssh echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/eatsfine-ec2-key.pem chmod 600 ~/.ssh/eatsfine-ec2-key.pem - - ssh -o StrictHostKeyChecking=no \ - -i <(echo "${{ secrets.EC2_SSH_KEY }}") \ - ${{ secrets.EC2_USERNAME }}@${{ secrets.LIVE_SERVER_IP }} << 'EOF' - + echo "Host eatsfine-ec2" >> ~/.ssh/config + echo " HostName ${{ secrets.LIVE_SERVER_IP }}" >> ~/.ssh/config + echo " User ${{ secrets.EC2_USERNAME }}" >> ~/.ssh/config + echo " IdentityFile ~/.ssh/eatsfine-ec2-key.pem" >> ~/.ssh/config + echo " StrictHostKeyChecking no" >> ~/.ssh/config + + - name: EC2 배포 + run: | + ssh eatsfine-ec2 << 'EOF' set -e cd /home/ec2-user/deploy @@ -68,4 +89,11 @@ jobs: docker compose up -d docker ps - EOF \ No newline at end of file + EOF + - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 제거 + if: always() + run: | + aws ec2 revoke-security-group-ingress \ + --group-id ${{ secrets.EC2_SECURITY_GROUP_ID }} \ + --ip-permissions \ + 'IpProtocol=tcp,FromPort=${{ secrets.EC2_SSH_PORT }},ToPort=${{ secrets.EC2_SSH_PORT }},IpRanges=[{CidrIp=${{ steps.GITHUB_ACTIONS_IP.outputs.ipv4 }}/32}]' \ No newline at end of file From 7d007847e1127baabebe4a3ff17d985a0a878c50 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:20:24 +0900 Subject: [PATCH 156/169] =?UTF-8?q?[FIX]:=20=EB=B0=B0=ED=8F=AC=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20main=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-test.yml | 4 ++-- .github/workflows/deploy.yml | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 7118468f..f85935c8 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -34,5 +34,5 @@ jobs: run: chmod +x gradlew shell: bash - - name: Test (테스트) - run: ./gradlew build + - name: Build & Test + run: ./gradlew clean build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ccd1ff91..7b8b4a3c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,6 @@ name: Deploy on: push: branches: - - develop - main jobs: @@ -43,7 +42,7 @@ jobs: password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} - name: 도커 이미지 빌드 - run: docker build --platform linux/amd64 -t ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be . + run: docker build --platform linux/amd64 -t ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest . - name: 도커 이미지 푸시 run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/eatsfine-be:latest From 6e189732ba64b09e3d3a916dac6d5d3660de1494 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:04:05 +0900 Subject: [PATCH 157/169] =?UTF-8?q?[CHORE]:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=20=EB=8F=84=EC=BB=A4=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7b8b4a3c..ff07eeda 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -78,7 +78,7 @@ jobs: - name: EC2 배포 run: | - ssh eatsfine-ec2 << 'EOF' + ssh eatsfine-ec2 << EOF set -e cd /home/ec2-user/deploy @@ -87,6 +87,10 @@ jobs: docker compose down docker compose up -d + # 불필요한 도커 이미지 및 컨테이너 정리 + docker image prune -f + docker container prune -f + docker ps EOF - name: GitHub Actions - SSH 및 컨테이너 실제 포트 접근 권한 제거 From 09ad69229836c900f3c17fd29920c946470a6847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A4=80=EA=B7=9C?= <144649271+sonjunkyu@users.noreply.github.com> Date: Sat, 24 Jan 2026 23:58:29 +0900 Subject: [PATCH 158/169] =?UTF-8?q?[FEAT]:=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=EC=98=88=EC=95=BD=20=EC=8B=9C=EA=B0=84=EB=8C=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C,=20=EA=B4=80=EB=A6=AC=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [REFACTOR]: 테이블 슬롯 조회 생성/실패 응답 코드 추가 및 에러코드 통일 * [FEAT]: StoreTableController에 가게 슬롯 조회 API 및 명세 추가 * [FEAT]: TableBlockRepository 생성 * [FEAT]: BookingRepository에 JPQL 메소드 추가 * [FEAT]: 테이블 슬롯 조회 응답 DTO, Converter 추가 * [FEAT]: 테이블 슬롯 계산, 검증 로직 추가 * [FEAT]: 테이블 슬롯 조회 서비스 로직 추가 * [FEAT]: 테이블 슬롯 상태 변경 성공/실패 에러 핸들러 추가 * [FEAT]: TableBlockController에 테이블 슬롯 상태 변경 API 및 명세 추가 * [FEAT]: TableBlockRepository 특정 테이블 차단 내역 조회 메소드 추가 * [FEAT]: BookingRepository 테이블 예약 여부 조회 메소드 추가 * [FEAT]: 테이블 상태 변경 요청/응답 DTO, Converter 추가 * [FEAT]: 테이블 상태 변경시 검증 로직 추가 * [FEAT]: 테이블 상태 변경 서비스 로직 추가 --- .../booking/repository/BookingRepository.java | 15 ++ .../controller/StoreTableController.java | 15 ++ .../controller/StoreTableControllerDocs.java | 35 +++++ .../converter/StoreTableConverter.java | 19 +++ .../storetable/dto/res/StoreTableResDto.java | 19 +++ .../status/StoreTableErrorStatus.java | 10 +- .../status/StoreTableSuccessStatus.java | 3 +- .../service/StoreTableQueryService.java | 9 ++ .../service/StoreTableQueryServiceImpl.java | 52 +++++++ .../storetable/util/SlotCalculator.java | 140 ++++++++++++++++++ .../validator/StoreTableValidator.java | 9 ++ .../controller/TableBlockController.java | 28 ++++ .../controller/TableBlockControllerDocs.java | 36 +++++ .../converter/TableBlockConverter.java | 18 +++ .../tableblock/dto/req/TableBlockReqDto.java | 23 +++ .../tableblock/dto/res/TableBlockResDto.java | 28 ++++ .../exception/TableBlockException.java | 10 ++ .../status/TableBlockErrorStatus.java | 41 +++++ .../status/TableBlockSuccessStatus.java | 37 +++++ .../repository/TableBlockRepository.java | 16 ++ .../service/TableBlockCommandService.java | 8 + .../service/TableBlockCommandServiceImpl.java | 95 ++++++++++++ .../validator/TableBlockValidator.java | 50 +++++++ 23 files changed, 711 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/storetable/util/SlotCalculator.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableblock/controller/TableBlockController.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableblock/controller/TableBlockControllerDocs.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableblock/converter/TableBlockConverter.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableblock/dto/req/TableBlockReqDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableblock/dto/res/TableBlockResDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableblock/exception/TableBlockException.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableblock/exception/status/TableBlockErrorStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableblock/exception/status/TableBlockSuccessStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableblock/repository/TableBlockRepository.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableblock/service/TableBlockCommandService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableblock/service/TableBlockCommandServiceImpl.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/tableblock/validator/TableBlockValidator.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java index a0f4c5e3..acb5b0a5 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java @@ -19,4 +19,19 @@ public interface BookingRepository extends JpaRepository { "and b.bookingTime = :time " + "and b.status IN ('CONFIRMED', 'PENDING')") List findReservedTableIds(Long storeId, LocalDate date, LocalTime time); + + @Query("SELECT b.bookingTime FROM BookingTable bt " + + "JOIN bt.booking b " + + "WHERE bt.storeTable.id = :tableId " + + "AND b.bookingDate = :date " + + "AND b.status IN ('CONFIRMED', 'PENDING')") + List findBookedTimesByTableAndDate(@Param("tableId") Long tableId, @Param("date") LocalDate date); + + @Query("SELECT COUNT(bt) > 0 FROM BookingTable bt " + + "JOIN bt.booking b " + + "WHERE bt.storeTable.id = :tableId " + + "AND b.bookingDate = :date " + + "AND b.bookingTime = :time " + + "AND b.status IN ('CONFIRMED', 'PENDING')") + boolean existsBookingByTableAndDateTime(@Param("tableId") Long tableId, @Param("date") LocalDate date, @Param("time") LocalTime time); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java index 25b86e72..d9c9ca9f 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java @@ -4,17 +4,22 @@ import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableSuccessStatus; import com.eatsfine.eatsfine.domain.storetable.service.StoreTableCommandService; +import com.eatsfine.eatsfine.domain.storetable.service.StoreTableQueryService; import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; + @Tag(name = "StoreTable", description = "가게 테이블 관리 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/v1") public class StoreTableController implements StoreTableControllerDocs { private final StoreTableCommandService storeTableCommandService; + private final StoreTableQueryService storeTableQueryService; @PostMapping("/stores/{storeId}/tables") public ApiResponse createTable( @@ -23,4 +28,14 @@ public ApiResponse createTable( ) { return ApiResponse.of(StoreTableSuccessStatus._TABLE_CREATED, storeTableCommandService.createTable(storeId, dto)); } + + @GetMapping("/stores/{storeId}/tables/{tableId}/slots") + public ApiResponse getTableSlots( + @PathVariable Long storeId, + @PathVariable Long tableId, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date + ) { + LocalDate targetDate = (date != null) ? date : LocalDate.now(); + return ApiResponse.of(StoreTableSuccessStatus._SLOT_LIST_FOUND, storeTableQueryService.getTableSlots(storeId, tableId, targetDate)); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java index 8e1f72b6..5e83c805 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java @@ -7,7 +7,11 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.LocalDate; public interface StoreTableControllerDocs { @@ -33,4 +37,35 @@ ApiResponse createTable( Long storeId, @RequestBody @Valid StoreTableReqDto.TableCreateDto dto ); + + @Operation( + summary = "테이블 예약 시간대 조회", + description = """ + 특정 테이블의 예약 가능한 시간대를 조회합니다. + - 동적 슬롯 생성 방식을 사용합니다. + - 영업시간을 기준으로 예약 간격(bookingIntervalMinutes)만큼 슬롯을 생성합니다. + - 각 슬롯의 상태는 다음과 같이 결정됩니다: + * BREAK_TIME: 브레이크타임에 해당하는 시간대 + * BLOCKED: 사장이 차단한 시간대 + * BOOKED: 이미 예약된 시간대 + * AVAILABLE: 예약 가능한 시간대 + - date 파라미터가 없으면 오늘 날짜로 조회합니다. + - 운영 시간 11:00~22:00, 예약 간격 30분이면 21:30이 마지막 슬롯입니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "슬롯 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "테이블 또는 영업시간을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "테이블이 해당 가게에 속하지 않음") + }) + ApiResponse getTableSlots( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + + @Parameter(description = "테이블 ID", required = true, example = "1") + Long tableId, + + @Parameter(description = "조회할 날짜 (yyyy-MM-dd 형식, 미입력 시 오늘 날짜)", example = "2026-01-12") + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date + ); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java index eb211f8b..9a00e2f5 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java @@ -2,6 +2,9 @@ import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.storetable.util.SlotCalculator; + +import java.util.List; public class StoreTableConverter { // StoreTable Entity를 생성 응답 DTO로 변환 @@ -21,4 +24,20 @@ public static StoreTableResDto.TableCreateDto toTableCreateDto(StoreTable table) .tableImageUrl(table.getTableImageUrl()) .build(); } + + public static StoreTableResDto.SlotListDto toSlotListDto(int totalCount, int availableCount, List slots) { + List slotDetails = slots.stream() + .map(slot -> StoreTableResDto.SlotDetailDto.builder() + .time(slot.time()) + .status(slot.status()) + .isAvailable(slot.isAvailable()) + .build()) + .toList(); + + return StoreTableResDto.SlotListDto.builder() + .totalSlotCount(totalCount) + .availableSlotCount(availableCount) + .slots(slotDetails) + .build(); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java index b4290c93..49ac4cce 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java @@ -1,9 +1,13 @@ package com.eatsfine.eatsfine.domain.storetable.dto.res; import com.eatsfine.eatsfine.domain.storetable.enums.SeatsType; +import com.eatsfine.eatsfine.domain.tableblock.enums.SlotStatus; +import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Builder; import java.math.BigDecimal; +import java.time.LocalTime; +import java.util.List; public class StoreTableResDto { @Builder @@ -21,4 +25,19 @@ public record TableCreateDto( Integer reviewCount, String tableImageUrl ) {} + + @Builder + public record SlotListDto( + int totalSlotCount, + int availableSlotCount, + List slots + ) {} + + @Builder + public record SlotDetailDto( + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") + LocalTime time, + SlotStatus status, + boolean isAvailable + ) {} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java index 3fcdafa4..95144a6d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java @@ -10,10 +10,12 @@ @AllArgsConstructor public enum StoreTableErrorStatus implements BaseErrorCode { - _TABLE_NOT_FOUND(HttpStatus.NOT_FOUND, "TABLE404", "테이블을 찾을 수 없습니다."), - _TABLE_INVALID_SEAT_RANGE(HttpStatus.BAD_REQUEST, "TABLE400", "최소 인원은 최대 인원보다 작거나 같아야 합니다."), - _TABLE_POSITION_OUT_OF_BOUNDS(HttpStatus.BAD_REQUEST, "TABLE401", "테이블 위치가 배치도 그리드 범위를 벗어났습니다."), - _TABLE_POSITION_OVERLAPS(HttpStatus.BAD_REQUEST, "TABLE402", "해당 위치에 이미 다른 테이블이 존재합니다."), + _TABLE_NOT_FOUND(HttpStatus.NOT_FOUND, "TABLE404_1", "테이블을 찾을 수 없습니다."), + _TABLE_INVALID_SEAT_RANGE(HttpStatus.BAD_REQUEST, "TABLE400_1", "최소 인원은 최대 인원보다 작거나 같아야 합니다."), + _TABLE_POSITION_OUT_OF_BOUNDS(HttpStatus.BAD_REQUEST, "TABLE400_2", "테이블 위치가 배치도 그리드 범위를 벗어났습니다."), + _TABLE_POSITION_OVERLAPS(HttpStatus.BAD_REQUEST, "TABLE400_3", "해당 위치에 이미 다른 테이블이 존재합니다."), + _TABLE_NOT_BELONGS_TO_STORE(HttpStatus.BAD_REQUEST, "TABLE400_4", "해당 테이블은 해당 가게에 속하지 않습니다."), + _NO_BUSINESS_HOURS(HttpStatus.NOT_FOUND, "TABLE404_2", "해당 요일의 영업시간 정보를 찾을 수 없습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java index 118f5484..8aa959d9 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java @@ -10,7 +10,8 @@ @AllArgsConstructor public enum StoreTableSuccessStatus implements BaseCode { - _TABLE_CREATED(HttpStatus.CREATED, "TABLE201", "성공적으로 테이블을 생성했습니다."), + _TABLE_CREATED(HttpStatus.CREATED, "TABLE201_1", "성공적으로 테이블을 생성했습니다."), + _SLOT_LIST_FOUND(HttpStatus.OK, "TABLE200_1", "테이블 시간 슬롯 조회에 성공했습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryService.java new file mode 100644 index 00000000..053edd2f --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryService.java @@ -0,0 +1,9 @@ +package com.eatsfine.eatsfine.domain.storetable.service; + +import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; + +import java.time.LocalDate; + +public interface StoreTableQueryService { + StoreTableResDto.SlotListDto getTableSlots(Long storeId, Long tableId, LocalDate date); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java new file mode 100644 index 00000000..285d9def --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java @@ -0,0 +1,52 @@ +package com.eatsfine.eatsfine.domain.storetable.service; + +import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; +import com.eatsfine.eatsfine.domain.storetable.converter.StoreTableConverter; +import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.storetable.exception.StoreTableException; +import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableErrorStatus; +import com.eatsfine.eatsfine.domain.storetable.repository.StoreTableRepository; +import com.eatsfine.eatsfine.domain.storetable.util.SlotCalculator; +import com.eatsfine.eatsfine.domain.storetable.validator.StoreTableValidator; +import com.eatsfine.eatsfine.domain.tableblock.entity.TableBlock; +import com.eatsfine.eatsfine.domain.tableblock.repository.TableBlockRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StoreTableQueryServiceImpl implements StoreTableQueryService{ + private final StoreTableRepository storeTableRepository; + private final TableBlockRepository tableBlockRepository; + private final BookingRepository bookingRepository; + + // 테이블 슬롯 조회 + @Override + public StoreTableResDto.SlotListDto getTableSlots(Long storeId, Long tableId, LocalDate date) { + StoreTable storeTable = storeTableRepository.findById(tableId) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); + + StoreTableValidator.validateTableBelongsToStore(storeTable, storeId); + + List tableBlocks = tableBlockRepository.findByStoreTableAndTargetDate(storeTable, date); + List bookedTimeList = bookingRepository.findBookedTimesByTableAndDate(tableId, date); + Set bookedTimes = new HashSet<>(bookedTimeList); + + SlotCalculator.SlotCalculationResult result = SlotCalculator.calculateSlots(storeTable, date, tableBlocks, bookedTimes); + + return StoreTableConverter.toSlotListDto( + result.totalSlotCount(), + result.availableSlotCount(), + result.slots() + ); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/util/SlotCalculator.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/util/SlotCalculator.java new file mode 100644 index 00000000..8c372ac8 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/util/SlotCalculator.java @@ -0,0 +1,140 @@ +package com.eatsfine.eatsfine.domain.storetable.util; + +import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.storetable.exception.StoreTableException; +import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableErrorStatus; +import com.eatsfine.eatsfine.domain.tableblock.entity.TableBlock; +import com.eatsfine.eatsfine.domain.tableblock.enums.SlotStatus; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public class SlotCalculator { + + private SlotCalculator() { + // 인스턴스화 방지 + } + + // 테이블 슬롯 계산 + public static SlotCalculationResult calculateSlots(StoreTable table, LocalDate date, List tableBlocks, Set bookedTimes) { + Store store = table.getTableLayout().getStore(); + DayOfWeek dayOfWeek = date.getDayOfWeek(); + + BusinessHours businessHours = store.findBusinessHoursByDay(dayOfWeek) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._NO_BUSINESS_HOURS)); + + // 휴무일인 경우 빈 결과 반환 + if (businessHours.isClosed()) { + return new SlotCalculationResult(0, 0, Collections.emptyList()); + } + + // 시간 슬롯 생성 + List timeSlots = generateTimeSlots( + businessHours.getOpenTime(), + businessHours.getCloseTime(), + store.getBookingIntervalMinutes() + ); + + // 차단된 시간대 추출 + Set blockedTimes = extractBlockedTimes(tableBlocks); + + // 각 슬롯의 상태 결정 + List slotDtoList = timeSlots.stream() + .map(slot -> determineSlotStatus( + slot.time(), + businessHours.getBreakStartTime(), + businessHours.getBreakEndTime(), + blockedTimes, + bookedTimes + )) + .toList(); + + // 통계 계산 + int totalCount = slotDtoList.size(); + int availableCount = (int) slotDtoList.stream() + .filter(SlotDto::isAvailable) + .count(); + + return new SlotCalculationResult(totalCount, availableCount, slotDtoList); + } + + // 영업시간 기준으로 시간 슬롯을 생성 + public static List generateTimeSlots( + LocalTime openTime, + LocalTime closeTime, + int intervalMinutes + ) { + List slots = new ArrayList<>(); + LocalTime current = openTime; + + // 예: 22:00 종료, 30분 간격 → 21:30이 마지막 + LocalTime lastSlotTime = closeTime.minusMinutes(intervalMinutes); + + while (!current.isAfter(lastSlotTime)) { + slots.add(new TimeSlot(current)); + current = current.plusMinutes(intervalMinutes); + } + + return slots; + } + + // TableBlock 엔티티 리스트에서 차단된 시간대를 추출 + public static Set extractBlockedTimes(List tableBlocks) { + return tableBlocks.stream() + .map(TableBlock::getStartTime) + .collect(Collectors.toSet()); + } + + // 슬롯 상태 결정 + public static SlotDto determineSlotStatus( + LocalTime slotTime, + LocalTime breakStart, + LocalTime breakEnd, + Set blockedTimes, + Set bookedTimes + ) { + // 브레이크 타임 체크 + if (breakStart != null && breakEnd != null) { + if (!slotTime.isBefore(breakStart) && slotTime.isBefore(breakEnd)) { + return new SlotDto(slotTime, SlotStatus.BREAK_TIME, false); + } + } + + // 차단 체크 + if (blockedTimes.contains(slotTime)) { + return new SlotDto(slotTime, SlotStatus.BLOCKED, false); + } + + // 예약 체크 + if (bookedTimes.contains(slotTime)) { + return new SlotDto(slotTime, SlotStatus.BOOKED, false); + } + + // 예약 가능 + return new SlotDto(slotTime, SlotStatus.AVAILABLE, true); + } + + public record SlotCalculationResult( + int totalSlotCount, + int availableSlotCount, + List slots + ) {} + + public record SlotDto( + LocalTime time, + SlotStatus status, + boolean isAvailable + ) {} + + public record TimeSlot( + LocalTime time + ) {} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/validator/StoreTableValidator.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/validator/StoreTableValidator.java index 0d3a018a..7c4ec079 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/validator/StoreTableValidator.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/validator/StoreTableValidator.java @@ -1,5 +1,6 @@ package com.eatsfine.eatsfine.domain.storetable.validator; +import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.storetable.exception.StoreTableException; import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableErrorStatus; @@ -60,4 +61,12 @@ private static boolean isOverlapping(int newX, int newY, int newWidth, int newHe return xOverlap && yOverlap; } + + // 테이블이 해당 가게에 속하는지 검증 + public static void validateTableBelongsToStore(StoreTable table, Long storeId) { + Store store = table.getTableLayout().getStore(); + if (!store.getId().equals(storeId)) { + throw new StoreTableException(StoreTableErrorStatus._TABLE_NOT_BELONGS_TO_STORE); + } + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/controller/TableBlockController.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/controller/TableBlockController.java new file mode 100644 index 00000000..0d9d2f07 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/controller/TableBlockController.java @@ -0,0 +1,28 @@ +package com.eatsfine.eatsfine.domain.tableblock.controller; + +import com.eatsfine.eatsfine.domain.tableblock.dto.req.TableBlockReqDto; +import com.eatsfine.eatsfine.domain.tableblock.dto.res.TableBlockResDto; +import com.eatsfine.eatsfine.domain.tableblock.exception.status.TableBlockSuccessStatus; +import com.eatsfine.eatsfine.domain.tableblock.service.TableBlockCommandService; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "TableBlock", description = "테이블 슬롯 차단/해제 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class TableBlockController implements TableBlockControllerDocs { + private final TableBlockCommandService tableBlockCommandService; + + @Override + @PatchMapping("/stores/{storeId}/tables/{tableId}/slots") + public ApiResponse updateSlotStatus( + @PathVariable Long storeId, + @PathVariable Long tableId, + @RequestBody TableBlockReqDto.SlotStatusUpdateDto dto + ) { + return ApiResponse.of(TableBlockSuccessStatus._SLOT_STATUS_UPDATED, tableBlockCommandService.updateSlotStatus(storeId, tableId, dto)); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/controller/TableBlockControllerDocs.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/controller/TableBlockControllerDocs.java new file mode 100644 index 00000000..ace32da1 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/controller/TableBlockControllerDocs.java @@ -0,0 +1,36 @@ +package com.eatsfine.eatsfine.domain.tableblock.controller; + +import com.eatsfine.eatsfine.domain.tableblock.dto.req.TableBlockReqDto; +import com.eatsfine.eatsfine.domain.tableblock.dto.res.TableBlockResDto; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.RequestBody; + +public interface TableBlockControllerDocs { + @Operation( + summary = "테이블 슬롯 상태 변경", + description = """ + 특정 테이블의 특정 시간대를 차단하거나 해제합니다. + - BLOCKED: 해당 시간대를 차단합니다 (DB에 저장) + - AVAILABLE: 차단을 해제합니다 (DB에서 삭제) + 차단된 시간대는 예약이 불가능하며, 슬롯 조회 시 BLOCKED 상태로 표시됩니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "슬롯 상태 변경 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "유효하지 않은 슬롯 상태 또는 테이블이 가게에 속하지 않음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "테이블을 찾을 수 없거나 차단 내역이 없음 (해제 시)") + }) + ApiResponse updateSlotStatus( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + + @Parameter(description = "테이블 ID", required = true, example = "1") + Long tableId, + + @RequestBody @Valid TableBlockReqDto.SlotStatusUpdateDto dto + ); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/converter/TableBlockConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/converter/TableBlockConverter.java new file mode 100644 index 00000000..9b9b327a --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/converter/TableBlockConverter.java @@ -0,0 +1,18 @@ +package com.eatsfine.eatsfine.domain.tableblock.converter; + +import com.eatsfine.eatsfine.domain.tableblock.dto.res.TableBlockResDto; +import com.eatsfine.eatsfine.domain.tableblock.entity.TableBlock; +import com.eatsfine.eatsfine.domain.tableblock.enums.SlotStatus; + +public class TableBlockConverter { + public static TableBlockResDto.SlotStatusUpdateDto toSlotStatusUpdateDto(TableBlock tableBlock, SlotStatus status) { + return TableBlockResDto.SlotStatusUpdateDto.builder() + .tableBlockId(tableBlock.getId()) + .storeTableId(tableBlock.getStoreTable().getId()) + .targetDate(tableBlock.getTargetDate()) + .startTime(tableBlock.getStartTime()) + .endTime(tableBlock.getEndTime()) + .status(status) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/dto/req/TableBlockReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/dto/req/TableBlockReqDto.java new file mode 100644 index 00000000..8cde0dd4 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/dto/req/TableBlockReqDto.java @@ -0,0 +1,23 @@ +package com.eatsfine.eatsfine.domain.tableblock.dto.req; + +import com.eatsfine.eatsfine.domain.tableblock.enums.SlotStatus; +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; +import java.time.LocalTime; + +public class TableBlockReqDto { + public record SlotStatusUpdateDto( + @NotNull(message = "날짜는 필수입니다.") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + LocalDate targetDate, + + @NotNull(message = "시작 시간은 필수입니다.") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") + LocalTime startTime, + + @NotNull(message = "상태는 필수입니다.") + SlotStatus status + ) {} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/dto/res/TableBlockResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/dto/res/TableBlockResDto.java new file mode 100644 index 00000000..3fd1cf95 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/dto/res/TableBlockResDto.java @@ -0,0 +1,28 @@ +package com.eatsfine.eatsfine.domain.tableblock.dto.res; + +import com.eatsfine.eatsfine.domain.tableblock.enums.SlotStatus; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Builder; + +import java.time.LocalDate; +import java.time.LocalTime; + +public class TableBlockResDto { + @Builder + public record SlotStatusUpdateDto( + Long tableBlockId, + + Long storeTableId, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + LocalDate targetDate, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") + LocalTime startTime, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") + LocalTime endTime, + + SlotStatus status + ) {} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/exception/TableBlockException.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/exception/TableBlockException.java new file mode 100644 index 00000000..29510160 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/exception/TableBlockException.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.tableblock.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class TableBlockException extends GeneralException { + public TableBlockException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/exception/status/TableBlockErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/exception/status/TableBlockErrorStatus.java new file mode 100644 index 00000000..d2b6daa0 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/exception/status/TableBlockErrorStatus.java @@ -0,0 +1,41 @@ +package com.eatsfine.eatsfine.domain.tableblock.exception.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum TableBlockErrorStatus implements BaseErrorCode { + _INVALID_SLOT_STATUS(HttpStatus.BAD_REQUEST, "BLOCK400_1", "유효하지 않은 슬롯 상태입니다. BLOCKED 또는 AVAILABLE만 가능합니다."), + _CANNOT_BLOCK_BOOKED_SLOT(HttpStatus.BAD_REQUEST, "BLOCK400_2", "이미 예약된 시간대는 차단할 수 없습니다."), + _CANNOT_UNBLOCK_BOOKED_SLOT(HttpStatus.BAD_REQUEST, "BLOCK400_3", "이미 예약된 시간대는 차단 해제 할 수 없습니다."), + _CANNOT_UNBLOCK_BREAK_TIME(HttpStatus.BAD_REQUEST, "BLOCK400_4", "브레이크타임은 영업시간 설정에서 변경할 수 있습니다."), + _TABLE_BLOCK_NOT_FOUND(HttpStatus.NOT_FOUND, "BLOCK404_1", "해당 시간대에 차단 내역을 찾을 수 없습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(false) + .message(message) + .code(code) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .isSuccess(false) + .httpStatus(httpStatus) + .message(message) + .code(code) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/exception/status/TableBlockSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/exception/status/TableBlockSuccessStatus.java new file mode 100644 index 00000000..216caa41 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/exception/status/TableBlockSuccessStatus.java @@ -0,0 +1,37 @@ +package com.eatsfine.eatsfine.domain.tableblock.exception.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum TableBlockSuccessStatus implements BaseCode { + _SLOT_STATUS_UPDATED(HttpStatus.OK, "BLOCK200_1", "테이블 슬롯 상태 변경에 성공했습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return ReasonDto.builder() + .isSuccess(true) + .message(message) + .code(code) + .build(); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return ReasonDto.builder() + .isSuccess(true) + .httpStatus(httpStatus) + .message(message) + .code(code) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/repository/TableBlockRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/repository/TableBlockRepository.java new file mode 100644 index 00000000..4aca3a2e --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/repository/TableBlockRepository.java @@ -0,0 +1,16 @@ +package com.eatsfine.eatsfine.domain.tableblock.repository; + +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.tableblock.entity.TableBlock; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; + +public interface TableBlockRepository extends JpaRepository { + List findByStoreTableAndTargetDate(StoreTable storeTable, LocalDate targetDate); + + Optional findByStoreTableAndTargetDateAndStartTime(StoreTable storeTable, LocalDate targetDate, LocalTime startTime); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/service/TableBlockCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/service/TableBlockCommandService.java new file mode 100644 index 00000000..0add9f8c --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/service/TableBlockCommandService.java @@ -0,0 +1,8 @@ +package com.eatsfine.eatsfine.domain.tableblock.service; + +import com.eatsfine.eatsfine.domain.tableblock.dto.req.TableBlockReqDto; +import com.eatsfine.eatsfine.domain.tableblock.dto.res.TableBlockResDto; + +public interface TableBlockCommandService { + TableBlockResDto.SlotStatusUpdateDto updateSlotStatus(Long storeId, Long tableId, TableBlockReqDto.SlotStatusUpdateDto dto); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/service/TableBlockCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/service/TableBlockCommandServiceImpl.java new file mode 100644 index 00000000..a0af490e --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/service/TableBlockCommandServiceImpl.java @@ -0,0 +1,95 @@ +package com.eatsfine.eatsfine.domain.tableblock.service; + +import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.storetable.exception.StoreTableException; +import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableErrorStatus; +import com.eatsfine.eatsfine.domain.storetable.repository.StoreTableRepository; +import com.eatsfine.eatsfine.domain.storetable.validator.StoreTableValidator; +import com.eatsfine.eatsfine.domain.tableblock.converter.TableBlockConverter; +import com.eatsfine.eatsfine.domain.tableblock.dto.req.TableBlockReqDto; +import com.eatsfine.eatsfine.domain.tableblock.dto.res.TableBlockResDto; +import com.eatsfine.eatsfine.domain.tableblock.entity.TableBlock; +import com.eatsfine.eatsfine.domain.tableblock.enums.SlotStatus; +import com.eatsfine.eatsfine.domain.tableblock.exception.TableBlockException; +import com.eatsfine.eatsfine.domain.tableblock.exception.status.TableBlockErrorStatus; +import com.eatsfine.eatsfine.domain.tableblock.repository.TableBlockRepository; +import com.eatsfine.eatsfine.domain.tableblock.validator.TableBlockValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalTime; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional +public class TableBlockCommandServiceImpl implements TableBlockCommandService { + private final StoreTableRepository storeTableRepository; + private final TableBlockRepository tableBlockRepository; + private final StoreRepository storeRepository; + private final BookingRepository bookingRepository; + + // 테이블 슬롯 상태 변경 + @Override + public TableBlockResDto.SlotStatusUpdateDto updateSlotStatus(Long storeId, Long tableId, TableBlockReqDto.SlotStatusUpdateDto dto) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + StoreTable table = storeTableRepository.findById(tableId) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); + + StoreTableValidator.validateTableBelongsToStore(table, storeId); + + // 브레이크 타임 시간대라면 예외 + TableBlockValidator.validateBreakTime(store, dto.targetDate(), dto.startTime()); + + // 예약 여부 조회 + boolean isBooked = bookingRepository.existsBookingByTableAndDateTime(tableId, dto.targetDate(), dto.startTime()); + + // 슬롯 차단 + if (dto.status() == SlotStatus.BLOCKED) { + TableBlockValidator.validateBlockBooking(isBooked); + + Optional existingBlock = tableBlockRepository + .findByStoreTableAndTargetDateAndStartTime(table, dto.targetDate(), dto.startTime()); + + if (existingBlock.isPresent()) { + return TableBlockConverter.toSlotStatusUpdateDto(existingBlock.get(), SlotStatus.BLOCKED); + } + + LocalTime endTime = dto.startTime().plusMinutes(store.getBookingIntervalMinutes()); + + TableBlock tableBlock = TableBlock.builder() + .storeTable(table) + .targetDate(dto.targetDate()) + .startTime(dto.startTime()) + .endTime(endTime) + .build(); + + TableBlock savedBlock = tableBlockRepository.save(tableBlock); + + return TableBlockConverter.toSlotStatusUpdateDto(savedBlock, SlotStatus.BLOCKED); + } + + // 슬롯 차단 해제 + if (dto.status() == SlotStatus.AVAILABLE) { + TableBlockValidator.validateUnblockBooking(isBooked); + + TableBlock tableBlock = tableBlockRepository + .findByStoreTableAndTargetDateAndStartTime(table, dto.targetDate(), dto.startTime()) + .orElseThrow(() -> new TableBlockException(TableBlockErrorStatus._TABLE_BLOCK_NOT_FOUND)); + + tableBlockRepository.delete(tableBlock); + + return TableBlockConverter.toSlotStatusUpdateDto(tableBlock, SlotStatus.AVAILABLE); + } + + throw new TableBlockException(TableBlockErrorStatus._INVALID_SLOT_STATUS); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/validator/TableBlockValidator.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/validator/TableBlockValidator.java new file mode 100644 index 00000000..7cd1d593 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/validator/TableBlockValidator.java @@ -0,0 +1,50 @@ +package com.eatsfine.eatsfine.domain.tableblock.validator; + +import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.tableblock.exception.TableBlockException; +import com.eatsfine.eatsfine.domain.tableblock.exception.status.TableBlockErrorStatus; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; + +public class TableBlockValidator { + private TableBlockValidator() { + // 인스턴스화 방지 + } + + // 브레이크 타임 검증 + public static void validateBreakTime(Store store, LocalDate targetDate, LocalTime startTime) { + DayOfWeek dayOfWeek = targetDate.getDayOfWeek(); + BusinessHours businessHours = store.getBusinessHoursByDay(dayOfWeek); + + boolean isBreakTime = isWithinBreakTime(businessHours.getBreakStartTime(), businessHours.getBreakEndTime(), startTime); + + if (isBreakTime) { + throw new TableBlockException(TableBlockErrorStatus._CANNOT_UNBLOCK_BREAK_TIME); + } + } + + public static boolean isWithinBreakTime(LocalTime breakStart, LocalTime breakEnd, LocalTime time) { + if (breakStart == null || breakEnd == null) { + return false; + } + + return !time.isBefore(breakStart) && time.isBefore(breakEnd); + } + + // 차단 시 예약 시간대 검증 + public static void validateBlockBooking(boolean isBooked) { + if (isBooked) { + throw new TableBlockException(TableBlockErrorStatus._CANNOT_BLOCK_BOOKED_SLOT); + } + } + + // 차단 해제시 예약 시간대 검증 + public static void validateUnblockBooking(boolean isBooked) { + if (isBooked) { + throw new TableBlockException(TableBlockErrorStatus._CANNOT_UNBLOCK_BOOKED_SLOT); + } + } +} \ No newline at end of file From 27654a5c5e4443ad9968149593d7851161925f45 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Sun, 25 Jan 2026 00:19:55 +0900 Subject: [PATCH 159/169] =?UTF-8?q?[FEAT]:=20=EA=B0=80=EA=B2=8C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=8B=9C=20=EC=82=AC=EC=97=85=EC=9E=90?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20Region=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B3=80=EA=B2=BD=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [REFACTOR]: 주소(Region) 저장 체계 변경 * [FEAT]: 사업자번호 검증 API 관련 의존성 및 설정파일 추가 * [FEAT]: 사업자번호 검증 로직 추가 * [FEAT]: 가게 등록 요청 시 사업자번호 검증부 추가 * [REFACTOR]: WebClient -> RestClient로 변경 * [FEAT]: Real, Mock으로 사업자번호 검증 분리 * [CHORE]: 사업자번호 검증 성공 로그 추가 * [FEAT]: application.yml에 사업자번호 검증 api key 설정부 추가 * [REFACTOR]: 사업자번호 검증 코드 패키지명 변경 * [FIX]: test의 application.yml에 더미 키값 추가 --- build.gradle | 3 + .../dto/BusinessNumberReqDto.java | 19 +++++ .../dto/BusinessNumberResDto.java | 26 ++++++ .../exception/BusinessNumberException.java | 11 +++ .../status/BusinessNumberErrorStatus.java | 40 ++++++++++ .../validator/BusinessNumberValidator.java | 6 ++ .../MockBusinessNumberValidator.java | 13 +++ .../RealBusinessNumberValidator.java | 79 +++++++++++++++++++ .../eatsfine/domain/region/entity/Region.java | 18 ++--- .../region/repository/RegionRepository.java | 3 + .../store/condition/StoreSearchCondition.java | 12 +-- .../domain/store/dto/StoreReqDto.java | 23 ++++-- .../repository/StoreRepositoryCustom.java | 6 +- .../store/repository/StoreRepositoryImpl.java | 22 +++--- .../service/StoreCommandServiceImpl.java | 18 ++++- .../store/service/StoreQueryServiceImpl.java | 2 +- .../global/config/RestClientConfig.java | 23 ++++++ src/main/resources/application.yml | 5 +- src/test/resources/application.yml | 3 + 19 files changed, 294 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businessnumber/dto/BusinessNumberReqDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businessnumber/dto/BusinessNumberResDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businessnumber/exception/BusinessNumberException.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businessnumber/status/BusinessNumberErrorStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businessnumber/validator/BusinessNumberValidator.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businessnumber/validator/MockBusinessNumberValidator.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/businessnumber/validator/RealBusinessNumberValidator.java create mode 100644 src/main/java/com/eatsfine/eatsfine/global/config/RestClientConfig.java diff --git a/build.gradle b/build.gradle index c9b5cfd7..70c0be39 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,9 @@ dependencies { implementation 'software.amazon.awssdk:s3' implementation 'software.amazon.awssdk:auth' + + // WebFlux + implementation 'org.springframework.boot:spring-boot-starter-webflux' } // --- QueryDSL --- def generated = 'build/generated/sources/annotationProcessor/java/main' diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/dto/BusinessNumberReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/dto/BusinessNumberReqDto.java new file mode 100644 index 00000000..b87f3f3d --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/dto/BusinessNumberReqDto.java @@ -0,0 +1,19 @@ +package com.eatsfine.eatsfine.domain.businessnumber.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Builder; + +public class BusinessNumberReqDto { + + @Builder + public record BusinessNumberDto( + @NotBlank(message = "사업자번호는 필수입니다.") + @Pattern(regexp = "^[0-9]{10}$", message = "사업자번호는 숫자 10자리여야 합니다.") + String businessNumber, + + @NotBlank + @Pattern(regexp = "^[0-9]{8}$", message = "개업일자는 YYYYMMDD 형식이어야 합니다.") + String startDate + ){} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/dto/BusinessNumberResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/dto/BusinessNumberResDto.java new file mode 100644 index 00000000..973bb521 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/dto/BusinessNumberResDto.java @@ -0,0 +1,26 @@ +package com.eatsfine.eatsfine.domain.businessnumber.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.util.List; + +public class BusinessNumberResDto { + + @Builder + public record BusinessNumberDto( + @JsonProperty("status_code") + String statusCode, + @JsonProperty("request_cnt") + int requestCnt, + @JsonProperty("valid_cnt") + int validCnt, + List data + ){ + public record BusinessDataDto( + @JsonProperty("b_no") + String bNo, + String valid + ){} + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/exception/BusinessNumberException.java b/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/exception/BusinessNumberException.java new file mode 100644 index 00000000..f2ffb33a --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/exception/BusinessNumberException.java @@ -0,0 +1,11 @@ +package com.eatsfine.eatsfine.domain.businessnumber.exception; + + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class BusinessNumberException extends GeneralException { + public BusinessNumberException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/status/BusinessNumberErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/status/BusinessNumberErrorStatus.java new file mode 100644 index 00000000..3ebf7288 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/status/BusinessNumberErrorStatus.java @@ -0,0 +1,40 @@ +package com.eatsfine.eatsfine.domain.businessnumber.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum BusinessNumberErrorStatus implements BaseErrorCode { + + _API_COMMUNICATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "BUSINESS_NUMBER500", "공공데이터 서버와 통신 중 오류가 발생했습니다."), + + _INVALID_BUSINESS_NUMBER(HttpStatus.BAD_REQUEST, "BUSINESS_NUMBER400", "사업자 번호가 유효하지 않습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(true) + .message(message) + .code(code) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(true) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/validator/BusinessNumberValidator.java b/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/validator/BusinessNumberValidator.java new file mode 100644 index 00000000..782f27bd --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/validator/BusinessNumberValidator.java @@ -0,0 +1,6 @@ +package com.eatsfine.eatsfine.domain.businessnumber.validator; + +public interface BusinessNumberValidator { + + void validate(String businessNumber, String startDate, String representativeName); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/validator/MockBusinessNumberValidator.java b/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/validator/MockBusinessNumberValidator.java new file mode 100644 index 00000000..b115d5d2 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/validator/MockBusinessNumberValidator.java @@ -0,0 +1,13 @@ +package com.eatsfine.eatsfine.domain.businessnumber.validator; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Component +@Profile({"local", "test"}) +public class MockBusinessNumberValidator implements BusinessNumberValidator { + @Override + public void validate(String businessNumber, String startDate, String representativeName) { + // pass + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/validator/RealBusinessNumberValidator.java b/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/validator/RealBusinessNumberValidator.java new file mode 100644 index 00000000..68cee2a1 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/businessnumber/validator/RealBusinessNumberValidator.java @@ -0,0 +1,79 @@ +package com.eatsfine.eatsfine.domain.businessnumber.validator; + +import com.eatsfine.eatsfine.domain.businessnumber.dto.BusinessNumberResDto; +import com.eatsfine.eatsfine.domain.businessnumber.exception.BusinessNumberException; +import com.eatsfine.eatsfine.domain.businessnumber.status.BusinessNumberErrorStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.Map; + +@Component +@Profile("!local & !test") +@RequiredArgsConstructor +@Slf4j +public class RealBusinessNumberValidator implements BusinessNumberValidator { + + private final RestClient businessWebClient; + + @Value("${api.service-key}") + private String serviceKey; + + @Override + public void validate(String businessNumber, String startDate, String representativeName) { + // 요청 바디 + Map body = Map.of( + "businesses", List.of( + Map.of( + "b_no", businessNumber, + "start_dt", startDate, + "p_nm", representativeName, + "p_nm2", "", + "b_nm", "", + "corp_no", "", + "b_sector", "", + "b_type", "", + "b_adr", "" + + ) + ) + ); + + BusinessNumberResDto.BusinessNumberDto response = businessWebClient.post() + .uri(uriBuilder -> uriBuilder + .path("/validate") + .queryParam("serviceKey", serviceKey) + .build()) + .body(body) + .retrieve() + .onStatus(status -> status.isError(), (request, res) -> { + log.error("[BusinessNumber API] 통신 에러 발생: {}", res.getStatusCode()); + throw new BusinessNumberException(BusinessNumberErrorStatus._API_COMMUNICATION_ERROR); + }) + .body(BusinessNumberResDto.BusinessNumberDto.class); + + if(response == null || response.data() == null || response.data().isEmpty()) { + log.error("[BusinessNumber API] 응답 데이터가 비어있습니다."); + throw new BusinessNumberException(BusinessNumberErrorStatus._API_COMMUNICATION_ERROR); + } + + List invalidNumbers = response.data().stream() + .filter(dto -> !"01".equals(dto.valid())) + .map(dto -> dto.bNo()) + .toList(); + + if(!invalidNumbers.isEmpty()) { + log.warn("[BusinessNumber API] 유효하지 않은 사업자 번호 발견: {}", invalidNumbers); + + throw new BusinessNumberException(BusinessNumberErrorStatus._INVALID_BUSINESS_NUMBER); + } + + }; + + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/region/entity/Region.java b/src/main/java/com/eatsfine/eatsfine/domain/region/entity/Region.java index b54e3f08..daf5ea13 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/region/entity/Region.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/region/entity/Region.java @@ -14,15 +14,15 @@ public class Region { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - // 시/도 (예: 서울특별시, 경기도) - @Column(name = "province", nullable = false) - private String province; + // 시/도 (예: 서울특별시, 경기도, 세종특별자치시) + @Column(name = "sido", nullable = false) + private String sido; - // 시/군/구 (예: 강남구, 성남시, 가평군) - @Column(name = "city", nullable = false) - private String city; + // 시/군/구 (예: 강남구, 성남시 분당구, "") + @Column(name = "sigungu", nullable = false) + private String sigungu; - // 구/읍/면/동 (예: 분당구, 진접읍, 역삼동 ..) - @Column(name = "district", nullable = false) - private String district; + // 법정동 (예: 역삼동, 서현동, 어진동) + @Column(name = "bname", nullable = false) + private String bname; } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/region/repository/RegionRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/region/repository/RegionRepository.java index cc772b7e..027dc032 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/region/repository/RegionRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/region/repository/RegionRepository.java @@ -3,5 +3,8 @@ import com.eatsfine.eatsfine.domain.region.entity.Region; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface RegionRepository extends JpaRepository { + Optional findBySidoAndSigunguAndBname(String sido, String sigungu, String bname); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/condition/StoreSearchCondition.java b/src/main/java/com/eatsfine/eatsfine/domain/store/condition/StoreSearchCondition.java index 2d75358e..0ba46a5e 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/condition/StoreSearchCondition.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/condition/StoreSearchCondition.java @@ -24,14 +24,14 @@ public class StoreSearchCondition { @Schema(description = "카테고리") Category category; - @Schema(description = "시/도 (예: 서울특별시, 경기도)") - String province; + @Schema(description = "시/도 (예: 서울특별시, 경기도, 세종특별자치시)") + String sido; - @Schema(description = "시/군/구 (예: 강남구, 성남시, 가평군)") - String city; + @Schema(description = "시/군/구 (예: 강남구, 성남시 분당구, \"\") ") + String sigungu; - @Schema(description = "구/읍/면/동 (예: 분당구, 진접읍, 역삼동 ..)") - String district; + @Schema(description = "법정동 (예: 역삼동, 서현동, 어진동)") + String bname; @Schema(description = "정렬 기준", example = "DISTANCE") StoreSortType sort = StoreSortType.DISTANCE; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java index 73301164..798dad94 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java @@ -1,6 +1,7 @@ package com.eatsfine.eatsfine.domain.store.dto; import com.eatsfine.eatsfine.domain.businesshours.dto.BusinessHoursReqDto; +import com.eatsfine.eatsfine.domain.businessnumber.dto.BusinessNumberReqDto; import com.eatsfine.eatsfine.domain.store.enums.Category; import com.eatsfine.eatsfine.domain.store.enums.DepositRate; import jakarta.validation.Valid; @@ -19,17 +20,29 @@ public record StoreCreateDto( @NotBlank(message = "가게명은 필수입니다.") String storeName, - @NotBlank(message = "사업자번호는 필수입니다.") - String businessNumber, + @Valid BusinessNumberReqDto.BusinessNumberDto businessNumberDto, + @NotBlank(message = "가게 설명은 필수입니다.") String description, - @NotNull(message = "지역은 필수입니다.") - Long regionId, + @NotBlank(message = "시/도는 필수입니다.") + String sido, // ex 경기도, 세종특별자치시 - @NotBlank(message = "주소는 필수입니다.") + @NotNull(message = "시/군/구는 필수입니다. (해당 사항 없을 경우 \"\"를 입력해주세요.)") + String sigungu, // ex 성남시 분당구, "" + + @NotBlank(message = "법정동은 필수입니다.") + String bname, // ex 서현동, 어진동 + + @NotBlank(message = "전체 주소는 필수입니다.") String address, + @NotNull(message = "위도는 필수입니다.") + double latitude, + + @NotNull(message = "경도는 필수입니다,.") + double longitude, + @Pattern( regexp = "^0\\d{1,2}-\\d{3,4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다." diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryCustom.java b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryCustom.java index 4b26cf38..906c5445 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryCustom.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryCustom.java @@ -13,9 +13,9 @@ Page searchStores( String keyword, Category category, StoreSortType sort, - String province, - String city, - String district, + String sido, + String sigungu, + String bname, Pageable pageable ); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryImpl.java index e4b162b5..34e6d4f0 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepositoryImpl.java @@ -34,9 +34,9 @@ public Page searchStores( String keyword, Category category, StoreSortType sort, - String province, - String city, - String district, + String sido, + String sigungu, + String bname, Pageable pageable ) { QStore store = QStore.store; @@ -59,18 +59,18 @@ public Page searchStores( } // 시/도 필터 - if (province != null) { - whereClause.and(region.province.eq(province)); + if (sido != null) { + whereClause.and(region.sido.eq(sido)); } - // 시도 필터 - if (city != null) { - whereClause.and(region.city.eq(city)); + // 시/군/구 필터 + if (sigungu != null) { + whereClause.and(region.sigungu.eq(sigungu)); } - // 구 필터 - if (district != null) { - whereClause.and(region.district.eq(district)); + // 법정동 필터 + if (bname != null) { + whereClause.and(region.bname.eq(bname)); } // 정렬 조건 생성 diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java index 7ceae863..d8c4c340 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java @@ -3,6 +3,7 @@ import com.eatsfine.eatsfine.domain.businesshours.converter.BusinessHoursConverter; import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; import com.eatsfine.eatsfine.domain.businesshours.validator.BusinessHoursValidator; +import com.eatsfine.eatsfine.domain.businessnumber.validator.BusinessNumberValidator; import com.eatsfine.eatsfine.domain.image.exception.ImageException; import com.eatsfine.eatsfine.domain.image.status.ImageErrorStatus; import com.eatsfine.eatsfine.domain.region.entity.Region; @@ -16,6 +17,7 @@ import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,16 +29,26 @@ @Service @Transactional @RequiredArgsConstructor +@Slf4j public class StoreCommandServiceImpl implements StoreCommandService { private final StoreRepository storeRepository; private final RegionRepository regionRepository; private final S3Service s3Service; + private final BusinessNumberValidator businessNumberValidator; // 가게 등록 @Override public StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto dto) { - Region region = regionRepository.findById(dto.regionId()) + + // TODO: 추후 Security Context 연동 시, 로그인된 사용자의 이름을 가져오도록 수정 예정 + businessNumberValidator.validate(dto.businessNumberDto().businessNumber(), dto.businessNumberDto().startDate(), "홍길동"); + log.info("사업자 번호 검증 성공: {}", dto.businessNumberDto().businessNumber()); + + + Region region = regionRepository.findBySidoAndSigunguAndBname( + dto.sido(), dto.sigungu(), dto.bname() + ) .orElseThrow(() -> new StoreException(RegionErrorStatus._REGION_NOT_FOUND)); // 영업시간 정상 여부 검증 @@ -45,11 +57,13 @@ public StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto dto) { Store store = Store.builder() .owner(null) // User 도메인 머지 후 owner 처리 예정 .storeName(dto.storeName()) - .businessNumber(dto.businessNumber()) + .businessNumber(dto.businessNumberDto().businessNumber()) .description(dto.description()) .address(dto.address()) .mainImageKey(null) // 별도 API로 구현 .region(region) + .latitude(dto.latitude()) + .longitude(dto.longitude()) .phoneNumber(dto.phoneNumber()) .category(dto.category()) .bookingIntervalMinutes(dto.bookingIntervalMinutes()) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java index fd06161a..ea3406f4 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java @@ -40,7 +40,7 @@ public StoreResDto.StoreSearchResDto search( Page resultPage = storeRepository.searchStores( cond.getLat(), cond.getLng(), cond.getKeyword(), cond.getCategory(), cond.getSort(), - cond.getProvince(), cond.getCity(), cond.getDistrict(), pageable + cond.getSido(), cond.getSigungu(), cond.getBname(), pageable ); LocalDateTime now = LocalDateTime.now(); diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/RestClientConfig.java b/src/main/java/com/eatsfine/eatsfine/global/config/RestClientConfig.java new file mode 100644 index 00000000..b90fb5e4 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/config/RestClientConfig.java @@ -0,0 +1,23 @@ +package com.eatsfine.eatsfine.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.DefaultUriBuilderFactory; + +@Configuration +public class RestClientConfig { + @Bean + public RestClient businessWebClient(RestClient.Builder builder) { + + String baseUrl = "https://api.odcloud.kr/api/nts-businessman/v1"; + // 인코딩 문제 방지 설정 + DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(baseUrl); + factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.NONE); + + return builder.uriBuilderFactory(factory) + .baseUrl(baseUrl) + .build(); + + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2338f34c..395bb7cf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,4 +9,7 @@ cloud: region: ${AWS_REGION} s3: bucket: ${AWS_S3_BUCKET} - base-url: ${AWS_S3_BASE_URL} \ No newline at end of file + base-url: ${AWS_S3_BASE_URL} + +api: + service-key: ${BIZ_API_KEY} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 1623481d..35b32f4f 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -23,3 +23,6 @@ cloud: s3: bucket: test-bucket base-url: https://test-bucket.s3.test-region.amazonaws.com + +api: + service-key: dummy-test-key From 19517c22cfd0d3f1915751f4532451f2eb834d84 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:13:07 +0900 Subject: [PATCH 160/169] =?UTF-8?q?[CHORE]:=20=EB=A9=88=EC=B6=B0=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EC=A0=84?= =?UTF-8?q?=EB=B6=80=20=EC=82=AD=EC=A0=9C=20=EB=AA=85=EB=A0=B9=EC=96=B4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ff07eeda..9c230820 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -89,7 +89,6 @@ jobs: # 불필요한 도커 이미지 및 컨테이너 정리 docker image prune -f - docker container prune -f docker ps EOF From c37c8b0877d20ef44dc0c777de553a72f71f3965 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Wed, 28 Jan 2026 00:08:02 +0900 Subject: [PATCH 161/169] =?UTF-8?q?[FEAT]:=20DTO=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=ED=95=84=EB=93=9C=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/PaymentResponseDTO.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java index 9a006481..385f5533 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java @@ -1,7 +1,5 @@ package com.eatsfine.eatsfine.domain.payment.dto.response; -import com.eatsfine.eatsfine.domain.payment.enums.PaymentMethod; -import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; import java.time.LocalDateTime; import java.util.List; @@ -27,7 +25,7 @@ public record CancelPaymentResultDTO( public record PaymentHistoryResultDTO( Long paymentId, Long bookingId, - String restaurantName, + String storeName, Integer amount, String paymentType, String paymentMethod, @@ -50,7 +48,7 @@ public record PaginationDTO( public record PaymentDetailResultDTO( Long paymentId, Long bookingId, - String restaurantName, + String storeName, String paymentMethod, String paymentProvider, Integer amount, @@ -61,4 +59,15 @@ public record PaymentDetailResultDTO( String receiptUrl, String refundInfo) { } + + public record PaymentSuccessResultDTO( + Long paymentId, + String status, + LocalDateTime approvedAt, + String orderId, + Integer amount, + String paymentMethod, + String paymentProvider, + String receiptUrl) { + } } From eb03b8b3a0b5c7f61aa578310aa77a55d02e5892 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Wed, 28 Jan 2026 00:10:28 +0900 Subject: [PATCH 162/169] =?UTF-8?q?[REFACTOR]:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20DTO=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/controller/PaymentController.java | 3 +-- .../domain/payment/entity/Payment.java | 4 ---- .../domain/payment/service/PaymentService.java | 18 +++++++++++------- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java index 83c10d6a..f50e17a1 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java @@ -1,5 +1,4 @@ package com.eatsfine.eatsfine.domain.payment.controller; - import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentConfirmDTO; import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; @@ -34,7 +33,7 @@ public ApiResponse requestPayment( @Operation(summary = "결제 승인", description = "토스페이먼츠 결제 승인을 요청합니다.") @PostMapping("/confirm") - public ApiResponse confirmPayment( + public ApiResponse confirmPayment( @RequestBody @Valid PaymentConfirmDTO dto) { return ApiResponse.onSuccess(paymentService.confirmPayment(dto)); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java index ebf44154..6e8b8360 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -62,10 +62,6 @@ public class Payment extends BaseEntity { @Column(name = "receipt_url") private String receiptUrl; - public void setPaymentKey(String paymentKey) { - this.paymentKey = paymentKey; - } - public void completePayment(LocalDateTime approvedAt, PaymentMethod method, String paymentKey, PaymentProvider provider, String receiptUrl) { this.paymentStatus = PaymentStatus.COMPLETED; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java index e8a9cb9e..ec092c0e 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -23,7 +23,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestClient; - +import org.springframework.data.domain.PageRequest; import java.time.LocalDateTime; import java.util.UUID; import java.util.List; @@ -71,7 +71,7 @@ public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestD } @Transactional(noRollbackFor = GeneralException.class) - public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmDTO dto) { + public PaymentResponseDTO.PaymentSuccessResultDTO confirmPayment(PaymentConfirmDTO dto) { Payment payment = paymentRepository.findByOrderId(dto.orderId()) .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); @@ -121,12 +121,15 @@ public PaymentResponseDTO.PaymentRequestResultDTO confirmPayment(PaymentConfirmD log.info("Payment confirmed for OrderID: {}", dto.orderId()); - return new PaymentResponseDTO.PaymentRequestResultDTO( + return new PaymentResponseDTO.PaymentSuccessResultDTO( payment.getId(), - payment.getBooking().getId(), + payment.getPaymentStatus().name(), + payment.getApprovedAt(), payment.getOrderId(), payment.getAmount(), - payment.getRequestedAt()); + payment.getPaymentMethod() != null ? payment.getPaymentMethod().name() : null, + payment.getPaymentProvider() != null ? payment.getPaymentProvider().name() : null, + payment.getReceiptUrl()); } @Transactional(noRollbackFor = GeneralException.class) @@ -171,7 +174,7 @@ public PaymentResponseDTO.PaymentListResponseDTO getPaymentList(Long userId, Int // page 기본값 처리 (만약 null이면 1, 0보다 작으면 1로 보정). Spring Data는 0-based index이므로 -1 int pageNumber = (page != null && page > 0) ? page - 1 : 0; - Pageable pageable = org.springframework.data.domain.PageRequest.of(pageNumber, size); + Pageable pageable = PageRequest.of(pageNumber, size); Page paymentPage; if (status != null && !status.isEmpty()) { @@ -197,7 +200,8 @@ public PaymentResponseDTO.PaymentListResponseDTO getPaymentList(Long userId, Int payment.getPaymentType().name(), payment.getPaymentMethod() != null ? payment.getPaymentMethod().name() : null, - payment.getPaymentProvider() != null ? payment.getPaymentProvider().name() + payment.getPaymentProvider() != null + ? payment.getPaymentProvider().name() : null, payment.getPaymentStatus().name(), payment.getApprovedAt())) From 5ee8ebace4409d6d64612283edbcd2eda16e7cb8 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Wed, 28 Jan 2026 00:10:52 +0900 Subject: [PATCH 163/169] =?UTF-8?q?[REFACTOR]:=20/=20=EB=B6=99=EC=97=AC?= =?UTF-8?q?=EC=A4=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/booking/controller/BookingController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java index bc91aa8d..2fd207af 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java @@ -50,7 +50,7 @@ public ApiResponse getAvailableTables( @Operation(summary = "예약 생성" , description = "가게,날짜,시간,인원,테이블 정보를 입력받아 예약을 생성합니다.") - @PostMapping("stores/{storeId}/bookings") + @PostMapping("/stores/{storeId}/bookings") public ApiResponse createBooking( @PathVariable Long storeId, @RequestBody @Valid BookingRequestDTO.CreateBookingDTO dto From 350c98c8ee25924ae625a918d269d03a6bc3e51d Mon Sep 17 00:00:00 2001 From: twodo0 Date: Wed, 28 Jan 2026 17:19:21 +0900 Subject: [PATCH 164/169] =?UTF-8?q?[FEAT]:=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=84=A4=EA=B3=84=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatsfine/domain/menu/entity/Menu.java | 61 +++++++++++++++++++ .../domain/menu/enums/MenuCategory.java | 17 ++++++ .../menu/repository/MenuRepository.java | 7 +++ .../eatsfine/domain/store/entity/Store.java | 23 +++++++ 4 files changed, 108 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/menu/enums/MenuCategory.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java new file mode 100644 index 00000000..6a3f3170 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java @@ -0,0 +1,61 @@ +package com.eatsfine.eatsfine.domain.menu.entity; + +import com.eatsfine.eatsfine.domain.menu.enums.MenuCategory; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "menu") +public class Menu extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id", nullable = false) + private Store store; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "description", length = 500) + private String description; + + @Column(name = "price", precision = 10, scale = 0, nullable = false) + private BigDecimal price; + + @Enumerated(EnumType.STRING) + @Column(name = "menu_category", nullable = false) + private MenuCategory menuCategory; + + @Column(name = "image_key") + private String imageKey; + + @Builder.Default + @Column(name = "is_sold_out", nullable = false) + private boolean isSoldOut = false; + + public void assignStore(Store store) { + this.store = store; + } + + // 품절 여부 변경 + public void updateSoldOut(boolean isSoldOut) { + this.isSoldOut = isSoldOut; + } + + // 메뉴 이미지 변경 + public void updateImageKey(String imageKey) { + this.imageKey = imageKey; + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/enums/MenuCategory.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/enums/MenuCategory.java new file mode 100644 index 00000000..576bde1b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/enums/MenuCategory.java @@ -0,0 +1,17 @@ +package com.eatsfine.eatsfine.domain.menu.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MenuCategory { + + MAIN("메인"), + SIDE("사이드"), + BEVERAGE("음료"), + ALCOHOL("주류"); + + + private final String description; +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java new file mode 100644 index 00000000..b0335d22 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.menu.repository; + +import com.eatsfine.eatsfine.domain.menu.entity.Menu; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MenuRepository extends JpaRepository { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index fcd2bb26..286fa6c0 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -3,6 +3,7 @@ import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; import com.eatsfine.eatsfine.domain.businesshours.exception.BusinessHoursException; import com.eatsfine.eatsfine.domain.businesshours.status.BusinessHoursErrorStatus; +import com.eatsfine.eatsfine.domain.menu.entity.Menu; import com.eatsfine.eatsfine.domain.region.entity.Region; import com.eatsfine.eatsfine.domain.store.dto.StoreReqDto; import com.eatsfine.eatsfine.domain.store.enums.Category; @@ -15,6 +16,7 @@ import com.eatsfine.eatsfine.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.BatchSize; import java.math.BigDecimal; import java.math.RoundingMode; @@ -91,6 +93,12 @@ public class Store extends BaseEntity { @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List businessHours = new ArrayList<>(); + @Builder.Default + @BatchSize(size = 100) + @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) + private List menus = new ArrayList<>(); + + @Builder.Default @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List tableImages = new ArrayList<>(); @@ -119,6 +127,21 @@ public void updateBusinessHours(DayOfWeek dayOfWeek, LocalTime open, LocalTime c businessHours.update(open, close, isClosed); } + + // 메뉴 추가 + public void addMenu(Menu menu) { + this.menus.add(menu); + menu.assignStore(this); + } + + // 메뉴 삭제 + public void removeMenu(Menu menu) { + this.menus.remove(menu); + menu.assignStore(null); + } + + + public void addTableImage(TableImage tableImage) { this.tableImages.add(tableImage); tableImage.assignStore(this); From 9466286b04a92761529a2319eece727cfc017b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A4=80=EA=B7=9C?= <144649271+sonjunkyu@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:50:21 +0900 Subject: [PATCH 165/169] =?UTF-8?q?[FEAT]:=20=EA=B0=80=EA=B2=8C=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=81=EC=84=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C/=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [FEAT]: 테이블 상세 정보 조회 성공 응답 코드 추가 * [FEAT]: 테이블 상세 정보 조회 api 및 명세 추가 * [FEAT]: 테이블 상세 정보 조회 응답 DTO, Converter 추가 * [FEAT]: 테이블 상세 정보 조회 서비스 로직 추가 * [FEAT]: 테이블 정보 수정 성공/실패 응답 코드 추가 * [FEAT]: 테이블 정보 수정 api 및 명세 추가 * [FEAT]: StoreTable 엔티티 편의 메소드 추가 * [FEAT]: 테이블 정보 수정 요청/응답 DTO, Converter 추가 * [FEAT]: 테이블 정보 수정 서비스 로직 구현 - 테이블 번호 수정 - 테이블 좌석 수 변경 - 테이블 유형 변경 * [FEAT]: 테이블 삭제 성공/실패 응답 코드 추가 * [FEAT]: 테이블 삭제 api 및 명세 추가 * [FEAT]: 테이블 삭제 응답 DTO, Converter 추가 * [FEAT]: 테이블 삭제 서비스 로직 구현 - 삭제 시 현재 시간을 기준으로 예약 존재 여부 확인 - 삭제 시 soft delete --- .../booking/repository/BookingRepository.java | 8 ++ .../controller/StoreTableController.java | 28 ++++ .../controller/StoreTableControllerDocs.java | 119 +++++++++++++++++ .../converter/StoreTableConverter.java | 54 ++++++++ .../storetable/dto/req/StoreTableReqDto.java | 22 ++++ .../storetable/dto/res/StoreTableResDto.java | 42 ++++++ .../domain/storetable/entity/StoreTable.java | 17 ++- .../status/StoreTableErrorStatus.java | 4 + .../status/StoreTableSuccessStatus.java | 3 + .../repository/StoreTableRepository.java | 5 + .../service/StoreTableCommandService.java | 4 + .../service/StoreTableCommandServiceImpl.java | 122 ++++++++++++++++++ .../service/StoreTableQueryService.java | 2 + .../service/StoreTableQueryServiceImpl.java | 31 +++++ 14 files changed, 460 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java index acb5b0a5..5b12c355 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java @@ -34,4 +34,12 @@ public interface BookingRepository extends JpaRepository { "AND b.bookingTime = :time " + "AND b.status IN ('CONFIRMED', 'PENDING')") boolean existsBookingByTableAndDateTime(@Param("tableId") Long tableId, @Param("date") LocalDate date, @Param("time") LocalTime time); + + @Query("SELECT COUNT(bt) > 0 FROM BookingTable bt " + + "JOIN bt.booking b " + + "WHERE bt.storeTable.id = :tableId " + + "AND (b.bookingDate > :currentDate " + + " OR (b.bookingDate = :currentDate AND b.bookingTime >= :currentTime)) " + + "AND b.status IN ('CONFIRMED', 'PENDING')") + boolean existsFutureBookingByTable(@Param("tableId") Long tableId, @Param("currentDate") LocalDate currentDate, @Param("currentTime") LocalTime currentTime); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java index d9c9ca9f..0b269537 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java @@ -7,6 +7,7 @@ import com.eatsfine.eatsfine.domain.storetable.service.StoreTableQueryService; import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.*; @@ -38,4 +39,31 @@ public ApiResponse getTableSlots( LocalDate targetDate = (date != null) ? date : LocalDate.now(); return ApiResponse.of(StoreTableSuccessStatus._SLOT_LIST_FOUND, storeTableQueryService.getTableSlots(storeId, tableId, targetDate)); } + + @GetMapping("/stores/{storeId}/tables/{tableId}") + public ApiResponse getTableDetail( + @PathVariable Long storeId, + @PathVariable Long tableId, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date + ) { + LocalDate targetDate = (date != null) ? date : LocalDate.now(); + return ApiResponse.of(StoreTableSuccessStatus._TABLE_DETAIL_FOUND, storeTableQueryService.getTableDetail(storeId, tableId, targetDate)); + } + + @PatchMapping("/stores/{storeId}/tables/{tableId}") + public ApiResponse updateTable( + @PathVariable Long storeId, + @PathVariable Long tableId, + @RequestBody @Valid StoreTableReqDto.TableUpdateDto dto + ) { + return ApiResponse.of(StoreTableSuccessStatus._TABLE_UPDATED, storeTableCommandService.updateTable(storeId, tableId, dto)); + } + + @DeleteMapping("/stores/{storeId}/tables/{tableId}") + public ApiResponse deleteTable( + @PathVariable Long storeId, + @PathVariable Long tableId + ) { + return ApiResponse.of(StoreTableSuccessStatus._TABLE_DELETED, storeTableCommandService.deleteTable(storeId, tableId)); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java index 5e83c805..fc4ce97d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java @@ -68,4 +68,123 @@ ApiResponse getTableSlots( @Parameter(description = "조회할 날짜 (yyyy-MM-dd 형식, 미입력 시 오늘 날짜)", example = "2026-01-12") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date ); + + @Operation( + summary = "테이블 상세 조회", + description = """ + 특정 테이블의 상세 정보를 조회합니다. + - 테이블 기본 정보 (최소/최대 인원, 이미지, 평점, 리뷰 수, 테이블 유형) + - 예약 가능 상태 (날짜별 총 슬롯 수, 예약 가능한 슬롯 수) + - date 파라미터가 없으면 오늘 날짜로 조회합니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "테이블 상세 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "테이블이 가게에 속하지 않음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "테이블을 찾을 수 없음") + }) + ApiResponse getTableDetail( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + + @Parameter(description = "테이블 ID", required = true, example = "1") + Long tableId, + + @Parameter(description = "조회 날짜 (yyyy-MM-dd)", example = "2026-01-23") + LocalDate date + ); + + @Operation( + summary = "테이블 정보 수정", + description = """ + 특정 테이블의 정보를 수정합니다. + + **통합 API**: 테이블 번호, 좌석 수, 테이블 유형을 하나의 API에서 처리합니다. + + - **선택적 업데이트**: 모든 필드가 Optional이며, 제공된 필드만 업데이트됩니다. + - **최소 하나 필수**: 최소 하나 이상의 필드는 반드시 제공되어야 합니다. + + 1. **테이블 번호 (tableNumber)**: + - 숫자 문자열로 전달 (예: "3") + - 자동으로 "N번 테이블" 형식으로 변환 + - 중복 시 기존 테이블과 번호 스왑 + + 2. **좌석 수 (minSeatCount, maxSeatCount)**: + - 둘 중 하나만 제공 시, 다른 값은 기존 값 유지 + - 최소 인원 ≤ 최대 인원 검증 + + 3. **테이블 유형 (seatsType)**: + - GENERAL, WINDOW, ROOM, BAR, OUTDOOR 중 선택 + + ### 응답: + - updatedTables: 변경된 테이블 정보만 표시 + - 번호 스왑 발생 시 두 테이블 모두 포함 + - 스왑 없을 시 요청 테이블만 포함 + + ### 예시: + ```json + // Request (모든 필드 수정) + { + "tableNumber": "5", + "minSeatCount": 2, + "maxSeatCount": 4, + "seatsType": "ROOM" + } + + // Request (번호만 수정) + { + "tableNumber": "3" + } + + // Request (좌석 수만 수정) + { + "minSeatCount": 4, + "maxSeatCount": 6 + } + + // Request (좌석 유형만 수정) + { + "seatsType": "WINDOW" + } + ``` + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "테이블 수정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 (수정 필드 없음, 좌석 범위 오류 등)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "가게 또는 테이블을 찾을 수 없음") + }) + ApiResponse updateTable( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + + @Parameter(description = "테이블 ID", required = true, example = "1") + Long tableId, + + @RequestBody @Valid StoreTableReqDto.TableUpdateDto dto + ); + + @Operation( + summary = "테이블 삭제", + description = """ + 특정 가게의 테이블을 삭제합니다. + + **삭제 조건:** + - 현재 시간 이후의 예약(CONFIRMED 또는 PENDING 상태)이 존재하는 테이블은 삭제할 수 없습니다. + - Soft Delete 방식으로 처리되어 실제 데이터는 삭제되지 않고 is_deleted 플래그가 true로 변경됩니다. + - deleted_at 필드에 삭제 시간이 기록됩니다. + - 삭제된 테이블 위치에 새 테이블 생성 시, 겹침 검증 로직에서 삭제된 테이블은 제외됩니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "테이블 삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "테이블에 미래 예약이 존재함"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "테이블 또는 가게를 찾을 수 없음") + }) + ApiResponse deleteTable( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + @Parameter(description = "테이블 ID", required = true, example = "1") + Long tableId + ); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java index 9a00e2f5..4ef71d78 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java @@ -1,9 +1,11 @@ package com.eatsfine.eatsfine.domain.storetable.converter; +import com.eatsfine.eatsfine.domain.storetable.dto.req.StoreTableReqDto; import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.storetable.util.SlotCalculator; +import java.time.LocalDate; import java.util.List; public class StoreTableConverter { @@ -40,4 +42,56 @@ public static StoreTableResDto.SlotListDto toSlotListDto(int totalCount, int ava .slots(slotDetails) .build(); } + + public static StoreTableResDto.TableDetailDto toTableDetailDto(StoreTable table, LocalDate targetDate, int totalSlotCount, int availableSlotCount) { + return StoreTableResDto.TableDetailDto.builder() + .tableId(table.getId()) + .minSeatCount(table.getMinSeatCount()) + .maxSeatCount(table.getMaxSeatCount()) + .tableImageUrl(table.getTableImageUrl()) + .rating(table.getRating()) + .reviewCount(0) // 리뷰 기능 미구현으로 0 반환 + .seatsType(table.getSeatsType()) + .reservationStatus( + StoreTableResDto.ReservationStatusDto.builder() + .targetDate(targetDate) + .totalSlotCount(totalSlotCount) + .availableSlotCount(availableSlotCount) + .build() + ) + .build(); + } + + public static StoreTableResDto.TableUpdateResultDto toTableUpdateResultDto(List updatedTables, StoreTableReqDto.TableUpdateDto requestDto) { + List updatedTableDtoList = updatedTables.stream() + .map(table -> { + var builder = StoreTableResDto.UpdatedTableDto.builder() + .tableId(table.getId()); // tableId는 항상 포함 + + // 요청 DTO에 있는 필드만 응답에 포함 + if (requestDto.tableNumber() != null) { + builder.tableNumber(table.getTableNumber()); + } + if (requestDto.minSeatCount() != null || requestDto.maxSeatCount() != null) { + builder.minSeatCount(table.getMinSeatCount()); + builder.maxSeatCount(table.getMaxSeatCount()); + } + if (requestDto.seatsType() != null) { + builder.seatsType(table.getSeatsType()); + } + + return builder.build(); + }) + .toList(); + + return StoreTableResDto.TableUpdateResultDto.builder() + .updatedTables(updatedTableDtoList) + .build(); + } + + public static StoreTableResDto.TableDeleteDto toTableDeleteDto(StoreTable table) { + return StoreTableResDto.TableDeleteDto.builder() + .tableId(table.getId()) + .build(); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/req/StoreTableReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/req/StoreTableReqDto.java index 559366da..ee544355 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/req/StoreTableReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/req/StoreTableReqDto.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; public class StoreTableReqDto { public record TableCreateDto( @@ -30,4 +31,25 @@ public record TableCreateDto( String tableImageUrl ) {} + + public record TableUpdateDto( + @Pattern(regexp = "^[1-9]\\d*$", message = "테이블 번호는 1 이상의 숫자여야 합니다.") + String tableNumber, + + @Min(value = 1, message = "최소 인원은 1명 이상이어야 합니다.") + @Max(value = 20, message = "최소 인원은 20명 이하여야 합니다.") + Integer minSeatCount, + + @Min(value = 1, message = "최대 인원은 1명 이상이어야 합니다.") + @Max(value = 20, message = "최대 인원은 20명 이하여야 합니다.") + Integer maxSeatCount, + + SeatsType seatsType + ) { + // 최소 하나의 필드는 있어야 함 + public boolean hasAnyUpdate() { + return tableNumber != null || minSeatCount != null + || maxSeatCount != null || seatsType != null; + } + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java index 49ac4cce..1472a7f7 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java @@ -3,9 +3,11 @@ import com.eatsfine.eatsfine.domain.storetable.enums.SeatsType; import com.eatsfine.eatsfine.domain.tableblock.enums.SlotStatus; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Builder; import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalTime; import java.util.List; @@ -40,4 +42,44 @@ public record SlotDetailDto( SlotStatus status, boolean isAvailable ) {} + + @Builder + public record TableDetailDto( + Long tableId, + Integer minSeatCount, + Integer maxSeatCount, + String tableImageUrl, + BigDecimal rating, + Integer reviewCount, + SeatsType seatsType, + ReservationStatusDto reservationStatus + ) {} + + @Builder + public record ReservationStatusDto( + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + LocalDate targetDate, + Integer totalSlotCount, + Integer availableSlotCount + ) {} + + @Builder + public record TableUpdateResultDto( + List updatedTables + ) {} + + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public record UpdatedTableDto( + Long tableId, + String tableNumber, + Integer minSeatCount, + Integer maxSeatCount, + SeatsType seatsType + ) {} + + @Builder + public record TableDeleteDto( + Long tableId + ) {} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java index bec6bc7d..ec01cf26 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java @@ -1,6 +1,5 @@ package com.eatsfine.eatsfine.domain.storetable.entity; -import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.storetable.enums.SeatsType; import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; import com.eatsfine.eatsfine.global.entity.BaseEntity; @@ -70,4 +69,20 @@ public class StoreTable extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + + // 테이블 번호 변경 + public void updateTableNumber(String tableNumber) { + this.tableNumber = tableNumber; + } + + // 테이블 좌석 수 변경 + public void updateSeatCount(int minSeatCount, int maxSeatCount) { + this.minSeatCount = minSeatCount; + this.maxSeatCount = maxSeatCount; + } + + // 테이블 유형 변경 + public void updateSeatsType(SeatsType seatsType) { + this.seatsType = seatsType; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java index 95144a6d..d916cc99 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableErrorStatus.java @@ -16,6 +16,10 @@ public enum StoreTableErrorStatus implements BaseErrorCode { _TABLE_POSITION_OVERLAPS(HttpStatus.BAD_REQUEST, "TABLE400_3", "해당 위치에 이미 다른 테이블이 존재합니다."), _TABLE_NOT_BELONGS_TO_STORE(HttpStatus.BAD_REQUEST, "TABLE400_4", "해당 테이블은 해당 가게에 속하지 않습니다."), _NO_BUSINESS_HOURS(HttpStatus.NOT_FOUND, "TABLE404_2", "해당 요일의 영업시간 정보를 찾을 수 없습니다."), + _NO_UPDATE_FIELD(HttpStatus.BAD_REQUEST, "TABLE400_5", "수정할 필드가 최소 하나 이상 필요합니다."), + _SAME_SEAT_COUNT(HttpStatus.BAD_REQUEST, "TABLE400_6", "기존 좌석 수와 동일합니다."), + _SAME_SEATS_TYPE(HttpStatus.BAD_REQUEST, "TABLE400_7", "기존 좌석 유형과 동일합니다."), + _TABLE_HAS_FUTURE_BOOKING(HttpStatus.BAD_REQUEST, "TABLE400_8", "해당 테이블에 존재하는 예약이 있어 삭제할 수 없습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java index 8aa959d9..de6cdec3 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/exception/status/StoreTableSuccessStatus.java @@ -12,6 +12,9 @@ public enum StoreTableSuccessStatus implements BaseCode { _TABLE_CREATED(HttpStatus.CREATED, "TABLE201_1", "성공적으로 테이블을 생성했습니다."), _SLOT_LIST_FOUND(HttpStatus.OK, "TABLE200_1", "테이블 시간 슬롯 조회에 성공했습니다."), + _TABLE_DETAIL_FOUND(HttpStatus.OK, "TABLE200_2", "테이블 상세 정보 조회에 성공했습니다."), + _TABLE_UPDATED(HttpStatus.OK, "TABLE200_3", "성공적으로 테이블 정보를 수정했습니다."), + _TABLE_DELETED(HttpStatus.OK, "TABLE200_4", "성공적으로 테이블을 삭제했습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java index 306997c5..5d34680b 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/repository/StoreTableRepository.java @@ -1,6 +1,7 @@ package com.eatsfine.eatsfine.domain.storetable.repository; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; @@ -8,6 +9,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface StoreTableRepository extends JpaRepository { @@ -16,4 +18,7 @@ public interface StoreTableRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT st FROM StoreTable st WHERE st.id IN :ids") List findAllByIdWithLock(@Param("ids") List ids); + + // 특정 레이아웃에서 특정 번호를 가진 활성 테이블 조회, 테이블 번호 중복 체크용 + Optional findByTableLayoutAndTableNumberAndIsDeletedFalse(TableLayout tableLayout, String tableNumber); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java index 58370247..515b7917 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java @@ -5,4 +5,8 @@ public interface StoreTableCommandService { StoreTableResDto.TableCreateDto createTable(Long storeId, StoreTableReqDto.TableCreateDto dto); + + StoreTableResDto.TableUpdateResultDto updateTable(Long storeId, Long tableId, StoreTableReqDto.TableUpdateDto dto); + + StoreTableResDto.TableDeleteDto deleteTable(Long storeId, Long tableId); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java index 8d95557b..12c6b775 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java @@ -1,5 +1,6 @@ package com.eatsfine.eatsfine.domain.storetable.service; +import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; import com.eatsfine.eatsfine.domain.store.exception.StoreException; import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; @@ -7,6 +8,8 @@ import com.eatsfine.eatsfine.domain.storetable.dto.req.StoreTableReqDto; import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.storetable.exception.StoreTableException; +import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableErrorStatus; import com.eatsfine.eatsfine.domain.storetable.repository.StoreTableRepository; import com.eatsfine.eatsfine.domain.storetable.validator.StoreTableValidator; import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; @@ -18,7 +21,11 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -27,6 +34,7 @@ public class StoreTableCommandServiceImpl implements StoreTableCommandService { private final StoreRepository storeRepository; private final TableLayoutRepository tableLayoutRepository; private final StoreTableRepository storeTableRepository; + private final BookingRepository bookingRepository; // 테이블 생성 @Override @@ -70,6 +78,88 @@ public StoreTableResDto.TableCreateDto createTable(Long storeId, StoreTableReqDt return StoreTableConverter.toTableCreateDto(savedTable); } + // 테이블 정보 수정 + @Override + public StoreTableResDto.TableUpdateResultDto updateTable(Long storeId, Long tableId, StoreTableReqDto.TableUpdateDto dto) { + // 최소 하나의 변경사항이 있는지 확인 + if (!dto.hasAnyUpdate()) { + throw new StoreTableException(StoreTableErrorStatus._NO_UPDATE_FIELD); + } + + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + StoreTable table = storeTableRepository.findById(tableId) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); + + StoreTableValidator.validateTableBelongsToStore(table, storeId); + + // 변경된 테이블 리스트 + List affectedTables = new ArrayList<>(); + affectedTables.add(table); + + // 테이블 번호 수정 + if (dto.tableNumber() != null) { + List swappedTables = updateTableNumber(table, dto.tableNumber()); + // 스왑된 테이블이 있으면 추가 + swappedTables.stream() + .filter(t -> !t.getId().equals(table.getId())) + .forEach(affectedTables::add); + } + + // 테이블 좌석 수 변경 + if (dto.minSeatCount() != null || dto.maxSeatCount() != null) { + // null인 필드는 기존 값 유지 + int finalMin = dto.minSeatCount() != null ? dto.minSeatCount() : table.getMinSeatCount(); + int finalMax = dto.maxSeatCount() != null ? dto.maxSeatCount() : table.getMaxSeatCount(); + + // 기존 값과 동일한지 검증 + if (finalMin == table.getMinSeatCount() && finalMax == table.getMaxSeatCount()) { + throw new StoreTableException(StoreTableErrorStatus._SAME_SEAT_COUNT); + } + + StoreTableValidator.validateSeatRange(finalMin, finalMax); + + table.updateSeatCount(finalMin, finalMax); + } + + // 테이블 유형 변경 + if (dto.seatsType() != null) { + if (table.getSeatsType() == dto.seatsType()) { + throw new StoreTableException(StoreTableErrorStatus._SAME_SEATS_TYPE); + } + table.updateSeatsType(dto.seatsType()); + } + + return StoreTableConverter.toTableUpdateResultDto(affectedTables, dto); + } + + // 테이블 삭제 + @Override + public StoreTableResDto.TableDeleteDto deleteTable(Long storeId, Long tableId) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + StoreTable table = storeTableRepository.findById(tableId) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); + + StoreTableValidator.validateTableBelongsToStore(table, storeId); + + // 현재 시간 기준 미래 예약 존재 여부 확인 + LocalDate currentDate = LocalDate.now(); + LocalTime currentTime = LocalTime.now(); + + boolean hasFutureBooking = bookingRepository.existsFutureBookingByTable(tableId, currentDate, currentTime); + + if (hasFutureBooking) { + throw new StoreTableException(StoreTableErrorStatus._TABLE_HAS_FUTURE_BOOKING); + } + + storeTableRepository.delete(table); + + return StoreTableConverter.toTableDeleteDto(table); + } + private String generateTableNumber(TableLayout layout) { List tables = layout.getTables(); @@ -90,4 +180,36 @@ private String generateTableNumber(TableLayout layout) { return String.format("%d번 테이블", maxNumber + 1); } + + private List updateTableNumber(StoreTable table, String newNumber) { + String newTableNumber = String.format("%s번 테이블", newNumber); + String currentTableNumber = table.getTableNumber(); + + List updatedTables = new ArrayList<>(); + updatedTables.add(table); + + // 기존 번호와 동일하면 변경 불필요 + if (currentTableNumber.equals(newTableNumber)) { + return updatedTables; + } + + // 같은 레이아웃에서 새 번호를 가진 테이블이 있는지 확인 + Optional existingTable = storeTableRepository + .findByTableLayoutAndTableNumberAndIsDeletedFalse( + table.getTableLayout(), + newTableNumber + ); + + // 중복된 번호를 가진 테이블이 있으면 스왑 + if (existingTable.isPresent()) { + StoreTable conflictTable = existingTable.get(); + conflictTable.updateTableNumber(currentTableNumber); + updatedTables.add(conflictTable); + } + + // 대상 테이블의 번호 변경 + table.updateTableNumber(newTableNumber); + + return updatedTables; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryService.java index 053edd2f..8d5e2462 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryService.java @@ -6,4 +6,6 @@ public interface StoreTableQueryService { StoreTableResDto.SlotListDto getTableSlots(Long storeId, Long tableId, LocalDate date); + + StoreTableResDto.TableDetailDto getTableDetail(Long storeId, Long tableId, LocalDate targetDate); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java index 285d9def..d2690ddb 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java @@ -1,6 +1,9 @@ package com.eatsfine.eatsfine.domain.storetable.service; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; import com.eatsfine.eatsfine.domain.storetable.converter.StoreTableConverter; import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; @@ -25,6 +28,7 @@ @RequiredArgsConstructor @Transactional(readOnly = true) public class StoreTableQueryServiceImpl implements StoreTableQueryService{ + private final StoreRepository storeRepository; private final StoreTableRepository storeTableRepository; private final TableBlockRepository tableBlockRepository; private final BookingRepository bookingRepository; @@ -32,6 +36,9 @@ public class StoreTableQueryServiceImpl implements StoreTableQueryService{ // 테이블 슬롯 조회 @Override public StoreTableResDto.SlotListDto getTableSlots(Long storeId, Long tableId, LocalDate date) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + StoreTable storeTable = storeTableRepository.findById(tableId) .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); @@ -49,4 +56,28 @@ public StoreTableResDto.SlotListDto getTableSlots(Long storeId, Long tableId, Lo result.slots() ); } + + @Override + public StoreTableResDto.TableDetailDto getTableDetail(Long storeId, Long tableId, LocalDate targetDate) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + StoreTable storeTable = storeTableRepository.findById(tableId) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); + + StoreTableValidator.validateTableBelongsToStore(storeTable, storeId); + + List tableBlocks = tableBlockRepository.findByStoreTableAndTargetDate(storeTable, targetDate); + List bookedTimeList = bookingRepository.findBookedTimesByTableAndDate(tableId, targetDate); + Set bookedTimes = new HashSet<>(bookedTimeList); + + SlotCalculator.SlotCalculationResult result = SlotCalculator.calculateSlots(storeTable, targetDate, tableBlocks, bookedTimes); + + return StoreTableConverter.toTableDetailDto( + storeTable, + targetDate, + result.totalSlotCount(), + result.availableSlotCount() + ); + } } From 3e99e0732f38c9a16a2cfeae9d517557c1498fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A4=80=EA=B7=9C?= <144649271+sonjunkyu@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:55:40 +0900 Subject: [PATCH 166/169] =?UTF-8?q?=EA=B0=80=EA=B2=8C=20=EA=B0=9C=EB=B3=84?= =?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D/=EC=82=AD=EC=A0=9C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [FEAT]: 테이블 이미지 업로드 API 및 명세 추가 * [FEAT]: StoreTable 이미지 업로드 메소드 추가 * [FEAT]: 테이블 이미지 업로드 응답 DTO, Converter 추가 * [FEAT]: 테이블 이미지 업로드 서비스 로직 추가 * [REFACTOR]: 테이블 상세 조회 시 테이블 이미지 URL을 포함하도록 수정 * [FEAT]: 테이블 이미지 삭제 API 및 명세 추가 * [FEAT]: StoreTable 테이블 이미지 삭제 메소드 추가 * [FEAT]: 테이블 이미지 삭제 응답 DTO, Converter 추가 * [FEAT]: 테이블 이미지 삭제 서비스 로직 추가 --- .../controller/StoreTableController.java | 23 ++++++++ .../controller/StoreTableControllerDocs.java | 57 +++++++++++++++++++ .../converter/StoreTableConverter.java | 17 +++++- .../storetable/dto/res/StoreTableResDto.java | 11 ++++ .../domain/storetable/entity/StoreTable.java | 10 ++++ .../service/StoreTableCommandService.java | 5 ++ .../service/StoreTableCommandServiceImpl.java | 57 +++++++++++++++++++ .../service/StoreTableQueryServiceImpl.java | 9 ++- 8 files changed, 186 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java index 0b269537..64835ec0 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java @@ -5,12 +5,15 @@ import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableSuccessStatus; import com.eatsfine.eatsfine.domain.storetable.service.StoreTableCommandService; import com.eatsfine.eatsfine.domain.storetable.service.StoreTableQueryService; +import com.eatsfine.eatsfine.domain.tableimage.status.TableImageSuccessStatus; import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDate; @@ -66,4 +69,24 @@ public ApiResponse deleteTable( ) { return ApiResponse.of(StoreTableSuccessStatus._TABLE_DELETED, storeTableCommandService.deleteTable(storeId, tableId)); } + + @PostMapping( + value = "/stores/{storeId}/tables/{tableId}/table-image", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) + public ApiResponse uploadTableImage( + @PathVariable Long storeId, + @PathVariable Long tableId, + @RequestPart("tableImage") MultipartFile tableImage + ) { + return ApiResponse.of(TableImageSuccessStatus._STORE_TABLE_IMAGE_UPLOAD_SUCCESS, storeTableCommandService.uploadTableImage(storeId, tableId, tableImage)); + } + + @DeleteMapping("/stores/{storeId}/tables/{tableId}/table-image") + public ApiResponse deleteTableImage( + @PathVariable Long storeId, + @PathVariable Long tableId + ) { + return ApiResponse.of(TableImageSuccessStatus._STORE_TABLE_IMAGE_DELETE_SUCCESS, storeTableCommandService.deleteTableImage(storeId, tableId)); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java index fc4ce97d..8e682ef0 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java @@ -5,11 +5,14 @@ import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDate; @@ -187,4 +190,58 @@ ApiResponse deleteTable( @Parameter(description = "테이블 ID", required = true, example = "1") Long tableId ); + + @Operation( + summary = "테이블 이미지 등록", + description = """ + 특정 테이블의 이미지를 등록합니다. + + - 테이블당 1개의 이미지만 등록 가능합니다. + - 기존 이미지가 있는 경우 자동으로 삭제되고 새 이미지로 교체됩니다. + - S3 저장 경로: stores/{storeId}/tables/{tableId}/ + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "테이블 이미지 등록 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 (빈 파일, 지원하지 않는 파일 형식 등)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "가게 또는 테이블을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "S3 업로드 실패") + }) + ApiResponse uploadTableImage( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + + @Parameter(description = "테이블 ID", required = true, example = "1") + Long tableId, + + @Parameter( + description = "업로드할 테이블 이미지 파일", + required = true, + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) + MultipartFile tableImage + ); + + @Operation( + summary = "테이블 이미지 삭제", + description = """ + 특정 테이블의 이미지를 삭제합니다. + + - 등록된 이미지가 없는 경우 404 에러가 발생합니다. + - S3에서 이미지가 삭제되고, DB의 이미지 URL도 null로 업데이트됩니다. + - 삭제 후 다시 이미지를 등록할 수 있습니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "테이블 이미지 삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "테이블이 해당 가게에 속하지 않음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "가게, 테이블을 찾을 수 없거나 이미지가 등록되지 않음") + }) + ApiResponse deleteTableImage( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + + @Parameter(description = "테이블 ID", required = true, example = "1") + Long tableId + ); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java index 4ef71d78..98619e07 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java @@ -43,12 +43,12 @@ public static StoreTableResDto.SlotListDto toSlotListDto(int totalCount, int ava .build(); } - public static StoreTableResDto.TableDetailDto toTableDetailDto(StoreTable table, LocalDate targetDate, int totalSlotCount, int availableSlotCount) { + public static StoreTableResDto.TableDetailDto toTableDetailDto(StoreTable table, LocalDate targetDate, int totalSlotCount, int availableSlotCount, String tableImageUrl) { return StoreTableResDto.TableDetailDto.builder() .tableId(table.getId()) .minSeatCount(table.getMinSeatCount()) .maxSeatCount(table.getMaxSeatCount()) - .tableImageUrl(table.getTableImageUrl()) + .tableImageUrl(tableImageUrl) .rating(table.getRating()) .reviewCount(0) // 리뷰 기능 미구현으로 0 반환 .seatsType(table.getSeatsType()) @@ -94,4 +94,17 @@ public static StoreTableResDto.TableDeleteDto toTableDeleteDto(StoreTable table) .tableId(table.getId()) .build(); } + + public static StoreTableResDto.UploadTableImageDto toUploadTableImageDto(Long tableId, String tableImageUrl) { + return StoreTableResDto.UploadTableImageDto.builder() + .tableId(tableId) + .tableImageUrl(tableImageUrl) + .build(); + } + + public static StoreTableResDto.DeleteTableImageDto toDeleteTableImageDto(Long tableId) { + return StoreTableResDto.DeleteTableImageDto.builder() + .tableId(tableId) + .build(); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java index 1472a7f7..cf04854d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java @@ -82,4 +82,15 @@ public record UpdatedTableDto( public record TableDeleteDto( Long tableId ) {} + + @Builder + public record UploadTableImageDto( + Long tableId, + String tableImageUrl + ) {} + + @Builder + public record DeleteTableImageDto( + Long tableId + ) {} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java index ec01cf26..0de743cd 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java @@ -85,4 +85,14 @@ public void updateSeatCount(int minSeatCount, int maxSeatCount) { public void updateSeatsType(SeatsType seatsType) { this.seatsType = seatsType; } + + // 테이블 이미지 업로드 + public void updateTableImage(String imageKey) { + this.tableImageUrl = imageKey; + } + + // 테이블 이미지 삭제 + public void deleteTableImage() { + this.tableImageUrl = null; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java index 515b7917..2f3d8ade 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java @@ -2,6 +2,7 @@ import com.eatsfine.eatsfine.domain.storetable.dto.req.StoreTableReqDto; import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; +import org.springframework.web.multipart.MultipartFile; public interface StoreTableCommandService { StoreTableResDto.TableCreateDto createTable(Long storeId, StoreTableReqDto.TableCreateDto dto); @@ -9,4 +10,8 @@ public interface StoreTableCommandService { StoreTableResDto.TableUpdateResultDto updateTable(Long storeId, Long tableId, StoreTableReqDto.TableUpdateDto dto); StoreTableResDto.TableDeleteDto deleteTable(Long storeId, Long tableId); + + StoreTableResDto.UploadTableImageDto uploadTableImage(Long storeId, Long tableId, MultipartFile tableImage); + + StoreTableResDto.DeleteTableImageDto deleteTableImage(Long storeId, Long tableId); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java index 12c6b775..61175009 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java @@ -1,6 +1,8 @@ package com.eatsfine.eatsfine.domain.storetable.service; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; +import com.eatsfine.eatsfine.domain.image.exception.ImageException; +import com.eatsfine.eatsfine.domain.image.status.ImageErrorStatus; import com.eatsfine.eatsfine.domain.store.exception.StoreException; import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; @@ -16,9 +18,11 @@ import com.eatsfine.eatsfine.domain.table_layout.exception.TableLayoutException; import com.eatsfine.eatsfine.domain.table_layout.exception.status.TableLayoutErrorStatus; import com.eatsfine.eatsfine.domain.table_layout.repository.TableLayoutRepository; +import com.eatsfine.eatsfine.global.s3.S3Service; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.math.BigDecimal; import java.time.LocalDate; @@ -35,6 +39,7 @@ public class StoreTableCommandServiceImpl implements StoreTableCommandService { private final TableLayoutRepository tableLayoutRepository; private final StoreTableRepository storeTableRepository; private final BookingRepository bookingRepository; + private final S3Service s3Service; // 테이블 생성 @Override @@ -160,6 +165,58 @@ public StoreTableResDto.TableDeleteDto deleteTable(Long storeId, Long tableId) { return StoreTableConverter.toTableDeleteDto(table); } + // 테이블 이미지 업로드 + @Override + public StoreTableResDto.UploadTableImageDto uploadTableImage(Long storeId, Long tableId, MultipartFile tableImage) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + StoreTable table = storeTableRepository.findById(tableId) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); + + StoreTableValidator.validateTableBelongsToStore(table, storeId); + + if (tableImage == null || tableImage.isEmpty()) { + throw new ImageException(ImageErrorStatus.EMPTY_FILE); + } + + // 기존 이미지가 존재할 경우 삭제 + if (table.getTableImageUrl() != null && !table.getTableImageUrl().isBlank()) { + s3Service.deleteByKey(table.getTableImageUrl()); + } + + String key = s3Service.upload(tableImage, "stores/" + storeId + "/tables/" + tableId); + + table.updateTableImage(key); + + // URL 변환 및 응답 + String tableImageUrl = s3Service.toUrl(key); + + return StoreTableConverter.toUploadTableImageDto(tableId, tableImageUrl); + } + + @Override + public StoreTableResDto.DeleteTableImageDto deleteTableImage(Long storeId, Long tableId) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + StoreTable table = storeTableRepository.findById(tableId) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); + + StoreTableValidator.validateTableBelongsToStore(table, storeId); + + // 이미지가 존재하는지 확인 + if (table.getTableImageUrl() == null || table.getTableImageUrl().isBlank()) { + throw new ImageException(ImageErrorStatus._IMAGE_NOT_FOUND); + } + + s3Service.deleteByKey(table.getTableImageUrl()); + + table.deleteTableImage(); + + return StoreTableConverter.toDeleteTableImageDto(tableId); + } + private String generateTableNumber(TableLayout layout) { List tables = layout.getTables(); diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java index d2690ddb..99e262bf 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java @@ -14,6 +14,7 @@ import com.eatsfine.eatsfine.domain.storetable.validator.StoreTableValidator; import com.eatsfine.eatsfine.domain.tableblock.entity.TableBlock; import com.eatsfine.eatsfine.domain.tableblock.repository.TableBlockRepository; +import com.eatsfine.eatsfine.global.s3.S3Service; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,6 +33,7 @@ public class StoreTableQueryServiceImpl implements StoreTableQueryService{ private final StoreTableRepository storeTableRepository; private final TableBlockRepository tableBlockRepository; private final BookingRepository bookingRepository; + private final S3Service s3Service; // 테이블 슬롯 조회 @Override @@ -57,6 +59,7 @@ public StoreTableResDto.SlotListDto getTableSlots(Long storeId, Long tableId, Lo ); } + // 테이블 상세 조회 @Override public StoreTableResDto.TableDetailDto getTableDetail(Long storeId, Long tableId, LocalDate targetDate) { storeRepository.findById(storeId) @@ -73,11 +76,15 @@ public StoreTableResDto.TableDetailDto getTableDetail(Long storeId, Long tableId SlotCalculator.SlotCalculationResult result = SlotCalculator.calculateSlots(storeTable, targetDate, tableBlocks, bookedTimes); + // S3 Key -> Url 변환 + String tableImageUrl = s3Service.toUrl(storeTable.getTableImageUrl()); + return StoreTableConverter.toTableDetailDto( storeTable, targetDate, result.totalSlotCount(), - result.availableSlotCount() + result.availableSlotCount(), + tableImageUrl ); } } From 6e01492fbbeec51442f8c5d9d4134da9dbd80a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= <83066622+CokaNuri@users.noreply.github.com> Date: Fri, 30 Jan 2026 19:22:21 +0900 Subject: [PATCH 167/169] =?UTF-8?q?[FEAT]=20:=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20API=20=EA=B0=9C=EB=B0=9C=20/=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B0=9C=EB=B0=9C=20=20(#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [FEAT] : 예약 취소 DTO 개발 * [FEAT] : 예약 취소 DTO 개발 * [FEAT] : 예약 취소 성공,실패 상태 추가 * [FIX] : 예약 상태 이름 변경 * [FEAT] : Booking 엔티티에 cancel 비지니스 메서드 추가 * [FEAT] : 예약 취소 API 구현 * [FEAT] : Booking 엔티티 취소 이유 필드와 취소 메서드 구현 * [FEAT] : BookingConverter 구현 * [FEAT] : 예약생성 응답 DTO에 paymentID, orderID 추가 * [CHORE] : 결제 요청 API 스웨거 설명 수정 * [REFACTOR] : 결제 승인 API에서 예약 상태도 변경하도록 코드 구조 변경 * [CHORE] : 결제 완료 처리 api 스웨거 설정 변경 * [REFACTOR] : 예약 생성 api에서 내부적으로 결제 요청 로직까지 진행하도록 코드 변경 * [FEAT] : 예약 엔티티에서 결제 완료된 결제키를 찾는 편의 메서드 개발 * [FEAT] : 예약 취소시 환불까지 연동되는 비즈니스로직 개발 * [FEAT] : 예약 조회 응답 DTO 개발 * [FEAT] : 예약 조회 Repository 개발 * [FEAT] : 예약 조회 Service 개발 * [FEAT] : 예약 조회 Controller 개발 * [CHORE] : application-local.yml 파일 설정 추가 * [FEAT] : BookingMenu 엔티티 개발 * [FEAT] : 예약 생성 요청 DTO 변경 * [FEAT] : 예약 생성 서비스 로직 변경(메뉴 고려) * [FIX] : 예약금, 결제금액 타입 Integer->Decimal로 변경 * [FIX] : 예약 조회 기본 페이지 1로 변경 * [FIX] : 불필요한 예약 완료 처리 api 삭제 * [REFACTOR] : 존재하지 않는 테이블을 포함해 에약을 생성하는 예외 처리 * [CHORE] : PAYMENT 컨트롤러 SWAGGER 설정 복원 * [FEAT] : 예약 취소 로직 예외 처리 추가 * [FIX] : 결제 금액 타입 Integer -> BigDecimal로 변경 --- .../booking/controller/BookingController.java | 44 +++++++-- .../booking/converter/BookingConverter.java | 28 ++++++ .../dto/request/BookingRequestDTO.java | 13 ++- .../dto/response/BookingResponseDTO.java | 44 ++++++++- .../domain/booking/entity/Booking.java | 39 +++++++- .../booking/entity/mapping/BookingMenu.java | 34 +++++++ .../domain/booking/enums/BookingStatus.java | 2 +- .../booking/repository/BookingRepository.java | 13 +++ .../service/BookingCommandService.java | 3 + .../service/BookingCommandServiceImpl.java | 95 ++++++++++++++++--- .../booking/service/BookingQueryService.java | 3 + .../service/BookingQueryServiceImpl.java | 60 ++++++++++++ .../booking/status/BookingErrorStatus.java | 5 +- .../booking/status/BookingSuccessStatus.java | 6 ++ .../dto/request/PaymentConfirmDTO.java | 4 +- .../dto/response/PaymentResponseDTO.java | 9 +- .../domain/payment/entity/Payment.java | 3 +- .../payment/service/PaymentService.java | 7 +- src/main/resources/application-local.yml | 7 ++ 19 files changed, 379 insertions(+), 40 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/booking/entity/mapping/BookingMenu.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java index 2fd207af..c1dbd056 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/controller/BookingController.java @@ -4,6 +4,7 @@ import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; import com.eatsfine.eatsfine.domain.booking.service.BookingCommandService; import com.eatsfine.eatsfine.domain.booking.service.BookingQueryService; +import com.eatsfine.eatsfine.domain.booking.status.BookingSuccessStatus; import com.eatsfine.eatsfine.domain.user.entity.User; import com.eatsfine.eatsfine.domain.user.repository.UserRepository; import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; @@ -60,15 +61,42 @@ public ApiResponse createBooking( return ApiResponse.onSuccess(bookingCommandService.createBooking(user, storeId, dto)); } - @Operation(summary = "결제 완료 처리", - description = "결제 완료 후 결제 정보를 입력받아 예약 상태를 업데이트합니다.") - @PatchMapping("/bookings/{bookingId}/payments-confirm") - public ApiResponse confirmPayment( - @PathVariable Long bookingId, - @RequestBody @Valid BookingRequestDTO.PaymentConfirmDTO dto - ) { + //불필요한 api 삭제 +// @Operation(summary = "예약 완료 처리", +// description = "결제 완료 후 결제 정보를 입력받아 예약 상태를 업데이트합니다. 주의) 외부에서 이 API를 호출하지 않고 " + +// "POST /api/v1/payments/confirm API 호출 후 내부적으로 이 API의 로직을 실행합니다.") +// @PatchMapping("/bookings/{bookingId}/payments-confirm") +// public ApiResponse confirmPayment( +// @PathVariable Long bookingId, +// @RequestBody @Valid BookingRequestDTO.PaymentConfirmDTO dto +// ) { +// +// return ApiResponse.onSuccess(bookingCommandService.confirmPayment(bookingId,dto)); +// } - return ApiResponse.onSuccess(bookingCommandService.confirmPayment(bookingId,dto)); + @Operation(summary = "예약 취소", + description = "예약을 취소하고 환불을 진행합니다.") + @PatchMapping("/bookings/{bookingId}/cancel") + public ApiResponse cancelBooking( + @PathVariable Long bookingId, + @RequestBody @Valid BookingRequestDTO.CancelBookingDTO dto + ) { + return ApiResponse.of(BookingSuccessStatus._BOOKING_CANCELED, + bookingCommandService.cancelBooking(bookingId, dto)); } + + @Operation(summary = "예약 내역 조회", + description = "마이페이지에서 나의 예약 내역을 조회합니다.") + @GetMapping("/users/bookings") + public ApiResponse getMyBookings( + @RequestParam(name = "status", required = false) String status, + @RequestParam(name = "page", defaultValue = "1") Integer page + ) { + User user = userRepository.findById(1L).orElseThrow(); // 임시로 임의의 유저 사용 + + // 서비스 호출 시 page - 1을 넘겨서 0-based index로 맞춰줍니다. + return ApiResponse.of(BookingSuccessStatus._BOOKING_FOUND, + bookingQueryService.getBookingList(user, status, page-1)); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/converter/BookingConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/converter/BookingConverter.java index cfd9e42b..a3107088 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/converter/BookingConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/converter/BookingConverter.java @@ -1,4 +1,32 @@ package com.eatsfine.eatsfine.domain.booking.converter; +import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; +import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; +import com.eatsfine.eatsfine.domain.store.entity.Store; + +import java.math.BigDecimal; +import java.util.List; + public class BookingConverter { + + public static BookingResponseDTO.CreateBookingResultDTO toCreateBookingResultDTO( + Booking booking, Store store, BigDecimal totalDeposit, + List resultTableDTOS, + PaymentResponseDTO.PaymentRequestResultDTO paymentInfo) { + + return BookingResponseDTO.CreateBookingResultDTO.builder() + .bookingId(booking.getId()) + .storeName(store.getStoreName()) + .date(booking.getBookingDate()) + .time(booking.getBookingTime()) + .partySize(booking.getPartySize()) + .status(booking.getStatus().name()) + .totalDeposit(totalDeposit) + .createdAt(booking.getCreatedAt()) + .tables(resultTableDTOS) + .paymentId(paymentInfo.paymentId()) + .orderId(paymentInfo.orderId()) + .build(); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java index 6616d469..42288939 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/request/BookingRequestDTO.java @@ -35,7 +35,13 @@ public record CreateBookingDTO( @NotNull @DateTimeFormat(pattern = "HH:mm") LocalTime time, @NotNull @Min(1) Integer partySize, @NotNull List tableIds, - @NotNull boolean isSplitAccepted + @NotNull boolean isSplitAccepted, + @NotNull List menuItems + ){} + + public record MenuOrderDto( + @NotNull Long menuId, + @NotNull @Min(1) Integer quantity ){} public record PaymentConfirmDTO( @@ -43,4 +49,9 @@ public record PaymentConfirmDTO( @NotNull Integer amount //실제 결제 금액 ){} + public record CancelBookingDTO( + @NotBlank String reason //예약 취소 사유 + + ){} + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java index ad1bf084..a3333706 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/dto/response/BookingResponseDTO.java @@ -1,7 +1,9 @@ package com.eatsfine.eatsfine.domain.booking.dto.response; +import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; import lombok.Builder; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -42,9 +44,11 @@ public record CreateBookingResultDTO( LocalDate date, LocalTime time, Integer partySize, - Integer totalDeposit, + BigDecimal totalDeposit, List tables, - LocalDateTime createdAt // 예약 생성 시간 + LocalDateTime createdAt, // 예약 생성 시간 + Long paymentId, // 결제 ID + String orderId // 주문 ID ){} @Builder @@ -61,6 +65,40 @@ public record ConfirmPaymentResultDTO( Long bookingId, String status, // CONFIRMED String paymentKey, // PG사 결제 키 - Integer amount // 최종 결제 금액 + BigDecimal amount // 최종 결제 금액 + ){} + + @Builder + public record CancelBookingResultDTO( + Long bookingId, + String status, // CANCELED + String cancelReason, // 취소 사유 + LocalDateTime canceledAt, // 취소 시간 + BigDecimal refundAmount // 환불 금액 + ){} + + @Builder + public record BookingPreviewListDTO( + List bookingList, + Integer listSize, + Integer totalPage, + Long totalElements, + Boolean isFirst, + Boolean isLast + + ){} + + @Builder + public record BookingPreviewDTO( + Long bookingId, + String storeName, + String storeAddress, + LocalDate bookingDate, + LocalTime bookingTime, + Integer partySize, + String tableNumbers, + BigDecimal amount, + String paymentMethod, + String status ){} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java index 386d7644..42f94925 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java @@ -1,8 +1,12 @@ package com.eatsfine.eatsfine.domain.booking.entity; +import com.eatsfine.eatsfine.domain.booking.entity.mapping.BookingMenu; import com.eatsfine.eatsfine.domain.booking.entity.mapping.BookingTable; import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; import com.eatsfine.eatsfine.domain.payment.entity.Payment; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; +import com.eatsfine.eatsfine.domain.payment.exception.PaymentException; +import com.eatsfine.eatsfine.domain.payment.status.PaymentErrorStatus; import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.user.entity.User; @@ -10,6 +14,7 @@ import jakarta.persistence.*; import lombok.*; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalTime; import java.util.ArrayList; @@ -60,8 +65,20 @@ public class Booking extends BaseEntity { private LocalTime bookingTime; @Enumerated(EnumType.STRING) + @Column(name = "status", length = 20, nullable = false) private BookingStatus status; + @Builder.Default + @OneToMany(mappedBy = "booking", cascade = CascadeType.ALL, orphanRemoval = true) + private List bookingMenus = new ArrayList<>(); + + public void addBookingMenu(BookingMenu bookingMenu) { + this.bookingMenus.add(bookingMenu); + if (bookingMenu.getBooking() != this) { + bookingMenu.confirmBooking(this); + } + } + public void addBookingTable(StoreTable storeTable) { BookingTable bookingTable = BookingTable.builder() .booking(this) @@ -70,10 +87,30 @@ public void addBookingTable(StoreTable storeTable) { this.bookingTables.add(bookingTable); } - private Integer depositAmount; + private BigDecimal depositAmount; + + private String cancelReason; public void confirm() { this.status = BookingStatus.CONFIRMED; } + public void cancel(String cancelReason) + { + this.status = BookingStatus.CANCELED; + this.cancelReason = cancelReason; + } + + //예약과 관련된 결제 중 결제 완료된 결제키 조회 + public String getSuccessPaymentKey() { + return this.payments.stream() + .filter(p -> p.getPaymentStatus() == PaymentStatus.COMPLETED) + .map(Payment::getPaymentKey) + .findFirst() + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); + } + + public void setDepositAmount(BigDecimal totalDeposit) { + this.depositAmount = totalDeposit; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/mapping/BookingMenu.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/mapping/BookingMenu.java new file mode 100644 index 00000000..aefc0374 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/mapping/BookingMenu.java @@ -0,0 +1,34 @@ +package com.eatsfine.eatsfine.domain.booking.entity.mapping; + +import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.menu.entity.Menu; +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +public class BookingMenu { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Integer quantity; + + private BigDecimal price; + + @ManyToOne(fetch = FetchType.LAZY) + private Booking booking; + + @ManyToOne(fetch = FetchType.LAZY) + private Menu menu; + + public void confirmBooking(Booking booking) { + this.booking = booking; + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/enums/BookingStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/enums/BookingStatus.java index 404fafc2..c0a94af6 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/enums/BookingStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/enums/BookingStatus.java @@ -2,5 +2,5 @@ public enum BookingStatus { - PENDING, CONFIRMED, COMPLETED, CANCELLED, NOSHOW + PENDING, CONFIRMED, COMPLETED, CANCELED, NOSHOW } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java index 5b12c355..efc5a43c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/repository/BookingRepository.java @@ -1,14 +1,20 @@ package com.eatsfine.eatsfine.domain.booking.repository; import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; +import com.eatsfine.eatsfine.domain.user.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.repository.query.Param; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; import java.time.LocalDate; import java.time.LocalTime; import java.util.List; +@Repository public interface BookingRepository extends JpaRepository { @@ -35,6 +41,13 @@ public interface BookingRepository extends JpaRepository { "AND b.status IN ('CONFIRMED', 'PENDING')") boolean existsBookingByTableAndDateTime(@Param("tableId") Long tableId, @Param("date") LocalDate date, @Param("time") LocalTime time); + + // 1. 특정 유저의 모든 예약을 최신순으로 페이징 조회 + @Query("select b from Booking b join fetch b.store where b.user = :user") + Page findAllByUser(@Param("user") User user, Pageable pageable); + + @Query("Select b from Booking b join fetch b.store where b.user = :user and b.status = :status") + Page findAllByUserAndStatus(@Param("user") User user, @Param("status") BookingStatus status, Pageable pageable); @Query("SELECT COUNT(bt) > 0 FROM BookingTable bt " + "JOIN bt.booking b " + "WHERE bt.storeTable.id = :tableId " + diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java index 5eacc66b..0928674a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandService.java @@ -14,4 +14,7 @@ public interface BookingCommandService { BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long storeId, BookingRequestDTO.CreateBookingDTO dto); BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(Long BookingId, BookingRequestDTO.PaymentConfirmDTO dto); + + BookingResponseDTO.CancelBookingResultDTO cancelBooking(Long bookingId, BookingRequestDTO.CancelBookingDTO dto); + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java index e41b4371..75e0a02f 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingCommandServiceImpl.java @@ -1,18 +1,30 @@ package com.eatsfine.eatsfine.domain.booking.service; +import com.eatsfine.eatsfine.domain.booking.converter.BookingConverter; import com.eatsfine.eatsfine.domain.booking.dto.request.BookingRequestDTO; import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.booking.entity.mapping.BookingMenu; import com.eatsfine.eatsfine.domain.booking.entity.mapping.BookingTable; import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; import com.eatsfine.eatsfine.domain.booking.exception.BookingException; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; import com.eatsfine.eatsfine.domain.booking.status.BookingErrorStatus; +import com.eatsfine.eatsfine.domain.menu.entity.Menu; +import com.eatsfine.eatsfine.domain.menu.repository.MenuRepository; +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; +import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; +import com.eatsfine.eatsfine.domain.payment.entity.Payment; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; +import com.eatsfine.eatsfine.domain.payment.exception.PaymentException; +import com.eatsfine.eatsfine.domain.payment.service.PaymentService; +import com.eatsfine.eatsfine.domain.payment.status.PaymentErrorStatus; import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.store.exception.StoreException; import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; +import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableErrorStatus; import com.eatsfine.eatsfine.domain.storetable.repository.StoreTableRepository; import com.eatsfine.eatsfine.domain.user.entity.User; import com.eatsfine.eatsfine.domain.user.repository.UserRepository; @@ -20,9 +32,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalTime; import java.util.List; +import java.util.stream.Collectors; import java.util.stream.Stream; @Service @@ -32,6 +47,8 @@ public class BookingCommandServiceImpl implements BookingCommandService{ private final StoreRepository storeRepository; private final StoreTableRepository storeTableRepository; private final BookingRepository bookingRepository; + private final PaymentService paymentService; + private final MenuRepository menuRepository; @Override @Transactional @@ -42,6 +59,11 @@ public BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long s List selectedTables = storeTableRepository.findAllByIdWithLock(dto.tableIds()); + // 요청한 ID 개수와 조회된 데이터 개수가 다르면, 존재하지 않는 ID가 포함된 것 + if (selectedTables.size() != dto.tableIds().size()) { + throw new StoreException(StoreTableErrorStatus._TABLE_NOT_FOUND); + } + //이미 예약된 테이블 있는지 최종 점검 List reservedTableIds = bookingRepository.findReservedTableIds(storeId, dto.date(), dto.time()); for (StoreTable storeTable : selectedTables) { @@ -50,11 +72,8 @@ public BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long s } } - int totalDeposit = store.getMinPrice() * dto.partySize(); // 자세한 예약금 로직은 추후 수정 - Booking booking = Booking.builder() - .depositAmount(totalDeposit) .bookingDate(dto.date()) .bookingTime(dto.time()) .partySize(dto.partySize()) @@ -66,7 +85,40 @@ public BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long s selectedTables.forEach(booking::addBookingTable); + + // 예약한 메뉴들 저장 및 총 메뉴 가격 계산 + BigDecimal itemTotalPrice = BigDecimal.ZERO; + for (BookingRequestDTO.MenuOrderDto menuItem : dto.menuItems()) { + Menu menu = menuRepository.findById(menuItem.menuId()) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND));//차후 수정 + + BookingMenu bookingMenu = BookingMenu.builder() + .quantity(menuItem.quantity()) + .menu(menu) + .booking(booking) + .price(menu.getPrice()) + .build(); + + booking.addBookingMenu(bookingMenu); + + BigDecimal itemQuantity = BigDecimal.valueOf(menuItem.quantity()); + itemTotalPrice = itemTotalPrice.add(menu.getPrice().multiply(itemQuantity)); + } + + // 총 예약금 계산 ( 전체 메뉴 가격 * 가게의 예약금 비율 ) + BigDecimal depositRate = BigDecimal.valueOf(store.getDepositRate().getPercent()); + BigDecimal hundred = BigDecimal.valueOf(100); + BigDecimal totalDeposit = itemTotalPrice + .multiply(depositRate) + .divide(hundred, 0, RoundingMode.HALF_UP); + booking.setDepositAmount(totalDeposit); + Booking savedBooking = bookingRepository.save(booking); + bookingRepository.flush(); + + // 결제 대기 데이터 생성 (내부 서비스 호출) + PaymentRequestDTO.RequestPaymentDTO paymentRequest = new PaymentRequestDTO.RequestPaymentDTO(savedBooking.getId()); + PaymentResponseDTO.PaymentRequestResultDTO paymentInfo = paymentService.requestPayment(paymentRequest); //BookingResponseDTO.BookingResultTableDTO로 변환 @@ -80,17 +132,8 @@ public BookingResponseDTO.CreateBookingResultDTO createBooking(User user, Long s .build()) .toList(); - return BookingResponseDTO.CreateBookingResultDTO.builder() - .bookingId(savedBooking.getId()) - .storeName(store.getStoreName()) - .date(savedBooking.getBookingDate()) - .time(savedBooking.getBookingTime()) - .partySize(savedBooking.getPartySize()) - .status(savedBooking.getStatus().name()) - .totalDeposit(totalDeposit) - .createdAt(savedBooking.getCreatedAt()) - .tables(resultTableDTOS) - .build(); + + return BookingConverter.toCreateBookingResultDTO(savedBooking,store,totalDeposit, resultTableDTOS,paymentInfo); } @Override @@ -113,7 +156,6 @@ public BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(Long bookingId, //예약 상태 확정으로 변경 booking.confirm(); - return BookingResponseDTO.ConfirmPaymentResultDTO.builder() .bookingId(booking.getId()) .status(booking.getStatus().name()) @@ -121,4 +163,27 @@ public BookingResponseDTO.ConfirmPaymentResultDTO confirmPayment(Long bookingId, .amount(booking.getDepositAmount()) .build(); } + + @Override + @Transactional + public BookingResponseDTO.CancelBookingResultDTO cancelBooking(Long bookingId, BookingRequestDTO.CancelBookingDTO dto) { + Booking booking = bookingRepository.findById(bookingId) + .orElseThrow(() -> new BookingException(BookingErrorStatus._BOOKING_NOT_FOUND)); + + // 예약 중 결제 완료된 결제의 결제키 이용 환불 로직 진행 + if(booking.getStatus() == BookingStatus.CONFIRMED) { + PaymentRequestDTO.CancelPaymentDTO cancelDto = new PaymentRequestDTO.CancelPaymentDTO(dto.reason()); + paymentService.cancelPayment(booking.getSuccessPaymentKey(), cancelDto); + } + + + //예약 상태 취소로 변경 + booking.cancel(dto.reason()); + + return BookingResponseDTO.CancelBookingResultDTO.builder() + .bookingId(booking.getId()) + .status(booking.getStatus().name()) + .refundAmount(booking.getDepositAmount()) + .build(); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java index cc9df066..02d05e27 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryService.java @@ -2,6 +2,7 @@ import com.eatsfine.eatsfine.domain.booking.dto.request.BookingRequestDTO; import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; +import com.eatsfine.eatsfine.domain.user.entity.User; import java.time.LocalDate; import java.time.LocalTime; @@ -11,4 +12,6 @@ public interface BookingQueryService { BookingResponseDTO.TimeSlotListDTO getAvailableTimeSlots(Long storeId, BookingRequestDTO.GetAvailableTimeDTO dto); BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, BookingRequestDTO.GetAvailableTableDTO dto); + + BookingResponseDTO.BookingPreviewListDTO getBookingList(User user, String status, Integer page); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java index 62192a5d..f522e724 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/service/BookingQueryServiceImpl.java @@ -2,17 +2,25 @@ import com.eatsfine.eatsfine.domain.booking.dto.request.BookingRequestDTO; import com.eatsfine.eatsfine.domain.booking.dto.response.BookingResponseDTO; +import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.booking.enums.BookingStatus; import com.eatsfine.eatsfine.domain.booking.exception.BookingException; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; import com.eatsfine.eatsfine.domain.booking.status.BookingErrorStatus; import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; +import com.eatsfine.eatsfine.domain.payment.entity.Payment; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; import com.eatsfine.eatsfine.domain.table_layout.repository.TableLayoutRepository; +import com.eatsfine.eatsfine.domain.user.entity.User; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,6 +28,7 @@ import java.time.LocalTime; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -127,4 +136,55 @@ public BookingResponseDTO.AvailableTableListDTO getAvailableTables(Long storeId, .tables(availableTables) .build(); } + + @Override + public BookingResponseDTO.BookingPreviewListDTO getBookingList(User user, String status, Integer page) { + PageRequest pageRequest = PageRequest.of(page, 10, Sort.by("bookingDate").descending()); + + Page bookingPage; + + if(status == null || status.equals("ALL")) { + bookingPage = bookingRepository.findAllByUser(user, pageRequest); + } else { + BookingStatus bookingStatus = BookingStatus.valueOf(status); + bookingPage = bookingRepository.findAllByUserAndStatus(user, bookingStatus, pageRequest); + } + + List bookingPreviewDTOList = bookingPage.getContent().stream() + .map(booking -> { + + // 성공한 결제 정보 추출 (1:N 대응) + Payment successPayment = booking.getPayments().stream() + .filter(p -> p.getPaymentStatus() == PaymentStatus.COMPLETED || p.getPaymentStatus() == PaymentStatus.REFUNDED) + .findFirst() + .orElse(null); + + // 테이블 번호들을 하나의 문자열로 합치기 + String tableNumbers = booking.getBookingTables().stream() + .map(bt -> bt.getStoreTable().getTableNumber().toString()) + .collect(Collectors.joining(", ")); + + return BookingResponseDTO.BookingPreviewDTO.builder() + .bookingId(booking.getId()) + .storeName(booking.getStore().getStoreName()) + .storeAddress(booking.getStore().getAddress()) + .bookingDate(booking.getBookingDate()) + .bookingTime(booking.getBookingTime()) + .partySize(booking.getPartySize()) + .tableNumbers(tableNumbers + "번") + .amount(successPayment != null ? successPayment.getAmount() : booking.getDepositAmount()) + .paymentMethod(successPayment != null ? successPayment.getPaymentMethod().name() : "미결제") + .status(booking.getStatus().name()) + .build(); + }).collect(Collectors.toList()); + + return BookingResponseDTO.BookingPreviewListDTO.builder() + .isLast(bookingPage.isLast()) + .isFirst(bookingPage.isFirst()) + .totalPage(bookingPage.getTotalPages()) + .totalElements(bookingPage.getTotalElements()) + .listSize(bookingPreviewDTOList.size()) + .bookingList(bookingPreviewDTOList) + .build(); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java index b5690932..7ebe586c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingErrorStatus.java @@ -17,8 +17,9 @@ public enum BookingErrorStatus implements BaseErrorCode { _INVALID_PARTY_SIZE(HttpStatus.BAD_REQUEST, "BOOKING4001", "인원 설정이 잘못되었습니다."), _ALREADY_RESERVED_TABLE(HttpStatus.CONFLICT, "BOOKING4091", "선택하신 테이블 중 이미 예약된 테이블이 포함되어 있습니다."), _ALREADY_CONFIRMED(HttpStatus.BAD_REQUEST,"BOOKING4002", "이미 확정된 예약입니다."), - _PAYMENT_AMOUNT_MISMATCH(HttpStatus.BAD_REQUEST, "BOOKING4003", "결제 금액이 일치하지 않습니다."); - + _PAYMENT_AMOUNT_MISMATCH(HttpStatus.BAD_REQUEST, "BOOKING4003", "결제 금액이 일치하지 않습니다."), + _ALREADY_CANCELED(HttpStatus.BAD_REQUEST,"BOOKING4004", "이미 취소된 예약입니다."), + ; private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingSuccessStatus.java index 1e6b1e3e..0454cec3 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingSuccessStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/status/BookingSuccessStatus.java @@ -13,6 +13,12 @@ public enum BookingSuccessStatus implements BaseCode { _BOOKING_FOUND(HttpStatus.OK, "BOOKING200", "성공적으로 예약을 조회 했습니다."), _BOOKING_DETAIL_FOUND(HttpStatus.FOUND, "BOOKING_DETAIL200", "성공적으로 예약 상세 내역을 조회했습니다."), + + _BOOKING_CREATED(HttpStatus.CREATED, "BOOKING201", "성공적으로 예약이 생성되었습니다."), + + _BOOKING_CONFIRMED(HttpStatus.OK, "BOOKING2001", "성공적으로 예약이 확정되었습니다."), + + _BOOKING_CANCELED(HttpStatus.OK, "BOOKING2002", "성공적으로 예약이 취소되었습니다.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java index 863c4a69..9c936a68 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentConfirmDTO.java @@ -3,9 +3,11 @@ import jakarta.validation.constraints.NotNull; import lombok.Builder; +import java.math.BigDecimal; + @Builder public record PaymentConfirmDTO( @NotNull String paymentKey, @NotNull String orderId, - @NotNull Integer amount) { + @NotNull BigDecimal amount) { } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java index 385f5533..8c690061 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/response/PaymentResponseDTO.java @@ -1,6 +1,7 @@ package com.eatsfine.eatsfine.domain.payment.dto.response; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; @@ -10,7 +11,7 @@ public record PaymentRequestResultDTO( Long paymentId, Long bookingId, String orderId, - Integer amount, + BigDecimal amount, LocalDateTime requestedAt) { } @@ -26,7 +27,7 @@ public record PaymentHistoryResultDTO( Long paymentId, Long bookingId, String storeName, - Integer amount, + BigDecimal amount, String paymentType, String paymentMethod, String paymentProvider, @@ -51,7 +52,7 @@ public record PaymentDetailResultDTO( String storeName, String paymentMethod, String paymentProvider, - Integer amount, + BigDecimal amount, String paymentType, String status, LocalDateTime requestedAt, @@ -65,7 +66,7 @@ public record PaymentSuccessResultDTO( String status, LocalDateTime approvedAt, String orderId, - Integer amount, + BigDecimal amount, String paymentMethod, String paymentProvider, String receiptUrl) { diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java index 6e8b8360..710c504d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -9,6 +9,7 @@ import jakarta.persistence.*; import lombok.*; +import java.math.BigDecimal; import java.time.LocalDateTime; @Entity @@ -32,7 +33,7 @@ public class Payment extends BaseEntity { private String orderId; @Column(name = "amount", nullable = false) - private Integer amount; + private BigDecimal amount; @Column(name = "payment_key") private String paymentKey; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java index ec092c0e..44d38aa8 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -23,6 +23,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestClient; + +import java.math.BigDecimal; import org.springframework.data.domain.PageRequest; import java.time.LocalDateTime; import java.util.UUID; @@ -47,7 +49,7 @@ public PaymentResponseDTO.PaymentRequestResultDTO requestPayment(PaymentRequestD String orderId = UUID.randomUUID().toString(); // 예약금 검증 - if (booking.getDepositAmount() == null || booking.getDepositAmount() <= 0) { + if (booking.getDepositAmount() == null || booking.getDepositAmount().compareTo(BigDecimal.ZERO) <= 0) { throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_DEPOSIT); } @@ -75,11 +77,10 @@ public PaymentResponseDTO.PaymentSuccessResultDTO confirmPayment(PaymentConfirmD Payment payment = paymentRepository.findByOrderId(dto.orderId()) .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); - if (!payment.getAmount().equals(dto.amount())) { + if (payment.getAmount().compareTo(dto.amount()) != 0) { payment.failPayment(); throw new PaymentException(PaymentErrorStatus._PAYMENT_INVALID_AMOUNT); } - // 토스 API 호출 TossPaymentResponse response; try { diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 6feee07c..a5a7c16b 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -27,3 +27,10 @@ spring: payment: toss: widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 + +cloud: + aws: + region: ap-northeast-2 + s3: + bucket: eatsfine-images + base-url: https://eatsfine-images.s3.ap-northeast-2.amazonaws.com \ No newline at end of file From 635014dbe42c59ade1d2e2406dc79e35178ee3d5 Mon Sep 17 00:00:00 2001 From: twodo0 Date: Fri, 30 Jan 2026 23:05:05 +0900 Subject: [PATCH 168/169] =?UTF-8?q?[FEAT]:=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20CRUD=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20S3?= =?UTF-8?q?=20=EC=88=98=EB=AA=85=EC=A3=BC=EA=B8=B0=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [FEAT]: 메뉴 도메인 DTO 추가 * [FEAT]: 메뉴 API 상태코드 추가 * [FEAT]: 메뉴, 메뉴 이미지 등록, 삭제 로직 추가 * [FEAT]: 메뉴 수정 DTO 추가 * [FEAT]: 메뉴 수정 성공 응답코드 추가 * [FEAT]: 메뉴 수정 로직 추가 * [REFACTOR]: S3 예외처리 세분화 * [FEAT]: 메뉴 조회 DTO 및 성공 응답코드 추가 * [FEAT]: 메뉴 조회 로직 추가 * [FEAT]: 메뉴 조회 시 N+1 방지를 위해 fetch join 쿼리 추가 * [FEAT]: 품절여부 변경 DTO 및 성공 응답코드 추가 * [FEAT]: 메뉴 품절여부 변경 로직 추가 * [FEAT]: 메뉴 삭제 시 soft delete 설정 * [FEAT]: @Valid, @RequestParam, @PathVaraible 유효성 검사 실패 시 에러 메시지 보이도록 메서드 추가 * [FEAT]: 이미지 선 업로드에 따른 S3 수명 주기 연동 로직 구현 * [REFACTOR]: 이미지 Url 리턴하도록 수정 * [FEAT]: 품절 여부 수정 요청이 기존과 동일하다면 바로 리턴하도록 조건 추가 * [REFACTOR]: 이미지 삭제 API를 이미 등록된 이미지 삭제하는 것으로 역할 수정 * [REFACTOR]: soft delete된 Menu는 안 가져오도록 쿼리 수정 * [REFACTOR]: @Where -> @SQLRestriction 으로 수정 * [FIX]: isSuccess(false)로 수정 * [REFACTOR]: 예약금 정책 변경에 따라 minPrice 필드 삭제 및 로직 삭제 * [REFACTOR]: 남아있던 minPrice 사용하는 로직들 수정 * [FEAT]: 사용하지 않는 removeMenu 메서드 삭제 * [REFACTOR]: 메뉴가 없는 가게도 조회될 수 있도록 쿼리 조건 수정 * [REFACTOR]: 트랜잭션 커밋 이후에 S3에 접근할 수 있도록 로직 수정 * [FEAT]: moveObject()에서 이동 경로가 동일한 경우 예외처리 추가 --- .../domain/image/status/ImageErrorStatus.java | 8 +- .../menu/controller/MenuController.java | 90 ++++++ .../domain/menu/converter/MenuConverter.java | 56 ++++ .../eatsfine/domain/menu/dto/MenuReqDto.java | 59 ++++ .../eatsfine/domain/menu/dto/MenuResDto.java | 75 +++++ .../eatsfine/domain/menu/entity/Menu.java | 25 ++ .../domain/menu/exception/MenuException.java | 10 + .../menu/repository/MenuRepository.java | 3 + .../menu/service/MenuCommandService.java | 15 + .../menu/service/MenuCommandServiceImpl.java | 278 ++++++++++++++++++ .../domain/menu/service/MenuQueryService.java | 7 + .../menu/service/MenuQueryServiceImpl.java | 45 +++ .../domain/menu/status/MenuErrorStatus.java | 40 +++ .../domain/menu/status/MenuSuccessStatus.java | 50 ++++ .../store/converter/StoreConverter.java | 1 - .../domain/store/dto/StoreReqDto.java | 5 - .../domain/store/dto/StoreResDto.java | 1 - .../eatsfine/domain/store/entity/Store.java | 24 +- .../store/repository/StoreRepository.java | 14 + .../service/StoreCommandServiceImpl.java | 2 - .../domain/store/status/StoreErrorStatus.java | 5 +- .../handler/GeneralExceptionAdvice.java | 22 ++ .../eatsfine/global/s3/S3Service.java | 30 +- 23 files changed, 826 insertions(+), 39 deletions(-) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/menu/controller/MenuController.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/menu/converter/MenuConverter.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuReqDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuResDto.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/menu/exception/MenuException.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandServiceImpl.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryService.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryServiceImpl.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuErrorStatus.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuSuccessStatus.java diff --git a/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java index f2008156..cfd97401 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java @@ -12,7 +12,9 @@ public enum ImageErrorStatus implements BaseErrorCode { EMPTY_FILE(HttpStatus.BAD_REQUEST, "IMAGE4001", "업로드할 파일이 비어 있습니다."), INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "IMAGE4002", "지원하지 않는 파일 형식입니다."), S3_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "IMAGE5001", "이미지 업로드에 실패했습니다."), - _IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당하는 이미지가 존재하지 않습니다.") + _IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당하는 이미지가 존재하지 않습니다."), + _INVALID_IMAGE_KEY(HttpStatus.BAD_REQUEST, "IMAGE4003", "유효하지 않은 이미지 키입니다."), + _INVALID_S3_DIRECTORY(HttpStatus.BAD_REQUEST, "IMAGE4004", "유효하지 않은 S3 디렉토리입니다."), ; @@ -23,7 +25,7 @@ public enum ImageErrorStatus implements BaseErrorCode { @Override public ErrorReasonDto getReason() { return ErrorReasonDto.builder() - .isSuccess(true) + .isSuccess(false) .message(message) .code(code) .build(); @@ -33,7 +35,7 @@ public ErrorReasonDto getReason() { public ErrorReasonDto getReasonHttpStatus() { return ErrorReasonDto.builder() .httpStatus(httpStatus) - .isSuccess(true) + .isSuccess(false) .code(code) .message(message) .build(); diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/controller/MenuController.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/controller/MenuController.java new file mode 100644 index 00000000..cfc3a737 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/controller/MenuController.java @@ -0,0 +1,90 @@ +package com.eatsfine.eatsfine.domain.menu.controller; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuReqDto; +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import com.eatsfine.eatsfine.domain.menu.service.MenuCommandService; +import com.eatsfine.eatsfine.domain.menu.service.MenuQueryService; +import com.eatsfine.eatsfine.domain.menu.status.MenuSuccessStatus; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "Menu", description = "가게 메뉴 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class MenuController { + + private final MenuCommandService menuCommandService; + private final MenuQueryService menuQueryService; + + @Operation(summary = "메뉴 이미지 선 업로드 API", description = "메뉴 등록 전에 이미지를 먼저 업로드하고 KEY를 반환합니다.") + @PostMapping(value = "/stores/{storeId}/menus/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadImage( + @PathVariable Long storeId, + @RequestPart("image") MultipartFile file + ){ + return ApiResponse.of(MenuSuccessStatus._MENU_IMAGE_UPLOAD_SUCCESS, menuCommandService.uploadImage(storeId, file)); + } + + @Operation(summary = "메뉴 등록 API", description = "가게의 메뉴들을 등록합니다.") + @PostMapping("/stores/{storeId}/menus") + public ApiResponse createMenus( + @PathVariable Long storeId, + @RequestBody @Valid MenuReqDto.MenuCreateDto dto + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_CREATE_SUCCESS, menuCommandService.createMenus(storeId, dto)); + } + + @Operation(summary = "메뉴 삭제 API", description = "가게의 메뉴들을 삭제합니다.") + @DeleteMapping("/stores/{storeId}/menus") + public ApiResponse deleteMenus( + @PathVariable Long storeId, + @RequestBody @Valid MenuReqDto.MenuDeleteDto dto + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_DELETE_SUCCESS, menuCommandService.deleteMenus(storeId, dto)); + } + + @Operation(summary = "메뉴 수정 API", description = "가게의 메뉴를 수정합니다.") + @PatchMapping("/stores/{storeId}/menus/{menuId}") + public ApiResponse updateMenu( + @PathVariable Long storeId, + @PathVariable Long menuId, + @RequestBody @Valid MenuReqDto.MenuUpdateDto dto + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_UPDATE_SUCCESS, menuCommandService.updateMenu(storeId, menuId, dto)); + } + + @Operation(summary = "품절 여부 변경 API", description = "메뉴의 품절 여부를 변경합니다.") + @PatchMapping("/stores/{storeId}/menus/{menuId}/sold-out") + public ApiResponse updateSoldOutStatus( + @PathVariable Long storeId, + @PathVariable Long menuId, + @RequestBody @Valid MenuReqDto.SoldOutUpdateDto dto + ){ + return ApiResponse.of(MenuSuccessStatus._SOLD_OUT_UPDATE_SUCCESS, menuCommandService.updateSoldOutStatus(storeId, menuId, dto.isSoldOut())); + } + + @Operation(summary = "등록된 메뉴 이미지 삭제 API", description = "이미 등록된 메뉴의 이미지를 삭제합니다.") + @DeleteMapping("/stores/{storeId}/menus/{menuId}/image") + public ApiResponse deleteMenuImage( + @PathVariable Long storeId, + @PathVariable Long menuId + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_IMAGE_DELETE_SUCCESS, menuCommandService.deleteMenuImage(storeId, menuId)); + } + + @Operation(summary = "메뉴 조회 API", description = "가게의 메뉴들을 조회합니다.") + @GetMapping("/stores/{storeId}/menus") + public ApiResponse getMenus( + @PathVariable Long storeId + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_LIST_SUCCESS, menuQueryService.getMenus(storeId)); + + } +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/converter/MenuConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/converter/MenuConverter.java new file mode 100644 index 00000000..702c318b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/converter/MenuConverter.java @@ -0,0 +1,56 @@ +package com.eatsfine.eatsfine.domain.menu.converter; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import com.eatsfine.eatsfine.domain.menu.entity.Menu; + +import java.util.List; + +public class MenuConverter { + + + public static MenuResDto.ImageUploadDto toImageUploadDto(String imageKey, String imageUrl){ + return MenuResDto.ImageUploadDto.builder() + .imageKey(imageKey) + .imageUrl(imageUrl) + .build(); + } + + public static MenuResDto.ImageDeleteDto toImageDeleteDto(String imageKey) { + return MenuResDto.ImageDeleteDto.builder() + .deletedImageKey(imageKey) + .build(); + } + + + public static MenuResDto.MenuCreateDto toCreateDto(List menuDtos) { + return MenuResDto.MenuCreateDto.builder() + .menus(menuDtos) + .build(); + } + + public static MenuResDto.MenuDeleteDto toDeleteDto(List menuIds){ + return MenuResDto.MenuDeleteDto.builder() + .deletedMenuIds(menuIds) + .build(); + } + + public static MenuResDto.MenuUpdateDto toUpdateDto(Menu menu, String updatedImageUrl){ + return MenuResDto.MenuUpdateDto.builder() + .menuId(menu.getId()) + .name(menu.getName()) + .description(menu.getDescription()) + .price(menu.getPrice()) + .category(menu.getMenuCategory()) + .imageUrl(updatedImageUrl) + .build(); + + } + + public static MenuResDto.SoldOutUpdateDto toSoldOutUpdateDto(Menu menu){ + return MenuResDto.SoldOutUpdateDto.builder() + .menuId(menu.getId()) + .isSoldOut(menu.isSoldOut()) + .build(); + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuReqDto.java new file mode 100644 index 00000000..bd733fad --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuReqDto.java @@ -0,0 +1,59 @@ +package com.eatsfine.eatsfine.domain.menu.dto; + +import com.eatsfine.eatsfine.domain.menu.enums.MenuCategory; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + +import java.math.BigDecimal; +import java.util.List; + +public class MenuReqDto { + + public record MenuCreateDto( + @Valid + @NotNull + @Size(min = 1, message = "최소 1개 이상의 메뉴를 등록해야 합니다.") + List menus + ){} + + + public record MenuDto( + @NotBlank(message = "메뉴 이름은 필수입니다.") + String name, + + @Size(max = 500, message = "설명은 500자 이내여야 합니다.") + String description, + + @NotNull(message = "가격은 필수입니다.") + @Min(value = 0, message = "가격은 0원 이상이어야 합니다.") + BigDecimal price, + + @NotNull(message = "카테고리는 필수입니다.") + MenuCategory category, + + String imageKey // 이미지는 선택 사항이므로 검증 없음 (nullable) + ){} + + + public record MenuDeleteDto( + @NotNull + @Size(min = 1, message = "삭제할 메뉴를 최소 1개 이상 선택해주세요.") + List menuIds + ){} + + public record MenuUpdateDto( + @Size(min = 1, message = "메뉴 이름은 1글자 이상이어야 합니다.") + String name, + @Size(max = 500, message = "설명은 500자 이내여야 합니다.") + String description, + @Min(value = 0, message = "가격은 0원 이상이어야 합니다.") + BigDecimal price, + MenuCategory category, + String imageKey + ){} + + public record SoldOutUpdateDto( + @NotNull + Boolean isSoldOut + ){} +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuResDto.java new file mode 100644 index 00000000..0a8b419c --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuResDto.java @@ -0,0 +1,75 @@ + +package com.eatsfine.eatsfine.domain.menu.dto; + +import com.eatsfine.eatsfine.domain.menu.enums.MenuCategory; +import lombok.Builder; + +import java.math.BigDecimal; +import java.util.List; + +public class MenuResDto { + + @Builder + public record ImageUploadDto( + String imageKey, // 메뉴 등록/수정 시 서버에 다시 보낼 키 + String imageUrl // 프론트엔드에서 즉시 미리보기를 위한 전체 URL + ){} + + + @Builder + public record ImageDeleteDto( + String deletedImageKey + ){} + + @Builder + public record MenuCreateDto( + List menus + ){} + + @Builder + public record MenuDto( + Long menuId, + String name, + String description, + BigDecimal price, + MenuCategory category, + String imageUrl + ){} + + @Builder + public record MenuDeleteDto( + List deletedMenuIds + ){} + + @Builder + public record MenuUpdateDto( + Long menuId, + String name, + String description, + BigDecimal price, + MenuCategory category, + String imageUrl + ){} + + @Builder + public record SoldOutUpdateDto( + Long menuId, + boolean isSoldOut + ){} + + @Builder + public record MenuListDto( + List menus + ){} + + @Builder + public record MenuDetailDto( + Long menuId, + String name, + String description, + BigDecimal price, + MenuCategory category, + String imageUrl, + boolean isSoldOut + ){} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java index 6a3f3170..83119ee0 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java @@ -5,8 +5,12 @@ import com.eatsfine.eatsfine.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; +import org.hibernate.annotations.Where; import java.math.BigDecimal; +import java.time.LocalDateTime; @Entity @Getter @@ -14,6 +18,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Table(name = "menu") +@SQLDelete(sql = "UPDATE menu SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") public class Menu extends BaseEntity { @Id @@ -44,6 +50,8 @@ public class Menu extends BaseEntity { @Column(name = "is_sold_out", nullable = false) private boolean isSoldOut = false; + private LocalDateTime deletedAt; + public void assignStore(Store store) { this.store = store; } @@ -58,4 +66,21 @@ public void updateImageKey(String imageKey) { this.imageKey = imageKey; } + // --- 메뉴 정보 수정 메서드 --- + + public void updateName(String name) { + this.name = name; + } + + public void updateDescription(String description) { + this.description = description; + } + + public void updatePrice(BigDecimal price) { + this.price = price; + } + + public void updateCategory(MenuCategory menuCategory) { + this.menuCategory = menuCategory; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/exception/MenuException.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/exception/MenuException.java new file mode 100644 index 00000000..c84882ff --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/exception/MenuException.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.menu.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class MenuException extends GeneralException { + public MenuException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java index b0335d22..80c6562c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java @@ -3,5 +3,8 @@ import com.eatsfine.eatsfine.domain.menu.entity.Menu; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface MenuRepository extends JpaRepository { + Optional findByImageKey(String imageKey); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandService.java new file mode 100644 index 00000000..862e407b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandService.java @@ -0,0 +1,15 @@ +package com.eatsfine.eatsfine.domain.menu.service; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuReqDto; +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import org.springframework.web.multipart.MultipartFile; + +public interface MenuCommandService { + MenuResDto.ImageUploadDto uploadImage(Long storeId, MultipartFile file); + MenuResDto.ImageDeleteDto deleteMenuImage(Long storeId, Long menuId); + MenuResDto.MenuCreateDto createMenus(Long storeId, MenuReqDto.MenuCreateDto menuCreateDto); + MenuResDto.MenuDeleteDto deleteMenus(Long storeId, MenuReqDto.MenuDeleteDto menuDeleteDto); + MenuResDto.MenuUpdateDto updateMenu(Long storeId, Long menuId, MenuReqDto.MenuUpdateDto menuUpdateDto); + MenuResDto.SoldOutUpdateDto updateSoldOutStatus(Long storeId, Long menuId, boolean isSoldOut); + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandServiceImpl.java new file mode 100644 index 00000000..9685c386 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandServiceImpl.java @@ -0,0 +1,278 @@ +package com.eatsfine.eatsfine.domain.menu.service; + +import com.eatsfine.eatsfine.domain.image.exception.ImageException; +import com.eatsfine.eatsfine.domain.image.status.ImageErrorStatus; +import com.eatsfine.eatsfine.domain.menu.converter.MenuConverter; +import com.eatsfine.eatsfine.domain.menu.dto.MenuReqDto; +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import com.eatsfine.eatsfine.domain.menu.entity.Menu; +import com.eatsfine.eatsfine.domain.menu.exception.MenuException; +import com.eatsfine.eatsfine.domain.menu.repository.MenuRepository; +import com.eatsfine.eatsfine.domain.menu.status.MenuErrorStatus; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import com.eatsfine.eatsfine.global.s3.S3Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.Transaction; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class MenuCommandServiceImpl implements MenuCommandService { + + private final S3Service s3Service; + private final StoreRepository storeRepository; + private final MenuRepository menuRepository; + + @Override + public MenuResDto.MenuCreateDto createMenus(Long storeId, MenuReqDto.MenuCreateDto dto) { + Store store = findAndVerifyStore(storeId); + + List menus = dto.menus().stream() + .map(menuDto -> { + Menu menu = Menu.builder() + .name(menuDto.name()) + .description(menuDto.description()) + .price(menuDto.price()) + .menuCategory(menuDto.category()) + .build(); + + // 임시 이미지 키가 있는 경우, 영구 경로로 이동하고 키를 설정 + String tempImageKey = menuDto.imageKey(); + if (tempImageKey != null && !tempImageKey.isBlank()) { + // 1. 새로운 영구 키 생성 + String extension = s3Service.extractExtension(tempImageKey); + String permanentImageKey = "stores/" + storeId + "/menus/" + UUID.randomUUID() + extension; + + // 2. S3에서 객체 이동 (임시 -> 영구) + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit(){ + try{ + s3Service.moveObject(tempImageKey, permanentImageKey); + } catch (Exception e) { + log.error("temp에서 영구로 이동 실패. Source: {}, Dest: {}", tempImageKey, permanentImageKey); + } + } + + }); + + // 3. 엔티티에 영구 키 저장 + menu.updateImageKey(permanentImageKey); + } + + store.addMenu(menu); + return menu; + }) + .toList(); + + List savedMenus = menuRepository.saveAll(menus); + + List menuDtos = savedMenus.stream().map( + menu -> MenuResDto.MenuDto.builder() + .menuId(menu.getId()) + .name(menu.getName()) + .description(menu.getDescription()) + .price(menu.getPrice()) + .category(menu.getMenuCategory()) + .imageUrl(s3Service.toUrl(menu.getImageKey())) + .build()) + .toList(); + + return MenuConverter.toCreateDto(menuDtos); + } + + @Override + public MenuResDto.MenuDeleteDto deleteMenus(Long storeId, MenuReqDto.MenuDeleteDto dto) { + Store store = findAndVerifyStore(storeId); + + List menuIds = dto.menuIds(); + List menusToDelete = menuRepository.findAllById(dto.menuIds()); + + if(menusToDelete.size() != menuIds.size()) { + throw new MenuException(MenuErrorStatus._MENU_NOT_FOUND); + } + + // 1. 모든 메뉴가 해당 가게 소유인지 확인하고, S3 이미지 삭제 + menusToDelete.forEach(menu -> { + verifyMenuBelongsToStore(menu, storeId); + // Soft Delete 시 연결된 S3 이미지도 함께 삭제 + if (menu.getImageKey() != null && !menu.getImageKey().isBlank()) { + String imageKey = menu.getImageKey(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + s3Service.deleteByKey(imageKey); + } + }); + } + }); + + // 2. DB에서 Soft Delete 실행 + // Menu 엔티티의 @SQLDelete 어노테이션 덕분에 deleteAll이 UPDATE로 동작함 + menuRepository.deleteAll(menusToDelete); + + // 3. Store 컬렉션에서 제거 + store.getMenus().removeAll(menusToDelete); + + return MenuConverter.toDeleteDto(menuIds); + } + + @Override + public MenuResDto.MenuUpdateDto updateMenu(Long storeId, Long menuId, MenuReqDto.MenuUpdateDto dto) { + Store store = findAndVerifyStore(storeId); + + // TODO: [보안] Spring Security 병합 후, 현재 로그인한 사용자가 이 가게의 주인인지 확인하는 로직 추가 + + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new MenuException(MenuErrorStatus._MENU_NOT_FOUND)); + + verifyMenuBelongsToStore(menu, storeId); + + // 이름, 설명, 가격, 카테고리 업데이트 + Optional.ofNullable(dto.name()).ifPresent(menu::updateName); + Optional.ofNullable(dto.description()).ifPresent(menu::updateDescription); + Optional.ofNullable(dto.price()).ifPresent(menu::updatePrice); + Optional.ofNullable(dto.category()).ifPresent(menu::updateCategory); + + Optional.ofNullable(dto.imageKey()).ifPresent(newImageKey -> { + // 1. [Safety] 변경된 내용이 없으면 스킵 (프론트에서 기존 키를 그대로 보낸 경우) + if (newImageKey.equals(menu.getImageKey())) { + return; + } + + // 새로운 이미지가 있다면 영구 경로로 이동 (Temp -> Perm) + if (newImageKey != null && !newImageKey.isBlank()) { + String extension = s3Service.extractExtension(newImageKey); + String permanentImageKey = "stores/" + storeId + "/menus/" + UUID.randomUUID() + extension; + String oldImageKey = menu.getImageKey(); + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + s3Service.moveObject(newImageKey, permanentImageKey); + if (oldImageKey != null && !oldImageKey.isBlank()) { + s3Service.deleteByKey(oldImageKey); + } + } + catch (Exception e) { + log.error("메뉴 이미지를 s3에 업데이트하는 데에 실패했습니다.", e); + } + } + }); + + menu.updateImageKey(permanentImageKey); + + } else { + // 빈 문자열("")인 경우 -> 이미지 삭제 요청 + if (menu.getImageKey() != null && !menu.getImageKey().isBlank()) { + String oldImageKey = menu.getImageKey(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + s3Service.deleteByKey(oldImageKey); + } + }); + } + menu.updateImageKey(null); + } + }); + + String updatedImageUrl = s3Service.toUrl(menu.getImageKey()); + + return MenuConverter.toUpdateDto(menu, updatedImageUrl); + } + + @Override + public MenuResDto.SoldOutUpdateDto updateSoldOutStatus(Long storeId, Long menuId, boolean isSoldOut) { + findAndVerifyStore(storeId); + + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new MenuException(MenuErrorStatus._MENU_NOT_FOUND)); + + verifyMenuBelongsToStore(menu, storeId); + + // 기존 값과 동일하다면 바로 리턴 + if(menu.isSoldOut() == isSoldOut) { + return MenuConverter.toSoldOutUpdateDto(menu); + } + + menu.updateSoldOut(isSoldOut); + + return MenuConverter.toSoldOutUpdateDto(menu); + + } + + @Override + public MenuResDto.ImageUploadDto uploadImage(Long storeId, MultipartFile file) { + Store store = findAndVerifyStore(storeId); + + if(file.isEmpty()) { + throw new ImageException(ImageErrorStatus.EMPTY_FILE); + } + + // 이미지를 항상 임시 경로에 업로드 + String tempPath = "temp/menus"; + String imageKey = s3Service.upload(file, tempPath); + + return MenuConverter.toImageUploadDto(imageKey, s3Service.toUrl(imageKey)); + } + + @Override + public MenuResDto.ImageDeleteDto deleteMenuImage(Long storeId, Long menuId) { + findAndVerifyStore(storeId); + + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new MenuException(MenuErrorStatus._MENU_NOT_FOUND)); + + verifyMenuBelongsToStore(menu, storeId); + + String imageKey = menu.getImageKey(); + + if (imageKey == null || imageKey.isBlank()) { + // 이미지가 없는 메뉴에 삭제 요청이 온 경우, 예외 + throw new ImageException(ImageErrorStatus._IMAGE_NOT_FOUND); + } + + // 1. S3에서 파일 삭제 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + s3Service.deleteByKey(imageKey); + } + }); + + // 2. DB에서 imageKey를 null로 업데이트 (Dirty Checking) + menu.updateImageKey(null); + + return MenuConverter.toImageDeleteDto(imageKey); // 삭제된 이미지의 키를 반환 + } + + private Store findAndVerifyStore(Long storeId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + // TODO: [보안] Spring Security 병합 후, 현재 로그인한 사용자가 이 가게의 주인인지 확인하는 로직 추가 + return store; + } + + private void verifyMenuBelongsToStore(Menu menu, Long storeId) { + if (!menu.getStore().getId().equals(storeId)) { + // 다른 가게의 메뉴를 조작하려는 시도 방지 + throw new StoreException(StoreErrorStatus._STORE_NOT_OWNER); + } + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryService.java new file mode 100644 index 00000000..f5c80cb7 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryService.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.menu.service; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; + +public interface MenuQueryService { + MenuResDto.MenuListDto getMenus(Long storeId); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryServiceImpl.java new file mode 100644 index 00000000..ddb0e546 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryServiceImpl.java @@ -0,0 +1,45 @@ +package com.eatsfine.eatsfine.domain.menu.service; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import com.eatsfine.eatsfine.global.s3.S3Service; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MenuQueryServiceImpl implements MenuQueryService { + private final StoreRepository storeRepository; + private final S3Service s3Service; + + @Override + public MenuResDto.MenuListDto getMenus(Long storeId) { + Store store = storeRepository.findByIdWithMenus(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + List menuDtos = store.getMenus().stream() + .map(menu -> MenuResDto.MenuDetailDto.builder() + .menuId(menu.getId()) + .name(menu.getName()) + .description(menu.getDescription()) + .price(menu.getPrice()) + .category(menu.getMenuCategory()) + .imageUrl(s3Service.toUrl(menu.getImageKey())) + .isSoldOut(menu.isSoldOut()) + .build() + ) + .toList(); + + return MenuResDto.MenuListDto.builder() + .menus(menuDtos) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuErrorStatus.java new file mode 100644 index 00000000..b467af0a --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuErrorStatus.java @@ -0,0 +1,40 @@ +package com.eatsfine.eatsfine.domain.menu.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum MenuErrorStatus implements BaseErrorCode { + + _MENU_NOT_FOUND(HttpStatus.NOT_FOUND, "MENU404", "메뉴를 찾을 수 없습니다."), + + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(false) + .message(message) + .code(code) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .isSuccess(false) + .httpStatus(httpStatus) + .message(message) + .code(code) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuSuccessStatus.java new file mode 100644 index 00000000..1a696bef --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuSuccessStatus.java @@ -0,0 +1,50 @@ +package com.eatsfine.eatsfine.domain.menu.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum MenuSuccessStatus implements BaseCode { + + + _MENU_IMAGE_UPLOAD_SUCCESS(HttpStatus.CREATED, "MENU201", "메뉴 이미지 업로드 성공"), + + _MENU_IMAGE_DELETE_SUCCESS(HttpStatus.OK, "MENU200", "메뉴 이미지 삭제 성공"), + + + _MENU_CREATE_SUCCESS(HttpStatus.CREATED, "MENU202", "메뉴 생성 성공"), + _MENU_DELETE_SUCCESS(HttpStatus.OK, "MENU2002", "메뉴 삭제 성공"), + _MENU_UPDATE_SUCCESS(HttpStatus.OK, "MENU2003", "메뉴 수정 성공"), + _SOLD_OUT_UPDATE_SUCCESS(HttpStatus.OK, "MENU2005", "품절 여부 변경 성공"), + _MENU_LIST_SUCCESS(HttpStatus.OK, "MENU2004", "메뉴 조회 성공"), + + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return ReasonDto.builder() + .isSuccess(true) + .message(message) + .code(code) + .build(); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return ReasonDto.builder() + .isSuccess(true) + .httpStatus(httpStatus) + .message(message) + .code(code) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java index 962c97aa..6166ca25 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java @@ -48,7 +48,6 @@ public static StoreResDto.StoreDetailDto toDetailDto(Store store, boolean isOpen .reviewCount(null) // reviewCount는 추후 리뷰 로직 구현 시 추가 예정 .mainImageUrl(store.getMainImageKey()) .tableImageUrls(Collections.emptyList()) // tableImages는 추후 사진 등록 API 구현 시 추가 예정 - .depositAmount(store.calculateDepositAmount()) .businessHours( store.getBusinessHours().stream() .map(BusinessHoursConverter::toSummary) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java index 798dad94..70e513c4 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java @@ -53,9 +53,6 @@ public record StoreCreateDto( @NotNull(message = "카테고리는 필수입니다.") Category category, - @NotNull(message = "최소 메뉴 가격은 필수입니다.") - int minPrice, - @NotNull(message = "예약금 비율은 필수입니다.") DepositRate depositRate, @@ -79,8 +76,6 @@ public record StoreUpdateDto( Category category, - Integer minPrice, - DepositRate depositRate, Integer bookingIntervalMinutes diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java index c9ee44a2..8431ff63 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java @@ -54,7 +54,6 @@ public record StoreDetailDto( Category category, BigDecimal rating, Long reviewCount, - BigDecimal depositAmount, String mainImageUrl, List tableImageUrls, List businessHours, diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index 286fa6c0..1d56603d 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -82,9 +82,6 @@ public class Store extends BaseEntity { @Column(name = "booking_interval_minutes", nullable = false) private int bookingIntervalMinutes = 30; - @Column(name = "min_price", nullable = false) - private int minPrice; - @Enumerated(EnumType.STRING) @Column(name = "deposit_rate", nullable = false) private DepositRate depositRate; @@ -95,7 +92,7 @@ public class Store extends BaseEntity { @Builder.Default @BatchSize(size = 100) - @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "store", cascade = CascadeType.ALL) private List menus = new ArrayList<>(); @@ -134,14 +131,6 @@ public void addMenu(Menu menu) { menu.assignStore(this); } - // 메뉴 삭제 - public void removeMenu(Menu menu) { - this.menus.remove(menu); - menu.assignStore(null); - } - - - public void addTableImage(TableImage tableImage) { this.tableImages.add(tableImage); tableImage.assignStore(this); @@ -173,14 +162,6 @@ public Optional findBusinessHoursByDay(DayOfWeek dayOfWeek) { .findFirst(); } - // 예약금 계산 메서드 - public BigDecimal calculateDepositAmount() { - return BigDecimal.valueOf(minPrice) - .multiply(BigDecimal.valueOf(depositRate.getPercent())) - .divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN); - } - - // StoreTable에 대한 연관관계 편의 메서드는 추후 추가 예정 // 가게 기본 정보 변경 메서드 public void updateBasicInfo(StoreReqDto.StoreUpdateDto dto) { @@ -200,9 +181,6 @@ public void updateBasicInfo(StoreReqDto.StoreUpdateDto dto) { this.category = dto.category(); } - if(dto.minPrice() != null) { - this.minPrice = dto.minPrice(); - } if(dto.depositRate() != null) { this.depositRate = dto.depositRate(); diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java index 7c72fd36..6cd3f595 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java @@ -2,7 +2,21 @@ import com.eatsfine.eatsfine.domain.store.entity.Store; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; public interface StoreRepository extends JpaRepository, StoreRepositoryCustom { + + @Query(""" + select s from Store s + left join fetch s.menus m + where s.id = :id + and (m.deletedAt IS NULL or m.id IS NULL) + +""") + Optional findByIdWithMenus(@Param("id") Long id); + } \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java index d8c4c340..e9054cc4 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java @@ -67,7 +67,6 @@ public StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto dto) { .phoneNumber(dto.phoneNumber()) .category(dto.category()) .bookingIntervalMinutes(dto.bookingIntervalMinutes()) - .minPrice(dto.minPrice()) .depositRate(dto.depositRate()) .build(); @@ -101,7 +100,6 @@ public List extractUpdatedFields(StoreReqDto.StoreUpdateDto dto) { if (dto.description() != null) updated.add("description"); if (dto.phoneNumber() != null) updated.add("phoneNumber"); if (dto.category() != null) updated.add("category"); - if (dto.minPrice() != null) updated.add("minPrice"); if (dto.depositRate() != null) updated.add("depositRate"); if (dto.bookingIntervalMinutes() != null) updated.add("bookingIntervalMinutes"); diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java index b52dda24..0e0f4557 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java @@ -13,7 +13,10 @@ public enum StoreErrorStatus implements BaseErrorCode { _STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404", "해당하는 가게를 찾을 수 없습니다."), _STORE_DETAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE_DETAIL404", "가게 상세 정보를 찾을 수 없습니다."), - _STORE_NOT_OPEN_ON_DAY(HttpStatus.NOT_FOUND,"STORE4041" , "해당 영업시간 정보를 찾을 수 없습니다."),; + _STORE_NOT_OPEN_ON_DAY(HttpStatus.NOT_FOUND,"STORE4041" , "해당 영업시간 정보를 찾을 수 없습니다."), + + _STORE_NOT_OWNER(HttpStatus.FORBIDDEN, "STORE403", "해당 가게의 주인이 아닙니다."), + ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java index 0c01904a..7b2212f4 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -4,11 +4,13 @@ import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; +import jakarta.validation.ConstraintViolationException; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.ServletWebRequest; @@ -34,6 +36,26 @@ public ResponseEntity exception(Exception e, WebRequest request) { return handleExceptionInternalFalse(e, ErrorStatus._INTERNAL_SERVER_ERROR, HttpHeaders.EMPTY, ErrorStatus._INTERNAL_SERVER_ERROR.getHttpStatus(), request, e.getMessage()); } + // @Valid 유효성 검사 실패 시 (RequestBody) + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request + ) { + String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + return handleExceptionInternalFalse(e, ErrorStatus._BAD_REQUEST, headers, status, request, errorMessage); + } + + // @RequestParam, @PathVariable 유효성 검사 실패 시 (ConstraintViolationException) + @ExceptionHandler + public ResponseEntity validation(ConstraintViolationException e, WebRequest request) { + // 첫 번째 에러 메시지만 추출 + String errorMessage = e.getConstraintViolations().iterator().next().getMessage(); + return handleExceptionInternalFalse(e, ErrorStatus._BAD_REQUEST, HttpHeaders.EMPTY, ErrorStatus._BAD_REQUEST.getHttpStatus(), request, errorMessage); + } + // 3. 커스텀 예외용 내부 응답 생성 메서드 private ResponseEntity handleExceptionInternal(Exception e, BaseErrorCode code, HttpHeaders headers, HttpServletRequest request) { // 정의하신 ApiResponse.onFailure(BaseErrorCode code, T result)를 호출합니다. diff --git a/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java b/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java index 774fc367..d3fdb380 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java +++ b/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java @@ -9,6 +9,7 @@ import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import java.io.IOException; @@ -46,7 +47,9 @@ public String upload(MultipartFile file, String directory) { } public void deleteByKey(String key) { - if (key == null || key.isBlank()) return; + if (key == null || key.isBlank()) { + throw new ImageException(ImageErrorStatus._INVALID_IMAGE_KEY); + } s3Client.deleteObject(DeleteObjectRequest.builder() .bucket(bucket) @@ -54,6 +57,27 @@ public void deleteByKey(String key) { .build()); } + public void moveObject(String sourceKey, String destinationKey) { + if (sourceKey == null || destinationKey == null || sourceKey.isBlank() || destinationKey.isBlank()) { + throw new ImageException(ImageErrorStatus._INVALID_IMAGE_KEY); + } + + if (sourceKey.equals(destinationKey)) { + throw new ImageException(ImageErrorStatus._INVALID_IMAGE_KEY); + } + + // 1. 객체 복사 + s3Client.copyObject(CopyObjectRequest.builder() + .sourceBucket(bucket) + .sourceKey(sourceKey) + .destinationBucket(bucket) + .destinationKey(destinationKey) + .build()); + + // 2. 원본(임시) 객체 삭제 + deleteByKey(sourceKey); + } + public String toUrl(String key) { if (key == null || key.isBlank()) return null; return baseUrl + "/" + key; @@ -61,13 +85,13 @@ public String toUrl(String key) { private String generateKey(MultipartFile file, String directory) { if(directory == null || directory.isBlank()) { - throw new IllegalArgumentException("S3 디렉토리는 비어있을 수 없습니다."); + throw new ImageException(ImageErrorStatus._INVALID_S3_DIRECTORY); } String extension = extractExtension(file.getOriginalFilename()); return directory + "/" + UUID.randomUUID() + extension; } - private String extractExtension(String filename) { + public String extractExtension(String filename) { // public으로 변경 if (filename == null || !filename.contains(".")) { throw new ImageException(ImageErrorStatus.INVALID_FILE_TYPE); } From f395a4da65d08e346b57cb4276de2a4ef5c9a1bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A4=80=EC=98=81?= Date: Sat, 31 Jan 2026 16:55:52 +0900 Subject: [PATCH 169/169] =?UTF-8?q?[FIX]=20:=20QClass=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EB=B9=8C=EB=93=9C=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/storetable/service/StoreTableQueryServiceImpl.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java index c43e7f20..99e262bf 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java @@ -34,9 +34,6 @@ public class StoreTableQueryServiceImpl implements StoreTableQueryService{ private final TableBlockRepository tableBlockRepository; private final BookingRepository bookingRepository; private final S3Service s3Service; - private final StoreTableRepository storeTableRepository; - private final TableBlockRepository tableBlockRepository; - private final BookingRepository bookingRepository; // 테이블 슬롯 조회 @Override