From 181ba8cc7f46580a1fc09be6bab4a3ea4263b589 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 23 Oct 2025 17:46:07 +0900 Subject: [PATCH 001/178] =?UTF-8?q?chore:=20=EC=B4=88=EA=B8=B0=20DB=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=83=9D=EC=84=B1=20(schema.sql)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- schema.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 schema.sql diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..e69de29 From 76ce4e2bb79c508c1dccc22caa72253be738e8f7 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 23 Oct 2025 18:10:23 +0900 Subject: [PATCH 002/178] =?UTF-8?q?chore:=20schema.sql=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EB=82=B4=EC=9A=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- schema.sql | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/schema.sql b/schema.sql index e69de29..ed0adc4 100644 --- a/schema.sql +++ b/schema.sql @@ -0,0 +1,195 @@ +-- ===== Clean drop (drop children first) ===== +DROP TABLE IF EXISTS comment_likes CASCADE; +DROP TABLE IF EXISTS article_views CASCADE; +DROP TABLE IF EXISTS comments CASCADE; +DROP TABLE IF EXISTS notifications CASCADE; +DROP TABLE IF EXISTS subscribes CASCADE; +DROP TABLE IF EXISTS interests_keywords CASCADE; +DROP TABLE IF EXISTS interests_articles CASCADE; +DROP TABLE IF EXISTS keywords CASCADE; +DROP TABLE IF EXISTS articles CASCADE; +DROP TABLE IF EXISTS interests CASCADE; +DROP TABLE IF EXISTS users CASCADE; + +-- ====================================================== +-- Users +-- ====================================================== +CREATE TABLE users +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + nickname VARCHAR(100) NOT NULL, + password VARCHAR(100) NOT NULL, + deleted_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ====================================================== +-- Articles +-- ====================================================== +CREATE TABLE articles +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + source VARCHAR(20) NOT NULL, + source_url VARCHAR(500) NOT NULL UNIQUE, + title VARCHAR(200) NOT NULL, + publish_date TIMESTAMP NOT NULL, + summary VARCHAR(200) NOT NULL, + comment_count INT NOT NULL DEFAULT 0, + view_count INT NOT NULL DEFAULT 0, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +-- ====================================================== +-- Article Views (per-user view tracking) +-- ====================================================== +CREATE TABLE article_views +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + article_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_article_views UNIQUE (user_id, article_id), + CONSTRAINT fk_article_views_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_article_views_article + FOREIGN KEY (article_id) REFERENCES articles (id) ON DELETE CASCADE +); +CREATE INDEX ix_article_views_user ON article_views (user_id); +CREATE INDEX ix_article_views_article ON article_views (article_id); + +-- ====================================================== +-- Interests +-- ====================================================== +CREATE TABLE interests +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + subscriber_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ====================================================== +-- Keywords +-- ====================================================== +CREATE TABLE keywords +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + keyword VARCHAR(50) NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ====================================================== +-- Interests <-> Keywords (M:N) +-- ====================================================== +CREATE TABLE interests_keywords +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + interest_id BIGINT NOT NULL, + keyword_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_interests_keywords UNIQUE (interest_id, keyword_id), + CONSTRAINT fk_interests_keywords_interest + FOREIGN KEY (interest_id) REFERENCES interests (id) ON DELETE CASCADE, + CONSTRAINT fk_interests_keywords_keyword + FOREIGN KEY (keyword_id) REFERENCES keywords (id) ON DELETE CASCADE +); +CREATE INDEX ix_interests_keywords_interest ON interests_keywords (interest_id); +CREATE INDEX ix_interests_keywords_keyword ON interests_keywords (keyword_id); + +-- ====================================================== +-- Interests <-> Articles (M:N) +-- ====================================================== +CREATE TABLE interests_articles +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + interest_id BIGINT NOT NULL, + article_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_interests_articles UNIQUE (interest_id, article_id), + CONSTRAINT fk_interests_articles_interest + FOREIGN KEY (interest_id) REFERENCES interests (id) ON DELETE CASCADE, + CONSTRAINT fk_interests_articles_article + FOREIGN KEY (article_id) REFERENCES articles (id) ON DELETE CASCADE +); +CREATE INDEX ix_interests_articles_interest ON interests_articles (interest_id); +CREATE INDEX ix_interests_articles_article ON interests_articles (article_id); + +-- ====================================================== +-- Subscribes (user follows interest) +-- ====================================================== +CREATE TABLE subscribes +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + interest_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_subscribes UNIQUE (user_id, interest_id), + CONSTRAINT fk_subscribes_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_subscribes_interest + FOREIGN KEY (interest_id) REFERENCES interests (id) ON DELETE CASCADE +); +CREATE INDEX ix_subscribes_user ON subscribes (user_id); +CREATE INDEX ix_subscribes_interest ON subscribes (interest_id); + +-- ====================================================== +-- Comments +-- ====================================================== +CREATE TABLE comments +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + article_id BIGINT NOT NULL, + content VARCHAR(500) NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + like_count INT NOT NULL DEFAULT 0, + CONSTRAINT fk_comments_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_comments_article + FOREIGN KEY (article_id) REFERENCES articles (id) ON DELETE CASCADE +); +CREATE INDEX ix_comments_user ON comments (user_id); +CREATE INDEX ix_comments_article ON comments (article_id); + +-- ====================================================== +-- Comment Likes +-- ====================================================== +CREATE TABLE comment_likes +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + comment_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_comment_likes UNIQUE (user_id, comment_id), + CONSTRAINT fk_comment_likes_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_comment_likes_comment + FOREIGN KEY (comment_id) REFERENCES comments (id) ON DELETE CASCADE +); +CREATE INDEX ix_comment_likes_user ON comment_likes (user_id); +CREATE INDEX ix_comment_likes_comment ON comment_likes (comment_id); + +-- ====================================================== +-- Notifications +-- ====================================================== +CREATE TABLE notifications +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + content VARCHAR(100) NOT NULL, + resource_type VARCHAR(30) NOT NULL, + resource_id BIGINT NOT NULL, + confirmed BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT fk_notifications_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); From 811b20857ea300f3b275f93d4a048166bc5ea98a Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 24 Oct 2025 01:05:35 +0900 Subject: [PATCH 003/178] =?UTF-8?q?style:=20=EC=A0=95=EC=A0=81=20=EB=A6=AC?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/static/assets/index-BBLciFoK.js | 81 ++++++++++++ .../static/assets/index-CHX_5t7G.css | 1 + .../assets/landing_comments-BoMt6RvV.svg | 104 +++++++++++++++ .../assets/landing_interests-CBQzCgwG.svg | 123 ++++++++++++++++++ .../assets/landing_notifications-BkwzqdfE.svg | 119 +++++++++++++++++ .../src/main/resources/static/favicon.ico | Bin 0 -> 168090 bytes .../static/fonts/pretendard/LICENSE.txt | 94 +++++++++++++ .../fonts/pretendard/Pretendard-Bold.woff2 | Bin 0 -> 791156 bytes .../fonts/pretendard/Pretendard-Regular.woff2 | Bin 0 -> 765892 bytes .../fonts/pretendard/PretendardVariable.woff2 | Bin 0 -> 2057688 bytes .../src/main/resources/static/index.html | 14 ++ 11 files changed, 536 insertions(+) create mode 100644 monew-api/src/main/resources/static/assets/index-BBLciFoK.js create mode 100644 monew-api/src/main/resources/static/assets/index-CHX_5t7G.css create mode 100644 monew-api/src/main/resources/static/assets/landing_comments-BoMt6RvV.svg create mode 100644 monew-api/src/main/resources/static/assets/landing_interests-CBQzCgwG.svg create mode 100644 monew-api/src/main/resources/static/assets/landing_notifications-BkwzqdfE.svg create mode 100644 monew-api/src/main/resources/static/favicon.ico create mode 100644 monew-api/src/main/resources/static/fonts/pretendard/LICENSE.txt create mode 100644 monew-api/src/main/resources/static/fonts/pretendard/Pretendard-Bold.woff2 create mode 100644 monew-api/src/main/resources/static/fonts/pretendard/Pretendard-Regular.woff2 create mode 100644 monew-api/src/main/resources/static/fonts/pretendard/PretendardVariable.woff2 create mode 100644 monew-api/src/main/resources/static/index.html diff --git a/monew-api/src/main/resources/static/assets/index-BBLciFoK.js b/monew-api/src/main/resources/static/assets/index-BBLciFoK.js new file mode 100644 index 0000000..5a10cb1 --- /dev/null +++ b/monew-api/src/main/resources/static/assets/index-BBLciFoK.js @@ -0,0 +1,81 @@ +(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))s(o);new MutationObserver(o=>{for(const f of o)if(f.type==="childList")for(const d of f.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&s(d)}).observe(document,{childList:!0,subtree:!0});function r(o){const f={};return o.integrity&&(f.integrity=o.integrity),o.referrerPolicy&&(f.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?f.credentials="include":o.crossOrigin==="anonymous"?f.credentials="omit":f.credentials="same-origin",f}function s(o){if(o.ep)return;o.ep=!0;const f=r(o);fetch(o.href,f)}})();function Ep(a){return a&&a.__esModule&&Object.prototype.hasOwnProperty.call(a,"default")?a.default:a}var oc={exports:{}},ki={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Z2;function Cp(){if(Z2)return ki;Z2=1;var a=Symbol.for("react.transitional.element"),i=Symbol.for("react.fragment");function r(s,o,f){var d=null;if(f!==void 0&&(d=""+f),o.key!==void 0&&(d=""+o.key),"key"in o){f={};for(var p in o)p!=="key"&&(f[p]=o[p])}else f=o;return o=f.ref,{$$typeof:a,type:s,key:d,ref:o!==void 0?o:null,props:f}}return ki.Fragment=i,ki.jsx=r,ki.jsxs=r,ki}var Q2;function _p(){return Q2||(Q2=1,oc.exports=Cp()),oc.exports}var m=_p(),cc={exports:{}},qi={},fc={exports:{}},dc={};/** + * @license React + * scheduler.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var K2;function Np(){return K2||(K2=1,(function(a){function i(B,K){var W=B.length;B.push(K);e:for(;0>>1,E=B[pe];if(0>>1;peo(Z,W))Po(xe,Z)?(B[pe]=xe,B[P]=W,pe=P):(B[pe]=Z,B[k]=W,pe=k);else if(Po(xe,W))B[pe]=xe,B[P]=W,pe=P;else break e}}return K}function o(B,K){var W=B.sortIndex-K.sortIndex;return W!==0?W:B.id-K.id}if(a.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var f=performance;a.unstable_now=function(){return f.now()}}else{var d=Date,p=d.now();a.unstable_now=function(){return d.now()-p}}var g=[],y=[],v=1,w=null,N=3,R=!1,T=!1,M=!1,C=!1,q=typeof setTimeout=="function"?setTimeout:null,A=typeof clearTimeout=="function"?clearTimeout:null,Y=typeof setImmediate<"u"?setImmediate:null;function Q(B){for(var K=r(y);K!==null;){if(K.callback===null)s(y);else if(K.startTime<=B)s(y),K.sortIndex=K.expirationTime,i(g,K);else break;K=r(y)}}function $(B){if(M=!1,Q(B),!T)if(r(g)!==null)T=!0,ee||(ee=!0,oe());else{var K=r(y);K!==null&&Se($,K.startTime-B)}}var ee=!1,J=-1,F=5,ae=-1;function re(){return C?!0:!(a.unstable_now()-aeB&&re());){var pe=w.callback;if(typeof pe=="function"){w.callback=null,N=w.priorityLevel;var E=pe(w.expirationTime<=B);if(B=a.unstable_now(),typeof E=="function"){w.callback=E,Q(B),K=!0;break t}w===r(g)&&s(g),Q(B)}else s(g);w=r(g)}if(w!==null)K=!0;else{var D=r(y);D!==null&&Se($,D.startTime-B),K=!1}}break e}finally{w=null,N=W,R=!1}K=void 0}}finally{K?oe():ee=!1}}}var oe;if(typeof Y=="function")oe=function(){Y(fe)};else if(typeof MessageChannel<"u"){var _e=new MessageChannel,Ue=_e.port2;_e.port1.onmessage=fe,oe=function(){Ue.postMessage(null)}}else oe=function(){q(fe,0)};function Se(B,K){J=q(function(){B(a.unstable_now())},K)}a.unstable_IdlePriority=5,a.unstable_ImmediatePriority=1,a.unstable_LowPriority=4,a.unstable_NormalPriority=3,a.unstable_Profiling=null,a.unstable_UserBlockingPriority=2,a.unstable_cancelCallback=function(B){B.callback=null},a.unstable_forceFrameRate=function(B){0>B||125pe?(B.sortIndex=W,i(y,B),r(g)===null&&B===r(y)&&(M?(A(J),J=-1):M=!0,Se($,W-pe))):(B.sortIndex=E,i(g,B),T||R||(T=!0,ee||(ee=!0,oe()))),B},a.unstable_shouldYield=re,a.unstable_wrapCallback=function(B){var K=N;return function(){var W=N;N=K;try{return B.apply(this,arguments)}finally{N=W}}}})(dc)),dc}var $2;function Dp(){return $2||($2=1,fc.exports=Np()),fc.exports}var mc={exports:{}},me={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var J2;function Op(){if(J2)return me;J2=1;var a=Symbol.for("react.transitional.element"),i=Symbol.for("react.portal"),r=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),o=Symbol.for("react.profiler"),f=Symbol.for("react.consumer"),d=Symbol.for("react.context"),p=Symbol.for("react.forward_ref"),g=Symbol.for("react.suspense"),y=Symbol.for("react.memo"),v=Symbol.for("react.lazy"),w=Symbol.iterator;function N(E){return E===null||typeof E!="object"?null:(E=w&&E[w]||E["@@iterator"],typeof E=="function"?E:null)}var R={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},T=Object.assign,M={};function C(E,D,k){this.props=E,this.context=D,this.refs=M,this.updater=k||R}C.prototype.isReactComponent={},C.prototype.setState=function(E,D){if(typeof E!="object"&&typeof E!="function"&&E!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,E,D,"setState")},C.prototype.forceUpdate=function(E){this.updater.enqueueForceUpdate(this,E,"forceUpdate")};function q(){}q.prototype=C.prototype;function A(E,D,k){this.props=E,this.context=D,this.refs=M,this.updater=k||R}var Y=A.prototype=new q;Y.constructor=A,T(Y,C.prototype),Y.isPureReactComponent=!0;var Q=Array.isArray,$={H:null,A:null,T:null,S:null,V:null},ee=Object.prototype.hasOwnProperty;function J(E,D,k,Z,P,xe){return k=xe.ref,{$$typeof:a,type:E,key:D,ref:k!==void 0?k:null,props:xe}}function F(E,D){return J(E.type,D,void 0,void 0,void 0,E.props)}function ae(E){return typeof E=="object"&&E!==null&&E.$$typeof===a}function re(E){var D={"=":"=0",":":"=2"};return"$"+E.replace(/[=:]/g,function(k){return D[k]})}var fe=/\/+/g;function oe(E,D){return typeof E=="object"&&E!==null&&E.key!=null?re(""+E.key):D.toString(36)}function _e(){}function Ue(E){switch(E.status){case"fulfilled":return E.value;case"rejected":throw E.reason;default:switch(typeof E.status=="string"?E.then(_e,_e):(E.status="pending",E.then(function(D){E.status==="pending"&&(E.status="fulfilled",E.value=D)},function(D){E.status==="pending"&&(E.status="rejected",E.reason=D)})),E.status){case"fulfilled":return E.value;case"rejected":throw E.reason}}throw E}function Se(E,D,k,Z,P){var xe=typeof E;(xe==="undefined"||xe==="boolean")&&(E=null);var se=!1;if(E===null)se=!0;else switch(xe){case"bigint":case"string":case"number":se=!0;break;case"object":switch(E.$$typeof){case a:case i:se=!0;break;case v:return se=E._init,Se(se(E._payload),D,k,Z,P)}}if(se)return P=P(E),se=Z===""?"."+oe(E,0):Z,Q(P)?(k="",se!=null&&(k=se.replace(fe,"$&/")+"/"),Se(P,D,k,"",function(dt){return dt})):P!=null&&(ae(P)&&(P=F(P,k+(P.key==null||E&&E.key===P.key?"":(""+P.key).replace(fe,"$&/")+"/")+se)),D.push(P)),1;se=0;var at=Z===""?".":Z+":";if(Q(E))for(var ze=0;ze"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(a)}catch(i){console.error(i)}}return a(),hc.exports=Mp(),hc.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var I2;function jp(){if(I2)return qi;I2=1;var a=Dp(),i=Lc(),r=zm();function s(e){var t="https://react.dev/errors/"+e;if(1E||(e.current=pe[E],pe[E]=null,E--)}function Z(e,t){E++,pe[E]=e.current,e.current=t}var P=D(null),xe=D(null),se=D(null),at=D(null);function ze(e,t){switch(Z(se,t),Z(xe,e),Z(P,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?x2(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=x2(t),e=b2(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}k(P),Z(P,e)}function dt(){k(P),k(xe),k(se)}function Bl(e){e.memoizedState!==null&&Z(at,e);var t=P.current,n=b2(t,e.type);t!==n&&(Z(xe,e),Z(P,n))}function Ra(e){xe.current===e&&(k(P),k(xe)),at.current===e&&(k(at),Ui._currentValue=W)}var Aa=Object.prototype.hasOwnProperty,kl=a.unstable_scheduleCallback,Ua=a.unstable_cancelCallback,ql=a.unstable_shouldYield,lr=a.unstable_requestPaint,gt=a.unstable_now,ir=a.unstable_getCurrentPriorityLevel,Dn=a.unstable_ImmediatePriority,za=a.unstable_UserBlockingPriority,na=a.unstable_NormalPriority,rr=a.unstable_LowPriority,Yl=a.unstable_IdlePriority,$s=a.log,Js=a.unstable_setDisableYieldValue,aa=null,mt=null;function It(e){if(typeof $s=="function"&&Js(e),mt&&typeof mt.setStrictMode=="function")try{mt.setStrictMode(aa,e)}catch{}}var ht=Math.clz32?Math.clz32:I,Fs=Math.log,Vl=Math.LN2;function I(e){return e>>>=0,e===0?32:31-(Fs(e)/Vl|0)|0}var he=256,ge=4194304;function lt(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function sr(e,t,n){var l=e.pendingLanes;if(l===0)return 0;var u=0,c=e.suspendedLanes,h=e.pingedLanes;e=e.warmLanes;var x=l&134217727;return x!==0?(l=x&~c,l!==0?u=lt(l):(h&=x,h!==0?u=lt(h):n||(n=x&~e,n!==0&&(u=lt(n))))):(x=l&~c,x!==0?u=lt(x):h!==0?u=lt(h):n||(n=l&~e,n!==0&&(u=lt(n)))),u===0?0:t!==0&&t!==u&&(t&c)===0&&(c=u&-u,n=t&-t,c>=n||c===32&&(n&4194048)!==0)?t:u}function Xl(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function f1(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function tf(){var e=he;return he<<=1,(he&4194048)===0&&(he=256),e}function nf(){var e=ge;return ge<<=1,(ge&62914560)===0&&(ge=4194304),e}function Ws(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Gl(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function d1(e,t,n,l,u,c){var h=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var x=e.entanglements,S=e.expirationTimes,U=e.hiddenUpdates;for(n=h&~n;0)":-1u||S[l]!==U[u]){var V=` +`+S[l].replace(" at new "," at ");return e.displayName&&V.includes("")&&(V=V.replace("",e.displayName)),V}while(1<=l&&0<=u);break}}}finally{au=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:"")?Ya(n):""}function v1(e){switch(e.tag){case 26:case 27:case 5:return Ya(e.type);case 16:return Ya("Lazy");case 13:return Ya("Suspense");case 19:return Ya("SuspenseList");case 0:case 15:return lu(e.type,!1);case 11:return lu(e.type.render,!1);case 1:return lu(e.type,!0);case 31:return Ya("Activity");default:return""}}function mf(e){try{var t="";do t+=v1(e),e=e.return;while(e);return t}catch(n){return` +Error generating stack: `+n.message+` +`+n.stack}}function Lt(e){switch(typeof e){case"bigint":case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function hf(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function x1(e){var t=hf(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),l=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var u=n.get,c=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return u.call(this)},set:function(h){l=""+h,c.call(this,h)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return l},setValue:function(h){l=""+h},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function cr(e){e._valueTracker||(e._valueTracker=x1(e))}function yf(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),l="";return e&&(l=hf(e)?e.checked?"true":"false":e.value),e=l,e!==n?(t.setValue(e),!0):!1}function fr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var b1=/[\n"\\]/g;function Ht(e){return e.replace(b1,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function iu(e,t,n,l,u,c,h,x){e.name="",h!=null&&typeof h!="function"&&typeof h!="symbol"&&typeof h!="boolean"?e.type=h:e.removeAttribute("type"),t!=null?h==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+Lt(t)):e.value!==""+Lt(t)&&(e.value=""+Lt(t)):h!=="submit"&&h!=="reset"||e.removeAttribute("value"),t!=null?ru(e,h,Lt(t)):n!=null?ru(e,h,Lt(n)):l!=null&&e.removeAttribute("value"),u==null&&c!=null&&(e.defaultChecked=!!c),u!=null&&(e.checked=u&&typeof u!="function"&&typeof u!="symbol"),x!=null&&typeof x!="function"&&typeof x!="symbol"&&typeof x!="boolean"?e.name=""+Lt(x):e.removeAttribute("name")}function pf(e,t,n,l,u,c,h,x){if(c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"&&(e.type=c),t!=null||n!=null){if(!(c!=="submit"&&c!=="reset"||t!=null))return;n=n!=null?""+Lt(n):"",t=t!=null?""+Lt(t):n,x||t===e.value||(e.value=t),e.defaultValue=t}l=l??u,l=typeof l!="function"&&typeof l!="symbol"&&!!l,e.checked=x?e.checked:!!l,e.defaultChecked=!!l,h!=null&&typeof h!="function"&&typeof h!="symbol"&&typeof h!="boolean"&&(e.name=h)}function ru(e,t,n){t==="number"&&fr(e.ownerDocument)===e||e.defaultValue===""+n||(e.defaultValue=""+n)}function Va(e,t,n,l){if(e=e.options,t){t={};for(var u=0;u"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),fu=!1;if(cn)try{var $l={};Object.defineProperty($l,"passive",{get:function(){fu=!0}}),window.addEventListener("test",$l,$l),window.removeEventListener("test",$l,$l)}catch{fu=!1}var Mn=null,du=null,mr=null;function Tf(){if(mr)return mr;var e,t=du,n=t.length,l,u="value"in Mn?Mn.value:Mn.textContent,c=u.length;for(e=0;e=Wl),Of=" ",Mf=!1;function jf(e,t){switch(e){case"keyup":return $1.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Rf(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Qa=!1;function F1(e,t){switch(e){case"compositionend":return Rf(t);case"keypress":return t.which!==32?null:(Mf=!0,Of);case"textInput":return e=t.data,e===Of&&Mf?null:e;default:return null}}function W1(e,t){if(Qa)return e==="compositionend"||!gu&&jf(e,t)?(e=Tf(),mr=du=Mn=null,Qa=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=l}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=qf(n)}}function Vf(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Vf(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Xf(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=fr(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=fr(e.document)}return t}function bu(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var iy=cn&&"documentMode"in document&&11>=document.documentMode,Ka=null,wu=null,ti=null,Su=!1;function Gf(e,t,n){var l=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Su||Ka==null||Ka!==fr(l)||(l=Ka,"selectionStart"in l&&bu(l)?l={start:l.selectionStart,end:l.selectionEnd}:(l=(l.ownerDocument&&l.ownerDocument.defaultView||window).getSelection(),l={anchorNode:l.anchorNode,anchorOffset:l.anchorOffset,focusNode:l.focusNode,focusOffset:l.focusOffset}),ti&&ei(ti,l)||(ti=l,l=as(wu,"onSelect"),0>=h,u-=h,dn=1<<32-ht(t)+u|n<c?c:8;var h=B.T,x={};B.T=x,so(e,!1,t,n);try{var S=u(),U=B.S;if(U!==null&&U(x,S),S!==null&&typeof S=="object"&&typeof S.then=="function"){var V=hy(S,l);pi(e,t,V,Rt(e))}else pi(e,t,l,Rt(e))}catch(G){pi(e,t,{then:function(){},status:"rejected",reason:G},Rt())}finally{K.p=c,B.T=h}}function xy(){}function io(e,t,n,l){if(e.tag!==5)throw Error(s(476));var u=Zd(e).queue;Gd(e,u,t,W,n===null?xy:function(){return Qd(e),n(l)})}function Zd(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:W,baseState:W,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:pn,lastRenderedState:W},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:pn,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function Qd(e){var t=Zd(e).next.queue;pi(e,t,{},Rt())}function ro(){return ot(Ui)}function Kd(){return Je().memoizedState}function $d(){return Je().memoizedState}function by(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=Rt();e=An(n);var l=Un(t,e,n);l!==null&&(At(l,t,n),ci(l,t,n)),t={cache:Lu()},e.payload=t;return}t=t.return}}function wy(e,t,n){var l=Rt();n={lane:l,revertLane:0,action:n,hasEagerState:!1,eagerState:null,next:null},Hr(e)?Fd(t,n):(n=_u(e,t,n,l),n!==null&&(At(n,e,l),Wd(n,t,l)))}function Jd(e,t,n){var l=Rt();pi(e,t,n,l)}function pi(e,t,n,l){var u={lane:l,revertLane:0,action:n,hasEagerState:!1,eagerState:null,next:null};if(Hr(e))Fd(t,u);else{var c=e.alternate;if(e.lanes===0&&(c===null||c.lanes===0)&&(c=t.lastRenderedReducer,c!==null))try{var h=t.lastRenderedState,x=c(h,n);if(u.hasEagerState=!0,u.eagerState=x,Nt(x,h))return br(e,t,u,0),Le===null&&xr(),!1}catch{}finally{}if(n=_u(e,t,u,l),n!==null)return At(n,e,l),Wd(n,t,l),!0}return!1}function so(e,t,n,l){if(l={lane:2,revertLane:qo(),action:l,hasEagerState:!1,eagerState:null,next:null},Hr(e)){if(t)throw Error(s(479))}else t=_u(e,n,l,2),t!==null&&At(t,e,2)}function Hr(e){var t=e.alternate;return e===ye||t!==null&&t===ye}function Fd(e,t){al=jr=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Wd(e,t,n){if((n&4194048)!==0){var l=t.lanes;l&=e.pendingLanes,n|=l,t.lanes=n,lf(e,n)}}var Br={readContext:ot,use:Ar,useCallback:Qe,useContext:Qe,useEffect:Qe,useImperativeHandle:Qe,useLayoutEffect:Qe,useInsertionEffect:Qe,useMemo:Qe,useReducer:Qe,useRef:Qe,useState:Qe,useDebugValue:Qe,useDeferredValue:Qe,useTransition:Qe,useSyncExternalStore:Qe,useId:Qe,useHostTransitionStatus:Qe,useFormState:Qe,useActionState:Qe,useOptimistic:Qe,useMemoCache:Qe,useCacheRefresh:Qe},Pd={readContext:ot,use:Ar,useCallback:function(e,t){return bt().memoizedState=[e,t===void 0?null:t],e},useContext:ot,useEffect:zd,useImperativeHandle:function(e,t,n){n=n!=null?n.concat([e]):null,Lr(4194308,4,kd.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Lr(4194308,4,e,t)},useInsertionEffect:function(e,t){Lr(4,2,e,t)},useMemo:function(e,t){var n=bt();t=t===void 0?null:t;var l=e();if(pa){It(!0);try{e()}finally{It(!1)}}return n.memoizedState=[l,t],l},useReducer:function(e,t,n){var l=bt();if(n!==void 0){var u=n(t);if(pa){It(!0);try{n(t)}finally{It(!1)}}}else u=t;return l.memoizedState=l.baseState=u,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:u},l.queue=e,e=e.dispatch=wy.bind(null,ye,e),[l.memoizedState,e]},useRef:function(e){var t=bt();return e={current:e},t.memoizedState=e},useState:function(e){e=to(e);var t=e.queue,n=Jd.bind(null,ye,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:ao,useDeferredValue:function(e,t){var n=bt();return lo(n,e,t)},useTransition:function(){var e=to(!1);return e=Gd.bind(null,ye,e.queue,!0,!1),bt().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var l=ye,u=bt();if(Ce){if(n===void 0)throw Error(s(407));n=n()}else{if(n=t(),Le===null)throw Error(s(349));(Te&124)!==0||xd(l,t,n)}u.memoizedState=n;var c={value:n,getSnapshot:t};return u.queue=c,zd(wd.bind(null,l,c,e),[e]),l.flags|=2048,il(9,zr(),bd.bind(null,l,c,n,t),null),n},useId:function(){var e=bt(),t=Le.identifierPrefix;if(Ce){var n=mn,l=dn;n=(l&~(1<<32-ht(l)-1)).toString(32)+n,t="«"+t+"R"+n,n=Rr++,0ie?(nt=ne,ne=null):nt=ne.sibling;var Ee=z(O,ne,j[ie],X);if(Ee===null){ne===null&&(ne=nt);break}e&&ne&&Ee.alternate===null&&t(O,ne),_=c(Ee,_,ie),ve===null?te=Ee:ve.sibling=Ee,ve=Ee,ne=nt}if(ie===j.length)return n(O,ne),Ce&&ca(O,ie),te;if(ne===null){for(;ieie?(nt=ne,ne=null):nt=ne.sibling;var Wn=z(O,ne,Ee.value,X);if(Wn===null){ne===null&&(ne=nt);break}e&&ne&&Wn.alternate===null&&t(O,ne),_=c(Wn,_,ie),ve===null?te=Wn:ve.sibling=Wn,ve=Wn,ne=nt}if(Ee.done)return n(O,ne),Ce&&ca(O,ie),te;if(ne===null){for(;!Ee.done;ie++,Ee=j.next())Ee=G(O,Ee.value,X),Ee!==null&&(_=c(Ee,_,ie),ve===null?te=Ee:ve.sibling=Ee,ve=Ee);return Ce&&ca(O,ie),te}for(ne=l(ne);!Ee.done;ie++,Ee=j.next())Ee=L(ne,O,ie,Ee.value,X),Ee!==null&&(e&&Ee.alternate!==null&&ne.delete(Ee.key===null?ie:Ee.key),_=c(Ee,_,ie),ve===null?te=Ee:ve.sibling=Ee,ve=Ee);return e&&ne.forEach(function(Tp){return t(O,Tp)}),Ce&&ca(O,ie),te}function Re(O,_,j,X){if(typeof j=="object"&&j!==null&&j.type===T&&j.key===null&&(j=j.props.children),typeof j=="object"&&j!==null){switch(j.$$typeof){case N:e:{for(var te=j.key;_!==null;){if(_.key===te){if(te=j.type,te===T){if(_.tag===7){n(O,_.sibling),X=u(_,j.props.children),X.return=O,O=X;break e}}else if(_.elementType===te||typeof te=="object"&&te!==null&&te.$$typeof===F&&e0(te)===_.type){n(O,_.sibling),X=u(_,j.props),vi(X,j),X.return=O,O=X;break e}n(O,_);break}else t(O,_);_=_.sibling}j.type===T?(X=ua(j.props.children,O.mode,X,j.key),X.return=O,O=X):(X=Sr(j.type,j.key,j.props,null,O.mode,X),vi(X,j),X.return=O,O=X)}return h(O);case R:e:{for(te=j.key;_!==null;){if(_.key===te)if(_.tag===4&&_.stateNode.containerInfo===j.containerInfo&&_.stateNode.implementation===j.implementation){n(O,_.sibling),X=u(_,j.children||[]),X.return=O,O=X;break e}else{n(O,_);break}else t(O,_);_=_.sibling}X=Ou(j,O.mode,X),X.return=O,O=X}return h(O);case F:return te=j._init,j=te(j._payload),Re(O,_,j,X)}if(Se(j))return ue(O,_,j,X);if(oe(j)){if(te=oe(j),typeof te!="function")throw Error(s(150));return j=te.call(j),le(O,_,j,X)}if(typeof j.then=="function")return Re(O,_,kr(j),X);if(j.$$typeof===Y)return Re(O,_,_r(O,j),X);qr(O,j)}return typeof j=="string"&&j!==""||typeof j=="number"||typeof j=="bigint"?(j=""+j,_!==null&&_.tag===6?(n(O,_.sibling),X=u(_,j),X.return=O,O=X):(n(O,_),X=Du(j,O.mode,X),X.return=O,O=X),h(O)):n(O,_)}return function(O,_,j,X){try{gi=0;var te=Re(O,_,j,X);return rl=null,te}catch(ne){if(ne===ui||ne===Dr)throw ne;var ve=Dt(29,ne,null,O.mode);return ve.lanes=X,ve.return=O,ve}finally{}}}var sl=t0(!0),n0=t0(!1),Vt=D(null),tn=null;function Ln(e){var t=e.alternate;Z(Pe,Pe.current&1),Z(Vt,e),tn===null&&(t===null||nl.current!==null||t.memoizedState!==null)&&(tn=e)}function a0(e){if(e.tag===22){if(Z(Pe,Pe.current),Z(Vt,e),tn===null){var t=e.alternate;t!==null&&t.memoizedState!==null&&(tn=e)}}else Hn()}function Hn(){Z(Pe,Pe.current),Z(Vt,Vt.current)}function gn(e){k(Vt),tn===e&&(tn=null),k(Pe)}var Pe=D(0);function Yr(e){for(var t=e;t!==null;){if(t.tag===13){var n=t.memoizedState;if(n!==null&&(n=n.dehydrated,n===null||n.data==="$?"||Po(n)))return t}else if(t.tag===19&&t.memoizedProps.revealOrder!==void 0){if((t.flags&128)!==0)return t}else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}function uo(e,t,n,l){t=e.memoizedState,n=n(l,t),n=n==null?t:v({},t,n),e.memoizedState=n,e.lanes===0&&(e.updateQueue.baseState=n)}var oo={enqueueSetState:function(e,t,n){e=e._reactInternals;var l=Rt(),u=An(l);u.payload=t,n!=null&&(u.callback=n),t=Un(e,u,l),t!==null&&(At(t,e,l),ci(t,e,l))},enqueueReplaceState:function(e,t,n){e=e._reactInternals;var l=Rt(),u=An(l);u.tag=1,u.payload=t,n!=null&&(u.callback=n),t=Un(e,u,l),t!==null&&(At(t,e,l),ci(t,e,l))},enqueueForceUpdate:function(e,t){e=e._reactInternals;var n=Rt(),l=An(n);l.tag=2,t!=null&&(l.callback=t),t=Un(e,l,n),t!==null&&(At(t,e,n),ci(t,e,n))}};function l0(e,t,n,l,u,c,h){return e=e.stateNode,typeof e.shouldComponentUpdate=="function"?e.shouldComponentUpdate(l,c,h):t.prototype&&t.prototype.isPureReactComponent?!ei(n,l)||!ei(u,c):!0}function i0(e,t,n,l){e=t.state,typeof t.componentWillReceiveProps=="function"&&t.componentWillReceiveProps(n,l),typeof t.UNSAFE_componentWillReceiveProps=="function"&&t.UNSAFE_componentWillReceiveProps(n,l),t.state!==e&&oo.enqueueReplaceState(t,t.state,null)}function ga(e,t){var n=t;if("ref"in t){n={};for(var l in t)l!=="ref"&&(n[l]=t[l])}if(e=e.defaultProps){n===t&&(n=v({},n));for(var u in e)n[u]===void 0&&(n[u]=e[u])}return n}var Vr=typeof reportError=="function"?reportError:function(e){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var t=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof e=="object"&&e!==null&&typeof e.message=="string"?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",e);return}console.error(e)};function r0(e){Vr(e)}function s0(e){console.error(e)}function u0(e){Vr(e)}function Xr(e,t){try{var n=e.onUncaughtError;n(t.value,{componentStack:t.stack})}catch(l){setTimeout(function(){throw l})}}function o0(e,t,n){try{var l=e.onCaughtError;l(n.value,{componentStack:n.stack,errorBoundary:t.tag===1?t.stateNode:null})}catch(u){setTimeout(function(){throw u})}}function co(e,t,n){return n=An(n),n.tag=3,n.payload={element:null},n.callback=function(){Xr(e,t)},n}function c0(e){return e=An(e),e.tag=3,e}function f0(e,t,n,l){var u=n.type.getDerivedStateFromError;if(typeof u=="function"){var c=l.value;e.payload=function(){return u(c)},e.callback=function(){o0(t,n,l)}}var h=n.stateNode;h!==null&&typeof h.componentDidCatch=="function"&&(e.callback=function(){o0(t,n,l),typeof u!="function"&&(Xn===null?Xn=new Set([this]):Xn.add(this));var x=l.stack;this.componentDidCatch(l.value,{componentStack:x!==null?x:""})})}function Ty(e,t,n,l,u){if(n.flags|=32768,l!==null&&typeof l=="object"&&typeof l.then=="function"){if(t=n.alternate,t!==null&&ii(t,n,u,!0),n=Vt.current,n!==null){switch(n.tag){case 13:return tn===null?zo():n.alternate===null&&Ge===0&&(Ge=3),n.flags&=-257,n.flags|=65536,n.lanes=u,l===ku?n.flags|=16384:(t=n.updateQueue,t===null?n.updateQueue=new Set([l]):t.add(l),Ho(e,l,u)),!1;case 22:return n.flags|=65536,l===ku?n.flags|=16384:(t=n.updateQueue,t===null?(t={transitions:null,markerInstances:null,retryQueue:new Set([l])},n.updateQueue=t):(n=t.retryQueue,n===null?t.retryQueue=new Set([l]):n.add(l)),Ho(e,l,u)),!1}throw Error(s(435,n.tag))}return Ho(e,l,u),zo(),!1}if(Ce)return t=Vt.current,t!==null?((t.flags&65536)===0&&(t.flags|=256),t.flags|=65536,t.lanes=u,l!==Ru&&(e=Error(s(422),{cause:l}),li(Bt(e,n)))):(l!==Ru&&(t=Error(s(423),{cause:l}),li(Bt(t,n))),e=e.current.alternate,e.flags|=65536,u&=-u,e.lanes|=u,l=Bt(l,n),u=co(e.stateNode,l,u),Vu(e,u),Ge!==4&&(Ge=2)),!1;var c=Error(s(520),{cause:l});if(c=Bt(c,n),Ci===null?Ci=[c]:Ci.push(c),Ge!==4&&(Ge=2),t===null)return!0;l=Bt(l,n),n=t;do{switch(n.tag){case 3:return n.flags|=65536,e=u&-u,n.lanes|=e,e=co(n.stateNode,l,e),Vu(n,e),!1;case 1:if(t=n.type,c=n.stateNode,(n.flags&128)===0&&(typeof t.getDerivedStateFromError=="function"||c!==null&&typeof c.componentDidCatch=="function"&&(Xn===null||!Xn.has(c))))return n.flags|=65536,u&=-u,n.lanes|=u,u=c0(u),f0(u,e,n,l),Vu(n,u),!1}n=n.return}while(n!==null);return!1}var d0=Error(s(461)),et=!1;function it(e,t,n,l){t.child=e===null?n0(t,null,n,l):sl(t,e.child,n,l)}function m0(e,t,n,l,u){n=n.render;var c=t.ref;if("ref"in l){var h={};for(var x in l)x!=="ref"&&(h[x]=l[x])}else h=l;return ha(t),l=Ku(e,t,n,h,c,u),x=$u(),e!==null&&!et?(Ju(e,t,u),vn(e,t,u)):(Ce&&x&&Mu(t),t.flags|=1,it(e,t,l,u),t.child)}function h0(e,t,n,l,u){if(e===null){var c=n.type;return typeof c=="function"&&!Nu(c)&&c.defaultProps===void 0&&n.compare===null?(t.tag=15,t.type=c,y0(e,t,c,l,u)):(e=Sr(n.type,null,l,t,t.mode,u),e.ref=t.ref,e.return=t,t.child=e)}if(c=e.child,!xo(e,u)){var h=c.memoizedProps;if(n=n.compare,n=n!==null?n:ei,n(h,l)&&e.ref===t.ref)return vn(e,t,u)}return t.flags|=1,e=fn(c,l),e.ref=t.ref,e.return=t,t.child=e}function y0(e,t,n,l,u){if(e!==null){var c=e.memoizedProps;if(ei(c,l)&&e.ref===t.ref)if(et=!1,t.pendingProps=l=c,xo(e,u))(e.flags&131072)!==0&&(et=!0);else return t.lanes=e.lanes,vn(e,t,u)}return fo(e,t,n,l,u)}function p0(e,t,n){var l=t.pendingProps,u=l.children,c=e!==null?e.memoizedState:null;if(l.mode==="hidden"){if((t.flags&128)!==0){if(l=c!==null?c.baseLanes|n:n,e!==null){for(u=t.child=e.child,c=0;u!==null;)c=c|u.lanes|u.childLanes,u=u.sibling;t.childLanes=c&~l}else t.childLanes=0,t.child=null;return g0(e,t,l,n)}if((n&536870912)!==0)t.memoizedState={baseLanes:0,cachePool:null},e!==null&&Nr(t,c!==null?c.cachePool:null),c!==null?yd(t,c):Gu(),a0(t);else return t.lanes=t.childLanes=536870912,g0(e,t,c!==null?c.baseLanes|n:n,n)}else c!==null?(Nr(t,c.cachePool),yd(t,c),Hn(),t.memoizedState=null):(e!==null&&Nr(t,null),Gu(),Hn());return it(e,t,u,n),t.child}function g0(e,t,n,l){var u=Bu();return u=u===null?null:{parent:We._currentValue,pool:u},t.memoizedState={baseLanes:n,cachePool:u},e!==null&&Nr(t,null),Gu(),a0(t),e!==null&&ii(e,t,l,!0),null}function Gr(e,t){var n=t.ref;if(n===null)e!==null&&e.ref!==null&&(t.flags|=4194816);else{if(typeof n!="function"&&typeof n!="object")throw Error(s(284));(e===null||e.ref!==n)&&(t.flags|=4194816)}}function fo(e,t,n,l,u){return ha(t),n=Ku(e,t,n,l,void 0,u),l=$u(),e!==null&&!et?(Ju(e,t,u),vn(e,t,u)):(Ce&&l&&Mu(t),t.flags|=1,it(e,t,n,u),t.child)}function v0(e,t,n,l,u,c){return ha(t),t.updateQueue=null,n=gd(t,l,n,u),pd(e),l=$u(),e!==null&&!et?(Ju(e,t,c),vn(e,t,c)):(Ce&&l&&Mu(t),t.flags|=1,it(e,t,n,c),t.child)}function x0(e,t,n,l,u){if(ha(t),t.stateNode===null){var c=Wa,h=n.contextType;typeof h=="object"&&h!==null&&(c=ot(h)),c=new n(l,c),t.memoizedState=c.state!==null&&c.state!==void 0?c.state:null,c.updater=oo,t.stateNode=c,c._reactInternals=t,c=t.stateNode,c.props=l,c.state=t.memoizedState,c.refs={},qu(t),h=n.contextType,c.context=typeof h=="object"&&h!==null?ot(h):Wa,c.state=t.memoizedState,h=n.getDerivedStateFromProps,typeof h=="function"&&(uo(t,n,h,l),c.state=t.memoizedState),typeof n.getDerivedStateFromProps=="function"||typeof c.getSnapshotBeforeUpdate=="function"||typeof c.UNSAFE_componentWillMount!="function"&&typeof c.componentWillMount!="function"||(h=c.state,typeof c.componentWillMount=="function"&&c.componentWillMount(),typeof c.UNSAFE_componentWillMount=="function"&&c.UNSAFE_componentWillMount(),h!==c.state&&oo.enqueueReplaceState(c,c.state,null),di(t,l,c,u),fi(),c.state=t.memoizedState),typeof c.componentDidMount=="function"&&(t.flags|=4194308),l=!0}else if(e===null){c=t.stateNode;var x=t.memoizedProps,S=ga(n,x);c.props=S;var U=c.context,V=n.contextType;h=Wa,typeof V=="object"&&V!==null&&(h=ot(V));var G=n.getDerivedStateFromProps;V=typeof G=="function"||typeof c.getSnapshotBeforeUpdate=="function",x=t.pendingProps!==x,V||typeof c.UNSAFE_componentWillReceiveProps!="function"&&typeof c.componentWillReceiveProps!="function"||(x||U!==h)&&i0(t,c,l,h),Rn=!1;var z=t.memoizedState;c.state=z,di(t,l,c,u),fi(),U=t.memoizedState,x||z!==U||Rn?(typeof G=="function"&&(uo(t,n,G,l),U=t.memoizedState),(S=Rn||l0(t,n,S,l,z,U,h))?(V||typeof c.UNSAFE_componentWillMount!="function"&&typeof c.componentWillMount!="function"||(typeof c.componentWillMount=="function"&&c.componentWillMount(),typeof c.UNSAFE_componentWillMount=="function"&&c.UNSAFE_componentWillMount()),typeof c.componentDidMount=="function"&&(t.flags|=4194308)):(typeof c.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=l,t.memoizedState=U),c.props=l,c.state=U,c.context=h,l=S):(typeof c.componentDidMount=="function"&&(t.flags|=4194308),l=!1)}else{c=t.stateNode,Yu(e,t),h=t.memoizedProps,V=ga(n,h),c.props=V,G=t.pendingProps,z=c.context,U=n.contextType,S=Wa,typeof U=="object"&&U!==null&&(S=ot(U)),x=n.getDerivedStateFromProps,(U=typeof x=="function"||typeof c.getSnapshotBeforeUpdate=="function")||typeof c.UNSAFE_componentWillReceiveProps!="function"&&typeof c.componentWillReceiveProps!="function"||(h!==G||z!==S)&&i0(t,c,l,S),Rn=!1,z=t.memoizedState,c.state=z,di(t,l,c,u),fi();var L=t.memoizedState;h!==G||z!==L||Rn||e!==null&&e.dependencies!==null&&Cr(e.dependencies)?(typeof x=="function"&&(uo(t,n,x,l),L=t.memoizedState),(V=Rn||l0(t,n,V,l,z,L,S)||e!==null&&e.dependencies!==null&&Cr(e.dependencies))?(U||typeof c.UNSAFE_componentWillUpdate!="function"&&typeof c.componentWillUpdate!="function"||(typeof c.componentWillUpdate=="function"&&c.componentWillUpdate(l,L,S),typeof c.UNSAFE_componentWillUpdate=="function"&&c.UNSAFE_componentWillUpdate(l,L,S)),typeof c.componentDidUpdate=="function"&&(t.flags|=4),typeof c.getSnapshotBeforeUpdate=="function"&&(t.flags|=1024)):(typeof c.componentDidUpdate!="function"||h===e.memoizedProps&&z===e.memoizedState||(t.flags|=4),typeof c.getSnapshotBeforeUpdate!="function"||h===e.memoizedProps&&z===e.memoizedState||(t.flags|=1024),t.memoizedProps=l,t.memoizedState=L),c.props=l,c.state=L,c.context=S,l=V):(typeof c.componentDidUpdate!="function"||h===e.memoizedProps&&z===e.memoizedState||(t.flags|=4),typeof c.getSnapshotBeforeUpdate!="function"||h===e.memoizedProps&&z===e.memoizedState||(t.flags|=1024),l=!1)}return c=l,Gr(e,t),l=(t.flags&128)!==0,c||l?(c=t.stateNode,n=l&&typeof n.getDerivedStateFromError!="function"?null:c.render(),t.flags|=1,e!==null&&l?(t.child=sl(t,e.child,null,u),t.child=sl(t,null,n,u)):it(e,t,n,u),t.memoizedState=c.state,e=t.child):e=vn(e,t,u),e}function b0(e,t,n,l){return ai(),t.flags|=256,it(e,t,n,l),t.child}var mo={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function ho(e){return{baseLanes:e,cachePool:sd()}}function yo(e,t,n){return e=e!==null?e.childLanes&~n:0,t&&(e|=Xt),e}function w0(e,t,n){var l=t.pendingProps,u=!1,c=(t.flags&128)!==0,h;if((h=c)||(h=e!==null&&e.memoizedState===null?!1:(Pe.current&2)!==0),h&&(u=!0,t.flags&=-129),h=(t.flags&32)!==0,t.flags&=-33,e===null){if(Ce){if(u?Ln(t):Hn(),Ce){var x=Xe,S;if(S=x){e:{for(S=x,x=en;S.nodeType!==8;){if(!x){x=null;break e}if(S=Jt(S.nextSibling),S===null){x=null;break e}}x=S}x!==null?(t.memoizedState={dehydrated:x,treeContext:oa!==null?{id:dn,overflow:mn}:null,retryLane:536870912,hydrationErrors:null},S=Dt(18,null,null,0),S.stateNode=x,S.return=t,t.child=S,yt=t,Xe=null,S=!0):S=!1}S||da(t)}if(x=t.memoizedState,x!==null&&(x=x.dehydrated,x!==null))return Po(x)?t.lanes=32:t.lanes=536870912,null;gn(t)}return x=l.children,l=l.fallback,u?(Hn(),u=t.mode,x=Zr({mode:"hidden",children:x},u),l=ua(l,u,n,null),x.return=t,l.return=t,x.sibling=l,t.child=x,u=t.child,u.memoizedState=ho(n),u.childLanes=yo(e,h,n),t.memoizedState=mo,l):(Ln(t),po(t,x))}if(S=e.memoizedState,S!==null&&(x=S.dehydrated,x!==null)){if(c)t.flags&256?(Ln(t),t.flags&=-257,t=go(e,t,n)):t.memoizedState!==null?(Hn(),t.child=e.child,t.flags|=128,t=null):(Hn(),u=l.fallback,x=t.mode,l=Zr({mode:"visible",children:l.children},x),u=ua(u,x,n,null),u.flags|=2,l.return=t,u.return=t,l.sibling=u,t.child=l,sl(t,e.child,null,n),l=t.child,l.memoizedState=ho(n),l.childLanes=yo(e,h,n),t.memoizedState=mo,t=u);else if(Ln(t),Po(x)){if(h=x.nextSibling&&x.nextSibling.dataset,h)var U=h.dgst;h=U,l=Error(s(419)),l.stack="",l.digest=h,li({value:l,source:null,stack:null}),t=go(e,t,n)}else if(et||ii(e,t,n,!1),h=(n&e.childLanes)!==0,et||h){if(h=Le,h!==null&&(l=n&-n,l=(l&42)!==0?1:Ps(l),l=(l&(h.suspendedLanes|n))!==0?0:l,l!==0&&l!==S.retryLane))throw S.retryLane=l,Fa(e,l),At(h,e,l),d0;x.data==="$?"||zo(),t=go(e,t,n)}else x.data==="$?"?(t.flags|=192,t.child=e.child,t=null):(e=S.treeContext,Xe=Jt(x.nextSibling),yt=t,Ce=!0,fa=null,en=!1,e!==null&&(qt[Yt++]=dn,qt[Yt++]=mn,qt[Yt++]=oa,dn=e.id,mn=e.overflow,oa=t),t=po(t,l.children),t.flags|=4096);return t}return u?(Hn(),u=l.fallback,x=t.mode,S=e.child,U=S.sibling,l=fn(S,{mode:"hidden",children:l.children}),l.subtreeFlags=S.subtreeFlags&65011712,U!==null?u=fn(U,u):(u=ua(u,x,n,null),u.flags|=2),u.return=t,l.return=t,l.sibling=u,t.child=l,l=u,u=t.child,x=e.child.memoizedState,x===null?x=ho(n):(S=x.cachePool,S!==null?(U=We._currentValue,S=S.parent!==U?{parent:U,pool:U}:S):S=sd(),x={baseLanes:x.baseLanes|n,cachePool:S}),u.memoizedState=x,u.childLanes=yo(e,h,n),t.memoizedState=mo,l):(Ln(t),n=e.child,e=n.sibling,n=fn(n,{mode:"visible",children:l.children}),n.return=t,n.sibling=null,e!==null&&(h=t.deletions,h===null?(t.deletions=[e],t.flags|=16):h.push(e)),t.child=n,t.memoizedState=null,n)}function po(e,t){return t=Zr({mode:"visible",children:t},e.mode),t.return=e,e.child=t}function Zr(e,t){return e=Dt(22,e,null,t),e.lanes=0,e.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},e}function go(e,t,n){return sl(t,e.child,null,n),e=po(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function S0(e,t,n){e.lanes|=t;var l=e.alternate;l!==null&&(l.lanes|=t),Uu(e.return,t,n)}function vo(e,t,n,l,u){var c=e.memoizedState;c===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:l,tail:n,tailMode:u}:(c.isBackwards=t,c.rendering=null,c.renderingStartTime=0,c.last=l,c.tail=n,c.tailMode=u)}function T0(e,t,n){var l=t.pendingProps,u=l.revealOrder,c=l.tail;if(it(e,t,l.children,n),l=Pe.current,(l&2)!==0)l=l&1|2,t.flags|=128;else{if(e!==null&&(e.flags&128)!==0)e:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&S0(e,n,t);else if(e.tag===19)S0(e,n,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;e.sibling===null;){if(e.return===null||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}l&=1}switch(Z(Pe,l),u){case"forwards":for(n=t.child,u=null;n!==null;)e=n.alternate,e!==null&&Yr(e)===null&&(u=n),n=n.sibling;n=u,n===null?(u=t.child,t.child=null):(u=n.sibling,n.sibling=null),vo(t,!1,u,n,c);break;case"backwards":for(n=null,u=t.child,t.child=null;u!==null;){if(e=u.alternate,e!==null&&Yr(e)===null){t.child=u;break}e=u.sibling,u.sibling=n,n=u,u=e}vo(t,!0,n,null,c);break;case"together":vo(t,!1,null,null,void 0);break;default:t.memoizedState=null}return t.child}function vn(e,t,n){if(e!==null&&(t.dependencies=e.dependencies),Vn|=t.lanes,(n&t.childLanes)===0)if(e!==null){if(ii(e,t,n,!1),(n&t.childLanes)===0)return null}else return null;if(e!==null&&t.child!==e.child)throw Error(s(153));if(t.child!==null){for(e=t.child,n=fn(e,e.pendingProps),t.child=n,n.return=t;e.sibling!==null;)e=e.sibling,n=n.sibling=fn(e,e.pendingProps),n.return=t;n.sibling=null}return t.child}function xo(e,t){return(e.lanes&t)!==0?!0:(e=e.dependencies,!!(e!==null&&Cr(e)))}function Ey(e,t,n){switch(t.tag){case 3:ze(t,t.stateNode.containerInfo),jn(t,We,e.memoizedState.cache),ai();break;case 27:case 5:Bl(t);break;case 4:ze(t,t.stateNode.containerInfo);break;case 10:jn(t,t.type,t.memoizedProps.value);break;case 13:var l=t.memoizedState;if(l!==null)return l.dehydrated!==null?(Ln(t),t.flags|=128,null):(n&t.child.childLanes)!==0?w0(e,t,n):(Ln(t),e=vn(e,t,n),e!==null?e.sibling:null);Ln(t);break;case 19:var u=(e.flags&128)!==0;if(l=(n&t.childLanes)!==0,l||(ii(e,t,n,!1),l=(n&t.childLanes)!==0),u){if(l)return T0(e,t,n);t.flags|=128}if(u=t.memoizedState,u!==null&&(u.rendering=null,u.tail=null,u.lastEffect=null),Z(Pe,Pe.current),l)break;return null;case 22:case 23:return t.lanes=0,p0(e,t,n);case 24:jn(t,We,e.memoizedState.cache)}return vn(e,t,n)}function E0(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps)et=!0;else{if(!xo(e,n)&&(t.flags&128)===0)return et=!1,Ey(e,t,n);et=(e.flags&131072)!==0}else et=!1,Ce&&(t.flags&1048576)!==0&&ed(t,Er,t.index);switch(t.lanes=0,t.tag){case 16:e:{e=t.pendingProps;var l=t.elementType,u=l._init;if(l=u(l._payload),t.type=l,typeof l=="function")Nu(l)?(e=ga(l,e),t.tag=1,t=x0(null,t,l,e,n)):(t.tag=0,t=fo(null,t,l,e,n));else{if(l!=null){if(u=l.$$typeof,u===Q){t.tag=11,t=m0(null,t,l,e,n);break e}else if(u===J){t.tag=14,t=h0(null,t,l,e,n);break e}}throw t=Ue(l)||l,Error(s(306,t,""))}}return t;case 0:return fo(e,t,t.type,t.pendingProps,n);case 1:return l=t.type,u=ga(l,t.pendingProps),x0(e,t,l,u,n);case 3:e:{if(ze(t,t.stateNode.containerInfo),e===null)throw Error(s(387));l=t.pendingProps;var c=t.memoizedState;u=c.element,Yu(e,t),di(t,l,null,n);var h=t.memoizedState;if(l=h.cache,jn(t,We,l),l!==c.cache&&zu(t,[We],n,!0),fi(),l=h.element,c.isDehydrated)if(c={element:l,isDehydrated:!1,cache:h.cache},t.updateQueue.baseState=c,t.memoizedState=c,t.flags&256){t=b0(e,t,l,n);break e}else if(l!==u){u=Bt(Error(s(424)),t),li(u),t=b0(e,t,l,n);break e}else{switch(e=t.stateNode.containerInfo,e.nodeType){case 9:e=e.body;break;default:e=e.nodeName==="HTML"?e.ownerDocument.body:e}for(Xe=Jt(e.firstChild),yt=t,Ce=!0,fa=null,en=!0,n=n0(t,null,l,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling}else{if(ai(),l===u){t=vn(e,t,n);break e}it(e,t,l,n)}t=t.child}return t;case 26:return Gr(e,t),e===null?(n=D2(t.type,null,t.pendingProps,null))?t.memoizedState=n:Ce||(n=t.type,e=t.pendingProps,l=is(se.current).createElement(n),l[ut]=t,l[vt]=e,st(l,n,e),Ie(l),t.stateNode=l):t.memoizedState=D2(t.type,e.memoizedProps,t.pendingProps,e.memoizedState),null;case 27:return Bl(t),e===null&&Ce&&(l=t.stateNode=C2(t.type,t.pendingProps,se.current),yt=t,en=!0,u=Xe,Qn(t.type)?(Io=u,Xe=Jt(l.firstChild)):Xe=u),it(e,t,t.pendingProps.children,n),Gr(e,t),e===null&&(t.flags|=4194304),t.child;case 5:return e===null&&Ce&&((u=l=Xe)&&(l=Py(l,t.type,t.pendingProps,en),l!==null?(t.stateNode=l,yt=t,Xe=Jt(l.firstChild),en=!1,u=!0):u=!1),u||da(t)),Bl(t),u=t.type,c=t.pendingProps,h=e!==null?e.memoizedProps:null,l=c.children,Jo(u,c)?l=null:h!==null&&Jo(u,h)&&(t.flags|=32),t.memoizedState!==null&&(u=Ku(e,t,py,null,null,n),Ui._currentValue=u),Gr(e,t),it(e,t,l,n),t.child;case 6:return e===null&&Ce&&((e=n=Xe)&&(n=Iy(n,t.pendingProps,en),n!==null?(t.stateNode=n,yt=t,Xe=null,e=!0):e=!1),e||da(t)),null;case 13:return w0(e,t,n);case 4:return ze(t,t.stateNode.containerInfo),l=t.pendingProps,e===null?t.child=sl(t,null,l,n):it(e,t,l,n),t.child;case 11:return m0(e,t,t.type,t.pendingProps,n);case 7:return it(e,t,t.pendingProps,n),t.child;case 8:return it(e,t,t.pendingProps.children,n),t.child;case 12:return it(e,t,t.pendingProps.children,n),t.child;case 10:return l=t.pendingProps,jn(t,t.type,l.value),it(e,t,l.children,n),t.child;case 9:return u=t.type._context,l=t.pendingProps.children,ha(t),u=ot(u),l=l(u),t.flags|=1,it(e,t,l,n),t.child;case 14:return h0(e,t,t.type,t.pendingProps,n);case 15:return y0(e,t,t.type,t.pendingProps,n);case 19:return T0(e,t,n);case 31:return l=t.pendingProps,n=t.mode,l={mode:l.mode,children:l.children},e===null?(n=Zr(l,n),n.ref=t.ref,t.child=n,n.return=t,t=n):(n=fn(e.child,l),n.ref=t.ref,t.child=n,n.return=t,t=n),t;case 22:return p0(e,t,n);case 24:return ha(t),l=ot(We),e===null?(u=Bu(),u===null&&(u=Le,c=Lu(),u.pooledCache=c,c.refCount++,c!==null&&(u.pooledCacheLanes|=n),u=c),t.memoizedState={parent:l,cache:u},qu(t),jn(t,We,u)):((e.lanes&n)!==0&&(Yu(e,t),di(t,null,null,n),fi()),u=e.memoizedState,c=t.memoizedState,u.parent!==l?(u={parent:l,cache:l},t.memoizedState=u,t.lanes===0&&(t.memoizedState=t.updateQueue.baseState=u),jn(t,We,l)):(l=c.cache,jn(t,We,l),l!==u.cache&&zu(t,[We],n,!0))),it(e,t,t.pendingProps.children,n),t.child;case 29:throw t.pendingProps}throw Error(s(156,t.tag))}function xn(e){e.flags|=4}function C0(e,t){if(t.type!=="stylesheet"||(t.state.loading&4)!==0)e.flags&=-16777217;else if(e.flags|=16777216,!A2(t)){if(t=Vt.current,t!==null&&((Te&4194048)===Te?tn!==null:(Te&62914560)!==Te&&(Te&536870912)===0||t!==tn))throw oi=ku,ud;e.flags|=8192}}function Qr(e,t){t!==null&&(e.flags|=4),e.flags&16384&&(t=e.tag!==22?nf():536870912,e.lanes|=t,fl|=t)}function xi(e,t){if(!Ce)switch(e.tailMode){case"hidden":t=e.tail;for(var n=null;t!==null;)t.alternate!==null&&(n=t),t=t.sibling;n===null?e.tail=null:n.sibling=null;break;case"collapsed":n=e.tail;for(var l=null;n!==null;)n.alternate!==null&&(l=n),n=n.sibling;l===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:l.sibling=null}}function Ve(e){var t=e.alternate!==null&&e.alternate.child===e.child,n=0,l=0;if(t)for(var u=e.child;u!==null;)n|=u.lanes|u.childLanes,l|=u.subtreeFlags&65011712,l|=u.flags&65011712,u.return=e,u=u.sibling;else for(u=e.child;u!==null;)n|=u.lanes|u.childLanes,l|=u.subtreeFlags,l|=u.flags,u.return=e,u=u.sibling;return e.subtreeFlags|=l,e.childLanes=n,t}function Cy(e,t,n){var l=t.pendingProps;switch(ju(t),t.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Ve(t),null;case 1:return Ve(t),null;case 3:return n=t.stateNode,l=null,e!==null&&(l=e.memoizedState.cache),t.memoizedState.cache!==l&&(t.flags|=2048),yn(We),dt(),n.pendingContext&&(n.context=n.pendingContext,n.pendingContext=null),(e===null||e.child===null)&&(ni(t)?xn(t):e===null||e.memoizedState.isDehydrated&&(t.flags&256)===0||(t.flags|=1024,ad())),Ve(t),null;case 26:return n=t.memoizedState,e===null?(xn(t),n!==null?(Ve(t),C0(t,n)):(Ve(t),t.flags&=-16777217)):n?n!==e.memoizedState?(xn(t),Ve(t),C0(t,n)):(Ve(t),t.flags&=-16777217):(e.memoizedProps!==l&&xn(t),Ve(t),t.flags&=-16777217),null;case 27:Ra(t),n=se.current;var u=t.type;if(e!==null&&t.stateNode!=null)e.memoizedProps!==l&&xn(t);else{if(!l){if(t.stateNode===null)throw Error(s(166));return Ve(t),null}e=P.current,ni(t)?td(t):(e=C2(u,l,n),t.stateNode=e,xn(t))}return Ve(t),null;case 5:if(Ra(t),n=t.type,e!==null&&t.stateNode!=null)e.memoizedProps!==l&&xn(t);else{if(!l){if(t.stateNode===null)throw Error(s(166));return Ve(t),null}if(e=P.current,ni(t))td(t);else{switch(u=is(se.current),e){case 1:e=u.createElementNS("http://www.w3.org/2000/svg",n);break;case 2:e=u.createElementNS("http://www.w3.org/1998/Math/MathML",n);break;default:switch(n){case"svg":e=u.createElementNS("http://www.w3.org/2000/svg",n);break;case"math":e=u.createElementNS("http://www.w3.org/1998/Math/MathML",n);break;case"script":e=u.createElement("div"),e.innerHTML=" + + + +
+ + From b1fcbe638aec1475e17a068c3718a9c311bd46a1 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 24 Oct 2025 01:07:42 +0900 Subject: [PATCH 004/178] =?UTF-8?q?chore:=20h2=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20bcrypt,=20swagger=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monew-api/build.gradle | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/monew-api/build.gradle b/monew-api/build.gradle index 270ca6e..7f76b73 100644 --- a/monew-api/build.gradle +++ b/monew-api/build.gradle @@ -8,11 +8,13 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' runtimeOnly 'org.postgresql:postgresql' -// runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.h2database:h2' implementation 'org.mapstruct:mapstruct:1.6.3' annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' annotationProcessor 'jakarta.annotation:jakarta.annotation-api' annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + implementation 'org.springframework.security:spring-security-crypto' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' } \ No newline at end of file From 9758c24616b3d79c148b4a95d5b0950d772e816e Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 24 Oct 2025 01:11:22 +0900 Subject: [PATCH 005/178] =?UTF-8?q?chore:=20QueryDSL,=20Swagger=20Configur?= =?UTF-8?q?ation=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/QuerydslConfig.java | 19 +++++++++++++ .../common/config/SwaggerConfig.java | 28 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/config/QuerydslConfig.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/config/SwaggerConfig.java diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/QuerydslConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/QuerydslConfig.java new file mode 100644 index 0000000..ca009b2 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/config/QuerydslConfig.java @@ -0,0 +1,19 @@ +package com.monew.monew_api.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext + private EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/SwaggerConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/SwaggerConfig.java new file mode 100644 index 0000000..4ec4ac6 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/config/SwaggerConfig.java @@ -0,0 +1,28 @@ +package com.monew.monew_api.common.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +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() { + return new OpenAPI() + .info(new Info() + .title("Monew API 문서") + .description("Monew 프로젝트의 REST API 명세서입니다.") + .version("v1.0.0") + .license(new License().name("MIT License"))) + .servers(List.of( + new Server().url("http://localhost:8080").description("개발 서버"), + new Server().url("https://api.monew.com").description("운영 서버") + )); + } +} From aeceaca76d5d398a619e0a1367a60d6807c8a615 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 24 Oct 2025 01:12:11 +0900 Subject: [PATCH 006/178] =?UTF-8?q?chore:=20Security=20Configuration=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_api/common/config/SecurityConfig.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/config/SecurityConfig.java diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/SecurityConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/SecurityConfig.java new file mode 100644 index 0000000..193c00f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/config/SecurityConfig.java @@ -0,0 +1,15 @@ +package com.monew.monew_api.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} From 0b321ff0bf5762f3fc502822fd7a9a86cb0146cb Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 24 Oct 2025 01:15:21 +0900 Subject: [PATCH 007/178] =?UTF-8?q?chore:=20MDC=20=EB=A1=9C=EA=B9=85=20?= =?UTF-8?q?=EB=B0=8F=20Logback=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 --- .../monew_api/common/config/WebConfig.java | 20 ++++++++ .../interceptor/MDCLoggingInterceptor.java | 47 +++++++++++++++++++ .../src/main/resources/logback-spring.xml | 40 ++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/config/WebConfig.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/interceptor/MDCLoggingInterceptor.java create mode 100644 monew-api/src/main/resources/logback-spring.xml diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/WebConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/WebConfig.java new file mode 100644 index 0000000..cfac2a7 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/config/WebConfig.java @@ -0,0 +1,20 @@ +package com.monew.monew_api.common.config; + +import com.monew.monew_api.common.interceptor.MDCLoggingInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final MDCLoggingInterceptor mdcLoggingInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(mdcLoggingInterceptor) + .addPathPatterns("/**"); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/interceptor/MDCLoggingInterceptor.java b/monew-api/src/main/java/com/monew/monew_api/common/interceptor/MDCLoggingInterceptor.java new file mode 100644 index 0000000..86d5afd --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/interceptor/MDCLoggingInterceptor.java @@ -0,0 +1,47 @@ +package com.monew.monew_api.common.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.UUID; + +@Component +public class MDCLoggingInterceptor implements HandlerInterceptor { + + private static final String REQUEST_ID = "requestId"; + private static final String CLIENT_IP = "clientIp"; + private static final String REQUEST_METHOD = "requestMethod"; + private static final String REQUEST_URI = "requestUri"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String requestId = UUID.randomUUID().toString().substring(0, 8); + String clientIp = extractClientIp(request); + + MDC.put(REQUEST_ID, requestId); + MDC.put(CLIENT_IP, clientIp); + MDC.put(REQUEST_METHOD, request.getMethod()); + MDC.put(REQUEST_URI, request.getRequestURI()); + + response.addHeader("X-Request-ID", requestId); + response.addHeader("X-Client-IP", clientIp); + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + MDC.clear(); + } + + private String extractClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip != null && !ip.isBlank()) { + return ip.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/monew-api/src/main/resources/logback-spring.xml b/monew-api/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..45c63f7 --- /dev/null +++ b/monew-api/src/main/resources/logback-spring.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + ${LOG_PATTERN} + + + + + + + + + + + ${LOG_FILE_PATH}/${LOG_FILE_NAME}.log + + ${LOG_PATTERN} + + + ${LOG_FILE_PATH}/${LOG_FILE_NAME}.%d{yyyy-MM-dd}.log + 30 + 1GB + + + + + + + + + \ No newline at end of file From 254167a3363d6e9553d9741df77981512ec5888d Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 24 Oct 2025 01:16:18 +0900 Subject: [PATCH 008/178] =?UTF-8?q?feat:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EA=B5=AC=EC=A1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/BaseException.java | 26 +++++++ .../monew_api/common/exception/ErrorCode.java | 35 +++++++++ .../common/exception/ErrorResponse.java | 35 +++++++++ .../exception/GlobalExceptionHandler.java | 77 +++++++++++++++++++ .../article/ArticleNotFoundException.java | 17 ++++ .../comment/CommentNotFoundException.java | 17 ++++ .../interest/InterestDuplicatedException.java | 17 ++++ .../interest/InterestNotFoundException.java | 17 ++++ .../NotificationNotFoundException.java | 17 ++++ .../user/UserEmailDuplicateException.java | 17 ++++ .../user/UserForbiddenException.java | 17 ++++ .../exception/user/UserNotFoundException.java | 17 ++++ .../user/UserUnauthorizedException.java | 17 ++++ 13 files changed, 326 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/BaseException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorResponse.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/GlobalExceptionHandler.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleNotFoundException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotFoundException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestDuplicatedException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestNotFoundException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationNotFoundException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserEmailDuplicateException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserForbiddenException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserNotFoundException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserUnauthorizedException.java diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/BaseException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/BaseException.java new file mode 100644 index 0000000..6c9c1bc --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/BaseException.java @@ -0,0 +1,26 @@ +package com.monew.monew_api.common.exception; + +import lombok.Getter; + +import java.time.Instant; +import java.util.Map; + +@Getter +public class BaseException extends RuntimeException { + + private final Instant timestamp; + private final ErrorCode errorCode; + private final Map details; + + public BaseException(ErrorCode errorCode) { + this.timestamp = Instant.now(); + this.errorCode = errorCode; + this.details = Map.of(); + } + + public BaseException(ErrorCode errorCode, Map details) { + this.timestamp = Instant.now(); + this.errorCode = errorCode; + this.details = details; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java new file mode 100644 index 0000000..ea54b9a --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java @@ -0,0 +1,35 @@ +package com.monew.monew_api.common.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ErrorCode { + + // 사용자 - USER + USER_EMAIL_DUPLICATED(HttpStatus.CONFLICT.value(), "이미 존재하는 이메일입니다."), + USER_UNAUTHORIZED(HttpStatus.UNAUTHORIZED.value(), "이메일 또는 비밀번호가 일치하지 않습니다."), + USER_FORBIDDEN(HttpStatus.FORBIDDEN.value(), "수정 또는 삭제 권한이 없습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "사용자 정보를 찾을 수 없습니다."), + + // 관심사 - INTEREST + INTEREST_DUPLICATED(HttpStatus.CONFLICT.value(), "유사한 관심사가 이미 존재합니다."), + INTEREST_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "관심사 정보를 찾을 수 없습니다."), + + // 뉴스 기사 - ARTICLE + ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "뉴스 기사 정보를 찾을 수 없습니다."), + + // 댓글 - COMMENT + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "댓글 정보를 찾을 수 없습니다."), + + // 알림 - NOTIFICATION + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "알림 정보를 찾을 수 없습니다."); + + private final int status; + private final String message; + + ErrorCode(int status, String message) { + this.status = status; + this.message = message; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorResponse.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorResponse.java new file mode 100644 index 0000000..c0bcdce --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorResponse.java @@ -0,0 +1,35 @@ +package com.monew.monew_api.common.exception; + +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +@Getter +@Builder +public class ErrorResponse { + private final Instant timestamp; + private final String code; + private final String message; + private final Map details; + private final String exceptionType; + private final int status; + + public static ErrorResponse of(BaseException e, String path) { + ErrorCode errorCode = e.getErrorCode(); + + Map mergedDetails = new HashMap<>(e.getDetails()); + mergedDetails.put("path", path); + + return ErrorResponse.builder() + .timestamp(e.getTimestamp()) + .status(errorCode.getStatus()) + .code(errorCode.name()) + .message(errorCode.getMessage()) + .details(mergedDetails) + .exceptionType(e.getClass().getSimpleName()) + .build(); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/GlobalExceptionHandler.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..00b7400 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,77 @@ +package com.monew.monew_api.common.exception; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BaseException.class) + public ResponseEntity handleBaseException(BaseException e, HttpServletRequest request) { + log.warn("[비즈니스 예외 발생] 코드: {}, 메시지: {}, 요청 URI: {}", + e.getErrorCode().name(), + e.getMessage(), + request.getRequestURI()); + + return ResponseEntity + .status(e.getErrorCode().getStatus()) + .body(ErrorResponse.of(e, request.getRequestURI())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException e, HttpServletRequest request) { + Map fieldErrors = new HashMap<>(); + for (FieldError fieldError : e.getBindingResult().getFieldErrors()) { + fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + + log.warn("[입력값 검증 실패] 요청 URI: {}, 에러 필드 수: {}, 상세: {}", + request.getRequestURI(), + fieldErrors.size(), + fieldErrors); + + ErrorResponse response = ErrorResponse.builder() + .timestamp(Instant.now()) + .status(400) + .code("VALIDATION_ERROR") + .message("요청 데이터의 유효성 검증에 실패했습니다.") + .details(Map.of( + "path", request.getRequestURI(), + "errors", fieldErrors + )) + .exceptionType(e.getClass().getSimpleName()) + .build(); + + return ResponseEntity.badRequest().body(response); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpectedException(Exception e, HttpServletRequest request) { + log.error("[서버 내부 오류] 예외 타입: {}, 메시지: {}, URI: {}", + e.getClass().getSimpleName(), + e.getMessage(), + request.getRequestURI(), + e); + + ErrorResponse response = ErrorResponse.builder() + .timestamp(Instant.now()) + .status(500) + .code("INTERNAL_SERVER_ERROR") + .message("서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요.") + .details(Map.of("path", request.getRequestURI())) + .exceptionType(e.getClass().getSimpleName()) + .build(); + + return ResponseEntity.status(500).body(response); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleNotFoundException.java new file mode 100644 index 0000000..b6b1ee0 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleNotFoundException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.article; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class ArticleNotFoundException extends BaseException { + + public ArticleNotFoundException() { + super(ErrorCode.ARTICLE_NOT_FOUND); + } + + public ArticleNotFoundException(Map details) { + super(ErrorCode.ARTICLE_NOT_FOUND, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotFoundException.java new file mode 100644 index 0000000..0e2e98b --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotFoundException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.comment; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class CommentNotFoundException extends BaseException { + + public CommentNotFoundException() { + super(ErrorCode.COMMENT_NOT_FOUND); + } + + public CommentNotFoundException(Map details) { + super(ErrorCode.COMMENT_NOT_FOUND, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestDuplicatedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestDuplicatedException.java new file mode 100644 index 0000000..6db377f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestDuplicatedException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.interest; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class InterestDuplicatedException extends BaseException { + + public InterestDuplicatedException() { + super(ErrorCode.INTEREST_DUPLICATED); + } + + public InterestDuplicatedException(Map details) { + super(ErrorCode.INTEREST_DUPLICATED, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestNotFoundException.java new file mode 100644 index 0000000..a8e4aeb --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestNotFoundException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.interest; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class InterestNotFoundException extends BaseException { + + public InterestNotFoundException() { + super(ErrorCode.INTEREST_NOT_FOUND); + } + + public InterestNotFoundException(Map details) { + super(ErrorCode.INTEREST_NOT_FOUND, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationNotFoundException.java new file mode 100644 index 0000000..954c995 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationNotFoundException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.notification; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class NotificationNotFoundException extends BaseException { + + public NotificationNotFoundException() { + super(ErrorCode.NOTIFICATION_NOT_FOUND); + } + + public NotificationNotFoundException(Map details) { + super(ErrorCode.NOTIFICATION_NOT_FOUND, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserEmailDuplicateException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserEmailDuplicateException.java new file mode 100644 index 0000000..9df967d --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserEmailDuplicateException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.user; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class UserEmailDuplicateException extends BaseException { + + public UserEmailDuplicateException() { + super(ErrorCode.USER_EMAIL_DUPLICATED); + } + + public UserEmailDuplicateException(Map details) { + super(ErrorCode.USER_EMAIL_DUPLICATED, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserForbiddenException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserForbiddenException.java new file mode 100644 index 0000000..795a4c8 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserForbiddenException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.user; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class UserForbiddenException extends BaseException { + + public UserForbiddenException() { + super(ErrorCode.USER_FORBIDDEN); + } + + public UserForbiddenException(Map details) { + super(ErrorCode.USER_FORBIDDEN, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserNotFoundException.java new file mode 100644 index 0000000..29d3064 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserNotFoundException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.user; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class UserNotFoundException extends BaseException { + + public UserNotFoundException() { + super(ErrorCode.USER_NOT_FOUND); + } + + public UserNotFoundException(Map details) { + super(ErrorCode.USER_NOT_FOUND, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserUnauthorizedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserUnauthorizedException.java new file mode 100644 index 0000000..e18eee8 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserUnauthorizedException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.user; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class UserUnauthorizedException extends BaseException { + + public UserUnauthorizedException() { + super(ErrorCode.USER_UNAUTHORIZED); + } + + public UserUnauthorizedException(Map details) { + super(ErrorCode.USER_UNAUTHORIZED, details); + } +} From 33434773f4072a9076024283da7099ac1b05ec45 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 24 Oct 2025 01:16:49 +0900 Subject: [PATCH 009/178] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20=EB=B2=A0?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20Auditing=20=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew/monew_api/MonewApiApplication.java | 2 ++ .../common/entity/BaseCreatedEntity.java | 20 +++++++++++++++ .../monew_api/common/entity/BaseIdEntity.java | 16 ++++++++++++ .../common/entity/BaseTimeEntity.java | 25 +++++++++++++++++++ 4 files changed, 63 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/entity/BaseCreatedEntity.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/entity/BaseIdEntity.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/entity/BaseTimeEntity.java diff --git a/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java b/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java index 3e6016a..13728a4 100644 --- a/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java +++ b/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing public class MonewApiApplication { public static void main(String[] args) { diff --git a/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseCreatedEntity.java b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseCreatedEntity.java new file mode 100644 index 0000000..9d5c107 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseCreatedEntity.java @@ -0,0 +1,20 @@ +package com.monew.monew_api.common.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.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseCreatedEntity extends BaseIdEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseIdEntity.java b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseIdEntity.java new file mode 100644 index 0000000..a3e8494 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseIdEntity.java @@ -0,0 +1,16 @@ +package com.monew.monew_api.common.entity; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@Getter +@MappedSuperclass +public abstract class BaseIdEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseTimeEntity.java b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseTimeEntity.java new file mode 100644 index 0000000..d1d7cbb --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseTimeEntity.java @@ -0,0 +1,25 @@ +package com.monew.monew_api.common.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 abstract class BaseTimeEntity extends BaseIdEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} From bba00bebde9f73cd6ad1cfc5395351c9f415e672 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 24 Oct 2025 01:29:40 +0900 Subject: [PATCH 010/178] =?UTF-8?q?chore:=20monew-api=20/=20monew-batch=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95=20=EB=B3=B4=EC=99=84=20?= =?UTF-8?q?(dev,=20prod)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - monew-api dev/prod: - ddl-auto를 validate로 변경하여 스키마 자동 변경 방지 - actuator 노출 범위 개선 (health, info, metrics, env) - health show-details 옵션 환경별로 분리 (dev=always, prod=never) - monew-batch dev/prod: - prod 배치 비활성 - Prometheus metrics export 및 actuator health 설정 보완 --- .../src/main/resources/application-dev.yml | 18 +++++++------- .../src/main/resources/application-prod.yml | 18 +++++++------- .../src/main/resources/application-dev.yml | 18 +++++++++++--- .../src/main/resources/application-prod.yml | 24 +++++++++++++------ 4 files changed, 50 insertions(+), 28 deletions(-) diff --git a/monew-api/src/main/resources/application-dev.yml b/monew-api/src/main/resources/application-dev.yml index 5cdb9d3..46404b4 100644 --- a/monew-api/src/main/resources/application-dev.yml +++ b/monew-api/src/main/resources/application-dev.yml @@ -10,7 +10,7 @@ spring: jpa: hibernate: - ddl-auto: update + ddl-auto: validate show-sql: true properties: hibernate: @@ -21,17 +21,17 @@ spring: max-file-size: 10MB max-request-size: 10MB -logging: - level: - root: INFO - org.hibernate.SQL: DEBUG - org.springframework.web: DEBUG - management: endpoints: web: exposure: - include: health,info + include: health, info, metrics, env endpoint: health: - show-details: always \ No newline at end of file + show-details: always + +logging: + level: + root: INFO + org.hibernate.SQL: DEBUG + org.springframework.web: DEBUG \ No newline at end of file diff --git a/monew-api/src/main/resources/application-prod.yml b/monew-api/src/main/resources/application-prod.yml index 5cdb9d3..89f29b3 100644 --- a/monew-api/src/main/resources/application-prod.yml +++ b/monew-api/src/main/resources/application-prod.yml @@ -10,7 +10,7 @@ spring: jpa: hibernate: - ddl-auto: update + ddl-auto: validate show-sql: true properties: hibernate: @@ -21,17 +21,17 @@ spring: max-file-size: 10MB max-request-size: 10MB -logging: - level: - root: INFO - org.hibernate.SQL: DEBUG - org.springframework.web: DEBUG - management: endpoints: web: exposure: - include: health,info + include: health, info endpoint: health: - show-details: always \ No newline at end of file + show-details: never + +logging: + level: + root: INFO + org.hibernate.SQL: DEBUG + org.springframework.web: DEBUG \ No newline at end of file diff --git a/monew-batch/src/main/resources/application-dev.yml b/monew-batch/src/main/resources/application-dev.yml index b8c4f13..085d655 100644 --- a/monew-batch/src/main/resources/application-dev.yml +++ b/monew-batch/src/main/resources/application-dev.yml @@ -1,3 +1,6 @@ +server: + port: 8081 + spring: datasource: url: ${DB_URL} @@ -25,14 +28,23 @@ spring: continue-on-error: true schema-locations: classpath:org/springframework/batch/core/schema-postgresql.sql -# 스케줄러 풀 task: scheduling: pool: size: 4 -server: - port: 8081 +management: + endpoints: + web: + exposure: + include: health, info, metrics, prometheus, env + endpoint: + health: + show-details: always + prometheus: + metrics: + export: + enabled: true logging: level: diff --git a/monew-batch/src/main/resources/application-prod.yml b/monew-batch/src/main/resources/application-prod.yml index aa61f2c..fb773e1 100644 --- a/monew-batch/src/main/resources/application-prod.yml +++ b/monew-batch/src/main/resources/application-prod.yml @@ -1,3 +1,6 @@ +server: + port: 8081 + spring: datasource: url: ${DB_URL} @@ -14,7 +17,7 @@ spring: jdbc: initialize-schema: never job: - enabled: true + enabled: false task: scheduling: @@ -25,14 +28,24 @@ management: endpoints: web: exposure: - include: health,metrics,prometheus + include: health, info, metrics, prometheus, beans endpoint: health: - show-details: never + show-details: when_authorized # 인증된 요청만 상세 노출 + probes: + enabled: true # ECS/K8s 헬스체크용 prometheus: metrics: export: enabled: true + metrics: + tags: + application: monew-batch # prometheus 필터 태그로 사용됨 + enable: + jvm: true + logback: true + process: true + spring.batch: true logging: level: @@ -45,7 +58,4 @@ aws: s3: access-key: ${AWS_ACCESS_KEY} secret-key: ${AWS_SECRET_KEY} - bucket: monew-backup - -server: - port: ${SERVER_PORT:8081} \ No newline at end of file + bucket: monew-backup \ No newline at end of file From d16ae4aac0c2516902c42f15cf07d21044ff0d46 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 24 Oct 2025 01:30:04 +0900 Subject: [PATCH 011/178] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=9A=A9=20H2=20PostgreSQL=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/test/resources/application-test.yml | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 monew-api/src/test/resources/application-test.yml diff --git a/monew-api/src/test/resources/application-test.yml b/monew-api/src/test/resources/application-test.yml new file mode 100644 index 0000000..85e22be --- /dev/null +++ b/monew-api/src/test/resources/application-test.yml @@ -0,0 +1,29 @@ +server: + port: 0 + +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + 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.PostgreSQLDialect + +# h2: +# console: +# enabled: true +# path: /h2-console + +logging: + level: + root: WARN + org.hibernate.SQL: DEBUG + org.springframework: WARN \ No newline at end of file From 497f35e9b73f8da94180323e07e377084c81b1c6 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Fri, 24 Oct 2025 10:54:03 +0900 Subject: [PATCH 012/178] =?UTF-8?q?chore=20:=20=EB=8D=94=EB=AF=B8=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data-Interests.sql | 26 ++++++++++++++++++++++++++ data-article_vews.sql | 8 ++++++++ data-articles.sql | 36 ++++++++++++++++++++++++++++++++++++ data-comments.sql | 28 ++++++++++++++++++++++++++++ data-notifications.sql | 23 +++++++++++++++++++++++ data-subscribes.sql | 10 ++++++++++ data-users.sql | 23 +++++++++++++++++++++++ 7 files changed, 154 insertions(+) create mode 100644 data-Interests.sql create mode 100644 data-article_vews.sql create mode 100644 data-articles.sql create mode 100644 data-comments.sql create mode 100644 data-notifications.sql create mode 100644 data-subscribes.sql create mode 100644 data-users.sql diff --git a/data-Interests.sql b/data-Interests.sql new file mode 100644 index 0000000..2d009f8 --- /dev/null +++ b/data-Interests.sql @@ -0,0 +1,26 @@ +--관심사(Interest) 및 키워드(Keyword) 도메인 +-- ========================= +INSERT INTO interests (name) VALUES +('IT 뉴스'), ('블록체인'), ('국내증시'), ('해외증시'), ('스타트업'), +('기술'), ('자동차'), ('여행'), ('건강'), ('교육'); + +INSERT INTO keywords (keyword) VALUES +('AI'), ('머신러닝'), ('가상화폐'), ('비트코인'), +('투자'), ('거시경제'), ('빅데이터'), ('핀테크'), +('물가'), ('인공지능'), ('아파트'), ('탄소중립'), +('투자'),('금리'), ('대출'), ('기술혁신'), +('자동차산업'), ('헬스케어'), ('여행지'), ('교육정책'), +('패션트렌드'), ('음악시장'), ('영화산업'), +('환경정책'), ('금융시장'), ('스타트업'), ('부동산세제'); + +INSERT INTO interests_keywords (interest_id, keyword_id) VALUES +(1,1), (1,2), (1,7), (1,15), +(2,1), (2,2), (2,3), (2,4), (2,8), +(3,1), (3,2), (3,3), (3,4), (3,5), (3,6), (3,10), (3,13), (3,15), (3,16), (3,20), +(4,5), (4,6), +(5,1), (5,2), (5,3), (5,4), (5,8), (5,10), (5,16), (5,20), +(6,6), (6,7), (6,8), (6,9), +(7,1), (7,2), (7,3), (7,4), (7,8), (7,10), (7,16), (7,20), +(8,1), (8,2), (8,3), (8,6), (8,9), (8,11), (8,17), (8,21), (8,7), +(9,1), (9,2), (9,3), (9,4), (9,8), (9,10), +(10,1), (10,2), (10,3); diff --git a/data-article_vews.sql b/data-article_vews.sql new file mode 100644 index 0000000..23de258 --- /dev/null +++ b/data-article_vews.sql @@ -0,0 +1,8 @@ +--조회(Article View) 도메인 +-- ========================= +INSERT INTO article_views (user_id, article_id) VALUES +(1,1), (1,2), (1,4), +(2,1), (2,2), +(3,1), (3,3), (3,5), +(4,3), +(5,1), (5,2), (5,4), (5,5); \ No newline at end of file diff --git a/data-articles.sql b/data-articles.sql new file mode 100644 index 0000000..bb99516 --- /dev/null +++ b/data-articles.sql @@ -0,0 +1,36 @@ +--뉴스/아티클(Article) 도메인 +-- ========================= +INSERT INTO articles (source, source_url, title, publish_date, summary, comment_count, view_count) VALUES + ('Chosun','https://biz.chosun.com/it-science/ict/2025/10/16/FY3SOPSY65EKBLQLZALLF2GV6I/','AI 적용한 네이버 블로그, 초기 이용자 반응은', NOW() - INTERVAL '7 days','AI로 맞춤 콘텐츠 추천, 개편 한 달 반응',3,180), + ('Chosun','https://www.chosun.com/economy/tech_it/2025/04/24/VMY3UPDHUNFK5HC6ZBSLJ6XX24/','네이버, 최신 AI 모델 오픈소스로 무료 공개', NOW() - INTERVAL '182 days','하이퍼클로바X 시드 모델 무료 제공',4,220), + ('Chosun','https://www.chosun.com/economy/tech_it/2025/10/23/R3VTTXIJYJBSZECXSMIE6SUCYM/','벤 만 앤트로픽 공동 창업자, 한국은 가장 기대되는 AI 시장', NOW() - INTERVAL '1 days','클로드 개발사, 한국 AI 시장 높이 평가',5,240), + ('Chosun','https://www.chosun.com/economy/tech_it/2025/10/23/4NOEW6JW3RF7VDN2HGKABN2U7U/','오픈AI, 한국 AI 리더십 위해 협력 강화 필요', NOW() - INTERVAL '1 days','소버린 AI와 글로벌 협력 듀얼 트랙 전략',4,195), + ('Chosun','https://www.chosun.com/economy/money/2025/10/23/62DIWDGSRRGKHJYIORYU3Q5VB4/','양자컴·원전주, 거품 논란 속 급락·급등 반복', NOW() - INTERVAL '1 days','AI 열풍 타고 상승했던 주식 변동성',3,150), + ('Chosun','https://www.chosun.com/economy/tech_it/2025/10/23/3OS6C7KDKJH5VCLB7DUFWO46TY/','AI로 플라스마 통제 쉬워져, 핵융합 발전 가까이', NOW() - INTERVAL '23 days','AI 기술로 핵융합 반응 정교한 제어',2,120), + ('Chosun','https://www.chosun.com/economy/economy_general/2025/10/22/IWNF7IWUM5GRXBYQV3OWYCAI7M/','기술 특례 상장 82곳 중 48곳 주가 하락', NOW() - INTERVAL '2 days','부실 심사로 뻥튀기 상장 악용 지적',3,135), + ('Naver','https://blog.naver.com/c1c1b1b1/224014877211','IT 일반, 네이버 언론사 제공 2025년 9월', NOW() - INTERVAL '33 days','구글 크롬 AI 제미나이 본격 적용',1,88), + ('Naver','https://n.news.naver.com/mnews/article/277/0005521038','SK하이닉스, 3분기 영업이익 7조 전망…HBM 수요 견조', NOW() - INTERVAL '8 days','고대역폭메모리 공급 확대로 실적 개선',4,230), + ('Naver','https://n.news.naver.com/mnews/article/421/0007885042','삼성전자, AI 반도체 수요 회복 기대감…주가 상승', NOW() - INTERVAL '5 days','메모리 가격 반등으로 실적 턴어라운드',3,190), + ('Naver','https://n.news.naver.com/mnews/article/011/0004402211','네이버 하이퍼클로바X, 기업용 AI 솔루션 확대', NOW() - INTERVAL '12 days','엔터프라이즈 시장 공략 본격화',5,270), + ('Naver','https://n.news.naver.com/mnews/article/008/0005115233','현대차, 자율주행 AI 기술 개발 박차…미국 투자 확대', NOW() - INTERVAL '6 days','소프트웨어 정의 차량 개발 가속',2,145), + ('Naver','https://n.news.naver.com/mnews/article/366/0001057889','LG에너지솔루션, 배터리 AI 품질검사 시스템 도입', NOW() - INTERVAL '9 days','불량률 20% 감소 효과 확인',3,175), + ('Naver','https://n.news.naver.com/mnews/article/018/0005911142','카카오, 생성형 AI 카카오아이 공개…톡·뮤직 연동', NOW() - INTERVAL '14 days','맞춤형 콘텐츠 큐레이션 강화',4,210), + ('Naver','https://n.news.naver.com/mnews/article/001/0015203344','금융위, AI 기반 불법금융 탐지 시스템 가동', NOW() - INTERVAL '7 days','보이스피싱·자금세탁 실시간 차단',2,130), + ('Naver','https://n.news.naver.com/mnews/article/015/0005099221','코스피 2900선 회복…외국인 반도체주 매수 지속', NOW() - INTERVAL '3 days','AI 수혜주 중심 상승세',5,290), + ('Naver','https://n.news.naver.com/mnews/article/277/0005520011','KT, AI 데이터센터 5000억 투자…2026년 완공', NOW() - INTERVAL '10 days','인천 송도에 초거대 AI 인프라 구축',3,165), + ('Naver','https://n.news.naver.com/mnews/article/052/0002200345','포스코, AI 기반 스마트공장 확대…탄소배출 10% 감축', NOW() - INTERVAL '11 days','공정 최적화로 친환경 생산 달성',2,120), + ('Naver','https://n.news.naver.com/article/001/0015666237','뉴욕증시, 고조되는 AI 거품론과 셧다운 우려 부각', NOW() - INTERVAL '18 days','시장 조정 압력 증가하며 차익실현 나타나',3,165), + ('Naver','https://n.news.naver.com/mnews/article/421/0008526173','서버실 갇힌 AI는 끝, 산업 현장 뛰어든 피지컬 AI', NOW() - INTERVAL '18 days','제조·물류 등 현장에서 실물 작업하는 AI 본격화',4,210); + +-- ========================= +INSERT INTO interests_articles (interest_id, article_id) VALUES + (1,1), (1,4), (1,5), + (2,2), (2,14), (2,10), (2,1), (2,15), + (3,3), + (4,4), (4,5), + (5,1), (5,2), (5,3), (5,4), (5,5), (5,5), (5,6), (5,7), (5,8), (5,9), (5,10), (5,11), (5,12), (5,13), (5,14), (5,15), + (6,1), (6,11), (6,2), (6,3), (6,4), (6,5), (6,5), (6,6), (6,7), (6,8), + (7,7), (7,10), (7,1), (7,15), + (8,1), (8,11), (8,2), (8,3), (8,4), (8,5), (8,14), (8,6), (8,7), (8,8), + (9,11),(9,1), (9,14), (9,2), (9,3), (9,4), (9,5), (9,5), (9,6), (9,7), (9,8), (9,9), + (10,1); \ No newline at end of file diff --git a/data-comments.sql b/data-comments.sql new file mode 100644 index 0000000..553cb76 --- /dev/null +++ b/data-comments.sql @@ -0,0 +1,28 @@ +--댓글(Comment) 및 좋아요 도메인 +-- ======================== +INSERT INTO comments (user_id, article_id, content, like_count) VALUES +(1,1,'유익한 기사네요!',2), +(2,2,'ETF 유입 데이터 참고합니다',1), +(3,3,'외인 수급 추세가 흥미롭네요',3), +(5,4,'빅테크 실적이 핵심이군요',2), +(4,5,'핀테크 투자 사이클 기대',4), +(6,6,'환율 변동 심하네요.',2), +(7,7,'AI 인재 수요 공감합니다.',1), +(8,8,'친환경 정책 응원합니다.',2), +(9,9,'스마트시티 기대돼요.',4), +(10,10,'금리 유지 다행이에요.',5), +(11,11,'AI 의료 발전 굿!',0), +(12,12,'세제 개편 찬성입니다.',2), +(13,13,'ESG 중요하죠.',0), +(14,14,'IPO 뉴스 재밌어요.',1), +(15,15,'AI 경쟁 치열하네요.',3); + +INSERT INTO comment_likes (user_id, comment_id) VALUES +(2,1), (3,1), +(1,2), (5,2), +(1,3), +(2,4), (3,4), (5,4), +(1,5), (2,5), +(6,5), +(7,6),(8,7),(9,8),(10,9),(11,10), +(12,11),(13,12),(14,13),(15,14),(13,15); \ No newline at end of file diff --git a/data-notifications.sql b/data-notifications.sql new file mode 100644 index 0000000..a6bd70f --- /dev/null +++ b/data-notifications.sql @@ -0,0 +1,23 @@ +--알림(Notification) 도메인 +-- ========================= +INSERT INTO notifications (user_id, content, resource_type, resource_id, confirmed) VALUES +(1, '구독 관심사에 새 기사 등록', 'article', 1, FALSE), +(1, '내 댓글에 새 좋아요', 'comment', 1, TRUE), +(2, '팔로우 관심사에 새 기사', 'article', 2, FALSE), +(3, '새 댓글 알림', 'comment', 3, TRUE), +(5, '관심 기사 업데이트', 'article', 5, TRUE), +(1,'새 댓글이 달렸습니다.','comment',10, FALSE), +(4,'환경 소식 알림','ARTICLE',4,TRUE), +(5,'스타트업 소식','ARTICLE',5,FALSE), +(6,'금융 뉴스 도착','ARTICLE',6,FALSE), +(7,'정치 관련 소식','ARTICLE',7,TRUE), +(8,'국제 이슈 속보','ARTICLE',8,FALSE), +(9,'스포츠 뉴스 업데이트','ARTICLE',9,TRUE), +(10,'문화 기사 알림','ARTICLE',10,FALSE), +(11,'기술 관련 업데이트','ARTICLE',11,TRUE), +(12,'자동차 산업 소식','ARTICLE',12,FALSE), +(13,'여행 기사 등록','ARTICLE',13,TRUE), +(14,'건강 정보 도착','ARTICLE',14,FALSE), +(15,'교육 소식 알림','ARTICLE',15,TRUE), + +; \ No newline at end of file diff --git a/data-subscribes.sql b/data-subscribes.sql new file mode 100644 index 0000000..62f8675 --- /dev/null +++ b/data-subscribes.sql @@ -0,0 +1,10 @@ +--구독(Subscribe) 도메인 +-- ========================= +INSERT INTO subscribes (user_id, interest_id) VALUES +(1,1), (1,2), (1,3), +(2,2), (2,4), +(3,1), (3,5), +(4,3), +(5,1), (5,2), (5,4), (5,5), +(6, 6), (7, 7), (8, 8), (9, 9), (10, 10), +(11, 11), (12, 12), (13, 13), (14, 14), (15, 15); \ No newline at end of file diff --git a/data-users.sql b/data-users.sql new file mode 100644 index 0000000..a9f88ea --- /dev/null +++ b/data-users.sql @@ -0,0 +1,23 @@ +--사용자(User) 도메인 +-- ========================= +INSERT INTO users (email, nickname, password) VALUES +('alice@test.com', 'Alice', 'pw_hash_1'), +('bob@test.com', 'Bob', 'pw_hash_2'), +('carol@test.com', 'Carol', 'pw_hash_3'), +('dan@test.com', 'Dan', 'pw_hash_4'), +('erin@test.com', 'Erin', 'pw_hash_5'), +('fiona@example.com', 'Fiona', 'pw1234'), +('george@example.com', 'George', 'pw1234'), +('harry@example.com', 'Harry', 'pw1234'), +('irene@example.com', 'Irene', 'pw1234'), +('jack@example.com', 'Jack', 'pw1234'), +('kate@example.com', 'Kate', 'pw1234'), +('leo@example.com', 'Leo', 'pw1234'), +('mia@example.com', 'Mia', 'pw1234'), +('nick@example.com', 'Nick', 'pw1234'), +('olivia@example.com', 'Olivia', 'pw1234'), +('peter@example.com', 'Peter', 'pw1234'), +('queen@example.com', 'Queen', 'pw1234'), +('ryan@example.com', 'Ryan', 'pw1234'), +('susan@example.com', 'Susan', 'pw1234'), +('tom@example.com', 'Tom', 'pw1234'); \ No newline at end of file From 876911d8e53ee06cd7c8e211e3d33a3182d2cd14 Mon Sep 17 00:00:00 2001 From: DoHanChoi Date: Fri, 24 Oct 2025 14:15:13 +0900 Subject: [PATCH 013/178] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=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 --- .../com/monew/monew_api/domain/user/User.java | 56 ++++++++++ .../user/controller/UserController.java | 48 +++++++++ .../monew_api/domain/user/dto/UserDto.java | 16 +++ .../domain/user/dto/UserLoginRequest.java | 23 ++++ .../domain/user/dto/UserRegisterRequest.java | 31 ++++++ .../domain/user/dto/UserUpdateRequest.java | 21 ++++ .../user/repository/UserRepository.java | 19 ++++ .../domain/user/service/UserService.java | 100 ++++++++++++++++++ 8 files changed, 314 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/domain/user/User.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserLoginRequest.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserRegisterRequest.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserUpdateRequest.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/domain/user/repository/UserRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/domain/user/service/UserService.java diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/User.java b/monew-api/src/main/java/com/monew/monew_api/domain/user/User.java new file mode 100644 index 0000000..84d62aa --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/domain/user/User.java @@ -0,0 +1,56 @@ +package com.monew.monew_api.domain.user; + +import com.monew.monew_api.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 255) + private String email; + + @Column(nullable = false, length = 100) + private String nickname; + + @Column(nullable = false, length = 100) + private String password; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder + public User(String email, String nickname, String password) { + this.email = email; + this.nickname = nickname; + this.password = password; + } + + public void updateNickname(String nickname) { + this.nickname = nickname; + } + + public void updatePassword(String password) { + this.password = password; + } + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } + + public boolean isDeleted() { + return this.deletedAt != null; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java b/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java new file mode 100644 index 0000000..91b5efe --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java @@ -0,0 +1,48 @@ +package com.monew.monew_api.domain.user.controller; + +import com.monew.monew_api.domain.user.dto.*; +import com.monew.monew_api.domain.user.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @PostMapping("/api/users") + public ResponseEntity signup(@Valid @RequestBody UserRegisterRequest request) { + UserDto response = userService.signup(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/api/users/login") + public ResponseEntity login(@Valid @RequestBody UserLoginRequest request) { + UserDto response = userService.login(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PatchMapping("/api/users/{userId}") + public ResponseEntity updateUser( + @PathVariable Long userId, + @Valid @RequestBody UserUpdateRequest request) { + UserDto response = userService.updateUser(userId, request); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/api/user/{userId}") + public ResponseEntity softDeleteUser(@PathVariable Long userId) { + userService.softDeleteUser(userId); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/api/user/{userId}/hard") + public ResponseEntity hardDeleteUser(@PathVariable Long userId) { + userService.hardDeleteUser(userId); + return ResponseEntity.noContent().build(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserDto.java b/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserDto.java new file mode 100644 index 0000000..25fbdd0 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserDto.java @@ -0,0 +1,16 @@ +package com.monew.monew_api.domain.user.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class UserDto { + + private Long id; + private String email; + private String nickname; + private LocalDateTime createdAt; +} diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserLoginRequest.java b/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserLoginRequest.java new file mode 100644 index 0000000..a4b08bc --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserLoginRequest.java @@ -0,0 +1,23 @@ +package com.monew.monew_api.domain.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UserLoginRequest { + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "유효한 이메일 형식이 아닙니다.") + private String email; + + @NotBlank(message = "비밀번호는 필수입니다.") + private String password; + + public UserLoginRequest(String email, String password) { + this.email = email; + this.password = password; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserRegisterRequest.java new file mode 100644 index 0000000..c7f6283 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserRegisterRequest.java @@ -0,0 +1,31 @@ +package com.monew.monew_api.domain.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UserRegisterRequest { + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "유효한 이메일 형식이 아닙니다.") + @Size(max = 255, message = "이메일은 255자를 초과할 수 없습니다.") + private String email; + + @NotBlank(message = "닉네임은 필수입니다.") + @Size(max = 100, message = "닉네임은 100자를 초과할 수 없습니다.") + private String nickname; + + @NotBlank(message = "비밀번호는 필수입니다.") + @Size(min = 8, max = 100, message = "비밀번호는 8자 이상 100자 이하여야 합니다.") + private String password; + + public UserRegisterRequest(String email, String nickname, String password) { + this.email = email; + this.nickname = nickname; + this.password = password; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserUpdateRequest.java b/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserUpdateRequest.java new file mode 100644 index 0000000..cde3a81 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserUpdateRequest.java @@ -0,0 +1,21 @@ +package com.monew.monew_api.domain.user.dto; + +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UserUpdateRequest { + + @Size(max = 100, message = "닉네임은 100자를 초과할 수 없습니다.") + private String nickname; + + public UserUpdateRequest(String nickname) { + this.nickname = nickname; + } + + public boolean hasNickname() { + return nickname != null && !nickname.isBlank(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/repository/UserRepository.java b/monew-api/src/main/java/com/monew/monew_api/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..61d76a9 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/domain/user/repository/UserRepository.java @@ -0,0 +1,19 @@ +package com.monew.monew_api.domain.user.repository; + +import com.monew.monew_api.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + boolean existsByEmail(String email); + + Optional findByEmail(String email); + + Optional findByIdAndDeletedAtIsNull(Long id); + + Optional findByEmailAndDeletedAtIsNull(String email); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/service/UserService.java b/monew-api/src/main/java/com/monew/monew_api/domain/user/service/UserService.java new file mode 100644 index 0000000..e26f822 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/domain/user/service/UserService.java @@ -0,0 +1,100 @@ +package com.monew.monew_api.domain.user.service; + +import com.monew.monew_api.common.exception.user.UserEmailDuplicateException; +import com.monew.monew_api.common.exception.user.UserNotFoundException; +import com.monew.monew_api.common.exception.user.UserUnauthorizedException; +import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.domain.user.dto.*; +import com.monew.monew_api.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public UserDto signup(UserRegisterRequest request) { + // 이메일 중복 체크 + if (userRepository.existsByEmail(request.getEmail())) { + throw new UserEmailDuplicateException(); + } + + // 비밀번호 암호화 + String encodedPassword = passwordEncoder.encode(request.getPassword()); + + // 사용자 생성 + User user = User.builder() + .email(request.getEmail()) + .nickname(request.getNickname()) + .password(encodedPassword) + .build(); + + User savedUser = userRepository.save(user); + + return UserDto.builder() + .id(savedUser.getId()) + .email(savedUser.getEmail()) + .nickname(savedUser.getNickname()) + .createdAt(savedUser.getCreatedAt()) + .build(); + } + + public UserDto login(UserLoginRequest request) { + // 이메일로 사용자 찾기 (논리삭제되지 않은 사용자만) + User user = userRepository.findByEmailAndDeletedAtIsNull(request.getEmail()) + .orElseThrow(UserUnauthorizedException::new); + + // 비밀번호 검증 + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + throw new UserUnauthorizedException(); + } + + return UserDto.builder() + .id(user.getId()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .createdAt(user.getCreatedAt()) + .build(); + } + + @Transactional + public UserDto updateUser(Long userId, UserUpdateRequest request) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(UserNotFoundException::new); + + // 닉네임 업데이트 + if (request.hasNickname()) { + user.updateNickname(request.getNickname()); + } + + return UserDto.builder() + .id(user.getId()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .createdAt(user.getCreatedAt()) + .build(); + } + + @Transactional + public void softDeleteUser(Long userId) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(UserNotFoundException::new); + + user.softDelete(); + } + + @Transactional + public void hardDeleteUser(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + userRepository.delete(user); + } +} From 6c5e4d54aaf9103b70ac059a08314729222f8f99 Mon Sep 17 00:00:00 2001 From: DoHanChoi Date: Fri, 24 Oct 2025 15:19:34 +0900 Subject: [PATCH 014/178] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A1=9C=EA=B9=85=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 --- .../user/controller/UserController.java | 12 ++++++ .../domain/user/service/UserService.java | 43 +++++++++++++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java b/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java index 91b5efe..3780824 100644 --- a/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java +++ b/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java @@ -4,10 +4,12 @@ import com.monew.monew_api.domain.user.service.UserService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +@Slf4j @RestController @RequiredArgsConstructor public class UserController { @@ -16,13 +18,17 @@ public class UserController { @PostMapping("/api/users") public ResponseEntity signup(@Valid @RequestBody UserRegisterRequest request) { + log.info("[API 요청] POST /api/users - 회원가입 요청, 이메일: {}", request.getEmail()); UserDto response = userService.signup(request); + log.info("[API 응답] POST /api/users - 회원가입 성공, 사용자 ID: {}", response.getId()); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @PostMapping("/api/users/login") public ResponseEntity login(@Valid @RequestBody UserLoginRequest request) { + log.info("[API 요청] POST /api/users/login - 로그인 요청, 이메일: {}", request.getEmail()); UserDto response = userService.login(request); + log.info("[API 응답] POST /api/users/login - 로그인 성공, 사용자 ID: {}", response.getId()); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -30,19 +36,25 @@ public ResponseEntity login(@Valid @RequestBody UserLoginRequest reques public ResponseEntity updateUser( @PathVariable Long userId, @Valid @RequestBody UserUpdateRequest request) { + log.info("[API 요청] PATCH /api/users/{} - 사용자 정보 수정 요청", userId); UserDto response = userService.updateUser(userId, request); + log.info("[API 응답] PATCH /api/users/{} - 사용자 정보 수정 성공", userId); return ResponseEntity.ok(response); } @DeleteMapping("/api/user/{userId}") public ResponseEntity softDeleteUser(@PathVariable Long userId) { + log.info("[API 요청] DELETE /api/user/{} - 사용자 삭제 요청", userId); userService.softDeleteUser(userId); + log.info("[API 응답] DELETE /api/user/{} - 사용자 삭제 성공", userId); return ResponseEntity.noContent().build(); } @DeleteMapping("/api/user/{userId}/hard") public ResponseEntity hardDeleteUser(@PathVariable Long userId) { + log.info("[API 요청] DELETE /api/user/{}/hard - 사용자 영구 삭제 요청", userId); userService.hardDeleteUser(userId); + log.info("[API 응답] DELETE /api/user/{}/hard - 사용자 영구 삭제 성공", userId); return ResponseEntity.noContent().build(); } } diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/service/UserService.java b/monew-api/src/main/java/com/monew/monew_api/domain/user/service/UserService.java index e26f822..da31661 100644 --- a/monew-api/src/main/java/com/monew/monew_api/domain/user/service/UserService.java +++ b/monew-api/src/main/java/com/monew/monew_api/domain/user/service/UserService.java @@ -7,10 +7,12 @@ import com.monew.monew_api.domain.user.dto.*; import com.monew.monew_api.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -21,8 +23,11 @@ public class UserService { @Transactional public UserDto signup(UserRegisterRequest request) { + log.info("[회원가입 시도] 이메일: {}, 닉네임: {}", request.getEmail(), request.getNickname()); + // 이메일 중복 체크 if (userRepository.existsByEmail(request.getEmail())) { + log.warn("[회원가입 실패] 이메일 중복: {}", request.getEmail()); throw new UserEmailDuplicateException(); } @@ -37,6 +42,7 @@ public UserDto signup(UserRegisterRequest request) { .build(); User savedUser = userRepository.save(user); + log.info("[회원가입 성공] 사용자 ID: {}, 이메일: {}", savedUser.getId(), savedUser.getEmail()); return UserDto.builder() .id(savedUser.getId()) @@ -47,15 +53,23 @@ public UserDto signup(UserRegisterRequest request) { } public UserDto login(UserLoginRequest request) { + log.info("[로그인 시도] 이메일: {}", request.getEmail()); + // 이메일로 사용자 찾기 (논리삭제되지 않은 사용자만) User user = userRepository.findByEmailAndDeletedAtIsNull(request.getEmail()) - .orElseThrow(UserUnauthorizedException::new); + .orElseThrow(() -> { + log.warn("[로그인 실패] 존재하지 않는 사용자: {}", request.getEmail()); + return new UserUnauthorizedException(); + }); // 비밀번호 검증 if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + log.warn("[로그인 실패] 비밀번호 불일치: {}", request.getEmail()); throw new UserUnauthorizedException(); } + log.info("[로그인 성공] 사용자 ID: {}, 이메일: {}", user.getId(), user.getEmail()); + return UserDto.builder() .id(user.getId()) .email(user.getEmail()) @@ -66,14 +80,23 @@ public UserDto login(UserLoginRequest request) { @Transactional public UserDto updateUser(Long userId, UserUpdateRequest request) { + log.info("[사용자 정보 수정 시도] 사용자 ID: {}", userId); + User user = userRepository.findByIdAndDeletedAtIsNull(userId) - .orElseThrow(UserNotFoundException::new); + .orElseThrow(() -> { + log.warn("[사용자 정보 수정 실패] 사용자를 찾을 수 없음: {}", userId); + return new UserNotFoundException(); + }); // 닉네임 업데이트 if (request.hasNickname()) { + log.debug("[닉네임 변경] 사용자 ID: {}, 변경 전: {}, 변경 후: {}", + userId, user.getNickname(), request.getNickname()); user.updateNickname(request.getNickname()); } + log.info("[사용자 정보 수정 성공] 사용자 ID: {}", userId); + return UserDto.builder() .id(user.getId()) .email(user.getEmail()) @@ -84,17 +107,29 @@ public UserDto updateUser(Long userId, UserUpdateRequest request) { @Transactional public void softDeleteUser(Long userId) { + log.info("[사용자 삭제 시도] 사용자 ID: {}", userId); + User user = userRepository.findByIdAndDeletedAtIsNull(userId) - .orElseThrow(UserNotFoundException::new); + .orElseThrow(() -> { + log.warn("[사용자 삭제 실패] 사용자를 찾을 수 없음: {}", userId); + return new UserNotFoundException(); + }); user.softDelete(); + log.info("[사용자 삭제 성공] 사용자 ID: {}, 이메일: {}", userId, user.getEmail()); } @Transactional public void hardDeleteUser(Long userId) { + log.info("[사용자 영구 삭제 시도] 사용자 ID: {}", userId); + User user = userRepository.findById(userId) - .orElseThrow(UserNotFoundException::new); + .orElseThrow(() -> { + log.warn("[사용자 영구 삭제 실패] 사용자를 찾을 수 없음: {}", userId); + return new UserNotFoundException(); + }); userRepository.delete(user); + log.warn("[사용자 영구 삭제 완료] 사용자 ID: {}, 이메일: {}", userId, user.getEmail()); } } From 36269a2c27009f0181487571492acd6a7f0a2bfb Mon Sep 17 00:00:00 2001 From: DoHanChoi Date: Fri, 24 Oct 2025 16:33:48 +0900 Subject: [PATCH 015/178] =?UTF-8?q?chore:=20=EA=B0=9C=EB=B0=9C=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20DB=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data-notifications.sql | 23 --------------- data-users.sql | 23 --------------- .../common/config/SecurityConfig.java | 28 ++++++++++++++++++- .../domain/user/dto/UserRegisterRequest.java | 2 +- .../src/main/resources/application-dev.yml | 14 ++++++++++ .../resources/db/data/data-article_views.sql | 0 .../main/resources/db/data/data-articles.sql | 6 ++-- .../main/resources/db/data/data-comments.sql | 0 .../main/resources/db/data/data-interests.sql | 12 ++++---- .../resources/db/data/data-notifications.sql | 22 +++++++++++++++ .../resources/db/data/data-subscribes.sql | 4 +-- .../src/main/resources/db/data/data-users.sql | 24 ++++++++++++++++ .../src/main/resources/db/schema.sql | 0 13 files changed, 99 insertions(+), 59 deletions(-) delete mode 100644 data-notifications.sql delete mode 100644 data-users.sql rename data-article_vews.sql => monew-api/src/main/resources/db/data/data-article_views.sql (100%) rename data-articles.sql => monew-api/src/main/resources/db/data/data-articles.sql (94%) rename data-comments.sql => monew-api/src/main/resources/db/data/data-comments.sql (100%) rename data-Interests.sql => monew-api/src/main/resources/db/data/data-interests.sql (72%) create mode 100644 monew-api/src/main/resources/db/data/data-notifications.sql rename data-subscribes.sql => monew-api/src/main/resources/db/data/data-subscribes.sql (68%) create mode 100644 monew-api/src/main/resources/db/data/data-users.sql rename schema.sql => monew-api/src/main/resources/db/schema.sql (100%) diff --git a/data-notifications.sql b/data-notifications.sql deleted file mode 100644 index a6bd70f..0000000 --- a/data-notifications.sql +++ /dev/null @@ -1,23 +0,0 @@ ---알림(Notification) 도메인 --- ========================= -INSERT INTO notifications (user_id, content, resource_type, resource_id, confirmed) VALUES -(1, '구독 관심사에 새 기사 등록', 'article', 1, FALSE), -(1, '내 댓글에 새 좋아요', 'comment', 1, TRUE), -(2, '팔로우 관심사에 새 기사', 'article', 2, FALSE), -(3, '새 댓글 알림', 'comment', 3, TRUE), -(5, '관심 기사 업데이트', 'article', 5, TRUE), -(1,'새 댓글이 달렸습니다.','comment',10, FALSE), -(4,'환경 소식 알림','ARTICLE',4,TRUE), -(5,'스타트업 소식','ARTICLE',5,FALSE), -(6,'금융 뉴스 도착','ARTICLE',6,FALSE), -(7,'정치 관련 소식','ARTICLE',7,TRUE), -(8,'국제 이슈 속보','ARTICLE',8,FALSE), -(9,'스포츠 뉴스 업데이트','ARTICLE',9,TRUE), -(10,'문화 기사 알림','ARTICLE',10,FALSE), -(11,'기술 관련 업데이트','ARTICLE',11,TRUE), -(12,'자동차 산업 소식','ARTICLE',12,FALSE), -(13,'여행 기사 등록','ARTICLE',13,TRUE), -(14,'건강 정보 도착','ARTICLE',14,FALSE), -(15,'교육 소식 알림','ARTICLE',15,TRUE), - -; \ No newline at end of file diff --git a/data-users.sql b/data-users.sql deleted file mode 100644 index a9f88ea..0000000 --- a/data-users.sql +++ /dev/null @@ -1,23 +0,0 @@ ---사용자(User) 도메인 --- ========================= -INSERT INTO users (email, nickname, password) VALUES -('alice@test.com', 'Alice', 'pw_hash_1'), -('bob@test.com', 'Bob', 'pw_hash_2'), -('carol@test.com', 'Carol', 'pw_hash_3'), -('dan@test.com', 'Dan', 'pw_hash_4'), -('erin@test.com', 'Erin', 'pw_hash_5'), -('fiona@example.com', 'Fiona', 'pw1234'), -('george@example.com', 'George', 'pw1234'), -('harry@example.com', 'Harry', 'pw1234'), -('irene@example.com', 'Irene', 'pw1234'), -('jack@example.com', 'Jack', 'pw1234'), -('kate@example.com', 'Kate', 'pw1234'), -('leo@example.com', 'Leo', 'pw1234'), -('mia@example.com', 'Mia', 'pw1234'), -('nick@example.com', 'Nick', 'pw1234'), -('olivia@example.com', 'Olivia', 'pw1234'), -('peter@example.com', 'Peter', 'pw1234'), -('queen@example.com', 'Queen', 'pw1234'), -('ryan@example.com', 'Ryan', 'pw1234'), -('susan@example.com', 'Susan', 'pw1234'), -('tom@example.com', 'Tom', 'pw1234'); \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/SecurityConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/SecurityConfig.java index 193c00f..01c4a3f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/config/SecurityConfig.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/config/SecurityConfig.java @@ -2,14 +2,40 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration public class SecurityConfig { + /** + * 개발 환경용 PasswordEncoder - 평문 비밀번호 사용 + * 테스트 및 개발 편의성을 위해 비밀번호를 암호화하지 않음 + */ @Bean - public PasswordEncoder passwordEncoder() { + @Profile("dev") + public PasswordEncoder devPasswordEncoder() { + return new PasswordEncoder() { + @Override + public String encode(CharSequence rawPassword) { + return rawPassword.toString(); + } + + @Override + public boolean matches(CharSequence rawPassword, String encodedPassword) { + return rawPassword.toString().equals(encodedPassword); + } + }; + } + + /** + * 프로덕션 환경용 PasswordEncoder - BCrypt 암호화 사용 + * 실제 배포 환경에서 안전한 비밀번호 저장 + */ + @Bean + @Profile("prod") + public PasswordEncoder prodPasswordEncoder() { return new BCryptPasswordEncoder(); } } diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserRegisterRequest.java index c7f6283..d6e9532 100644 --- a/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserRegisterRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserRegisterRequest.java @@ -20,7 +20,7 @@ public class UserRegisterRequest { private String nickname; @NotBlank(message = "비밀번호는 필수입니다.") - @Size(min = 8, max = 100, message = "비밀번호는 8자 이상 100자 이하여야 합니다.") + @Size(min = 1, max = 100, message = "비밀번호는 100자 이하여야 합니다.") private String password; public UserRegisterRequest(String email, String nickname, String password) { diff --git a/monew-api/src/main/resources/application-dev.yml b/monew-api/src/main/resources/application-dev.yml index 46404b4..aad5fc5 100644 --- a/monew-api/src/main/resources/application-dev.yml +++ b/monew-api/src/main/resources/application-dev.yml @@ -8,6 +8,20 @@ spring: password: ${DB_PASSWORD} driver-class-name: org.postgresql.Driver + sql: + init: + mode: always # 수동으로 제어하려면 never, 자동 실행하려면 always + schema-locations: classpath:db/schema.sql + data-locations: + - classpath:db/data/data-users.sql + - classpath:db/data/data-interests.sql + - classpath:db/data/data-articles.sql + - classpath:db/data/data-article_views.sql + - classpath:db/data/data-comments.sql + - classpath:db/data/data-notifications.sql + - classpath:db/data/data-subscribes.sql + continue-on-error: false + jpa: hibernate: ddl-auto: validate diff --git a/data-article_vews.sql b/monew-api/src/main/resources/db/data/data-article_views.sql similarity index 100% rename from data-article_vews.sql rename to monew-api/src/main/resources/db/data/data-article_views.sql diff --git a/data-articles.sql b/monew-api/src/main/resources/db/data/data-articles.sql similarity index 94% rename from data-articles.sql rename to monew-api/src/main/resources/db/data/data-articles.sql index bb99516..8c1fdde 100644 --- a/data-articles.sql +++ b/monew-api/src/main/resources/db/data/data-articles.sql @@ -28,9 +28,9 @@ INSERT INTO interests_articles (interest_id, article_id) VALUES (2,2), (2,14), (2,10), (2,1), (2,15), (3,3), (4,4), (4,5), - (5,1), (5,2), (5,3), (5,4), (5,5), (5,5), (5,6), (5,7), (5,8), (5,9), (5,10), (5,11), (5,12), (5,13), (5,14), (5,15), - (6,1), (6,11), (6,2), (6,3), (6,4), (6,5), (6,5), (6,6), (6,7), (6,8), + (5,1), (5,2), (5,3), (5,4), (5,5), (5,6), (5,7), (5,8), (5,9), (5,10), (5,11), (5,12), (5,13), (5,14), (5,15), + (6,1), (6,11), (6,2), (6,3), (6,4), (6,5), (6,6), (6,7), (6,8), (7,7), (7,10), (7,1), (7,15), (8,1), (8,11), (8,2), (8,3), (8,4), (8,5), (8,14), (8,6), (8,7), (8,8), - (9,11),(9,1), (9,14), (9,2), (9,3), (9,4), (9,5), (9,5), (9,6), (9,7), (9,8), (9,9), + (9,11),(9,1), (9,14), (9,2), (9,3), (9,4), (9,5), (9,6), (9,7), (9,8), (9,9), (10,1); \ No newline at end of file diff --git a/data-comments.sql b/monew-api/src/main/resources/db/data/data-comments.sql similarity index 100% rename from data-comments.sql rename to monew-api/src/main/resources/db/data/data-comments.sql diff --git a/data-Interests.sql b/monew-api/src/main/resources/db/data/data-interests.sql similarity index 72% rename from data-Interests.sql rename to monew-api/src/main/resources/db/data/data-interests.sql index 2d009f8..8b4c738 100644 --- a/data-Interests.sql +++ b/monew-api/src/main/resources/db/data/data-interests.sql @@ -8,19 +8,19 @@ INSERT INTO keywords (keyword) VALUES ('AI'), ('머신러닝'), ('가상화폐'), ('비트코인'), ('투자'), ('거시경제'), ('빅데이터'), ('핀테크'), ('물가'), ('인공지능'), ('아파트'), ('탄소중립'), -('투자'),('금리'), ('대출'), ('기술혁신'), +('금리'), ('대출'), ('기술혁신'), ('자동차산업'), ('헬스케어'), ('여행지'), ('교육정책'), ('패션트렌드'), ('음악시장'), ('영화산업'), ('환경정책'), ('금융시장'), ('스타트업'), ('부동산세제'); INSERT INTO interests_keywords (interest_id, keyword_id) VALUES -(1,1), (1,2), (1,7), (1,15), +(1,1), (1,2), (1,7), (1,14), (2,1), (2,2), (2,3), (2,4), (2,8), -(3,1), (3,2), (3,3), (3,4), (3,5), (3,6), (3,10), (3,13), (3,15), (3,16), (3,20), +(3,1), (3,2), (3,3), (3,4), (3,5), (3,6), (3,10), (3,14), (3,15), (3,19), (4,5), (4,6), -(5,1), (5,2), (5,3), (5,4), (5,8), (5,10), (5,16), (5,20), +(5,1), (5,2), (5,3), (5,4), (5,8), (5,10), (5,15), (5,19), (6,6), (6,7), (6,8), (6,9), -(7,1), (7,2), (7,3), (7,4), (7,8), (7,10), (7,16), (7,20), -(8,1), (8,2), (8,3), (8,6), (8,9), (8,11), (8,17), (8,21), (8,7), +(7,1), (7,2), (7,3), (7,4), (7,8), (7,10), (7,15), (7,19), +(8,1), (8,2), (8,3), (8,6), (8,9), (8,11), (8,16), (8,20), (8,7), (9,1), (9,2), (9,3), (9,4), (9,8), (9,10), (10,1), (10,2), (10,3); diff --git a/monew-api/src/main/resources/db/data/data-notifications.sql b/monew-api/src/main/resources/db/data/data-notifications.sql new file mode 100644 index 0000000..add140b --- /dev/null +++ b/monew-api/src/main/resources/db/data/data-notifications.sql @@ -0,0 +1,22 @@ +--알림(Notification) 도메인 +-- ========================= +INSERT INTO notifications (user_id, content, resource_type, resource_id, confirmed) VALUES +(1, '구독 관심사에 새 기사 등록', 'article', 1, FALSE), +(1, '내 댓글에 새 좋아요', 'comment', 1, TRUE), +(2, '팔로우 관심사에 새 기사', 'article', 2, FALSE), +(3, '새 댓글 알림', 'comment', 3, TRUE), +(5, '관심 기사 업데이트', 'article', 5, TRUE), +(1,'새 댓글이 달렸습니다.','comment',10, FALSE), +(4,'환경 소식 알림','article',4,TRUE), +(5,'스타트업 소식','article',5,FALSE), +(6,'금융 뉴스 도착','article',6,FALSE), +(7,'정치 관련 소식','article',7,TRUE), +(8,'국제 이슈 속보','article',8,FALSE), +(9,'스포츠 뉴스 업데이트','article',9,TRUE), +(10,'문화 기사 알림','article',10,FALSE), +(11,'기술 관련 업데이트','article',11,TRUE), +(12,'자동차 산업 소식','article',12,FALSE), +(13,'여행 기사 등록','article',13,TRUE), +(14,'건강 정보 도착','article',14,FALSE), +(15,'교육 소식 알림','article',15,TRUE) +; \ No newline at end of file diff --git a/data-subscribes.sql b/monew-api/src/main/resources/db/data/data-subscribes.sql similarity index 68% rename from data-subscribes.sql rename to monew-api/src/main/resources/db/data/data-subscribes.sql index 62f8675..a7115c6 100644 --- a/data-subscribes.sql +++ b/monew-api/src/main/resources/db/data/data-subscribes.sql @@ -6,5 +6,5 @@ INSERT INTO subscribes (user_id, interest_id) VALUES (3,1), (3,5), (4,3), (5,1), (5,2), (5,4), (5,5), -(6, 6), (7, 7), (8, 8), (9, 9), (10, 10), -(11, 11), (12, 12), (13, 13), (14, 14), (15, 15); \ No newline at end of file +(6, 6), (7, 7), (8, 8), (9, 9), (10, 10) +; \ No newline at end of file diff --git a/monew-api/src/main/resources/db/data/data-users.sql b/monew-api/src/main/resources/db/data/data-users.sql new file mode 100644 index 0000000..290dd10 --- /dev/null +++ b/monew-api/src/main/resources/db/data/data-users.sql @@ -0,0 +1,24 @@ +--사용자(User) 도메인 +-- ========================= +-- 비밀번호: Pass1! (영문+숫자+특수문자 포함, 6자) +INSERT INTO users (email, nickname, password) VALUES +('alice@test.com', 'Alice', 'Pass1!'), +('bob@test.com', 'Bob', 'Pass1!'), +('carol@test.com', 'Carol', 'Pass1!'), +('dan@test.com', 'Dan', 'Pass1!'), +('erin@test.com', 'Erin', 'Pass1!'), +('fiona@example.com', 'Fiona', 'Pass1!'), +('george@example.com', 'George', 'Pass1!'), +('harry@example.com', 'Harry', 'Pass1!'), +('irene@example.com', 'Irene', 'Pass1!'), +('jack@example.com', 'Jack', 'Pass1!'), +('kate@example.com', 'Kate', 'Pass1!'), +('leo@example.com', 'Leo', 'Pass1!'), +('mia@example.com', 'Mia', 'Pass1!'), +('nick@example.com', 'Nick', 'Pass1!'), +('olivia@example.com', 'Olivia', 'Pass1!'), +('peter@example.com', 'Peter', 'Pass1!'), +('queen@example.com', 'Queen', 'Pass1!'), +('ryan@example.com', 'Ryan', 'Pass1!'), +('susan@example.com', 'Susan', 'Pass1!'), +('tom@example.com', 'Tom', 'Pass1!'); \ No newline at end of file diff --git a/schema.sql b/monew-api/src/main/resources/db/schema.sql similarity index 100% rename from schema.sql rename to monew-api/src/main/resources/db/schema.sql From edc519064a085939cef031fd811ab7fd8f1832ed Mon Sep 17 00:00:00 2001 From: DoHanChoi Date: Fri, 24 Oct 2025 16:38:25 +0900 Subject: [PATCH 016/178] =?UTF-8?q?refactor:=20User=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=A4=91=EB=B3=B5=20id=20=ED=95=84=EB=93=9C=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 --- .../src/main/java/com/monew/monew_api/domain/user/User.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/User.java b/monew-api/src/main/java/com/monew/monew_api/domain/user/User.java index 84d62aa..945a156 100644 --- a/monew-api/src/main/java/com/monew/monew_api/domain/user/User.java +++ b/monew-api/src/main/java/com/monew/monew_api/domain/user/User.java @@ -15,10 +15,6 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User extends BaseTimeEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(nullable = false, unique = true, length = 255) private String email; From a3b8c54a32be72725a024993a4041ea0920db16b Mon Sep 17 00:00:00 2001 From: DoHanChoi Date: Mon, 27 Oct 2025 13:12:27 +0900 Subject: [PATCH 017/178] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20AP?= =?UTF-8?q?I=20=EC=9D=91=EB=8B=B5=20=EC=83=81=ED=83=9C=20OK=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 --- .../monew/monew_api/domain/user/controller/UserController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java b/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java index 3780824..9d08374 100644 --- a/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java +++ b/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java @@ -29,7 +29,7 @@ public ResponseEntity login(@Valid @RequestBody UserLoginRequest reques log.info("[API 요청] POST /api/users/login - 로그인 요청, 이메일: {}", request.getEmail()); UserDto response = userService.login(request); log.info("[API 응답] POST /api/users/login - 로그인 성공, 사용자 ID: {}", response.getId()); - return ResponseEntity.status(HttpStatus.CREATED).body(response); + return ResponseEntity.ok(response); } @PatchMapping("/api/users/{userId}") From b8c21a15f7727ce740bb2f08b018e202070eda4b Mon Sep 17 00:00:00 2001 From: truuuely Date: Tue, 28 Oct 2025 09:24:02 +0900 Subject: [PATCH 018/178] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=EB=A6=AC?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(article=20->=20interest)=20=EB=B0=8F=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EC=B2=98=EB=A6=AC=EC=9A=A9=20=EB=8D=94=EB=AF=B8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/db/data/data-notifications.sql | 66 +++++++++++++------ 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/monew-api/src/main/resources/db/data/data-notifications.sql b/monew-api/src/main/resources/db/data/data-notifications.sql index add140b..9b02111 100644 --- a/monew-api/src/main/resources/db/data/data-notifications.sql +++ b/monew-api/src/main/resources/db/data/data-notifications.sql @@ -1,22 +1,48 @@ --알림(Notification) 도메인 -- ========================= -INSERT INTO notifications (user_id, content, resource_type, resource_id, confirmed) VALUES -(1, '구독 관심사에 새 기사 등록', 'article', 1, FALSE), -(1, '내 댓글에 새 좋아요', 'comment', 1, TRUE), -(2, '팔로우 관심사에 새 기사', 'article', 2, FALSE), -(3, '새 댓글 알림', 'comment', 3, TRUE), -(5, '관심 기사 업데이트', 'article', 5, TRUE), -(1,'새 댓글이 달렸습니다.','comment',10, FALSE), -(4,'환경 소식 알림','article',4,TRUE), -(5,'스타트업 소식','article',5,FALSE), -(6,'금융 뉴스 도착','article',6,FALSE), -(7,'정치 관련 소식','article',7,TRUE), -(8,'국제 이슈 속보','article',8,FALSE), -(9,'스포츠 뉴스 업데이트','article',9,TRUE), -(10,'문화 기사 알림','article',10,FALSE), -(11,'기술 관련 업데이트','article',11,TRUE), -(12,'자동차 산업 소식','article',12,FALSE), -(13,'여행 기사 등록','article',13,TRUE), -(14,'건강 정보 도착','article',14,FALSE), -(15,'교육 소식 알림','article',15,TRUE) -; \ No newline at end of file +INSERT INTO notifications (user_id, content, resource_type, resource_id, confirmed) +VALUES (1, '구독 관심사에 새 기사 등록', 'interest', 1, FALSE), + (1, '내 댓글에 새 좋아요', 'comment', 1, TRUE), + (2, '팔로우 관심사에 새 기사', 'interest', 2, FALSE), + (3, '새 댓글 알림', 'comment', 3, TRUE), + (5, '관심 기사 업데이트', 'interest', 5, TRUE), + (1, '새 댓글이 달렸습니다.', 'comment', 10, FALSE), + (4, '환경 소식 알림', 'interest', 4, TRUE), + (5, '스타트업 소식', 'interest', 5, FALSE), + (6, '금융 뉴스 도착', 'interest', 6, FALSE), + (7, '정치 관련 소식', 'interest', 7, TRUE), + (8, '국제 이슈 속보', 'interest', 8, FALSE), + (9, '스포츠 뉴스 업데이트', 'interest', 9, TRUE), + (10, '문화 기사 알림', 'interest', 10, FALSE), + (11, '기술 관련 업데이트', 'interest', 11, TRUE), + (12, '자동차 산업 소식', 'interest', 12, FALSE), + (13, '여행 기사 등록', 'interest', 13, TRUE), + (14, '건강 정보 도착', 'interest', 14, FALSE), + (15, '교육 소식 알림', 'interest', 15, TRUE); + +INSERT INTO notifications +(user_id, content, resource_type, resource_id, confirmed, created_at, updated_at) +VALUES + -- 페이징 테스트용. user_id = 1 : 미확인 알림 16개 + (1, 'user 1 - 테스트 알림 1 (미확인)', 'interest', 1, FALSE, '2025-10-28 10:00:00', '2025-10-28 10:00:00'), + (1, 'user 1 - 테스트 알림 2 (미확인)', 'comment', 1, FALSE, '2025-10-28 09:00:00', '2025-10-28 09:00:00'), + (1, 'user 1 - 테스트 알림 3 (미확인)', 'interest', 2, FALSE, '2025-10-28 08:00:00', '2025-10-28 08:00:00'), + (1, 'user 1 - 테스트 알림 4 (미확인)', 'comment', 2, FALSE, '2025-10-28 07:00:00', '2025-10-28 07:00:00'), + (1, 'user 1 - 테스트 알림 5 (미확인)', 'interest', 3, FALSE, '2025-10-28 06:00:00', '2025-10-28 06:00:00'), + (1, 'user 1 - 테스트 알림 6 (미확인)', 'interest', 1, FALSE, '2025-10-28 05:00:00', '2025-10-28 05:00:00'), + (1, 'user 1 - 테스트 알림 7 (미확인)', 'comment', 3, FALSE, '2025-10-28 04:00:00', '2025-10-28 04:00:00'), + (1, 'user 1 - 테스트 알림 8 (미확인)', 'interest', 4, FALSE, '2025-10-28 03:00:00', '2025-10-28 03:00:00'), + (1, 'user 1 - 테스트 알림 9 (미확인)', 'comment', 4, FALSE, '2025-10-28 02:00:00', '2025-10-28 02:00:00'), + (1, 'user 1 - 테스트 알림 10 (미확인)', 'interest', 5, FALSE, '2025-10-28 01:00:00', '2025-10-28 01:00:00'), + (1, 'user 1 - 테스트 알림 11 (미확인)', 'interest', 1, FALSE, '2025-10-27 23:00:00', '2025-10-27 23:00:00'), + (1, 'user 1 - 테스트 알림 12 (미확인)', 'comment', 5, FALSE, '2025-10-27 22:00:00', '2025-10-27 22:00:00'), + (1, 'user 1 - 테스트 알림 13 (미확인)', 'interest', 2, FALSE, '2025-10-27 21:00:00', '2025-10-27 21:00:00'), + (1, 'user 1 - 테스트 알림 14 (미확인)', 'comment', 6, FALSE, '2025-10-27 20:00:00', '2025-10-27 20:00:00'), + (1, 'user 1 - 테스트 알림 15 (미확인)', 'interest', 6, FALSE, '2025-10-27 19:00:00', '2025-10-27 19:00:00'), + (1, 'user 1 - 테스트 알림 16 (미확인)', 'interest', 7, FALSE, '2025-10-27 18:00:00', '2025-10-27 18:00:00'), + + -- user_id = 1 : 확인된 알람 4개 + (1, 'user 1 - 테스트 알림 17 (확인됨)', 'comment', 7, TRUE, '2025-10-27 17:00:00', '2025-10-27 18:00:00'), + (1, 'user 1 - 테스트 알림 18 (확인됨)', 'interest', 8, TRUE, '2025-10-27 16:00:00', '2025-10-27 17:00:00'), + (1, 'user 1 - 테스트 알림 19 (확인됨)', 'comment', 8, TRUE, '2025-10-27 15:00:00', '2025-10-27 15:30:00'), + (1, 'user 1 - 테스트 알림 20 (확인됨)', 'interest', 9, TRUE, '2025-10-27 14:00:00', '2025-10-27 14:20:00'); \ No newline at end of file From 7f05f9d9d464e822ca0281b3153826cbb20ad680 Mon Sep 17 00:00:00 2001 From: truuuely Date: Tue, 28 Oct 2025 09:25:56 +0900 Subject: [PATCH 019/178] =?UTF-8?q?fix:=20=EB=A7=B5=ED=95=91=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EB=AA=85=20=EB=B3=80=EA=B2=BD=20(interests?= =?UTF-8?q?=5Farticles=20->=20interest=5Farticles,=20interests=5Fkeywords?= =?UTF-8?q?=20->=20interest=5Fkeywords)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/resources/db/data/data-articles.sql | 2 +- .../main/resources/db/data/data-interests.sql | 2 +- monew-api/src/main/resources/db/schema.sql | 28 +++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/monew-api/src/main/resources/db/data/data-articles.sql b/monew-api/src/main/resources/db/data/data-articles.sql index 8c1fdde..d3f0b60 100644 --- a/monew-api/src/main/resources/db/data/data-articles.sql +++ b/monew-api/src/main/resources/db/data/data-articles.sql @@ -23,7 +23,7 @@ INSERT INTO articles (source, source_url, title, publish_date, summary, comment_ ('Naver','https://n.news.naver.com/mnews/article/421/0008526173','서버실 갇힌 AI는 끝, 산업 현장 뛰어든 피지컬 AI', NOW() - INTERVAL '18 days','제조·물류 등 현장에서 실물 작업하는 AI 본격화',4,210); -- ========================= -INSERT INTO interests_articles (interest_id, article_id) VALUES +INSERT INTO interest_articles (interest_id, article_id) VALUES (1,1), (1,4), (1,5), (2,2), (2,14), (2,10), (2,1), (2,15), (3,3), diff --git a/monew-api/src/main/resources/db/data/data-interests.sql b/monew-api/src/main/resources/db/data/data-interests.sql index 8b4c738..789e8e7 100644 --- a/monew-api/src/main/resources/db/data/data-interests.sql +++ b/monew-api/src/main/resources/db/data/data-interests.sql @@ -13,7 +13,7 @@ INSERT INTO keywords (keyword) VALUES ('패션트렌드'), ('음악시장'), ('영화산업'), ('환경정책'), ('금융시장'), ('스타트업'), ('부동산세제'); -INSERT INTO interests_keywords (interest_id, keyword_id) VALUES +INSERT INTO interest_keywords (interest_id, keyword_id) VALUES (1,1), (1,2), (1,7), (1,14), (2,1), (2,2), (2,3), (2,4), (2,8), (3,1), (3,2), (3,3), (3,4), (3,5), (3,6), (3,10), (3,14), (3,15), (3,19), diff --git a/monew-api/src/main/resources/db/schema.sql b/monew-api/src/main/resources/db/schema.sql index ed0adc4..e83ce52 100644 --- a/monew-api/src/main/resources/db/schema.sql +++ b/monew-api/src/main/resources/db/schema.sql @@ -4,8 +4,8 @@ DROP TABLE IF EXISTS article_views CASCADE; DROP TABLE IF EXISTS comments CASCADE; DROP TABLE IF EXISTS notifications CASCADE; DROP TABLE IF EXISTS subscribes CASCADE; -DROP TABLE IF EXISTS interests_keywords CASCADE; -DROP TABLE IF EXISTS interests_articles CASCADE; +DROP TABLE IF EXISTS interest_keywords CASCADE; +DROP TABLE IF EXISTS interest_articles CASCADE; DROP TABLE IF EXISTS keywords CASCADE; DROP TABLE IF EXISTS articles CASCADE; DROP TABLE IF EXISTS interests CASCADE; @@ -85,40 +85,40 @@ CREATE TABLE keywords -- ====================================================== -- Interests <-> Keywords (M:N) -- ====================================================== -CREATE TABLE interests_keywords +CREATE TABLE interest_keywords ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, interest_id BIGINT NOT NULL, keyword_id BIGINT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT uq_interests_keywords UNIQUE (interest_id, keyword_id), - CONSTRAINT fk_interests_keywords_interest + CONSTRAINT uq_interest_keywords UNIQUE (interest_id, keyword_id), + CONSTRAINT fk_interest_keywords_interest FOREIGN KEY (interest_id) REFERENCES interests (id) ON DELETE CASCADE, - CONSTRAINT fk_interests_keywords_keyword + CONSTRAINT fk_interest_keywords_keyword FOREIGN KEY (keyword_id) REFERENCES keywords (id) ON DELETE CASCADE ); -CREATE INDEX ix_interests_keywords_interest ON interests_keywords (interest_id); -CREATE INDEX ix_interests_keywords_keyword ON interests_keywords (keyword_id); +CREATE INDEX ix_interest_keywords_interest ON interest_keywords (interest_id); +CREATE INDEX ix_interest_keywords_keyword ON interest_keywords (keyword_id); -- ====================================================== -- Interests <-> Articles (M:N) -- ====================================================== -CREATE TABLE interests_articles +CREATE TABLE interest_articles ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, interest_id BIGINT NOT NULL, article_id BIGINT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT uq_interests_articles UNIQUE (interest_id, article_id), - CONSTRAINT fk_interests_articles_interest + CONSTRAINT uq_interest_articles UNIQUE (interest_id, article_id), + CONSTRAINT fk_interest_articles_interest FOREIGN KEY (interest_id) REFERENCES interests (id) ON DELETE CASCADE, - CONSTRAINT fk_interests_articles_article + CONSTRAINT fk_interest_articles_article FOREIGN KEY (article_id) REFERENCES articles (id) ON DELETE CASCADE ); -CREATE INDEX ix_interests_articles_interest ON interests_articles (interest_id); -CREATE INDEX ix_interests_articles_article ON interests_articles (article_id); +CREATE INDEX ix_interest_articles_interest ON interest_articles (interest_id); +CREATE INDEX ix_interest_articles_article ON interest_articles (article_id); -- ====================================================== -- Subscribes (user follows interest) From 7f09bffef55ec52c7111d2edac3b4617201ee2a8 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 24 Oct 2025 15:47:43 +0900 Subject: [PATCH 020/178] =?UTF-8?q?feat:=20Article,=20ArticleView=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_api/article/entity/Article.java | 46 +++++++++++++++++++ .../monew_api/article/entity/ArticleView.java | 31 +++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java new file mode 100644 index 0000000..2e836a3 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java @@ -0,0 +1,46 @@ +package com.monew.monew_api.article.entity; + +import com.monew.monew_api.common.entity.BaseIdEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * 뉴스 기사 테이블 + */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "articles") +public class Article extends BaseIdEntity { + + @Column(nullable = false, length = 20) + private String source; + + @Column(name = "source_url", nullable = false, length = 500, unique = true) + private String sourceUrl; + + @Column(nullable = false, length = 200) + private String title; + + @Column(name = "publish_date", nullable = false) + private LocalDateTime publishDate; + + @Column(nullable = false, length = 200) + private String summary; + + @Column(name = "comment_count", nullable = false) + private int commentCount = 0; + + @Column(name = "view_count", nullable = false) + private int viewCount = 0; + + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java new file mode 100644 index 0000000..b963aa3 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java @@ -0,0 +1,31 @@ +package com.monew.monew_api.article.entity; + +import com.monew.monew_api.common.entity.BaseCreatedEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * 뉴스 기사 조회 테이블 + */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table( + name = "article_views", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "article_id"}), + indexes = { + @Index(name = "ix_article_views_user", columnList = "user_id"), + @Index(name = "ix_article_views_article", columnList = "article_id") + } +) +public class ArticleView extends BaseCreatedEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "article_id", nullable = false) + private Long articleId; +} From 1f31f533d7cbd4e6c6145a144093ab210048aa6c Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 24 Oct 2025 17:04:32 +0900 Subject: [PATCH 021/178] =?UTF-8?q?feat:=20ArticleDto,=20ArticleViewDto,?= =?UTF-8?q?=20CursorPageResponseArticleDto=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_api/article/dto/ArticleDto.java | 29 +++++++++++++++++ .../monew_api/article/dto/ArticleViewDto.java | 31 +++++++++++++++++++ .../dto/CursorPageResponseArticleDto.java | 26 ++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleViewDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/dto/CursorPageResponseArticleDto.java diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleDto.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleDto.java new file mode 100644 index 0000000..adce2d6 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleDto.java @@ -0,0 +1,29 @@ +package com.monew.monew_api.article.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 단일 기사 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ArticleDto { + + private Long id; // 기사 ID + private String source; // 출처 + private String sourceUrl; // 원본 URL + private String title; // 제목 + private LocalDateTime publishDate; // 발행일 + private String summary; // 요약 + private int commentCount; // 댓글 수 + private int viewCount; // 조회 수 + private boolean viewedByMe; // 내가 조회했는지 여부 +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleViewDto.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleViewDto.java new file mode 100644 index 0000000..372551f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleViewDto.java @@ -0,0 +1,31 @@ +package com.monew.monew_api.article.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 기사 조회 기록 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ArticleViewDto { + + private Long id; // 조회 기록 ID + private UUID viewedBy; // 조회한 사용자 ID + private LocalDateTime createdAt; // 조회 시각 + private UUID articleId; // 기사 ID + private String source; // 기사 출처 + private String sourceUrl; // 기사 원본 URL + private String articleTitle; // 기사 제목 + private LocalDateTime articlePublishedDate; // 기사 발행일 + private String articleSummary; // 기사 요약 + private int articleCommentCount; // 댓글 수 + private int articleViewCount; // 조회 수 +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/CursorPageResponseArticleDto.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/CursorPageResponseArticleDto.java new file mode 100644 index 0000000..1471503 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/CursorPageResponseArticleDto.java @@ -0,0 +1,26 @@ +package com.monew.monew_api.article.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 커서 기반 페이지 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CursorPageResponseArticleDto { + + private List content; // 페이지 데이터 + private String nextCursor; // 다음 커서 값 + private LocalDateTime nextAfter; // 커서 기준 다음 시각 + private int size; // 요청한 페이지 크기 + private long totalElements; // 전체 데이터 수 + private boolean hasNext; // 다음 페이지 여부 +} \ No newline at end of file From 2361f3cb97e2bd83db6564e0e9ac3a26e187de81 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 24 Oct 2025 17:05:48 +0900 Subject: [PATCH 022/178] =?UTF-8?q?feat:=20=EA=B3=84=EC=B8=B5=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80=20(=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/controller/ArticleController.java | 13 +++++++++++++ .../article/repository/ArticleRepository.java | 8 ++++++++ .../article/repository/ArticleViewRepository.java | 8 ++++++++ .../monew_api/article/service/ArticleService.java | 12 ++++++++++++ 4 files changed, 41 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleViewRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java diff --git a/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java new file mode 100644 index 0000000..597785c --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java @@ -0,0 +1,13 @@ +package com.monew.monew_api.article.controller; + +import com.monew.monew_api.article.service.ArticleService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/articles") +public class ArticleController { + + private final ArticleService articleService; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java new file mode 100644 index 0000000..ff4a0ee --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java @@ -0,0 +1,8 @@ +package com.monew.monew_api.article.repository; + +import com.monew.monew_api.article.entity.Article; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ArticleRepository extends JpaRepository { + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleViewRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleViewRepository.java new file mode 100644 index 0000000..2bce33e --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleViewRepository.java @@ -0,0 +1,8 @@ +package com.monew.monew_api.article.repository; + +import com.monew.monew_api.article.entity.ArticleView; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ArticleViewRepository extends JpaRepository { + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java new file mode 100644 index 0000000..7f56bdb --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java @@ -0,0 +1,12 @@ +package com.monew.monew_api.article.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ArticleService { + +} From f87d9367e878b43804c66133fbfea6d4d65dee4c Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 24 Oct 2025 17:49:28 +0900 Subject: [PATCH 023/178] =?UTF-8?q?feat:=20ArticleViewDto=20->=20viewedBy?= =?UTF-8?q?=EC=99=80=20articleId=20Long=20=ED=83=80=EC=9E=85=EC=9C=BC?= =?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 --- .../java/com/monew/monew_api/article/dto/ArticleViewDto.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleViewDto.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleViewDto.java index 372551f..8f7069c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleViewDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleViewDto.java @@ -18,9 +18,9 @@ public class ArticleViewDto { private Long id; // 조회 기록 ID - private UUID viewedBy; // 조회한 사용자 ID + private Long viewedBy; // 조회한 사용자 ID private LocalDateTime createdAt; // 조회 시각 - private UUID articleId; // 기사 ID + private Long articleId; // 기사 ID private String source; // 기사 출처 private String sourceUrl; // 기사 원본 URL private String articleTitle; // 기사 제목 From 33bae71aa4e8669822edd07925fd250057721bce Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Sat, 25 Oct 2025 02:58:08 +0900 Subject: [PATCH 024/178] =?UTF-8?q?feat:=20QueryDSL=EC=9A=A9=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=EC=99=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=B2=B4=20=EC=B6=94=EA=B0=80=20(=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=20=ED=99=98=EA=B2=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/repository/ArticleQueryRepository.java | 5 +++++ .../article/repository/ArticleQueryRepositoryImpl.java | 10 ++++++++++ 2 files changed, 15 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepository.java new file mode 100644 index 0000000..a9fda68 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepository.java @@ -0,0 +1,5 @@ +package com.monew.monew_api.article.repository; + +public interface ArticleQueryRepository { + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java new file mode 100644 index 0000000..751a812 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java @@ -0,0 +1,10 @@ +package com.monew.monew_api.article.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class ArticleQueryRepositoryImpl implements ArticleQueryRepository { + + private final JPAQueryFactory queryFactory; +} From 8ea6674b42bac5ae4f0f76684c81484de2bdcec0 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Sat, 25 Oct 2025 02:58:56 +0900 Subject: [PATCH 025/178] =?UTF-8?q?feat:=20=EC=A4=91=EB=B3=B5=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/ArticleAlreadyViewedException.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleAlreadyViewedException.java diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleAlreadyViewedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleAlreadyViewedException.java new file mode 100644 index 0000000..62d0cd4 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleAlreadyViewedException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.article; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class ArticleAlreadyViewedException extends BaseException { + + public ArticleAlreadyViewedException() { + super(ErrorCode.ARTICLE_ALREADY_VIEWED); + } + + public ArticleAlreadyViewedException(Map details) { + super(ErrorCode.ARTICLE_ALREADY_VIEWED, details); + } +} From 8feae6dd58561ee41e52bd3a63875b039192a48d Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:05:33 +0900 Subject: [PATCH 026/178] =?UTF-8?q?feat:=20=EC=9E=84=EC=8B=9C=20Interest?= =?UTF-8?q?=EC=99=80=20InterestArticles=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20Article=EA=B3=BC=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_api/article/entity/Article.java | 9 ++++-- .../monew_api/article/entity/Interest.java | 27 +++++++++++++++++ .../article/entity/InterestArticles.java | 29 +++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java index 2e836a3..352c7e3 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java @@ -1,14 +1,14 @@ package com.monew.monew_api.article.entity; import com.monew.monew_api.common.entity.BaseIdEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; /** * 뉴스 기사 테이블 @@ -43,4 +43,7 @@ public class Article extends BaseIdEntity { @Column(name = "is_deleted", nullable = false) private boolean isDeleted = false; + + @OneToMany(mappedBy = "article", cascade = CascadeType.ALL, orphanRemoval = true) + private List interestArticles = new ArrayList<>(); } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java new file mode 100644 index 0000000..5c1f4e5 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java @@ -0,0 +1,27 @@ +package com.monew.monew_api.article.entity; + +import com.monew.monew_api.common.entity.BaseIdEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +/** + * 관심사 테이블 + */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "interests") +public class Interest extends BaseIdEntity { + + @Column(nullable = false, unique = true, length = 50) + private String name; + + @OneToMany(mappedBy = "interest", cascade = CascadeType.ALL, orphanRemoval = true) + private List interestArticles = new ArrayList<>(); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java new file mode 100644 index 0000000..4cf6fbd --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java @@ -0,0 +1,29 @@ +package com.monew.monew_api.article.entity; + +import com.monew.monew_api.common.entity.BaseIdEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * 기사 - 관심사 연결 테이블 + */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table( + name = "interests_articles", + uniqueConstraints = @UniqueConstraint(columnNames = {"article_id", "interest_id"}) +) +public class InterestArticles extends BaseIdEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "article_id", nullable = false) + private Article article; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "interest_id", nullable = false) + private Interest interest; +} From caaf886eeb8dcae2053f24522caac782e62bb42d Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:06:20 +0900 Subject: [PATCH 027/178] =?UTF-8?q?feat:=20Article=20Controller,=20Service?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/controller/ArticleController.java | 98 ++++++++++++++++++ .../article/service/ArticleService.java | 99 +++++++++++++++++++ .../monew_api/common/exception/ErrorCode.java | 1 + 3 files changed, 198 insertions(+) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java index 597785c..a15eb95 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java @@ -1,13 +1,111 @@ package com.monew.monew_api.article.controller; +import com.monew.monew_api.article.dto.ArticleDto; +import com.monew.monew_api.article.dto.ArticleViewDto; +import com.monew.monew_api.article.dto.CursorPageResponseArticleDto; import com.monew.monew_api.article.service.ArticleService; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; +import java.util.List; + @RestController @RequiredArgsConstructor @RequestMapping("/api/articles") public class ArticleController { + private final String DEFAULT_ARTICLE_SOURCE = "Naver"; + private final ArticleService articleService; + + @PostMapping("/{articleId}/article-views") + public ResponseEntity viewArticle( + @PathVariable Long articleId, + @RequestHeader("Monew-Request-User-ID") Long userId + ) { + ArticleViewDto dto = articleService.recordArticleView(articleId, userId); + return ResponseEntity.status(HttpStatus.OK).body(dto); + } + + @GetMapping + public ResponseEntity> getArticles( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Long interestId, + @RequestParam(required = false) List sourceIn, + // + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime publishDateFrom, + // + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime publishDateTo, + // + @RequestParam(defaultValue = "publishDate") String orderBy, + @RequestParam(defaultValue = "DESC") String direction, + @RequestParam(required = false) String cursor, + @RequestParam(required = false) + // + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime after, + // + @RequestParam(defaultValue = "10") int limit, + @RequestHeader("Monew-Request-User-ID") Long userId + ) { + if (sourceIn == null || sourceIn.isEmpty()) { + sourceIn = List.of(DEFAULT_ARTICLE_SOURCE); + } + + System.out.println("publishDateFrom: " + publishDateFrom); + System.out.println("publishDateTo: " + publishDateTo); + + LocalDateTime now = LocalDateTime.now(); + if (publishDateFrom == null) { + publishDateFrom = now.minusDays(7); + } + + if (publishDateTo == null) { + publishDateTo = now; + } + + CursorPageResponseArticleDto dto = articleService.getArticles( + keyword, interestId, sourceIn, + publishDateFrom, publishDateTo, + orderBy, direction, + cursor, after, limit, userId + ); + + return ResponseEntity.status(HttpStatus.OK).body(dto); + } + + @GetMapping("/{articleId}") + public ResponseEntity getArticleById( + @PathVariable Long articleId, + @RequestHeader("Monew-Request-User-ID") Long userId + ) { + ArticleDto dto = articleService.findArticle(articleId, userId); + return ResponseEntity.status(HttpStatus.OK).body(dto); + } + + @GetMapping("/sources") + public ResponseEntity> getSources() { + List sources = articleService.getAllSources(); + return ResponseEntity.status(HttpStatus.OK).body(sources); + } + + @DeleteMapping("/{articleId}") + public ResponseEntity deleteArticle(@PathVariable Long articleId) { + articleService.softDeleteArticle(articleId); + return ResponseEntity.status(HttpStatus.OK).build(); + } + + @DeleteMapping("/{articleId}/hard") + public ResponseEntity hardDeleteArticle(@PathVariable Long articleId) { + articleService.hardDeleteArticle(articleId); + return ResponseEntity.status(HttpStatus.OK).build(); + } } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java index 7f56bdb..3eac462 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java @@ -1,12 +1,111 @@ package com.monew.monew_api.article.service; +import com.monew.monew_api.article.dto.ArticleDto; +import com.monew.monew_api.article.dto.ArticleViewDto; +import com.monew.monew_api.article.dto.CursorPageResponseArticleDto; +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.article.entity.ArticleView; +import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_api.article.repository.ArticleViewRepository; +import com.monew.monew_api.common.exception.article.ArticleAlreadyViewedException; +import com.monew.monew_api.common.exception.article.ArticleNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.List; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class ArticleService { + private final ArticleRepository articleRepository; + private final ArticleViewRepository articleViewRepository; + + @Transactional + public ArticleViewDto recordArticleView(Long articleId, Long userId) { + if (articleViewRepository.existsByUserIdAndArticleId(userId, articleId)) { + throw new ArticleAlreadyViewedException(); + } + + Article article = articleRepository.findByIdAndIsDeletedFalse(articleId) + .orElseThrow(ArticleNotFoundException::new); + + ArticleView articleView = new ArticleView(); + articleView.setUserId(userId); + articleView.setArticleId(articleId); + + ArticleView saved = articleViewRepository.save(articleView); + + return ArticleViewDto.builder() + .id(saved.getId()) + .viewedBy(userId) + .createdAt(saved.getCreatedAt()) + .articleId(articleId) + .source(article.getSource()) + .sourceUrl(article.getSourceUrl()) + .articleTitle(article.getTitle()) + .articlePublishedDate(article.getPublishDate()) + .articleSummary(article.getSummary()) + .articleCommentCount(article.getCommentCount()) + .articleViewCount(article.getViewCount() + 1) + .build(); + } + + public CursorPageResponseArticleDto getArticles( + String keyword, Long interestId, List sourceIn, + LocalDateTime publishDateFrom, LocalDateTime publishDateTo, + String orderBy, String direction, + String cursor, LocalDateTime after, int limit, Long userId + ) { + return articleRepository.searchArticles( + keyword, + interestId, + sourceIn, + publishDateFrom, + publishDateTo, + orderBy, + direction, + cursor, + after, + limit, + userId + ); + } + + public ArticleDto findArticle(Long articleId, Long userId) { + Article article = articleRepository.findByIdAndIsDeletedFalse(articleId) + .orElseThrow(ArticleNotFoundException::new); + + boolean viewedByMe = articleViewRepository.existsByUserIdAndArticleId(userId, articleId); + + return ArticleDto.builder() + .id(article.getId()) + .source(article.getSource()) + .sourceUrl(article.getSourceUrl()) + .title(article.getTitle()) + .publishDate(article.getPublishDate()) + .summary(article.getSummary()) + .viewCount(article.getViewCount()) + .viewedByMe(viewedByMe) + .build(); + } + + public List getAllSources() { + return articleRepository.findDistinctSources(); + } + + @Transactional + public void softDeleteArticle(Long articleId) { + Article article = articleRepository.findByIdAndIsDeletedFalse(articleId) + .orElseThrow(ArticleNotFoundException::new); + article.setDeleted(true); + } + + @Transactional + public void hardDeleteArticle(Long articleId) { + articleRepository.deleteById(articleId); + } } diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java index ea54b9a..6593f09 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java @@ -18,6 +18,7 @@ public enum ErrorCode { // 뉴스 기사 - ARTICLE ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "뉴스 기사 정보를 찾을 수 없습니다."), + ARTICLE_ALREADY_VIEWED(HttpStatus.CONFLICT.value(), "이미 조회한 기사입니다."), // 댓글 - COMMENT COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "댓글 정보를 찾을 수 없습니다."), From cfa7bfd5a5bdbc441f14965914614992938e6728 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:08:27 +0900 Subject: [PATCH 028/178] =?UTF-8?q?feat:=20Article=EA=B3=BC=20ArticleView?= =?UTF-8?q?=20JPARepository=20=EC=BF=BC=EB=A6=AC=20=EB=A9=94=EC=86=8C?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=8A=B9=EC=A0=95?= =?UTF-8?q?=20=EC=BF=BC=EB=A6=AC=20=EA=B5=AC=ED=98=84=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20CustomRepository=EA=B3=BC=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=B2=B4=20=EA=B5=AC=ED=98=84(Dto=20QueryProjection)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_api/article/dto/ArticleDto.java | 21 +++- .../repository/ArticleQueryRepository.java | 12 ++ .../ArticleQueryRepositoryImpl.java | 108 ++++++++++++++++++ .../article/repository/ArticleRepository.java | 10 +- .../repository/ArticleViewRepository.java | 1 + 5 files changed, 148 insertions(+), 4 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleDto.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleDto.java index adce2d6..284a31c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleDto.java @@ -1,12 +1,11 @@ package com.monew.monew_api.article.dto; -import lombok.AllArgsConstructor; +import com.querydsl.core.annotations.QueryProjection; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import java.time.LocalDateTime; -import java.util.UUID; /** * 단일 기사 응답 DTO @@ -14,7 +13,6 @@ @Getter @Builder @NoArgsConstructor -@AllArgsConstructor public class ArticleDto { private Long id; // 기사 ID @@ -26,4 +24,21 @@ public class ArticleDto { private int commentCount; // 댓글 수 private int viewCount; // 조회 수 private boolean viewedByMe; // 내가 조회했는지 여부 + + @QueryProjection + public ArticleDto( + Long id, String source, String sourceUrl, + String title, LocalDateTime publishDate, String summary, + int commentCount, int viewCount, boolean viewedByMe + ) { + this.id = id; + this.source = source; + this.sourceUrl = sourceUrl; + this.title = title; + this.publishDate = publishDate; + this.summary = summary; + this.commentCount = commentCount; + this.viewCount = viewCount; + this.viewedByMe = viewedByMe; + } } diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepository.java index a9fda68..5a296b0 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepository.java @@ -1,5 +1,17 @@ package com.monew.monew_api.article.repository; +import com.monew.monew_api.article.dto.ArticleDto; +import com.monew.monew_api.article.dto.CursorPageResponseArticleDto; + +import java.time.LocalDateTime; +import java.util.List; + public interface ArticleQueryRepository { + CursorPageResponseArticleDto searchArticles( + String keyword, Long interestId, List sourceIn, + LocalDateTime publishDateFrom, LocalDateTime publishDateTo, + String orderBy, String direction, + String cursor, LocalDateTime after, int limit, Long userId + ); } diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java index 751a812..a60a772 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java @@ -1,10 +1,118 @@ package com.monew.monew_api.article.repository; +import com.monew.monew_api.article.dto.ArticleDto; +import com.monew.monew_api.article.dto.CursorPageResponseArticleDto; +import com.monew.monew_api.article.dto.QArticleDto; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import java.time.LocalDateTime; +import java.util.List; + +import static com.monew.monew_api.article.entity.QArticle.*; +import static com.monew.monew_api.article.entity.QInterestArticles.interestArticles; +import static com.monew.monew_api.article.entity.QArticleView.*; + @RequiredArgsConstructor public class ArticleQueryRepositoryImpl implements ArticleQueryRepository { private final JPAQueryFactory queryFactory; + + @Override + public CursorPageResponseArticleDto searchArticles( + String keyword, Long interestId, List sourceIn, + LocalDateTime publishDateFrom, LocalDateTime publishDateTo, + String orderBy, String direction, + String cursor, LocalDateTime after, int limit, Long userId + ) { + List articleDtos = queryFactory + .select(new QArticleDto( + article.id, + article.source, + article.sourceUrl, + article.title, + article.publishDate, + article.summary, + article.commentCount, + article.viewCount, + JPAExpressions + .selectOne() + .from(articleView) + .where( + articleView.articleId.eq(article.id) + .and(articleView.userId.eq(userId)) + ) + .exists() + )) + .from(article) + .where( + article.isDeleted.isFalse(), + keywordContains(keyword), + interestEq(interestId), + sourceIn(sourceIn), + publishDateBetween(publishDateFrom, publishDateTo) + ) + .orderBy(order(orderBy, direction)) + .limit(limit + 1) + .fetch(); + + boolean hasNext = articleDtos.size() > limit; + if (hasNext) { + articleDtos.remove(limit); + } + + String nextCursor = hasNext ? String.valueOf(articleDtos.get(articleDtos.size() - 1).getId()) : null; + LocalDateTime nextAfter = hasNext ? articleDtos.get(articleDtos.size() - 1).getPublishDate() : null; + + return CursorPageResponseArticleDto.builder() + .content(articleDtos) + .nextCursor(nextCursor) + .nextAfter(nextAfter) + .size(limit) + .hasNext(hasNext) + .build(); + } + + private BooleanExpression keywordContains(String keyword) { + return (keyword == null || keyword.isBlank()) + ? null + : article.title.containsIgnoreCase(keyword) + .or(article.summary.containsIgnoreCase(keyword)); + } + + private BooleanExpression interestEq(Long interestId) { + if (interestId == null) return null; + + return article.id.in( + JPAExpressions + .select(interestArticles.article.id) + .from(interestArticles) + .where(interestArticles.interest.id.eq(interestId)) + ); + } + + private BooleanExpression sourceIn(List sourceIn) { + return article.source.in(sourceIn); + } + + private BooleanExpression publishDateBetween(LocalDateTime from, LocalDateTime to) { + return article.publishDate.between(from, to); + } + + private OrderSpecifier order(String orderBy, String direction) { + OrderSpecifier order; + switch (orderBy) { + case "commentCount" -> order = direction.equalsIgnoreCase("ASC") + ? article.commentCount.asc() : article.commentCount.desc(); + case "viewCount" -> order = direction.equalsIgnoreCase("ASC") + ? article.viewCount.asc() : article.viewCount.desc(); + default -> order = direction.equalsIgnoreCase("ASC") + ? article.publishDate.asc() : article.publishDate.desc(); + } + + return order; + } } diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java index ff4a0ee..779dc6e 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java @@ -2,7 +2,15 @@ import com.monew.monew_api.article.entity.Article; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; -public interface ArticleRepository extends JpaRepository { +import java.util.List; +import java.util.Optional; +public interface ArticleRepository extends JpaRepository, ArticleQueryRepository { + + Optional
findByIdAndIsDeletedFalse(Long id); + + @Query("SELECT DISTINCT a.source FROM Article a WHERE a.isDeleted = false") + List findDistinctSources(); } diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleViewRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleViewRepository.java index 2bce33e..509a766 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleViewRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleViewRepository.java @@ -5,4 +5,5 @@ public interface ArticleViewRepository extends JpaRepository { + boolean existsByUserIdAndArticleId(Long userId, Long articleId); } From fb3bac853005852b8252894d6c672e78051da46b Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:35:24 +0900 Subject: [PATCH 029/178] =?UTF-8?q?chore:=20Article=20Controller,=20Servic?= =?UTF-8?q?e=20=EB=A1=9C=EA=B9=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/controller/ArticleController.java | 50 +++++++++++--- .../article/service/ArticleService.java | 66 +++++++++++++++++-- 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java index a15eb95..8259c70 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java @@ -5,6 +5,7 @@ import com.monew.monew_api.article.dto.CursorPageResponseArticleDto; import com.monew.monew_api.article.service.ArticleService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -13,24 +14,32 @@ import java.time.LocalDateTime; import java.util.List; +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/articles") public class ArticleController { - private final String DEFAULT_ARTICLE_SOURCE = "Naver"; - + private static final String DEFAULT_ARTICLE_SOURCE = "Naver"; private final ArticleService articleService; + /** + * 기사 조회 기록 등록 + */ @PostMapping("/{articleId}/article-views") public ResponseEntity viewArticle( @PathVariable Long articleId, @RequestHeader("Monew-Request-User-ID") Long userId ) { + log.info("[API 요청] POST /api/articles/{}/article-views - 기사 조회 기록 요청, 사용자 ID: {}", articleId, userId); ArticleViewDto dto = articleService.recordArticleView(articleId, userId); + log.info("[API 응답] POST /api/articles/{}/article-views - 조회 기록 성공, 조회 기록 ID: {}", articleId, dto.getId()); return ResponseEntity.status(HttpStatus.OK).body(dto); } + /** + * 기사 목록 조회 (검색/필터/페이징 포함) + */ @GetMapping public ResponseEntity> getArticles( @RequestParam(required = false) String keyword, @@ -49,29 +58,29 @@ public ResponseEntity> getArticles( @RequestParam(defaultValue = "DESC") String direction, @RequestParam(required = false) String cursor, @RequestParam(required = false) - // @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime after, - // @RequestParam(defaultValue = "10") int limit, @RequestHeader("Monew-Request-User-ID") Long userId ) { + log.info("[API 요청] GET /api/articles - 기사 목록 조회 요청, 사용자 ID: {}, 키워드: {}, 관심사 ID: {}", + userId, keyword, interestId); + if (sourceIn == null || sourceIn.isEmpty()) { sourceIn = List.of(DEFAULT_ARTICLE_SOURCE); } - System.out.println("publishDateFrom: " + publishDateFrom); - System.out.println("publishDateTo: " + publishDateTo); - LocalDateTime now = LocalDateTime.now(); if (publishDateFrom == null) { publishDateFrom = now.minusDays(7); } - if (publishDateTo == null) { publishDateTo = now; } + log.debug("[조회 파라미터] sourceIn: {}, 기간: {} ~ {}, 정렬: {} {}, limit: {}", + sourceIn, publishDateFrom, publishDateTo, orderBy, direction, limit); + CursorPageResponseArticleDto dto = articleService.getArticles( keyword, interestId, sourceIn, publishDateFrom, publishDateTo, @@ -79,33 +88,54 @@ public ResponseEntity> getArticles( cursor, after, limit, userId ); + log.info("[API 응답] GET /api/articles - 조회 성공, 반환된 기사 수: {}", dto.getContent().size()); return ResponseEntity.status(HttpStatus.OK).body(dto); } + /** + * 단일 기사 상세 조회 + */ @GetMapping("/{articleId}") public ResponseEntity getArticleById( @PathVariable Long articleId, @RequestHeader("Monew-Request-User-ID") Long userId ) { + log.info("[API 요청] GET /api/articles/{} - 기사 상세 조회 요청, 사용자 ID: {}", articleId, userId); ArticleDto dto = articleService.findArticle(articleId, userId); + log.info("[API 응답] GET /api/articles/{} - 기사 상세 조회 성공", articleId); return ResponseEntity.status(HttpStatus.OK).body(dto); } + /** + * 기사 출처 목록 조회 + */ @GetMapping("/sources") public ResponseEntity> getSources() { + log.info("[API 요청] GET /api/articles/sources - 뉴스 출처 목록 조회 요청"); List sources = articleService.getAllSources(); + log.info("[API 응답] GET /api/articles/sources - 뉴스 출처 목록 조회 성공, 개수: {}", sources.size()); return ResponseEntity.status(HttpStatus.OK).body(sources); } + /** + * 기사 논리 삭제 + */ @DeleteMapping("/{articleId}") public ResponseEntity deleteArticle(@PathVariable Long articleId) { + log.info("[API 요청] DELETE /api/articles/{} - 기사 논리 삭제 요청", articleId); articleService.softDeleteArticle(articleId); - return ResponseEntity.status(HttpStatus.OK).build(); + log.info("[API 응답] DELETE /api/articles/{} - 기사 논리 삭제 성공", articleId); + return ResponseEntity.noContent().build(); } + /** + * 기사 영구 삭제 + */ @DeleteMapping("/{articleId}/hard") public ResponseEntity hardDeleteArticle(@PathVariable Long articleId) { + log.info("[API 요청] DELETE /api/articles/{}/hard - 기사 영구 삭제 요청", articleId); articleService.hardDeleteArticle(articleId); - return ResponseEntity.status(HttpStatus.OK).build(); + log.info("[API 응답] DELETE /api/articles/{}/hard - 기사 영구 삭제 성공", articleId); + return ResponseEntity.noContent().build(); } } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java index 3eac462..43e4f85 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java @@ -10,12 +10,14 @@ import com.monew.monew_api.common.exception.article.ArticleAlreadyViewedException; import com.monew.monew_api.common.exception.article.ArticleNotFoundException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -24,20 +26,33 @@ public class ArticleService { private final ArticleRepository articleRepository; private final ArticleViewRepository articleViewRepository; + /** + * 기사 조회 기록 등록 + */ @Transactional public ArticleViewDto recordArticleView(Long articleId, Long userId) { + log.info("[기사 조회 기록 시도] 기사 ID: {}, 사용자 ID: {}", articleId, userId); + + // 이미 조회한 기사라면 예외 발생 if (articleViewRepository.existsByUserIdAndArticleId(userId, articleId)) { + log.warn("[조회 기록 실패] 이미 조회한 기사입니다. 사용자 ID: {}, 기사 ID: {}", userId, articleId); throw new ArticleAlreadyViewedException(); } + // 기사 존재 여부 확인 Article article = articleRepository.findByIdAndIsDeletedFalse(articleId) - .orElseThrow(ArticleNotFoundException::new); + .orElseThrow(() -> { + log.warn("[조회 기록 실패] 존재하지 않는 기사: {}", articleId); + return new ArticleNotFoundException(); + }); + // 조회 기록 생성 ArticleView articleView = new ArticleView(); articleView.setUserId(userId); articleView.setArticleId(articleId); ArticleView saved = articleViewRepository.save(articleView); + log.info("[조회 기록 성공] 기사 ID: {}, 사용자 ID: {}", articleId, userId); return ArticleViewDto.builder() .id(saved.getId()) @@ -54,13 +69,18 @@ public ArticleViewDto recordArticleView(Long articleId, Long userId) { .build(); } + /** + * 기사 목록 조회 (검색/필터/페이징 포함) + */ public CursorPageResponseArticleDto getArticles( String keyword, Long interestId, List sourceIn, LocalDateTime publishDateFrom, LocalDateTime publishDateTo, String orderBy, String direction, String cursor, LocalDateTime after, int limit, Long userId ) { - return articleRepository.searchArticles( + log.info("[기사 목록 조회] 사용자 ID: {}, 키워드: {}, 관심사 ID: {}", userId, keyword, interestId); + + CursorPageResponseArticleDto result = articleRepository.searchArticles( keyword, interestId, sourceIn, @@ -73,13 +93,25 @@ public CursorPageResponseArticleDto getArticles( limit, userId ); + + log.info("[기사 목록 조회 완료] 조회된 기사 수: {}", result.getContent().size()); + return result; } + /** + * 단일 기사 상세 조회 + */ public ArticleDto findArticle(Long articleId, Long userId) { + log.info("[기사 상세 조회 시도] 기사 ID: {}, 사용자 ID: {}", articleId, userId); + Article article = articleRepository.findByIdAndIsDeletedFalse(articleId) - .orElseThrow(ArticleNotFoundException::new); + .orElseThrow(() -> { + log.warn("[기사 상세 조회 실패] 존재하지 않는 기사: {}", articleId); + return new ArticleNotFoundException(); + }); boolean viewedByMe = articleViewRepository.existsByUserIdAndArticleId(userId, articleId); + log.debug("[기사 상세 조회 성공] 기사 ID: {}, 사용자 ID: {}, 조회 여부: {}", articleId, userId, viewedByMe); return ArticleDto.builder() .id(article.getId()) @@ -93,19 +125,41 @@ public ArticleDto findArticle(Long articleId, Long userId) { .build(); } + /** + * 전체 뉴스 소스 목록 조회 + */ public List getAllSources() { - return articleRepository.findDistinctSources(); + log.info("[뉴스 출처 목록 조회]"); + List sources = articleRepository.findDistinctSources(); + log.debug("[뉴스 출처 조회 완료] 출처 개수: {}", sources.size()); + return sources; } + /** + * 기사 논리 삭제 + */ @Transactional public void softDeleteArticle(Long articleId) { + log.info("[기사 논리 삭제 시도] 기사 ID: {}", articleId); + Article article = articleRepository.findByIdAndIsDeletedFalse(articleId) - .orElseThrow(ArticleNotFoundException::new); + .orElseThrow(() -> { + log.warn("[논리 삭제 실패] 존재하지 않는 기사: {}", articleId); + return new ArticleNotFoundException(); + }); + article.setDeleted(true); + log.info("[논리 삭제 성공] 기사 ID: {}", articleId); } + /** + * 기사 영구 삭제 + */ @Transactional public void hardDeleteArticle(Long articleId) { + log.info("[기사 영구 삭제 시도] 기사 ID: {}", articleId); + articleRepository.deleteById(articleId); + log.warn("[기사 영구 삭제 완료] 기사 ID: {}", articleId); } -} +} \ No newline at end of file From 16b660ac15f5a43df541d440040e3c20ea93fac1 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:44:17 +0900 Subject: [PATCH 030/178] =?UTF-8?q?refactor:=20interest=5Farticles=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=9D=B4=EB=A6=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=BD=94?= =?UTF-8?q?=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 --- .../com/monew/monew_api/article/entity/InterestArticles.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java index 4cf6fbd..0e6adc6 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java @@ -14,7 +14,7 @@ @NoArgsConstructor @Entity @Table( - name = "interests_articles", + name = "interest_articles", uniqueConstraints = @UniqueConstraint(columnNames = {"article_id", "interest_id"}) ) public class InterestArticles extends BaseIdEntity { From 672653b14924b48167e7d363d3b459594a7e623b Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 28 Oct 2025 11:06:26 +0900 Subject: [PATCH 031/178] =?UTF-8?q?refactor:=20Setter=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20=EB=B0=8F=20=EC=83=9D=EC=84=B1=EC=9E=90,=20=EC=9D=98?= =?UTF-8?q?=EB=AF=B8=EC=9E=88=EB=8A=94=20=EB=8F=84=EB=A9=94=EC=9D=B8=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 --- .../com/monew/monew_api/article/entity/Article.java | 10 ++++++++-- .../monew/monew_api/article/entity/ArticleView.java | 4 ++-- .../com/monew/monew_api/article/entity/Interest.java | 2 -- .../monew_api/article/entity/InterestArticles.java | 2 -- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java index 352c7e3..04535a7 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java @@ -1,10 +1,10 @@ package com.monew.monew_api.article.entity; import com.monew.monew_api.common.entity.BaseIdEntity; +import com.monew.monew_api.common.exception.article.ArticleNotFoundException; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; import java.time.LocalDateTime; import java.util.ArrayList; @@ -14,7 +14,6 @@ * 뉴스 기사 테이블 */ @Getter -@Setter @NoArgsConstructor @Entity @Table(name = "articles") @@ -46,4 +45,11 @@ public class Article extends BaseIdEntity { @OneToMany(mappedBy = "article", cascade = CascadeType.ALL, orphanRemoval = true) private List interestArticles = new ArrayList<>(); + + public void softDelete() { + if (this.isDeleted) { + throw new ArticleNotFoundException(); + } + this.isDeleted = true; + } } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java index b963aa3..60685ca 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java @@ -2,16 +2,16 @@ import com.monew.monew_api.common.entity.BaseCreatedEntity; import jakarta.persistence.*; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; /** * 뉴스 기사 조회 테이블 */ @Getter -@Setter @NoArgsConstructor +@AllArgsConstructor @Entity @Table( name = "article_views", diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java index 5c1f4e5..f56dcdf 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java @@ -4,7 +4,6 @@ import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; import java.util.ArrayList; import java.util.List; @@ -13,7 +12,6 @@ * 관심사 테이블 */ @Getter -@Setter @NoArgsConstructor @Entity @Table(name = "interests") diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java index 0e6adc6..4fd524c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java @@ -4,13 +4,11 @@ import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; /** * 기사 - 관심사 연결 테이블 */ @Getter -@Setter @NoArgsConstructor @Entity @Table( From 1ff49dabb13c8069a672448a415567d39819681d Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 28 Oct 2025 11:06:50 +0900 Subject: [PATCH 032/178] =?UTF-8?q?refactor:=20=EC=88=98=EC=A0=95=EB=90=9C?= =?UTF-8?q?=20=EC=97=94=ED=8B=B0=ED=8B=B0=EC=97=90=20=EB=94=B0=EB=9D=BC=20?= =?UTF-8?q?=EC=BD=94=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 --- .../monew/monew_api/article/service/ArticleService.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java index 43e4f85..46b60a1 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java @@ -47,10 +47,7 @@ public ArticleViewDto recordArticleView(Long articleId, Long userId) { }); // 조회 기록 생성 - ArticleView articleView = new ArticleView(); - articleView.setUserId(userId); - articleView.setArticleId(articleId); - + ArticleView articleView = new ArticleView(userId, articleId); ArticleView saved = articleViewRepository.save(articleView); log.info("[조회 기록 성공] 기사 ID: {}, 사용자 ID: {}", articleId, userId); @@ -148,7 +145,7 @@ public void softDeleteArticle(Long articleId) { return new ArticleNotFoundException(); }); - article.setDeleted(true); + article.softDelete(); log.info("[논리 삭제 성공] 기사 ID: {}", articleId); } From aa2f3f0f46389424014bbeedeeb3c52368314100 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 28 Oct 2025 11:07:53 +0900 Subject: [PATCH 033/178] =?UTF-8?q?refactor:=20=EB=94=B0=EB=A1=9C=20Reques?= =?UTF-8?q?tDTO=20=EC=83=9D=EC=84=B1=20=ED=9B=84=20@ModelAttribute=20?= =?UTF-8?q?=EB=B0=8F=20Bean=20Validation=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/controller/ArticleController.java | 57 ++++++++----------- .../article/dto/ArticleSearchRequest.java | 49 ++++++++++++++++ 2 files changed, 72 insertions(+), 34 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleSearchRequest.java diff --git a/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java index 8259c70..bcc0c92 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java @@ -1,14 +1,15 @@ package com.monew.monew_api.article.controller; import com.monew.monew_api.article.dto.ArticleDto; +import com.monew.monew_api.article.dto.ArticleSearchRequest; import com.monew.monew_api.article.dto.ArticleViewDto; import com.monew.monew_api.article.dto.CursorPageResponseArticleDto; import com.monew.monew_api.article.service.ArticleService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; @@ -42,54 +43,42 @@ public ResponseEntity viewArticle( */ @GetMapping public ResponseEntity> getArticles( - @RequestParam(required = false) String keyword, - @RequestParam(required = false) Long interestId, - @RequestParam(required = false) List sourceIn, - // - @RequestParam(required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - LocalDateTime publishDateFrom, - // - @RequestParam(required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - LocalDateTime publishDateTo, - // - @RequestParam(defaultValue = "publishDate") String orderBy, - @RequestParam(defaultValue = "DESC") String direction, - @RequestParam(required = false) String cursor, - @RequestParam(required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - LocalDateTime after, - @RequestParam(defaultValue = "10") int limit, + @Validated @ModelAttribute ArticleSearchRequest request, @RequestHeader("Monew-Request-User-ID") Long userId ) { log.info("[API 요청] GET /api/articles - 기사 목록 조회 요청, 사용자 ID: {}, 키워드: {}, 관심사 ID: {}", - userId, keyword, interestId); + userId, request.getKeyword(), request.getInterestId()); - if (sourceIn == null || sourceIn.isEmpty()) { - sourceIn = List.of(DEFAULT_ARTICLE_SOURCE); + if (request.getSourceIn() == null || request.getSourceIn().isEmpty()) { + request.setSourceIn(List.of(DEFAULT_ARTICLE_SOURCE)); } LocalDateTime now = LocalDateTime.now(); - if (publishDateFrom == null) { - publishDateFrom = now.minusDays(7); + if (request.getPublishDateFrom() == null) { + request.setPublishDateFrom(now.minusDays(7)); } - if (publishDateTo == null) { - publishDateTo = now; + if (request.getPublishDateTo() == null) { + request.setPublishDateTo(now); } - log.debug("[조회 파라미터] sourceIn: {}, 기간: {} ~ {}, 정렬: {} {}, limit: {}", - sourceIn, publishDateFrom, publishDateTo, orderBy, direction, limit); + log.debug("[조회 파라미터] {}", request); CursorPageResponseArticleDto dto = articleService.getArticles( - keyword, interestId, sourceIn, - publishDateFrom, publishDateTo, - orderBy, direction, - cursor, after, limit, userId + request.getKeyword(), + request.getInterestId(), + request.getSourceIn(), + request.getPublishDateFrom(), + request.getPublishDateTo(), + request.getOrderBy(), + request.getDirection(), + request.getCursor(), + request.getAfter(), + request.getLimit(), + userId ); log.info("[API 응답] GET /api/articles - 조회 성공, 반환된 기사 수: {}", dto.getContent().size()); - return ResponseEntity.status(HttpStatus.OK).body(dto); + return ResponseEntity.ok(dto); } /** diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleSearchRequest.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleSearchRequest.java new file mode 100644 index 0000000..d7c6808 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleSearchRequest.java @@ -0,0 +1,49 @@ +package com.monew.monew_api.article.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ArticleSearchRequest { + + @Size(max = 50, message = "검색어(keyword)는 최대 50자까지 입력할 수 있습니다.") + private String keyword; + private Long interestId; + private List sourceIn; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime publishDateFrom; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime publishDateTo; + + @Pattern(regexp = "^(publishDate|viewCount|commentCount)$", + message = "정렬 기준(orderBy)은 publishDate, viewCount, commentCount 중 하나여야 합니다.") + private String orderBy = "publishDate"; + + @Pattern(regexp = "^(ASC|DESC)$", + message = "정렬 방향(direction)은 ASC 또는 DESC만 가능합니다.") + private String direction = "DESC"; + + private String cursor; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime after; + + @Min(value = 1, message = "limit은 1 이상이어야 합니다.") + @Max(value = 50, message = "limit은 최대 50까지만 가능합니다.") + private int limit = 10; +} From e4ea5e9a37e5ca2a863de96e41c16cd79ce72ac6 Mon Sep 17 00:00:00 2001 From: DoHanChoi Date: Tue, 28 Oct 2025 11:21:21 +0900 Subject: [PATCH 034/178] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=9B=84=20=EC=9A=94=EC=B2=AD=20=ED=97=A4=EB=8D=94=EC=97=90=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20ID=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_api/common/config/WebConfig.java | 6 ++++ .../interceptor/UserIdHeaderInterceptor.java | 35 +++++++++++++++++++ .../user/controller/UserController.java | 4 ++- 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/interceptor/UserIdHeaderInterceptor.java diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/WebConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/WebConfig.java index cfac2a7..397c98b 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/config/WebConfig.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/config/WebConfig.java @@ -1,6 +1,7 @@ package com.monew.monew_api.common.config; import com.monew.monew_api.common.interceptor.MDCLoggingInterceptor; +import com.monew.monew_api.common.interceptor.UserIdHeaderInterceptor; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; @@ -11,10 +12,15 @@ public class WebConfig implements WebMvcConfigurer { private final MDCLoggingInterceptor mdcLoggingInterceptor; + private final UserIdHeaderInterceptor userIdHeaderInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(mdcLoggingInterceptor) .addPathPatterns("/**"); + + registry.addInterceptor(userIdHeaderInterceptor) + .addPathPatterns("/**") + .excludePathPatterns("/api/users/login", "/api/users"); } } diff --git a/monew-api/src/main/java/com/monew/monew_api/common/interceptor/UserIdHeaderInterceptor.java b/monew-api/src/main/java/com/monew/monew_api/common/interceptor/UserIdHeaderInterceptor.java new file mode 100644 index 0000000..b4e3d05 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/interceptor/UserIdHeaderInterceptor.java @@ -0,0 +1,35 @@ +package com.monew.monew_api.common.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Slf4j +@Component +public class UserIdHeaderInterceptor implements HandlerInterceptor { + + private static final String USER_ID_HEADER = "MoNew-Request-User-ID"; + private static final String MDC_USER_ID_KEY = "userId"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String userId = request.getHeader(USER_ID_HEADER); + + if (userId != null && !userId.isBlank()) { + MDC.put(MDC_USER_ID_KEY, userId); + log.info("[사용자 헤더 감지] {} 헤더 값: {}, URI: {}", USER_ID_HEADER, userId, request.getRequestURI()); + } else { + log.info("[사용자 헤더 없음] {} 헤더가 요청에 포함되지 않음, URI: {}", USER_ID_HEADER, request.getRequestURI()); + } + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + MDC.remove(MDC_USER_ID_KEY); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java b/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java index 9d08374..ab9a8eb 100644 --- a/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java +++ b/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java @@ -29,7 +29,9 @@ public ResponseEntity login(@Valid @RequestBody UserLoginRequest reques log.info("[API 요청] POST /api/users/login - 로그인 요청, 이메일: {}", request.getEmail()); UserDto response = userService.login(request); log.info("[API 응답] POST /api/users/login - 로그인 성공, 사용자 ID: {}", response.getId()); - return ResponseEntity.ok(response); + return ResponseEntity.ok() + .header("MoNew-Request-User-ID", response.getId().toString()) + .body(response); } @PatchMapping("/api/users/{userId}") From 3d79f5ad9114330686dfa72166bc187db1c74d21 Mon Sep 17 00:00:00 2001 From: truuuely Date: Tue, 28 Oct 2025 16:40:50 +0900 Subject: [PATCH 035/178] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=B3=84=20=EB=AF=B8=ED=99=95=EC=9D=B8=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=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 --- .../common/dto/CursorPageResponse.java | 14 ++++ .../controller/NotificationController.java | 30 +++++++ .../NotificationCursorPageRequest.java | 21 +++++ .../dto/response/NotificationDto.java | 17 ++++ .../notification/entity/Notification.java | 33 ++++++++ .../notification/enums/ResourceType.java | 5 ++ .../repository/NotificationRepository.java | 7 ++ .../NotificationRepositoryCustom.java | 9 +++ .../NotificationRepositoryCustomImpl.java | 78 +++++++++++++++++++ .../service/NotificationService.java | 20 +++++ 10 files changed, 234 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/dto/CursorPageResponse.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/notification/dto/request/NotificationCursorPageRequest.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/notification/dto/response/NotificationDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/notification/enums/ResourceType.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustom.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustomImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java diff --git a/monew-api/src/main/java/com/monew/monew_api/common/dto/CursorPageResponse.java b/monew-api/src/main/java/com/monew/monew_api/common/dto/CursorPageResponse.java new file mode 100644 index 0000000..9424881 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/dto/CursorPageResponse.java @@ -0,0 +1,14 @@ +package com.monew.monew_api.common.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record CursorPageResponse( + List content, + String nextCursor, + LocalDateTime nextAfter, + int size, + long totalElements, + boolean hasNext +) { +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java new file mode 100644 index 0000000..a50de66 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java @@ -0,0 +1,30 @@ +package com.monew.monew_api.notification.controller; + +import com.monew.monew_api.common.dto.CursorPageResponse; +import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest; +import com.monew.monew_api.notification.dto.response.NotificationDto; +import com.monew.monew_api.notification.service.NotificationService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications") +public class NotificationController { + private static final String REQUEST_HEADER_USER_ID = "MoNew-Request-User-ID"; + private final NotificationService notificationService; + + @GetMapping + public ResponseEntity> getNotifications(@RequestHeader(REQUEST_HEADER_USER_ID) Long userId, + @ModelAttribute @Valid NotificationCursorPageRequest cursorPageRequest) { + log.info("[API 요청] GET /api/notifications - 전체 조회, 사용자 ID: {}", userId); + CursorPageResponse notifications = notificationService.getNonConfirmedNotifications(userId, cursorPageRequest); + log.info("[API 응답] GET /api/notifications - 조회 기록 성공, 사용자 ID: {}, 알림 개수: {}", userId, notifications.size()); + + return ResponseEntity.ok(notifications); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/dto/request/NotificationCursorPageRequest.java b/monew-api/src/main/java/com/monew/monew_api/notification/dto/request/NotificationCursorPageRequest.java new file mode 100644 index 0000000..64e5d18 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/dto/request/NotificationCursorPageRequest.java @@ -0,0 +1,21 @@ +package com.monew.monew_api.notification.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +public record NotificationCursorPageRequest( + String cursor, + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime after, + + @NotNull(message = "조회할 개수는 필수값입니다.") + @Min(value = 1, message = "조회 개수는 1 이상이어야 합니다.") + @Max(value = 50, message = "조회 개수는 50을 초과할 수 없습니다.") + Integer limit +) { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/dto/response/NotificationDto.java b/monew-api/src/main/java/com/monew/monew_api/notification/dto/response/NotificationDto.java new file mode 100644 index 0000000..8e2fdd9 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/dto/response/NotificationDto.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.notification.dto.response; + +import com.monew.monew_api.notification.enums.ResourceType; + +import java.time.LocalDateTime; + +public record NotificationDto( + Long id, + LocalDateTime createdAt, + LocalDateTime updatedAt, + boolean confirmed, + Long userId, + String content, + ResourceType resourceType, + Long resourceId +) { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java b/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java new file mode 100644 index 0000000..be25bb5 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java @@ -0,0 +1,33 @@ +package com.monew.monew_api.notification.entity; + +import com.monew.monew_api.common.entity.BaseTimeEntity; +import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.notification.enums.ResourceType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "notifications") +@Entity +public class Notification extends BaseTimeEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(nullable = false, length = 100) + private String content; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ResourceType resourceType; + + @Column(nullable = false) + private Long resourceId; + + @Column(nullable = false) + private boolean confirmed; +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/enums/ResourceType.java b/monew-api/src/main/java/com/monew/monew_api/notification/enums/ResourceType.java new file mode 100644 index 0000000..ee5a875 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/enums/ResourceType.java @@ -0,0 +1,5 @@ +package com.monew.monew_api.notification.enums; + +public enum ResourceType { + interest, comment +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..a6e2ef5 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java @@ -0,0 +1,7 @@ +package com.monew.monew_api.notification.repository; + +import com.monew.monew_api.notification.entity.Notification; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationRepository extends JpaRepository, NotificationRepositoryCustom { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustom.java b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustom.java new file mode 100644 index 0000000..0a0196d --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.monew.monew_api.notification.repository; + +import com.monew.monew_api.common.dto.CursorPageResponse; +import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest; +import com.monew.monew_api.notification.dto.response.NotificationDto; + +public interface NotificationRepositoryCustom { + CursorPageResponse findAllNonConfirmedNotifications(Long id, NotificationCursorPageRequest cursorPageRequest); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustomImpl.java b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustomImpl.java new file mode 100644 index 0000000..ada59bb --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustomImpl.java @@ -0,0 +1,78 @@ +package com.monew.monew_api.notification.repository; + +import com.monew.monew_api.common.dto.CursorPageResponse; +import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest; +import com.monew.monew_api.notification.dto.response.NotificationDto; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.monew.monew_api.notification.entity.QNotification.notification; + +@Repository +@RequiredArgsConstructor +public class NotificationRepositoryCustomImpl implements NotificationRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public CursorPageResponse findAllNonConfirmedNotifications(Long userId, NotificationCursorPageRequest cursorPageRequest) { + List results = queryFactory + .select(Projections.constructor(NotificationDto.class, + notification.id, + notification.createdAt, + notification.updatedAt, + notification.confirmed, + notification.user.id, + notification.content, + notification.resourceType, + notification.resourceId)) + .from(notification) + .where( + notification.user.id.eq(userId), + notification.confirmed.isFalse(), + cursorPredicate(cursorPageRequest.cursor(), cursorPageRequest.after()) + ) + .orderBy(notification.createdAt.desc(), notification.id.asc()) + .limit(cursorPageRequest.limit() + 1) + .fetch(); + + Long totalCountTemp = queryFactory + .select(notification.count()) + .from(notification) + .where(notification.user.id.eq(userId).and(notification.confirmed.isFalse())) + .fetchOne(); + + long totalElements = totalCountTemp != null ? totalCountTemp : 0; + + if (results.size() <= cursorPageRequest.limit()) { + return new CursorPageResponse<>(results, null, null, results.size(), totalElements, false); + } + + results.remove(results.size() - 1); + NotificationDto last = results.get(results.size() - 1); + + return new CursorPageResponse<>( + results, + String.valueOf(last.id()), + last.createdAt(), + results.size(), + totalElements, + true + ); + } + + private BooleanExpression cursorPredicate(String cursor, LocalDateTime after) { + if (cursor == null || cursor.isBlank() || after == null) { + return null; + } + + return (notification.createdAt.eq(after).and(notification.id.gt(Long.parseLong(cursor)))) + .or(notification.createdAt.lt(after)); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java new file mode 100644 index 0000000..c1c40da --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java @@ -0,0 +1,20 @@ +package com.monew.monew_api.notification.service; + +import com.monew.monew_api.common.dto.CursorPageResponse; +import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest; +import com.monew.monew_api.notification.dto.response.NotificationDto; +import com.monew.monew_api.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class NotificationService { + private final NotificationRepository notificationRepository; + + public CursorPageResponse getNonConfirmedNotifications(Long userId, NotificationCursorPageRequest cursorPageRequest) { + return notificationRepository.findAllNonConfirmedNotifications(userId, cursorPageRequest); + } +} \ No newline at end of file From 21bc99b52d0a12cc9e0bed80898cc3f00822f26d Mon Sep 17 00:00:00 2001 From: truuuely Date: Tue, 28 Oct 2025 16:41:20 +0900 Subject: [PATCH 036/178] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=EB=8D=94?= =?UTF-8?q?=EB=AF=B8=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B0=92=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 --- monew-api/src/main/resources/db/data/data-notifications.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monew-api/src/main/resources/db/data/data-notifications.sql b/monew-api/src/main/resources/db/data/data-notifications.sql index 9b02111..0f4032f 100644 --- a/monew-api/src/main/resources/db/data/data-notifications.sql +++ b/monew-api/src/main/resources/db/data/data-notifications.sql @@ -26,8 +26,8 @@ VALUES -- 페이징 테스트용. user_id = 1 : 미확인 알림 16개 (1, 'user 1 - 테스트 알림 1 (미확인)', 'interest', 1, FALSE, '2025-10-28 10:00:00', '2025-10-28 10:00:00'), (1, 'user 1 - 테스트 알림 2 (미확인)', 'comment', 1, FALSE, '2025-10-28 09:00:00', '2025-10-28 09:00:00'), - (1, 'user 1 - 테스트 알림 3 (미확인)', 'interest', 2, FALSE, '2025-10-28 08:00:00', '2025-10-28 08:00:00'), - (1, 'user 1 - 테스트 알림 4 (미확인)', 'comment', 2, FALSE, '2025-10-28 07:00:00', '2025-10-28 07:00:00'), + (1, 'user 1 - 테스트 알림 3 (미확인)', 'interest', 2, FALSE, '2025-10-28 09:00:00', '2025-10-28 09:00:00'), + (1, 'user 1 - 테스트 알림 4 (미확인)', 'comment', 2, FALSE, '2025-10-28 09:00:00', '2025-10-28 09:00:00'), (1, 'user 1 - 테스트 알림 5 (미확인)', 'interest', 3, FALSE, '2025-10-28 06:00:00', '2025-10-28 06:00:00'), (1, 'user 1 - 테스트 알림 6 (미확인)', 'interest', 1, FALSE, '2025-10-28 05:00:00', '2025-10-28 05:00:00'), (1, 'user 1 - 테스트 알림 7 (미확인)', 'comment', 3, FALSE, '2025-10-28 04:00:00', '2025-10-28 04:00:00'), From 55b427047e67e7d1092e8c842521bab6307dca25 Mon Sep 17 00:00:00 2001 From: truuuely Date: Tue, 28 Oct 2025 22:34:36 +0900 Subject: [PATCH 037/178] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EB=8B=A8?= =?UTF-8?q?=EA=B1=B4=20=ED=99=95=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=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 --- .../monew_api/common/exception/ErrorCode.java | 4 ++- .../NotificationAccessDeniedException.java | 17 ++++++++++++ ...NotificationAlreadyConfirmedException.java | 17 ++++++++++++ .../controller/NotificationController.java | 11 +++++++- .../notification/entity/Notification.java | 4 +++ .../service/NotificationService.java | 27 +++++++++++++++++++ 6 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAccessDeniedException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAlreadyConfirmedException.java diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java index 6593f09..2535714 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java @@ -24,7 +24,9 @@ public enum ErrorCode { COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "댓글 정보를 찾을 수 없습니다."), // 알림 - NOTIFICATION - NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "알림 정보를 찾을 수 없습니다."); + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "알림 정보를 찾을 수 없습니다."), + NOTIFICATION_ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "해당 알림에 접근할 권한이 없습니다."), + NOTIFICATION_ALREADY_CONFIRMED(HttpStatus.CONFLICT.value(), "이미 확인된 알림입니다."); private final int status; private final String message; diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAccessDeniedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAccessDeniedException.java new file mode 100644 index 0000000..1ec3406 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAccessDeniedException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.notification; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class NotificationAccessDeniedException extends BaseException { + + public NotificationAccessDeniedException() { + super(ErrorCode.NOTIFICATION_ACCESS_DENIED); + } + + public NotificationAccessDeniedException(Map details) { + super(ErrorCode.NOTIFICATION_ACCESS_DENIED, details); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAlreadyConfirmedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAlreadyConfirmedException.java new file mode 100644 index 0000000..d361c2a --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAlreadyConfirmedException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.notification; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class NotificationAlreadyConfirmedException extends BaseException { + + public NotificationAlreadyConfirmedException() { + super(ErrorCode.NOTIFICATION_ALREADY_CONFIRMED); + } + + public NotificationAlreadyConfirmedException(Map details) { + super(ErrorCode.NOTIFICATION_ALREADY_CONFIRMED, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java index a50de66..80d80a0 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java @@ -23,8 +23,17 @@ public ResponseEntity> getNotifications(@Req @ModelAttribute @Valid NotificationCursorPageRequest cursorPageRequest) { log.info("[API 요청] GET /api/notifications - 전체 조회, 사용자 ID: {}", userId); CursorPageResponse notifications = notificationService.getNonConfirmedNotifications(userId, cursorPageRequest); - log.info("[API 응답] GET /api/notifications - 조회 기록 성공, 사용자 ID: {}, 알림 개수: {}", userId, notifications.size()); + log.info("[API 응답] GET /api/notifications - 전체 조회 성공, 사용자 ID: {}, 알림 개수: {}", userId, notifications.size()); return ResponseEntity.ok(notifications); } + + @PatchMapping("/{notificationId}") + public ResponseEntity confirmOne(@RequestHeader(REQUEST_HEADER_USER_ID) Long userId, + @PathVariable("notificationId") Long notificationId) { + log.info("[API 요청] PATCH /api/notifications/{} - 알림 단건 확인, 사용자 ID: {}", notificationId, userId); + notificationService.setOneConfirmed(userId, notificationId); + log.info("[API 요청] PATCH /api/notifications/{} - 알림 단건 확인 성공, 사용자 ID: {}", notificationId, userId); + return ResponseEntity.ok().build(); + } } diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java b/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java index be25bb5..a893bf7 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java @@ -30,4 +30,8 @@ public class Notification extends BaseTimeEntity { @Column(nullable = false) private boolean confirmed; + + public void confirm() { + this.confirmed = true; + } } diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java index c1c40da..4c8f2c1 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java @@ -1,13 +1,19 @@ package com.monew.monew_api.notification.service; import com.monew.monew_api.common.dto.CursorPageResponse; +import com.monew.monew_api.common.exception.notification.NotificationAccessDeniedException; +import com.monew.monew_api.common.exception.notification.NotificationAlreadyConfirmedException; +import com.monew.monew_api.common.exception.notification.NotificationNotFoundException; import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest; import com.monew.monew_api.notification.dto.response.NotificationDto; +import com.monew.monew_api.notification.entity.Notification; import com.monew.monew_api.notification.repository.NotificationRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @RequiredArgsConstructor @Service @Transactional(readOnly = true) @@ -17,4 +23,25 @@ public class NotificationService { public CursorPageResponse getNonConfirmedNotifications(Long userId, NotificationCursorPageRequest cursorPageRequest) { return notificationRepository.findAllNonConfirmedNotifications(userId, cursorPageRequest); } + + @Transactional + public void setOneConfirmed(Long userId, Long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> { + log.warn("[알림 조회 실패] 존재하지 않는 알림: {}", notificationId); + return new NotificationNotFoundException(); + }); + + if (!notification.getUser().getId().equals(userId)) { + log.warn("[알림 확인 실패] 권한 없는 사용자: {}, 알림: {}", userId, notificationId); + throw new NotificationAccessDeniedException(); + } + + if (notification.isConfirmed()) { + log.warn("[알림 중복 확인] 이미 확인된 알림: {}", notificationId); + throw new NotificationAlreadyConfirmedException(); + } + + notification.confirm(); + } } \ No newline at end of file From aee505cf42b9663fbade86db5c4be822011ec6e9 Mon Sep 17 00:00:00 2001 From: truuuely Date: Wed, 29 Oct 2025 02:06:41 +0900 Subject: [PATCH 038/178] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=ED=99=95=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=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 --- .../notification/controller/NotificationController.java | 9 +++++++++ .../notification/repository/NotificationRepository.java | 9 +++++++++ .../notification/service/NotificationService.java | 7 +++++++ 3 files changed, 25 insertions(+) diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java index 80d80a0..a829ca8 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java @@ -36,4 +36,13 @@ public ResponseEntity confirmOne(@RequestHeader(REQUEST_HEADER_USER_ID) Lo log.info("[API 요청] PATCH /api/notifications/{} - 알림 단건 확인 성공, 사용자 ID: {}", notificationId, userId); return ResponseEntity.ok().build(); } + + @PatchMapping + public ResponseEntity confirmAll(@RequestHeader("Monew-Request-User-ID") Long userId) { + log.info("[API 요청] PATCH /api/notifications - 알림 전체 확인, 사용자 ID: {}", userId); + notificationService.setAllConfirmed(userId); + log.info("[API 요청] PATCH /api/notifications - 알림 전체 확인 성공, 사용자 ID: {}", userId); + + return ResponseEntity.ok().build(); + } } diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java index a6e2ef5..df0b49e 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java @@ -2,6 +2,15 @@ import com.monew.monew_api.notification.entity.Notification; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; public interface NotificationRepository extends JpaRepository, NotificationRepositoryCustom { + + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE Notification n SET n.confirmed = true, n.updatedAt = CURRENT_TIMESTAMP + WHERE n.user.id = :userId AND n.confirmed = false + """) + int confirmAllByUserId(Long userId); } diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java index 4c8f2c1..b96ea25 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java @@ -44,4 +44,11 @@ public void setOneConfirmed(Long userId, Long notificationId) { notification.confirm(); } + + @Transactional + public void setAllConfirmed(Long userId) { + int affectedRows = notificationRepository.confirmAllByUserId(userId); + + log.info("[알림 전체 확인] 사용자 ID: {}, 확인된 알림 개수: {}개", userId, affectedRows); + } } \ No newline at end of file From fb2dcb987b891452ac041ba20a1dd7f5e2eabfb7 Mon Sep 17 00:00:00 2001 From: truuuely Date: Wed, 29 Oct 2025 09:59:15 +0900 Subject: [PATCH 039/178] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=82=B4=20=EC=9E=98=EB=AA=BB=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=EB=90=9C=20=EB=A1=9C=EA=B7=B8=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 --- .../notification/controller/NotificationController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java index a829ca8..c36bb24 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java @@ -33,7 +33,7 @@ public ResponseEntity confirmOne(@RequestHeader(REQUEST_HEADER_USER_ID) Lo @PathVariable("notificationId") Long notificationId) { log.info("[API 요청] PATCH /api/notifications/{} - 알림 단건 확인, 사용자 ID: {}", notificationId, userId); notificationService.setOneConfirmed(userId, notificationId); - log.info("[API 요청] PATCH /api/notifications/{} - 알림 단건 확인 성공, 사용자 ID: {}", notificationId, userId); + log.info("[API 응답] PATCH /api/notifications/{} - 알림 단건 확인 성공, 사용자 ID: {}", notificationId, userId); return ResponseEntity.ok().build(); } @@ -41,7 +41,7 @@ public ResponseEntity confirmOne(@RequestHeader(REQUEST_HEADER_USER_ID) Lo public ResponseEntity confirmAll(@RequestHeader("Monew-Request-User-ID") Long userId) { log.info("[API 요청] PATCH /api/notifications - 알림 전체 확인, 사용자 ID: {}", userId); notificationService.setAllConfirmed(userId); - log.info("[API 요청] PATCH /api/notifications - 알림 전체 확인 성공, 사용자 ID: {}", userId); + log.info("[API 응답] PATCH /api/notifications - 알림 전체 확인 성공, 사용자 ID: {}", userId); return ResponseEntity.ok().build(); } From 9264552e1fa499965c75bde85536384a96e4e43d Mon Sep 17 00:00:00 2001 From: truuuely Date: Wed, 29 Oct 2025 10:40:05 +0900 Subject: [PATCH 040/178] =?UTF-8?q?chore:=20show-sql=20=EC=98=B5=EC=85=98?= =?UTF-8?q?=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sql 중복 출력 문제 제거 --- monew-api/src/main/resources/application-dev.yml | 2 +- monew-api/src/main/resources/application-prod.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/monew-api/src/main/resources/application-dev.yml b/monew-api/src/main/resources/application-dev.yml index aad5fc5..14ffbe7 100644 --- a/monew-api/src/main/resources/application-dev.yml +++ b/monew-api/src/main/resources/application-dev.yml @@ -25,7 +25,7 @@ spring: jpa: hibernate: ddl-auto: validate - show-sql: true + show-sql: false properties: hibernate: format_sql: true diff --git a/monew-api/src/main/resources/application-prod.yml b/monew-api/src/main/resources/application-prod.yml index 89f29b3..b93e46e 100644 --- a/monew-api/src/main/resources/application-prod.yml +++ b/monew-api/src/main/resources/application-prod.yml @@ -11,7 +11,7 @@ spring: jpa: hibernate: ddl-auto: validate - show-sql: true + show-sql: false properties: hibernate: format_sql: true From d6b157c7a7c51ff17c1bc2dae0299b782e9434e2 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Tue, 28 Oct 2025 22:08:50 +0900 Subject: [PATCH 041/178] =?UTF-8?q?build=20:=20=EC=9C=A0=EC=82=AC=EB=8F=84?= =?UTF-8?q?=20=EA=B3=84=EC=82=B0=EC=9A=A9=20=EC=9D=98=EC=A1=B4=EC=84=B1=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 --- monew-api/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/monew-api/build.gradle b/monew-api/build.gradle index 7f76b73..9075a78 100644 --- a/monew-api/build.gradle +++ b/monew-api/build.gradle @@ -17,4 +17,5 @@ dependencies { annotationProcessor 'jakarta.persistence:jakarta.persistence-api' implementation 'org.springframework.security:spring-security-crypto' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' + implementation 'org.apache.commons:commons-text:1.10.0' // 유사도 계산용 } \ No newline at end of file From 616eb7bbd0cb0993d4a50947a9dfa3d04ae5aaae Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Wed, 29 Oct 2025 09:19:35 +0900 Subject: [PATCH 042/178] =?UTF-8?q?feat=20:=20dto=20=EB=B0=8F=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=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 --- .../interest/dto/InterestOrderBy.java | 5 ++ .../request/CursorPageRequestInterestDto.java | 18 ++++++++ .../dto/request/InterestRegisterRequest.java | 17 +++++++ .../CursorPageResponseInterestDto.java | 16 +++++++ .../interest/dto/response/InterestDto.java | 15 ++++++ .../monew_api/interest/entity/Interest.java | 46 +++++++++++++++++++ .../interest/entity/InterestKeyword.java | 35 ++++++++++++++ .../monew_api/interest/entity/Keyword.java | 22 +++++++++ 8 files changed, 174 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/dto/InterestOrderBy.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestRegisterRequest.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/dto/response/CursorPageResponseInterestDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/entity/Keyword.java diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/InterestOrderBy.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/InterestOrderBy.java new file mode 100644 index 0000000..85b95af --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/InterestOrderBy.java @@ -0,0 +1,5 @@ +package com.monew.monew_api.interest.dto; + +public enum InterestOrderBy { + name, subscriberCount +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java new file mode 100644 index 0000000..90ff154 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java @@ -0,0 +1,18 @@ +package com.monew.monew_api.interest.dto.request; + +import com.monew.monew_api.interest.dto.InterestOrderBy; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Sort.Direction; + +@ParameterObject +public record CursorPageRequestInterestDto( + + String keyword, // 검색어(관심사, 키워드) + @NotNull InterestOrderBy orderBy, + @NotNull Direction direction, // 정렬 방향 (ASC, DESC) + String cursor, // 커서 값 + LocalDateTime after, // + @NotNull int limit // 커서 페이지 크기 +) {} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestRegisterRequest.java new file mode 100644 index 0000000..877dc68 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestRegisterRequest.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.interest.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record InterestRegisterRequest( + + @JsonProperty("name") + @Size(min = 1, max = 50) + String name, + + @Size(min = 1, max = 10) + List keywords +) { + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/CursorPageResponseInterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/CursorPageResponseInterestDto.java new file mode 100644 index 0000000..d5bf8e2 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/CursorPageResponseInterestDto.java @@ -0,0 +1,16 @@ +package com.monew.monew_api.interest.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +public record CursorPageResponseInterestDto( + List content, // 실제 데이터 목록 + String nextCursor, // 다음 페이지 요청 위한 커서 + LocalDateTime nextAfter, // 커서 기준 시점 + int size, // 한페이지에 담긴 데이터 개수 + Long totalElements, // 전체 데이터 개수 + boolean hasNext // 다음 페이지 존재 여부 + +) { + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java new file mode 100644 index 0000000..e524841 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java @@ -0,0 +1,15 @@ +package com.monew.monew_api.interest.dto.response; + +import java.util.List; +import lombok.Builder; + +@Builder +public record InterestDto ( + Long id, + String name, + List keywords, + Long subscriberCount, + boolean subscribedByMe +){ + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java new file mode 100644 index 0000000..1339ef3 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java @@ -0,0 +1,46 @@ +package com.monew.monew_api.interest.entity; + +import com.monew.monew_api.common.entity.BaseTimeEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.Table; +import java.util.HashSet; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "interests") +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class Interest extends BaseTimeEntity { + + @Column(length = 100, nullable = false, unique = true) + private String name; + + @Column(nullable = false) + private int subscriberCount = 0; + + @OneToMany(mappedBy = "interest", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("createdAt ASC") + private Set keywords = new HashSet<>(); + + private Interest(String name, int subscriberCount){ + this.name = name; + this.subscriberCount = subscriberCount; + } + + public static Interest create(String interestName) { + return new Interest(interestName, 0);} + + public InterestKeyword addKeyword(Keyword keyword) { + InterestKeyword interestKeyword = InterestKeyword.create(this, keyword); + this.keywords.add(interestKeyword); + return interestKeyword; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java new file mode 100644 index 0000000..fb58734 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java @@ -0,0 +1,35 @@ +package com.monew.monew_api.interest.entity; + +import com.monew.monew_api.common.entity.BaseTimeEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "interest_keywords") +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class InterestKeyword extends BaseTimeEntity { + +@ManyToOne(fetch = FetchType.LAZY) +@JoinColumn(name = "interest_id", nullable = false) + private Interest interest; + +@ManyToOne(fetch = FetchType.LAZY) +@JoinColumn(name = "keyword_id", nullable = false) + private Keyword keyword; + +public static InterestKeyword create(Interest interest, Keyword keyword) { + return new InterestKeyword(interest, keyword); + } + +} + + + diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Keyword.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Keyword.java new file mode 100644 index 0000000..2305036 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Keyword.java @@ -0,0 +1,22 @@ +package com.monew.monew_api.interest.entity; + +import com.monew.monew_api.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "keywords") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Keyword extends BaseTimeEntity { + + @Column(name = "keyword", length = 50, nullable = false, unique = true) + private String keyword; + +} From 1d8be31997442ccc9747b53866b40be67324e55c Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Wed, 29 Oct 2025 09:21:10 +0900 Subject: [PATCH 043/178] =?UTF-8?q?refactor=20:=20article=20=EB=82=B4=20in?= =?UTF-8?q?terest=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_api/article/entity/Interest.java | 25 ------------------- .../article/entity/InterestArticles.java | 1 + 2 files changed, 1 insertion(+), 25 deletions(-) delete mode 100644 monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java deleted file mode 100644 index f56dcdf..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.monew.monew_api.article.entity; - -import com.monew.monew_api.common.entity.BaseIdEntity; -import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.ArrayList; -import java.util.List; - -/** - * 관심사 테이블 - */ -@Getter -@NoArgsConstructor -@Entity -@Table(name = "interests") -public class Interest extends BaseIdEntity { - - @Column(nullable = false, unique = true, length = 50) - private String name; - - @OneToMany(mappedBy = "interest", cascade = CascadeType.ALL, orphanRemoval = true) - private List interestArticles = new ArrayList<>(); -} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java index 4fd524c..9d58c37 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java @@ -1,6 +1,7 @@ package com.monew.monew_api.article.entity; import com.monew.monew_api.common.entity.BaseIdEntity; +import com.monew.monew_api.interest.entity.Interest; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; From 6649ff279f10e36f96e5ea7ddb969d6505a8b0ef Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Wed, 29 Oct 2025 09:23:21 +0900 Subject: [PATCH 044/178] =?UTF-8?q?feat=20:=20=EA=B4=80=EC=8B=AC=EC=82=AC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20dto=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/InterestUpdateRequest.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestUpdateRequest.java diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestUpdateRequest.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestUpdateRequest.java new file mode 100644 index 0000000..bb6f85c --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestUpdateRequest.java @@ -0,0 +1,14 @@ +package com.monew.monew_api.interest.dto.request; + +import jakarta.validation.constraints.Size; +import java.util.List; +import lombok.Builder; + + +@Builder +public record InterestUpdateRequest( + @Size(min = 1, max = 10) + List keywords +) { + +} From 095c5dbede81c565a4172d5fb98ac0cbd0ffe5c4 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Wed, 29 Oct 2025 09:24:15 +0900 Subject: [PATCH 045/178] =?UTF-8?q?feat=20:=20=EA=B4=80=EC=8B=AC=EC=82=AC?= =?UTF-8?q?=20CRUD=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/InterestController.java | 81 ++++++ .../interest/mapper/InterestMapper.java | 26 ++ .../repository/InterestRepository.java | 10 + .../repository/InterestRepositoryCustom.java | 26 ++ .../InterestRepositoryCustomImpl.java | 129 ++++++++++ .../repository/KeywordRepository.java | 28 ++ .../interest/service/InterestService.java | 19 ++ .../interest/service/InterestServiceImpl.java | 239 ++++++++++++++++++ 8 files changed, 558 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustom.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/repository/KeywordRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java b/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java new file mode 100644 index 0000000..e308d63 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java @@ -0,0 +1,81 @@ +package com.monew.monew_api.interest.controller; + +import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; +import com.monew.monew_api.interest.dto.request.InterestRegisterRequest; +import com.monew.monew_api.interest.dto.request.InterestUpdateRequest; +import com.monew.monew_api.interest.dto.response.CursorPageResponseInterestDto; +import com.monew.monew_api.interest.dto.response.InterestDto; +import com.monew.monew_api.interest.service.InterestService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/interests") +@RequiredArgsConstructor +@Slf4j +public class InterestController { + + private final InterestService interestService; + + @PostMapping + public ResponseEntity createInterest( + @RequestBody @Valid InterestRegisterRequest request + ){ + log.info("[API 요청] POST/api/interests/ - 관심사 등록 요청 : {}", request); + InterestDto response = interestService.createInterest(request); + log.info("[API 응답] POST/api/interests/ - 관심사 등록 응답 : {}", response); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + + @GetMapping + public ResponseEntity getInterests( + @RequestHeader("Monew-Request-User-Id") Long userId, + @ParameterObject @ModelAttribute CursorPageRequestInterestDto request + ){ + log.info("[API 요청] GET/api/interests/ - 관심사 조회 요청 : {}", request); + CursorPageResponseInterestDto response = interestService.getInterests(userId,request); + log.info("[API 응답] GET/api/interests/ - 관심사 조회 응답 : {}", response); + return ResponseEntity.ok(response); + } + + + @DeleteMapping("/{interestId}") + public ResponseEntity deleteInterest( + @PathVariable Long interestId + ){ + log.info("[API 요청] DELETE/api/interests/{} - 관심사 삭제 요청", interestId); + interestService.deleteInterest(interestId); + log.info("[API 응답] DELETE/api/interests/{} - 관심사 삭제 응답", interestId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + + @PatchMapping("/{interestId}") + public ResponseEntity updateInterestKeywords( + @PathVariable Long interestId, + @RequestBody InterestUpdateRequest request + ){ + log.info("[API 요청] PATCH/api/interests/{} - 관심사 키워드 수정 요청 : {}", interestId, request); + InterestDto response = interestService + .updateInterestKeywords(request, interestId); + log.info("[API 응답] PATCH/api/interests/{} - 관심사 키워드 수정 응답 : {}", interestId, response); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java b/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java new file mode 100644 index 0000000..efe91f8 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java @@ -0,0 +1,26 @@ +package com.monew.monew_api.interest.mapper; + +import com.monew.monew_api.interest.dto.response.InterestDto; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.InterestKeyword; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import org.mapstruct.Mapper; + + +@Mapper(componentModel = "spring") +public interface InterestMapper { + + InterestDto toInterestDto(Interest interest, List keywords, Boolean subscribedByMe); + + // 커스텀 매핑 메서드 (Set -> List) + default List map(Set interestKeywords) { + if (interestKeywords == null) { + return Collections.emptyList(); + } + return interestKeywords.stream() + .map(ik -> ik.getKeyword().getKeyword()) + .toList(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java new file mode 100644 index 0000000..924acbc --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java @@ -0,0 +1,10 @@ +package com.monew.monew_api.interest.repository; + +import com.monew.monew_api.interest.entity.Interest; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InterestRepository extends JpaRepository, + InterestRepositoryCustom { + + boolean existsByName(String name); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustom.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustom.java new file mode 100644 index 0000000..f600928 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustom.java @@ -0,0 +1,26 @@ +package com.monew.monew_api.interest.repository; + +import com.monew.monew_api.interest.dto.InterestOrderBy; +import com.monew.monew_api.interest.entity.Interest; +import java.time.LocalDateTime; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort.Direction; + +public interface InterestRepositoryCustom { + + Slice findAll( + String keyword, + InterestOrderBy sortBy, + Direction direction, + String cursor, + LocalDateTime after, + int limit + ); + + long countFilteredTotalElements( + String keyword, + InterestOrderBy sortBy, + Direction direction + ); +} + diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java new file mode 100644 index 0000000..2f98943 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java @@ -0,0 +1,129 @@ +package com.monew.monew_api.interest.repository; + +import com.monew.monew_api.interest.entity.QInterest; +import com.monew.monew_api.interest.entity.QInterestKeyword; +import com.monew.monew_api.interest.entity.QKeyword; +import com.monew.monew_api.interest.dto.InterestOrderBy; +import com.monew.monew_api.interest.entity.Interest; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class InterestRepositoryCustomImpl implements InterestRepositoryCustom { + + private final JPAQueryFactory queryFactory; + private final QInterest i = QInterest.interest; + + @Override + public Slice findAll( + String searchKeyword, InterestOrderBy sortBy, Direction direction, + String cursor, LocalDateTime after, int limit) { + + BooleanBuilder builder = new BooleanBuilder(); + + if (after != null){ + builder.and(i.updatedAt.goe(after)); + } + + // 커서 조건 + if (cursor != null && !cursor.isBlank()) { + if (sortBy == InterestOrderBy.name) { + if (direction == Direction.ASC) builder.and(i.name.gt(cursor)); + else builder.and(i.name.lt(cursor)); + } else if (sortBy == InterestOrderBy.subscriberCount) { + int v = Integer.parseInt(cursor); + if (direction == Direction.ASC) builder.and(i.subscriberCount.gt(v)); + else builder.and(i.subscriberCount.lt(v)); + } + } + + // 정렬 조건 + OrderSpecifier primaryOrder = + (sortBy == InterestOrderBy.name) + ? (direction == Direction.ASC ? i.name.asc() : i.name.desc()) + : (direction == Direction.ASC ? i.subscriberCount.asc() : i.subscriberCount.desc()); + + OrderSpecifier createdAtOrder = + (direction == Direction.ASC ? i.createdAt.asc() : i.createdAt.desc()); + + QInterestKeyword ikFilter = new QInterestKeyword("ikFilter"); // 필터링 판별 전용 + QKeyword kFilter = new QKeyword("kFilter"); + + QInterestKeyword ikAll = new QInterestKeyword("ikAll"); // 전체 로딩 전용 + QKeyword kAll = new QKeyword("kAll"); + + JPAQuery query = queryFactory + .selectFrom(i) + .distinct(); + + + // 검색어가 있는 경우 + if (searchKeyword != null && !searchKeyword.isBlank()) { + BooleanExpression nameLike = i.name.containsIgnoreCase(searchKeyword); + + // 같은 관심사에 포함된 키워드 중 검색어가 포함되는 행만 매칭! + query.leftJoin(i.keywords, ikFilter) + .on(ikFilter.interest.eq(i)) + .leftJoin(ikFilter.keyword, kFilter) + .on(kFilter.keyword.containsIgnoreCase(searchKeyword)); + + builder.and(nameLike.or(kFilter.id.isNotNull())); + } + + // 전체 키워드 로딩 + query.leftJoin(i.keywords, ikAll).fetchJoin() + .leftJoin(ikAll.keyword, kAll).fetchJoin(); + + query.where(builder) + .orderBy(primaryOrder, createdAtOrder); + + List results = query.limit(limit + 1).fetch(); + + boolean hasNext = results.size() > limit; + if (hasNext) results = results.subList(0, limit); + + Pageable pageable = PageRequest.of(0, limit); + return new SliceImpl<>(results, pageable, hasNext); + } + + @Override + public long countFilteredTotalElements(String keyword, InterestOrderBy sortBy, Direction direction) { + + BooleanBuilder where = new BooleanBuilder(); + + QInterestKeyword ikFilter = new QInterestKeyword("ikFilter"); + QKeyword kFilter = new QKeyword("kFilter"); + + JPAQuery query = queryFactory + .select(i.id.countDistinct()) + .from(i); + + if (keyword != null && !keyword.isBlank()) { + BooleanExpression nameLike = i.name.containsIgnoreCase(keyword); + + query.leftJoin(i.keywords, ikFilter) + .on(ikFilter.interest.eq(i)) + .leftJoin(ikFilter.keyword, kFilter) + .on(kFilter.keyword.containsIgnoreCase(keyword)); + + where.and(nameLike.or(kFilter.id.isNotNull())); + } + + Long count = query.where(where).fetchOne(); + return (count == null) ? 0L : count; + } +} + diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/KeywordRepository.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/KeywordRepository.java new file mode 100644 index 0000000..17e0c91 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/KeywordRepository.java @@ -0,0 +1,28 @@ +package com.monew.monew_api.interest.repository; + + +import com.monew.monew_api.interest.entity.Keyword; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface KeywordRepository extends JpaRepository { + + Optional findByKeyword(String keyword); + + List findAllByKeywordIn(Collection keywords); + + @Query("SELECT k FROM Keyword k " + + "WHERE k IN :keywords AND NOT EXISTS (" + + "SELECT 1 FROM InterestKeyword ik WHERE ik.keyword = k" + + ")" + ) + List findOrphanKeywordsIn(@Param("keywords") Collection keywords); + + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java new file mode 100644 index 0000000..ad3aef1 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java @@ -0,0 +1,19 @@ +package com.monew.monew_api.interest.service; + +import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; +import com.monew.monew_api.interest.dto.request.InterestRegisterRequest; +import com.monew.monew_api.interest.dto.request.InterestUpdateRequest; +import com.monew.monew_api.interest.dto.response.CursorPageResponseInterestDto; +import com.monew.monew_api.interest.dto.response.InterestDto; + +public interface InterestService { + + InterestDto createInterest(InterestRegisterRequest request); + CursorPageResponseInterestDto getInterests(Long userId, CursorPageRequestInterestDto cursorRequest); + InterestDto updateInterestKeywords(InterestUpdateRequest request, Long interestId); + void deleteInterest(Long interestId); + // 구독 관련 메서드 추후에 구현 예정!!! + // 구독 + //- 사용자는 관심사를 구독할 수 있습니다. + //- 구독한 관심사와 관련된 뉴스 기사가 등록되면 알림을 받을 수 있습니다. +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java new file mode 100644 index 0000000..27baea0 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -0,0 +1,239 @@ +package com.monew.monew_api.interest.service; + +import com.monew.monew_api.common.exception.interest.InterestDuplicatedException; +import com.monew.monew_api.common.exception.interest.InterestNotFoundException; +import com.monew.monew_api.common.exception.user.UserNotFoundException; +import com.monew.monew_api.domain.user.repository.UserRepository; +import com.monew.monew_api.interest.dto.InterestOrderBy; +import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; +import com.monew.monew_api.interest.dto.request.InterestRegisterRequest; +import com.monew.monew_api.interest.dto.request.InterestUpdateRequest; +import com.monew.monew_api.interest.dto.response.CursorPageResponseInterestDto; +import com.monew.monew_api.interest.dto.response.InterestDto; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.InterestKeyword; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_api.interest.mapper.InterestMapper; +import com.monew.monew_api.interest.repository.InterestRepository; +import com.monew.monew_api.interest.repository.KeywordRepository; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.text.similarity.LevenshteinDistance; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class InterestServiceImpl implements InterestService { + + private final InterestRepository interestRepository; + private final UserRepository userRepository; + private final KeywordRepository keywordRepository; + private final InterestMapper interestMapper; + + @Override + @Transactional + public InterestDto createInterest(InterestRegisterRequest request) { + + log.info("새로운 관심사 등록 요청: {}", request); + String interestName = request.name(); + + // 유사도 검사 + String similarName = findSimilarInterestName(interestName); + if(similarName != null) { + Map details = new HashMap<>(); + details.put("name", similarName); + log.warn("유사한 관심사 이름: {}", similarName); + throw new InterestDuplicatedException(details); + } + + Interest interest = Interest.create(interestName); + + // 키워드 저장 + Set keywordSet = new HashSet<>(request.keywords()); + for(String keyword : keywordSet) { + Keyword getKeyword = keywordRepository.findByKeyword(keyword) + .orElseGet(() -> keywordRepository.save(new Keyword(keyword))); + interest.addKeyword(getKeyword); + } + + Interest savedInterest = interestRepository.save(interest); + + List keywords = savedInterest.getKeywords().stream() + .map(ik -> ik.getKeyword().getKeyword()) + .collect(Collectors.toList()); + + InterestDto response = interestMapper.toInterestDto(savedInterest, keywords, false); + log.info("관심사 등록 완료: {}", response); + + return response; + } + + @Override + @Transactional(readOnly = true) + public CursorPageResponseInterestDto getInterests(Long userId, + CursorPageRequestInterestDto request) { + + log.info("관심사 조회 요청 : {}", request); + userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + + final String keyword = (request.keyword() == null) ? null : request.keyword(); + final InterestOrderBy orderBy = (request.orderBy() == null) ? InterestOrderBy.name : request.orderBy(); + final Direction direction = (request.direction() == null) ? Direction.ASC : request.direction(); + final String cursor = request.cursor(); + final LocalDateTime after = request.after(); + final int limit = request.limit(); + + Slice slices = interestRepository.findAll( + keyword, orderBy, direction, cursor, after, limit); + + List interests = slices.getContent(); + +// Set interestIds = interests.stream().map(Interest::getId).collect(Collectors.toSet()); + + List interestDtos = new ArrayList<>(interests.size()); + for (Interest interest : interests) { + List keywords = new ArrayList<>(); + for(InterestKeyword ik : interest.getKeywords()) { + String name = ik.getKeyword().getKeyword(); + keywords.add(name); + } + // ⭐️⭐️ 구독 여부 확인 조회 코드 필요!!!! + interestDtos.add(interestMapper.toInterestDto(interest,keywords,false)); + } + + Long totalElements = interestRepository.countFilteredTotalElements(keyword, orderBy,direction); + + boolean hasNext = slices.hasNext(); + String nextCursor = null; + LocalDateTime nextAfter = null; + + if (!interests.isEmpty()) { + Interest last = interests.get(interests.size() - 1); + switch (orderBy) { + case name -> { + String name = last.getName(); + nextCursor = (name != null && !name.isBlank()) + ? name + : String.valueOf(last.getId()); + } + case subscriberCount -> nextCursor = String.valueOf(last.getSubscriberCount()); + default -> nextCursor = String.valueOf(last.getId()); + } nextAfter = last.getCreatedAt(); + } + + return new CursorPageResponseInterestDto( + interestDtos, nextCursor, nextAfter, interestDtos.size(), totalElements, hasNext); + } + + + @Override + @Transactional + public InterestDto updateInterestKeywords( + InterestUpdateRequest request, Long interestId) { + log.info("interestId = {}, 관심사 키워드 수정 요청 : {}", interestId, request); + +// userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + Interest interest = interestRepository.findById(interestId).orElseThrow(InterestNotFoundException::new); + + updateKeywords(interest, request.keywords()); + + List keywords = interest.getKeywords().stream() + .map(ik -> ik.getKeyword().getKeyword()) + .collect(Collectors.toList()); + + // ⭐️⭐️구독 여부 가져오는 코드 추가 필요!! + + log.info("interestId = {}, 관심사 키워드 수정 완료 : {}", interestId, keywords); + return interestMapper.toInterestDto(interest, keywords, false); + } + + + @Override + @Transactional + public void deleteInterest(Long interestId) { + log.info("관심사 삭제 요청 : interestId = {}", interestId); + Interest interest = interestRepository.findById(interestId) + .orElseThrow(InterestNotFoundException::new); + + interestRepository.delete(interest); + log.info("관심사 삭제 완료 : interestId = {}", interestId); + } + + + private String findSimilarInterestName(String newInterestName) { + for (Interest existingInterest : interestRepository.findAll()) { + double similarity = calculateSimilarity(existingInterest.getName(), newInterestName); + if (similarity >= 0.8) { + return existingInterest.getName(); + } + } + return null; + } + + + private double calculateSimilarity(String name1, String name2) { + if(name1 == null || name2 == null) { + return 0.0; + } + LevenshteinDistance levenshtein = LevenshteinDistance.getDefaultInstance(); + int distance = levenshtein.apply(name1, name2); + int maxLength = Math.max(name1.length(), name2.length()); + return 1.0 - ((double) distance / maxLength); + } + + + private void updateKeywords( + Interest interest, @Size(min = 1, max = 10) List requestKeywords) { + + Map savedKeywords = interest.getKeywords().stream() + .collect(Collectors.toMap( + ik -> ik.getKeyword().getKeyword(), + ik -> ik)); + + Set requestKeywordSet = new HashSet<>(requestKeywords); + + List existingKeywords = keywordRepository.findAllByKeywordIn(requestKeywordSet); + Map existingKeywordMap = existingKeywords.stream() + .collect(Collectors.toMap(Keyword::getKeyword, k -> k)); + + for (String keyword : requestKeywordSet) { + if (!savedKeywords.containsKey(keyword)) { + Keyword getKeyword = existingKeywordMap.getOrDefault(keyword, new Keyword(keyword)); + if (getKeyword.getId() == null) { + getKeyword = keywordRepository.save(getKeyword); + } + interest.addKeyword(getKeyword); + } else { + savedKeywords.remove(keyword); + } + } + removeOrphanKeywords(interest, savedKeywords); + } + + + private void removeOrphanKeywords(Interest interest, Map toRemove) { + if (toRemove.isEmpty()) {return;} + List removedKeyword = new ArrayList<>(); + + for (InterestKeyword interestKeyword : toRemove.values()) { + interest.getKeywords().remove(interestKeyword); + removedKeyword.add(interestKeyword.getKeyword()); + } + + List toDelete = keywordRepository.findOrphanKeywordsIn(removedKeyword); + keywordRepository.deleteAll(toDelete); + } +} From c46200c18f303a66ca8983a002a4d7159f7cab91 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Wed, 29 Oct 2025 11:34:11 +0900 Subject: [PATCH 046/178] =?UTF-8?q?refactor=20:=20=EB=B9=8C=EB=8D=94=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interest/dto/request/InterestUpdateRequest.java | 1 - .../monew/monew_api/interest/dto/response/InterestDto.java | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestUpdateRequest.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestUpdateRequest.java index bb6f85c..acd5898 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestUpdateRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestUpdateRequest.java @@ -5,7 +5,6 @@ import lombok.Builder; -@Builder public record InterestUpdateRequest( @Size(min = 1, max = 10) List keywords diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java index e524841..08a51c8 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java @@ -3,13 +3,12 @@ import java.util.List; import lombok.Builder; -@Builder -public record InterestDto ( +public record InterestDto( Long id, String name, List keywords, Long subscriberCount, boolean subscribedByMe -){ +) { } From d202e2c057a5bd59d183a0ebffac76a10f933041 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Wed, 29 Oct 2025 11:35:06 +0900 Subject: [PATCH 047/178] =?UTF-8?q?refactor=20:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/InterestController.java | 10 ++++---- .../request/CursorPageRequestInterestDto.java | 5 ++-- .../monew_api/interest/entity/Interest.java | 5 ++-- .../interest/entity/InterestKeyword.java | 16 ++++++------ .../InterestRepositoryCustomImpl.java | 24 ++++++++++++------ .../interest/service/InterestService.java | 6 ++++- .../interest/service/InterestServiceImpl.java | 25 +++++++++++-------- 7 files changed, 55 insertions(+), 36 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java b/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java index e308d63..88c9e78 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java @@ -34,7 +34,7 @@ public class InterestController { @PostMapping public ResponseEntity createInterest( @RequestBody @Valid InterestRegisterRequest request - ){ + ) { log.info("[API 요청] POST/api/interests/ - 관심사 등록 요청 : {}", request); InterestDto response = interestService.createInterest(request); log.info("[API 응답] POST/api/interests/ - 관심사 등록 응답 : {}", response); @@ -46,9 +46,9 @@ public ResponseEntity createInterest( public ResponseEntity getInterests( @RequestHeader("Monew-Request-User-Id") Long userId, @ParameterObject @ModelAttribute CursorPageRequestInterestDto request - ){ + ) { log.info("[API 요청] GET/api/interests/ - 관심사 조회 요청 : {}", request); - CursorPageResponseInterestDto response = interestService.getInterests(userId,request); + CursorPageResponseInterestDto response = interestService.getInterests(userId, request); log.info("[API 응답] GET/api/interests/ - 관심사 조회 응답 : {}", response); return ResponseEntity.ok(response); } @@ -57,7 +57,7 @@ public ResponseEntity getInterests( @DeleteMapping("/{interestId}") public ResponseEntity deleteInterest( @PathVariable Long interestId - ){ + ) { log.info("[API 요청] DELETE/api/interests/{} - 관심사 삭제 요청", interestId); interestService.deleteInterest(interestId); log.info("[API 응답] DELETE/api/interests/{} - 관심사 삭제 응답", interestId); @@ -69,7 +69,7 @@ public ResponseEntity deleteInterest( public ResponseEntity updateInterestKeywords( @PathVariable Long interestId, @RequestBody InterestUpdateRequest request - ){ + ) { log.info("[API 요청] PATCH/api/interests/{} - 관심사 키워드 수정 요청 : {}", interestId, request); InterestDto response = interestService .updateInterestKeywords(request, interestId); diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java index 90ff154..c0d7f1b 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java @@ -14,5 +14,6 @@ public record CursorPageRequestInterestDto( @NotNull Direction direction, // 정렬 방향 (ASC, DESC) String cursor, // 커서 값 LocalDateTime after, // - @NotNull int limit // 커서 페이지 크기 -) {} + @NotNull Integer limit // 커서 페이지 크기 +){ +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java index 1339ef3..070deca 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java @@ -30,13 +30,14 @@ public class Interest extends BaseTimeEntity { @OrderBy("createdAt ASC") private Set keywords = new HashSet<>(); - private Interest(String name, int subscriberCount){ + private Interest(String name, int subscriberCount) { this.name = name; this.subscriberCount = subscriberCount; } public static Interest create(String interestName) { - return new Interest(interestName, 0);} + return new Interest(interestName, 0); + } public InterestKeyword addKeyword(Keyword keyword) { InterestKeyword interestKeyword = InterestKeyword.create(this, keyword); diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java index fb58734..466dd95 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java @@ -17,16 +17,16 @@ @AllArgsConstructor public class InterestKeyword extends BaseTimeEntity { -@ManyToOne(fetch = FetchType.LAZY) -@JoinColumn(name = "interest_id", nullable = false) - private Interest interest; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "interest_id", nullable = false) + private Interest interest; -@ManyToOne(fetch = FetchType.LAZY) -@JoinColumn(name = "keyword_id", nullable = false) - private Keyword keyword; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "keyword_id", nullable = false) + private Keyword keyword; -public static InterestKeyword create(Interest interest, Keyword keyword) { - return new InterestKeyword(interest, keyword); + public static InterestKeyword create(Interest interest, Keyword keyword) { + return new InterestKeyword(interest, keyword); } } diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java index 2f98943..1f68e0d 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java @@ -34,19 +34,25 @@ public Slice findAll( BooleanBuilder builder = new BooleanBuilder(); - if (after != null){ + if (after != null) { builder.and(i.updatedAt.goe(after)); } // 커서 조건 if (cursor != null && !cursor.isBlank()) { if (sortBy == InterestOrderBy.name) { - if (direction == Direction.ASC) builder.and(i.name.gt(cursor)); - else builder.and(i.name.lt(cursor)); + if (direction == Direction.ASC) { + builder.and(i.name.gt(cursor)); + } else { + builder.and(i.name.lt(cursor)); + } } else if (sortBy == InterestOrderBy.subscriberCount) { int v = Integer.parseInt(cursor); - if (direction == Direction.ASC) builder.and(i.subscriberCount.gt(v)); - else builder.and(i.subscriberCount.lt(v)); + if (direction == Direction.ASC) { + builder.and(i.subscriberCount.gt(v)); + } else { + builder.and(i.subscriberCount.lt(v)); + } } } @@ -69,7 +75,6 @@ public Slice findAll( .selectFrom(i) .distinct(); - // 검색어가 있는 경우 if (searchKeyword != null && !searchKeyword.isBlank()) { BooleanExpression nameLike = i.name.containsIgnoreCase(searchKeyword); @@ -93,14 +98,17 @@ public Slice findAll( List results = query.limit(limit + 1).fetch(); boolean hasNext = results.size() > limit; - if (hasNext) results = results.subList(0, limit); + if (hasNext) { + results = results.subList(0, limit); + } Pageable pageable = PageRequest.of(0, limit); return new SliceImpl<>(results, pageable, hasNext); } @Override - public long countFilteredTotalElements(String keyword, InterestOrderBy sortBy, Direction direction) { + public long countFilteredTotalElements(String keyword, InterestOrderBy sortBy, + Direction direction) { BooleanBuilder where = new BooleanBuilder(); diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java index ad3aef1..fc2341a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java @@ -9,8 +9,12 @@ public interface InterestService { InterestDto createInterest(InterestRegisterRequest request); - CursorPageResponseInterestDto getInterests(Long userId, CursorPageRequestInterestDto cursorRequest); + + CursorPageResponseInterestDto getInterests(Long userId, + CursorPageRequestInterestDto cursorRequest); + InterestDto updateInterestKeywords(InterestUpdateRequest request, Long interestId); + void deleteInterest(Long interestId); // 구독 관련 메서드 추후에 구현 예정!!! // 구독 diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index 27baea0..c16b8f1 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -52,7 +52,7 @@ public InterestDto createInterest(InterestRegisterRequest request) { // 유사도 검사 String similarName = findSimilarInterestName(interestName); - if(similarName != null) { + if (similarName != null) { Map details = new HashMap<>(); details.put("name", similarName); log.warn("유사한 관심사 이름: {}", similarName); @@ -63,7 +63,7 @@ public InterestDto createInterest(InterestRegisterRequest request) { // 키워드 저장 Set keywordSet = new HashSet<>(request.keywords()); - for(String keyword : keywordSet) { + for (String keyword : keywordSet) { Keyword getKeyword = keywordRepository.findByKeyword(keyword) .orElseGet(() -> keywordRepository.save(new Keyword(keyword))); interest.addKeyword(getKeyword); @@ -90,7 +90,8 @@ public CursorPageResponseInterestDto getInterests(Long userId, userRepository.findById(userId).orElseThrow(UserNotFoundException::new); final String keyword = (request.keyword() == null) ? null : request.keyword(); - final InterestOrderBy orderBy = (request.orderBy() == null) ? InterestOrderBy.name : request.orderBy(); + final InterestOrderBy orderBy = + (request.orderBy() == null) ? InterestOrderBy.name : request.orderBy(); final Direction direction = (request.direction() == null) ? Direction.ASC : request.direction(); final String cursor = request.cursor(); final LocalDateTime after = request.after(); @@ -106,15 +107,15 @@ public CursorPageResponseInterestDto getInterests(Long userId, List interestDtos = new ArrayList<>(interests.size()); for (Interest interest : interests) { List keywords = new ArrayList<>(); - for(InterestKeyword ik : interest.getKeywords()) { + for (InterestKeyword ik : interest.getKeywords()) { String name = ik.getKeyword().getKeyword(); keywords.add(name); } // ⭐️⭐️ 구독 여부 확인 조회 코드 필요!!!! - interestDtos.add(interestMapper.toInterestDto(interest,keywords,false)); + interestDtos.add(interestMapper.toInterestDto(interest, keywords, false)); } - Long totalElements = interestRepository.countFilteredTotalElements(keyword, orderBy,direction); + Long totalElements = interestRepository.countFilteredTotalElements(keyword, orderBy, direction); boolean hasNext = slices.hasNext(); String nextCursor = null; @@ -131,7 +132,8 @@ public CursorPageResponseInterestDto getInterests(Long userId, } case subscriberCount -> nextCursor = String.valueOf(last.getSubscriberCount()); default -> nextCursor = String.valueOf(last.getId()); - } nextAfter = last.getCreatedAt(); + } + nextAfter = last.getCreatedAt(); } return new CursorPageResponseInterestDto( @@ -146,7 +148,8 @@ public InterestDto updateInterestKeywords( log.info("interestId = {}, 관심사 키워드 수정 요청 : {}", interestId, request); // userRepository.findById(userId).orElseThrow(UserNotFoundException::new); - Interest interest = interestRepository.findById(interestId).orElseThrow(InterestNotFoundException::new); + Interest interest = interestRepository.findById(interestId) + .orElseThrow(InterestNotFoundException::new); updateKeywords(interest, request.keywords()); @@ -185,7 +188,7 @@ private String findSimilarInterestName(String newInterestName) { private double calculateSimilarity(String name1, String name2) { - if(name1 == null || name2 == null) { + if (name1 == null || name2 == null) { return 0.0; } LevenshteinDistance levenshtein = LevenshteinDistance.getDefaultInstance(); @@ -225,7 +228,9 @@ private void updateKeywords( private void removeOrphanKeywords(Interest interest, Map toRemove) { - if (toRemove.isEmpty()) {return;} + if (toRemove.isEmpty()) { + return; + } List removedKeyword = new ArrayList<>(); for (InterestKeyword interestKeyword : toRemove.values()) { From 224070a71355723753547e7427be03c2cdd7fbc8 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Thu, 30 Oct 2025 10:17:39 +0900 Subject: [PATCH 048/178] =?UTF-8?q?refactor=20:=20InterestRepositoryCustom?= =?UTF-8?q?Impl=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InterestRepositoryCustomImpl.java | 155 +++++++++--------- 1 file changed, 76 insertions(+), 79 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java index 1f68e0d..a1fe0c2 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java @@ -1,14 +1,11 @@ package com.monew.monew_api.interest.repository; import com.monew.monew_api.interest.entity.QInterest; -import com.monew.monew_api.interest.entity.QInterestKeyword; -import com.monew.monew_api.interest.entity.QKeyword; import com.monew.monew_api.interest.dto.InterestOrderBy; import com.monew.monew_api.interest.entity.Interest; import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Order; import com.querydsl.core.types.OrderSpecifier; -import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.LocalDateTime; import java.util.List; @@ -25,113 +22,113 @@ public class InterestRepositoryCustomImpl implements InterestRepositoryCustom { private final JPAQueryFactory queryFactory; - private final QInterest i = QInterest.interest; + private static final QInterest i = QInterest.interest; @Override public Slice findAll( String searchKeyword, InterestOrderBy sortBy, Direction direction, - String cursor, LocalDateTime after, int limit) { + String cursor, LocalDateTime after, int limit + ) { BooleanBuilder builder = new BooleanBuilder(); + addSearchConditions(builder, searchKeyword); // 관심사, 키워드 부분일치 조건 - if (after != null) { - builder.and(i.updatedAt.goe(after)); - } - - // 커서 조건 if (cursor != null && !cursor.isBlank()) { - if (sortBy == InterestOrderBy.name) { - if (direction == Direction.ASC) { - builder.and(i.name.gt(cursor)); - } else { - builder.and(i.name.lt(cursor)); - } - } else if (sortBy == InterestOrderBy.subscriberCount) { - int v = Integer.parseInt(cursor); - if (direction == Direction.ASC) { - builder.and(i.subscriberCount.gt(v)); - } else { - builder.and(i.subscriberCount.lt(v)); - } - } + builder.and(createCursorCondition(sortBy, direction, cursor)); // 커서 조건 } - // 정렬 조건 - OrderSpecifier primaryOrder = - (sortBy == InterestOrderBy.name) - ? (direction == Direction.ASC ? i.name.asc() : i.name.desc()) - : (direction == Direction.ASC ? i.subscriberCount.asc() : i.subscriberCount.desc()); + OrderSpecifier orderSpecifier = createOrderSpecifier(sortBy, direction); + OrderSpecifier idOrderSpecifier = direction == Direction.ASC ? i.id.asc() : i.id.desc(); - OrderSpecifier createdAtOrder = - (direction == Direction.ASC ? i.createdAt.asc() : i.createdAt.desc()); + List results = queryFactory + .selectFrom(i) + .where(builder) + .orderBy(orderSpecifier, idOrderSpecifier) + .limit(limit + 1) + .fetch(); - QInterestKeyword ikFilter = new QInterestKeyword("ikFilter"); // 필터링 판별 전용 - QKeyword kFilter = new QKeyword("kFilter"); + boolean hasNext = results.size() > limit; + if (hasNext) { + results.remove(limit); + } - QInterestKeyword ikAll = new QInterestKeyword("ikAll"); // 전체 로딩 전용 - QKeyword kAll = new QKeyword("kAll"); + Pageable pageable = PageRequest.of(0, limit); + return new SliceImpl<>(results, pageable, hasNext); + } - JPAQuery query = queryFactory - .selectFrom(i) - .distinct(); - // 검색어가 있는 경우 - if (searchKeyword != null && !searchKeyword.isBlank()) { - BooleanExpression nameLike = i.name.containsIgnoreCase(searchKeyword); + private BooleanBuilder createCursorCondition(InterestOrderBy sortBy, Direction direction, + String cursor) { + BooleanBuilder builder = new BooleanBuilder(); + + if (sortBy == InterestOrderBy.subscriberCount) { + Long cursorSubscriberCount = Long.parseLong(cursor); + handleSubscriberCountCursor(builder, cursorSubscriberCount, direction); + } else { + handleNameCursor(builder, cursor, direction); + } + return builder; + } - // 같은 관심사에 포함된 키워드 중 검색어가 포함되는 행만 매칭! - query.leftJoin(i.keywords, ikFilter) - .on(ikFilter.interest.eq(i)) - .leftJoin(ikFilter.keyword, kFilter) - .on(kFilter.keyword.containsIgnoreCase(searchKeyword)); - builder.and(nameLike.or(kFilter.id.isNotNull())); + private void handleSubscriberCountCursor(BooleanBuilder builder, Long cursorSubscriberCount, + Direction direction) { + if (direction == Direction.DESC) { + builder.and(i.subscriberCount.lt(cursorSubscriberCount)); + } else { + builder.and(i.subscriberCount.gt(cursorSubscriberCount)); } + } - // 전체 키워드 로딩 - query.leftJoin(i.keywords, ikAll).fetchJoin() - .leftJoin(ikAll.keyword, kAll).fetchJoin(); - query.where(builder) - .orderBy(primaryOrder, createdAtOrder); + private void handleNameCursor(BooleanBuilder builder, String cursor, Direction direction) { + if (direction == Direction.DESC) { + builder.and(i.name.lt(cursor)); + } else { + builder.and(i.name.eq(cursor)); + } + } - List results = query.limit(limit + 1).fetch(); - boolean hasNext = results.size() > limit; - if (hasNext) { - results = results.subList(0, limit); + private OrderSpecifier createOrderSpecifier(InterestOrderBy sortBy, Direction direction) { + Order order = (direction == Direction.DESC) ? Order.DESC : Order.ASC; + + switch (sortBy) { + case subscriberCount: + return new OrderSpecifier<>(order, i.subscriberCount); + case name: + return new OrderSpecifier<>(order, i.name); + default: + throw new IllegalStateException("Unhandled sort by: " + sortBy); } + } - Pageable pageable = PageRequest.of(0, limit); - return new SliceImpl<>(results, pageable, hasNext); + + private void addSearchConditions(BooleanBuilder builder, String searchKeyword) { + if (searchKeyword != null && !searchKeyword.isEmpty()) { + builder.and( + i.name.containsIgnoreCase(searchKeyword) + .or(i.keywords.any().keyword.keyword.containsIgnoreCase(searchKeyword)) + ); + } } + @Override public long countFilteredTotalElements(String keyword, InterestOrderBy sortBy, Direction direction) { + QInterest i = QInterest.interest; - BooleanBuilder where = new BooleanBuilder(); - - QInterestKeyword ikFilter = new QInterestKeyword("ikFilter"); - QKeyword kFilter = new QKeyword("kFilter"); - - JPAQuery query = queryFactory - .select(i.id.countDistinct()) - .from(i); - - if (keyword != null && !keyword.isBlank()) { - BooleanExpression nameLike = i.name.containsIgnoreCase(keyword); - - query.leftJoin(i.keywords, ikFilter) - .on(ikFilter.interest.eq(i)) - .leftJoin(ikFilter.keyword, kFilter) - .on(kFilter.keyword.containsIgnoreCase(keyword)); + BooleanBuilder builder = new BooleanBuilder(); + addSearchConditions(builder, keyword); - where.and(nameLike.or(kFilter.id.isNotNull())); - } + Long count = queryFactory + .select(i.count()) + .from(i) + .where(builder) + .fetchOne(); - Long count = query.where(where).fetchOne(); - return (count == null) ? 0L : count; + return count != null ? count : 0; } } From 0dab5b304b7e60112937e08c83b1ff77c11d17cf Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Thu, 30 Oct 2025 10:18:05 +0900 Subject: [PATCH 049/178] =?UTF-8?q?refactor=20:=20InterestServiceImpl=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interest/service/InterestServiceImpl.java | 55 +++++++++++-------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index c16b8f1..0a8a95b 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -102,8 +102,6 @@ public CursorPageResponseInterestDto getInterests(Long userId, List interests = slices.getContent(); -// Set interestIds = interests.stream().map(Interest::getId).collect(Collectors.toSet()); - List interestDtos = new ArrayList<>(interests.size()); for (Interest interest : interests) { List keywords = new ArrayList<>(); @@ -115,32 +113,17 @@ public CursorPageResponseInterestDto getInterests(Long userId, interestDtos.add(interestMapper.toInterestDto(interest, keywords, false)); } - Long totalElements = interestRepository.countFilteredTotalElements(keyword, orderBy, direction); - +// Set interestIds = interests.stream().map(Interest::getId).collect(Collectors.toSet()); boolean hasNext = slices.hasNext(); - String nextCursor = null; - LocalDateTime nextAfter = null; - if (!interests.isEmpty()) { - Interest last = interests.get(interests.size() - 1); - switch (orderBy) { - case name -> { - String name = last.getName(); - nextCursor = (name != null && !name.isBlank()) - ? name - : String.valueOf(last.getId()); - } - case subscriberCount -> nextCursor = String.valueOf(last.getSubscriberCount()); - default -> nextCursor = String.valueOf(last.getId()); - } - nextAfter = last.getCreatedAt(); - } + String nextCursor = calculateNextCursor(interests, orderBy, hasNext); + LocalDateTime nextAfter = calculateNextAfter(interests); + long totalElements = interestRepository.countFilteredTotalElements(keyword, orderBy, direction); return new CursorPageResponseInterestDto( interestDtos, nextCursor, nextAfter, interestDtos.size(), totalElements, hasNext); } - @Override @Transactional public InterestDto updateInterestKeywords( @@ -163,7 +146,6 @@ public InterestDto updateInterestKeywords( return interestMapper.toInterestDto(interest, keywords, false); } - @Override @Transactional public void deleteInterest(Long interestId) { @@ -198,6 +180,35 @@ private double calculateSimilarity(String name1, String name2) { } + private String calculateNextCursor(List interests, InterestOrderBy orderBy, + boolean hasNext) { + if (!hasNext || interests.isEmpty()) { + return null; + } + Interest last = interests.get(interests.size() - 1); + String cursorValue = ""; + switch (orderBy) { + case name: + cursorValue = last.getName(); + break; + case subscriberCount: + cursorValue = String.valueOf(last.getSubscriberCount()); + break; + default: + throw new IllegalArgumentException("invalid order"); + } + return cursorValue; + } + + + private LocalDateTime calculateNextAfter(List interests) { + if (!interests.isEmpty()) { + return interests.get(interests.size() - 1).getCreatedAt(); + } + return null; + } + + private void updateKeywords( Interest interest, @Size(min = 1, max = 10) List requestKeywords) { From 6cd3611939c6c5bb1042961df66512cca4468fe7 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Thu, 30 Oct 2025 10:43:16 +0900 Subject: [PATCH 050/178] =?UTF-8?q?refactor=20:=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=B9=8C=EB=8D=94=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/monew/monew_api/interest/dto/response/InterestDto.java | 1 - 1 file changed, 1 deletion(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java index 08a51c8..7edeec3 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java @@ -1,7 +1,6 @@ package com.monew.monew_api.interest.dto.response; import java.util.List; -import lombok.Builder; public record InterestDto( Long id, From 00793e270e48b1f4b1426cb8d9355c847491924f Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Fri, 24 Oct 2025 11:51:16 +0900 Subject: [PATCH 051/178] =?UTF-8?q?chore:=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=ED=91=B8=EC=8B=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_api/comments/entity/Comment.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java new file mode 100644 index 0000000..ddae26f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java @@ -0,0 +1,75 @@ +package com.monew.monew_api.comments.entity; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +import com.monew.monew_api.common.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table( + name = "comments", + indexes = { + @Index(name = "ix_comments_user", columnList = "user_id"), + @Index(name = "ix_comments_article", columnList = "article_id") + } +) +@SQLDelete(sql = "UPDATE comments SET is_deleted = true, updated_at = now() WHERE id = ?") +@Where(clause = "is_deleted = false") +public class Comment extends BaseTimeEntity { + + // @ManyToOne(fetch = FetchType.LAZY) + // @JoinColumn(name = "user_id", nullable = false) + // private User user; + // + // @ManyToOne(fetch = FetchType.LAZY) + // @JoinColumn(name = "article_id", nullable = false) + // private Article article; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "article_id", nullable = false) + private Long articleId; + + @Size(max = 500) + @Column(name = "content", nullable = false, length = 500) + private String content; + + @Column(name = "is_deleted", nullable = false) + private boolean deleted = false; + + @Column(name = "like_count", nullable = false) + private int likeCount = 0; + + private Comment(Long userId, Long articleId, String content) { + this.userId = userId; + this.articleId = articleId; + this.content = content; + } + + // factory + public static Comment of(Long userId, Long articleId, String content) { + return new Comment(userId, articleId, content); + } + + // 비즈니스 로직 + public void updateContent(String content) { this.content = content; } + + public void increaseLike() { this.likeCount += 1; } + + public void decreaseLike() { + if (this.likeCount > 0) this.likeCount -= 1; + } + +} From 72c95c59d2b13437b3944726a0174b333312e08d Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Fri, 24 Oct 2025 17:01:04 +0900 Subject: [PATCH 052/178] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CommentController.java | 89 +++++++++++++++++ .../comments/dto/CommentRequestDto.java | 28 ++++++ .../comments/dto/CommentResponseDto.java | 27 ++++++ .../comments/entity/CommentLike.java | 52 ++++++++++ .../repository/CommentLikeRepository.java | 13 +++ .../repository/CommentRepository.java | 15 +++ .../comments/service/CommentService.java | 97 +++++++++++++++++++ 7 files changed, 321 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRequestDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentResponseDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java new file mode 100644 index 0000000..8ec858c --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java @@ -0,0 +1,89 @@ +package com.monew.monew_api.comments.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.monew.monew_api.comments.dto.CommentRequestDto; +import com.monew.monew_api.comments.dto.CommentResponseDto; +import com.monew.monew_api.comments.service.CommentService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class CommentController { + + private final CommentService commentService; + + @PostMapping("/comments") + public ResponseEntity writeComment( + @RequestHeader("MoNew-Request-User-ID") Long userId, + @Valid @RequestBody CommentRequestDto request + ) { + Long savedId = commentService.write( + userId, + request.getArticleIdAsLong(), + request.getContent() + ); + return ResponseEntity.status(HttpStatus.CREATED) + .body(String.valueOf(savedId)); + } + + @GetMapping("/articles/{articleId}/comments") + public ResponseEntity> getCommentsByArticle( + @PathVariable Long articleId + ) { + List comments = commentService.getCommentsByArticleId(articleId); + return ResponseEntity.ok(comments); + } + + @PatchMapping("/comments/{commentId}") + public ResponseEntity updateComment( + @RequestHeader("MoNew-Request-User-ID") Long userId, + @PathVariable Long commentId, + @Valid @RequestBody CommentRequestDto request + ) { + commentService.update(userId, commentId, request.getContent()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/comments/{commentId}") + public ResponseEntity deleteComment( + @RequestHeader("MoNew-Request-User-ID") Long userId, + @PathVariable Long commentId + ) { + commentService.delete(userId, commentId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/comments/{commentId}/like") + public ResponseEntity likeComment( + @RequestHeader("MoNew-Request-User-ID") Long userId, + @PathVariable Long commentId + ) { + commentService.like(userId, commentId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/comments/{commentId}/like") + public ResponseEntity unlikeComment( + @RequestHeader("MoNew-Request-User-ID") Long userId, + @PathVariable Long commentId + ) { + commentService.unlike(userId, commentId); + return ResponseEntity.noContent().build(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRequestDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRequestDto.java new file mode 100644 index 0000000..bb3cb12 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRequestDto.java @@ -0,0 +1,28 @@ +package com.monew.monew_api.comments.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class CommentRequestDto { + + @NotNull(message = "articleId는 필수입니다.") + private String articleId; + + @NotBlank(message = "댓글 내용을 작성해주세요.") + @Size(max = 500, message = "댓글은 최대 500자까지 작성 가능합니다.") + private String content; + + public Long getArticleIdAsLong() { + try { + return Long.parseLong(articleId); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("잘못된 ID 형식입니다: " + articleId); + } + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentResponseDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentResponseDto.java new file mode 100644 index 0000000..693e279 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentResponseDto.java @@ -0,0 +1,27 @@ +package com.monew.monew_api.comments.dto; + +import com.monew.monew_api.comments.entity.Comment; + +public record CommentResponseDto( + String id, + String articleId, + String userId, + String content, + int likeCount, + String createdAt, + String updatedAt +) { + + public static CommentResponseDto from(Comment comment) { + return new CommentResponseDto( + String.valueOf(comment.getId()), + String.valueOf(comment.getArticleId()), + String.valueOf(comment.getUserId()), + comment.getContent(), + comment.getLikeCount(), + comment.getCreatedAt().toString(), + comment.getUpdatedAt().toString() + ); + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java new file mode 100644 index 0000000..898e926 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java @@ -0,0 +1,52 @@ +package com.monew.monew_api.comments.entity; + +import com.monew.monew_api.common.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table( + name = "coment_likes", + uniqueConstraints = { + @UniqueConstraint(name = "uq_comment_likes", columnNames = {"user_id", "comment_id"}) + }, + indexes = { + @Index(name = "ix_comment_likes_user", columnList = "user_id"), + @Index(name = "ix_comment_likes_comment", columnList = "comment_id") + } + +) + +public class CommentLike extends BaseTimeEntity { + + // @ManyToOne(fetch = LAZY) @JoinColumn(name="user_id", nullable=false) + // private User user; + + // @ManyToOne(fetch = LAZY) @JoinColumn(name="comment_id", nullable=false) + // private Comment comment; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "comment_id", nullable = false) + private Long commentId; + + private CommentLike(Long userId, Long commentId) { + this.userId = userId; + this.commentId = commentId; + } + + public static CommentLike of(Long userId, Long commentId) { + return new CommentLike(userId, commentId); + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java new file mode 100644 index 0000000..bfea3fa --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java @@ -0,0 +1,13 @@ +package com.monew.monew_api.comments.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.monew.monew_api.comments.entity.CommentLike; + +public interface CommentLikeRepository extends JpaRepository { + + boolean existsByCommentIdAndUserId(Long commentId, Long userId); + + void deleteByCommentIdAndUserId(Long commentId, Long userId); + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java new file mode 100644 index 0000000..7e6b377 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java @@ -0,0 +1,15 @@ +package com.monew.monew_api.comments.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.monew.monew_api.comments.entity.Comment; + +@Repository +public interface CommentRepository extends JpaRepository { + + List findByArticleIdOrderByCreatedAtDesc(Long articleId); + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java new file mode 100644 index 0000000..43180e0 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -0,0 +1,97 @@ +package com.monew.monew_api.comments.service; + +import java.util.List; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.monew.monew_api.comments.dto.CommentResponseDto; +import com.monew.monew_api.comments.entity.Comment; +import com.monew.monew_api.comments.entity.CommentLike; +import com.monew.monew_api.comments.repository.CommentLikeRepository; +import com.monew.monew_api.comments.repository.CommentRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + private final CommentLikeRepository commentLikeRepository; + // private final ApplicationEventPublisher eventPublisher; // 알림/활동 로그용 이벤트 훅 + + // 댓글 작성 + @Transactional + public Long write(Long userId, Long articleId, String content) { + Comment comment = Comment.of(userId, articleId, content); + Comment saved = commentRepository.save(comment); + + // 댓글 생성 시 이벤트 발행 (알림/활동로그) + // eventPublisher.publishEvent(new CommentCreatedEvent(saved.getId(), userId, articleId)); + + return saved.getId(); + } + + // 댓글 조회 + public List getCommentsByArticleId(Long articleId) { + List comments = commentRepository.findByArticleIdOrderByCreatedAtDesc(articleId); + + return comments.stream() + .map(CommentResponseDto::from) + .toList(); + } + + // 댓글 수정 (작성자) + @Transactional + public void update(Long userId, Long commentId, String content) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다.")); + validateOwner(userId, comment); + comment.updateContent(content); + } + + private void validateOwner(Long userId, Comment comment) { + if (!comment.getUserId().equals(userId)) { + throw new IllegalStateException("작성자만 수행할 수 있습니다."); + } + } + + // 댓글 삭제 (작성자, soft delete) + @Transactional + public void delete(Long userId, Long commentId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다.")); + validateOwner(userId, comment); + commentRepository.delete(comment); + } + + // 댓글 좋아요 (중복 방지 + 카운트 증가) + @Transactional + public void like(Long userId, Long commentId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다.")); + try{ + commentLikeRepository.save(CommentLike.of(userId, commentId)); + comment.increaseLike(); + } catch (DataIntegrityViolationException e) { + throw new IllegalArgumentException("이미 좋아요한 댓글입니다."); + } + } + + // 댓글 좋아요 취소 (좋아요 취소 + 카운트 감소) + @Transactional + public void unlike(Long userId, Long commentId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다.")); + boolean liked = commentLikeRepository.existsByCommentIdAndUserId(commentId, userId); + if(!liked){ + return; + } + commentLikeRepository.deleteByCommentIdAndUserId(userId, commentId); + comment.decreaseLike(); + } +} From bab2e3d7623de15902d0e68075a25f7df7a3c5d3 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Tue, 28 Oct 2025 10:46:43 +0900 Subject: [PATCH 053/178] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CommentController.java | 16 +++- .../monew_api/comments/entity/Comment.java | 33 +++---- .../comments/entity/CommentLike.java | 33 ++++--- .../comments/event/CommentCreatedEvent.java | 9 ++ .../comments/event/CommentLikedEvent.java | 9 ++ .../monew_api/comments/mapper/UuidMapper.java | 68 +++++++++++++++ .../repository/CommentLikeRepository.java | 4 +- .../repository/CommentRepository.java | 8 +- .../repository/CommentRepositoryCustom.java | 29 +++++++ .../impl/CommentRepositoryImpl.java | 81 +++++++++++++++++ .../comments/service/CommentService.java | 86 +++++++++++++------ .../comments/CommentPurgeJobConfig.java | 48 +++++++++++ .../comments/CommentPurgeProperties.java | 11 +++ .../comments/CommentPurgeScheduler.java | 32 +++++++ .../comments/service/CommentPurgeService.java | 49 +++++++++++ 15 files changed, 453 insertions(+), 63 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/mapper/UuidMapper.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeJobConfig.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeProperties.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeScheduler.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/comments/service/CommentPurgeService.java diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java index 8ec858c..23757df 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java @@ -1,7 +1,9 @@ package com.monew.monew_api.comments.controller; +import java.time.LocalDateTime; import java.util.List; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -12,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.monew.monew_api.comments.dto.CommentRequestDto; @@ -44,9 +47,18 @@ public ResponseEntity writeComment( @GetMapping("/articles/{articleId}/comments") public ResponseEntity> getCommentsByArticle( - @PathVariable Long articleId + @PathVariable Long articleId, + @RequestParam(required = false, defaultValue = "10") Integer limit, + @RequestParam(required = false, defaultValue = "created") String sort, + @RequestParam(required = false) Long cursorId, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime cursorCreatedAt, + @RequestParam(required = false) Integer cursorLikeCount ) { - List comments = commentService.getCommentsByArticleId(articleId); + + List comments = commentService.getCommentsByArticleIdWithCursor( + articleId, limit, sort, cursorId, cursorCreatedAt, cursorLikeCount + ); return ResponseEntity.ok(comments); } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java index ddae26f..6538743 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java @@ -4,11 +4,16 @@ import org.hibernate.annotations.Where; import com.monew.monew_api.common.entity.BaseTimeEntity; +import com.monew.monew_api.domain.user.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.Getter; @@ -28,21 +33,19 @@ @Where(clause = "is_deleted = false") public class Comment extends BaseTimeEntity { - // @ManyToOne(fetch = FetchType.LAZY) - // @JoinColumn(name = "user_id", nullable = false) - // private User user; - // + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + // @ManyToOne(fetch = FetchType.LAZY) // @JoinColumn(name = "article_id", nullable = false) // private Article article; - @Column(name = "user_id", nullable = false) - private Long userId; - @Column(name = "article_id", nullable = false) private Long articleId; @Size(max = 500) + @NotBlank @Column(name = "content", nullable = false, length = 500) private String content; @@ -52,24 +55,24 @@ public class Comment extends BaseTimeEntity { @Column(name = "like_count", nullable = false) private int likeCount = 0; - private Comment(Long userId, Long articleId, String content) { - this.userId = userId; + private Comment(User user, Long articleId, String content) { + this.user = user; this.articleId = articleId; this.content = content; } - // factory - public static Comment of(Long userId, Long articleId, String content) { - return new Comment(userId, articleId, content); + public static Comment of(User user, Long articleId, String content) { + return new Comment(user, articleId, content); } - // 비즈니스 로직 + public boolean isOwnedBy(Long userId) { return this.user != null && this.user.getId().equals(userId); } public void updateContent(String content) { this.content = content; } public void increaseLike() { this.likeCount += 1; } - public void decreaseLike() { if (this.likeCount > 0) this.likeCount -= 1; } - + public Long getUserId() { + return this.user != null ? this.user.getId() : null; + } } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java index 898e926..88a35f5 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java @@ -1,10 +1,14 @@ package com.monew.monew_api.comments.entity; -import com.monew.monew_api.common.entity.BaseTimeEntity; +import com.monew.monew_api.common.entity.BaseCreatedEntity; +import com.monew.monew_api.domain.user.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; @@ -15,7 +19,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @Table( - name = "coment_likes", + name = "comment_likes", uniqueConstraints = { @UniqueConstraint(name = "uq_comment_likes", columnNames = {"user_id", "comment_id"}) }, @@ -26,27 +30,32 @@ ) -public class CommentLike extends BaseTimeEntity { +public class CommentLike extends BaseCreatedEntity { - // @ManyToOne(fetch = LAZY) @JoinColumn(name="user_id", nullable=false) - // private User user; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="user_id", nullable=false) + private User user; // @ManyToOne(fetch = LAZY) @JoinColumn(name="comment_id", nullable=false) // private Comment comment; - @Column(name = "user_id", nullable = false) - private Long userId; - @Column(name = "comment_id", nullable = false) private Long commentId; - private CommentLike(Long userId, Long commentId) { - this.userId = userId; + private CommentLike(User userId, Long commentId) { + this.user = userId; this.commentId = commentId; } - public static CommentLike of(Long userId, Long commentId) { - return new CommentLike(userId, commentId); + public static CommentLike of(User user, Long commentId) { + return new CommentLike(user, commentId); + } + + public boolean isByUser(Long userId) { + return this.user != null && this.user.getId().equals(userId); } + public boolean isForComment(Long commentId) { + return this.commentId.equals(commentId); + } } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java new file mode 100644 index 0000000..c673b30 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java @@ -0,0 +1,9 @@ +package com.monew.monew_api.comments.event; + +import java.time.LocalDateTime; + +public record CommentCreatedEvent(Long commentId, + Long userId, + Long articleId, + LocalDateTime createdAt) { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java new file mode 100644 index 0000000..67e7889 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java @@ -0,0 +1,9 @@ +package com.monew.monew_api.comments.event; + +import java.time.LocalDateTime; + +public record CommentLikedEvent(Long commentId, + Long commentAuthorId, + Long likedByUserId, + LocalDateTime createdAt) { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/mapper/UuidMapper.java b/monew-api/src/main/java/com/monew/monew_api/comments/mapper/UuidMapper.java new file mode 100644 index 0000000..1bb4e28 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/mapper/UuidMapper.java @@ -0,0 +1,68 @@ +package com.monew.monew_api.comments.mapper; + +import java.nio.ByteBuffer; +import java.util.Base64; +import java.util.UUID; + +public final class UuidMapper { + + private UuidMapper() { + // Utility class + } + + /** + * UUID(String) → Long(BigInt) + * + * 프론트에서 전달된 UUID를 내부 DB에서 사용하는 Long으로 변환 + */ + public static Long toLong(String uuidString) { + if (uuidString == null || uuidString.isBlank()) { + return null; + } + + UUID uuid = UUID.fromString(uuidString); + long msb = uuid.getMostSignificantBits(); + long lsb = uuid.getLeastSignificantBits(); + + // msb + lsb를 합쳐서 Long으로 변환 (충돌 최소화) + // 64bit에 맞추기 위해 XOR 사용 + return Math.abs(msb ^ lsb); + } + + /** + * Long(BigInt) → UUID(String) + * + * DB ID(Long)를 프론트로 보낼 때 UUID 형태 문자열로 변환 + */ + public static String toUuid(Long id) { + if (id == null) { + return null; + } + + // 단방향 매핑이므로 실제 UUID가 아니라 가상 UUID 생성 + UUID fakeUuid = new UUID(id, 0L); + return fakeUuid.toString(); + } + + /** + * Long(BigInt) → Base64 짧은 토큰 (선택적 사용) + * + * 프론트에서 짧은 키로 쓸 수 있음 (선택 사항) + */ + public static String toBase64(Long id) { + if (id == null) return null; + ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES); + buffer.putLong(id); + return Base64.getUrlEncoder().withoutPadding().encodeToString(buffer.array()); + } + + /** + * Base64 → Long(BigInt) + */ + public static Long fromBase64(String encoded) { + if (encoded == null || encoded.isBlank()) return null; + byte[] bytes = Base64.getUrlDecoder().decode(encoded); + ByteBuffer buffer = ByteBuffer.wrap(bytes); + return buffer.getLong(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java index bfea3fa..f097036 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java @@ -6,8 +6,8 @@ public interface CommentLikeRepository extends JpaRepository { - boolean existsByCommentIdAndUserId(Long commentId, Long userId); + boolean existsByCommentIdAndUser_Id(Long commentId, Long userId); - void deleteByCommentIdAndUserId(Long commentId, Long userId); + void deleteByCommentIdAndUser_Id(Long commentId, Long userId); } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java index 7e6b377..97089f3 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java @@ -1,15 +1,9 @@ package com.monew.monew_api.comments.repository; -import java.util.List; - import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; import com.monew.monew_api.comments.entity.Comment; -@Repository -public interface CommentRepository extends JpaRepository { - - List findByArticleIdOrderByCreatedAtDesc(Long articleId); +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java new file mode 100644 index 0000000..a843209 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java @@ -0,0 +1,29 @@ +package com.monew.monew_api.comments.repository; + +import java.time.LocalDateTime; +import java.util.List; + +import com.monew.monew_api.comments.entity.Comment; + +public interface CommentRepositoryCustom { + List findCommentsByArticleIdWithCursor( + Long articleId, + Long cursorId, + int limit, + String sort); + + List findPageByArticleIdOrderByCreatedAtDesc( + Long articleId, + Long cursorId, + LocalDateTime cursorCreatedAt, + int limit + ); + + List findPageByArticleIdOrderByLikeCountDesc( + Long articleId, + Long cursorId, + Integer cursorLikeCount, + int limit + ); + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java new file mode 100644 index 0000000..2ba0343 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java @@ -0,0 +1,81 @@ +package com.monew.monew_api.comments.repository.impl; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.monew.monew_api.comments.entity.Comment; +import com.monew.monew_api.comments.entity.QComment; +import com.monew.monew_api.comments.repository.CommentRepositoryCustom; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CommentRepositoryImpl implements CommentRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + private final QComment c = QComment.comment; + + @Override + public List findCommentsByArticleIdWithCursor(Long articleId, Long cursorId, int limit, String sort) { + OrderSpecifier orderBy = "like".equalsIgnoreCase(sort) ? c.likeCount.desc() : c.createdAt.desc(); + BooleanExpression byArticle = c.articleId.eq(articleId); + BooleanExpression ltCursor = cursorId != null ? c.id.lt(cursorId) : null; + + return jpaQueryFactory + .selectFrom(c) + .where(byArticle, ltCursor) + .orderBy(orderBy, c.id.desc()) + .limit(limit) + .fetch(); + } + + @Override + public List findPageByArticleIdOrderByCreatedAtDesc(Long articleId, Long cursorId, + LocalDateTime cursorCreatedAt, int limit) { + + BooleanExpression byArticle = c.articleId.eq(articleId); + BooleanExpression afterCursor = buildCreatedAtCursor(cursorId, cursorCreatedAt); + + return jpaQueryFactory + .selectFrom(c) + .where(byArticle, afterCursor) + .orderBy(c.createdAt.desc(), c.id.desc()) + .limit(limit) + .fetch(); + } + + @Override + public List findPageByArticleIdOrderByLikeCountDesc(Long articleId, Long cursorId, Integer cursorLikeCount, + int limit) { + BooleanExpression byArticle = c.articleId.eq(articleId); + BooleanExpression afterCursor = buildLikeCountCursor(cursorId, cursorLikeCount); + + return jpaQueryFactory + .selectFrom(c) + .where(byArticle, afterCursor) + .orderBy(c.likeCount.desc(), c.id.desc()) + .limit(limit) + .fetch(); + } + + private BooleanExpression buildCreatedAtCursor(Long cursorId, LocalDateTime cursorCreatedAt) { + if (cursorId == null || cursorCreatedAt == null) return null; + + return c.createdAt.lt(cursorCreatedAt) + .or(c.createdAt.eq(cursorCreatedAt).and(c.id.lt(cursorId))); + } + + private BooleanExpression buildLikeCountCursor(Long cursorId, Integer cursorLikeCount) { + if (cursorId == null || cursorLikeCount == null) return null; + + return c.likeCount.lt(cursorLikeCount) + .or(c.likeCount.eq(cursorLikeCount).and(c.id.lt(cursorId))); + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index 43180e0..d26f4e9 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -1,5 +1,6 @@ package com.monew.monew_api.comments.service; +import java.time.LocalDateTime; import java.util.List; import org.springframework.context.ApplicationEventPublisher; @@ -7,14 +8,20 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.monew.monew_api.comments.event.CommentCreatedEvent; +import com.monew.monew_api.comments.event.CommentLikedEvent; import com.monew.monew_api.comments.dto.CommentResponseDto; import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.comments.repository.CommentLikeRepository; import com.monew.monew_api.comments.repository.CommentRepository; +import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -22,76 +29,105 @@ public class CommentService { private final CommentRepository commentRepository; private final CommentLikeRepository commentLikeRepository; - // private final ApplicationEventPublisher eventPublisher; // 알림/활동 로그용 이벤트 훅 + private final ApplicationEventPublisher eventPublisher; + private final UserRepository userRepository; + + private static final String SORT_CREATED = "created"; + private static final String SORT_LIKE = "like"; - // 댓글 작성 @Transactional public Long write(Long userId, Long articleId, String content) { - Comment comment = Comment.of(userId, articleId, content); + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")); + Comment comment = Comment.of(user, articleId, content); Comment saved = commentRepository.save(comment); + log.info("[COMMENT][CREATE] userId={}, articleId={}, commentId={}", userId, articleId, saved.getId()); // 댓글 생성 시 이벤트 발행 (알림/활동로그) - // eventPublisher.publishEvent(new CommentCreatedEvent(saved.getId(), userId, articleId)); + eventPublisher.publishEvent(new CommentCreatedEvent(saved.getId(), userId, articleId, saved.getCreatedAt())); return saved.getId(); } - // 댓글 조회 - public List getCommentsByArticleId(Long articleId) { - List comments = commentRepository.findByArticleIdOrderByCreatedAtDesc(articleId); - - return comments.stream() - .map(CommentResponseDto::from) - .toList(); - } - - // 댓글 수정 (작성자) @Transactional public void update(Long userId, Long commentId, String content) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다.")); validateOwner(userId, comment); + log.info("[COMMENT][UPDATE] userId={}, commentId={}, contentLength={}", userId, commentId, content.length()); comment.updateContent(content); } private void validateOwner(Long userId, Comment comment) { - if (!comment.getUserId().equals(userId)) { + if (!comment.isOwnedBy(userId)) { throw new IllegalStateException("작성자만 수행할 수 있습니다."); } } - // 댓글 삭제 (작성자, soft delete) @Transactional public void delete(Long userId, Long commentId) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다.")); validateOwner(userId, comment); + log.info("[COMMENT][DELETE] userId={}, commentId={}", userId, commentId); commentRepository.delete(comment); } - // 댓글 좋아요 (중복 방지 + 카운트 증가) @Transactional public void like(Long userId, Long commentId) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다.")); - try{ - commentLikeRepository.save(CommentLike.of(userId, commentId)); - comment.increaseLike(); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")); + + try { + commentLikeRepository.save(CommentLike.of(user, commentId)); + comment.increaseLike(); + + log.info("[COMMENT][LIKE] userId={}, commentId={}", userId, commentId); + eventPublisher.publishEvent( + new CommentLikedEvent(comment.getId(), comment.getUser().getId(), userId, LocalDateTime.now()) + ); } catch (DataIntegrityViolationException e) { - throw new IllegalArgumentException("이미 좋아요한 댓글입니다."); + return; } } - // 댓글 좋아요 취소 (좋아요 취소 + 카운트 감소) @Transactional public void unlike(Long userId, Long commentId) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다.")); - boolean liked = commentLikeRepository.existsByCommentIdAndUserId(commentId, userId); - if(!liked){ + boolean liked = commentLikeRepository.existsByCommentIdAndUser_Id(commentId, userId); + if (!liked) { return; } - commentLikeRepository.deleteByCommentIdAndUserId(userId, commentId); + commentLikeRepository.deleteByCommentIdAndUser_Id(commentId, userId); + log.info("[COMMENT][UNLIKE] userId={}, commentId={}", userId, commentId); comment.decreaseLike(); } + + public List getCommentsByArticleIdWithCursor( + Long articleId, + int limit, + String sort, + Long cursorId, + LocalDateTime cursorCreatedAt, + Integer cursorLikeCount + ) { + String s = (sort == null) ? SORT_CREATED : sort.toLowerCase(); + + List page; + if (SORT_LIKE.equals(s)) { + page = commentRepository.findPageByArticleIdOrderByLikeCountDesc( + articleId, cursorId, cursorLikeCount, limit + ); + } else { + page = commentRepository.findPageByArticleIdOrderByCreatedAtDesc( + articleId, cursorId, cursorCreatedAt, limit + ); + } + + return page.stream().map(CommentResponseDto::from).toList(); + } } diff --git a/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeJobConfig.java new file mode 100644 index 0000000..9b4ea76 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeJobConfig.java @@ -0,0 +1,48 @@ +package com.monew.monew_batch.comments; + +import java.time.LocalDateTime; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import com.monew.monew_batch.comments.service.CommentPurgeService; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableBatchProcessing +@EnableConfigurationProperties(CommentPurgeProperties.class) +@RequiredArgsConstructor +public class CommentPurgeJobConfig { + + private final CommentPurgeService purgeService; + private final CommentPurgeProperties props; + + @Bean + public Job commentPurgeJob(JobRepository jobRepository, Step commentPurgeStep) { + return new JobBuilder("commentPurgeJob", jobRepository) + .start(commentPurgeStep) + .build(); + } + + @Bean + public Step commentPurgeStep(JobRepository jobRepository, + PlatformTransactionManager transactionManager) { + return new StepBuilder("commentPurgeStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + LocalDateTime cutoff = LocalDateTime.now().minusMinutes(props.getRetentionMinutes()); + purgeService.purge(cutoff); + return RepeatStatus.FINISHED; + }, transactionManager) + .build(); + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeProperties.java b/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeProperties.java new file mode 100644 index 0000000..e2442f9 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeProperties.java @@ -0,0 +1,11 @@ +package com.monew.monew_batch.comments; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "monew.comments.purge") +public class CommentPurgeProperties { + + private int retentionMinutes = 5; + public int getRetentionMinutes() { return retentionMinutes; } + public void setRetentionMinutes(int retentionMinutes) { this.retentionMinutes = retentionMinutes; } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeScheduler.java new file mode 100644 index 0000000..12b0bb8 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeScheduler.java @@ -0,0 +1,32 @@ +package com.monew.monew_batch.comments; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +@EnableScheduling +public class CommentPurgeScheduler { + + private final JobLauncher jobLauncher; + private final Job commentPurgeJob; + + @Scheduled(cron = "0 * * * * *") // 프로토타입: 1분마다 + public void runPurgeJob() throws Exception { + JobParameters params = new JobParametersBuilder() + .addLong("ts", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(commentPurgeJob, params); + } + +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/comments/service/CommentPurgeService.java b/monew-batch/src/main/java/com/monew/monew_batch/comments/service/CommentPurgeService.java new file mode 100644 index 0000000..782d70a --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/comments/service/CommentPurgeService.java @@ -0,0 +1,49 @@ +package com.monew.monew_batch.comments.service; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CommentPurgeService { + + @PersistenceContext + private final EntityManager em; + + private static final int BATCH_SIZE = 500; + + @Transactional + public int purge(LocalDateTime cutoff) { + int totalDeleted = 0; + + while (true) { + List ids = em.createQuery( + "select c.id from Comment c " + + "where c.deleted = true and c.updatedAt < :cutoff", Long.class) + .setParameter("cutoff", cutoff) + .setMaxResults(BATCH_SIZE) + .getResultList(); + + if (ids.isEmpty()) break; + + int deleted = em.createQuery("delete from Comment c where c.id in :ids") + .setParameter("ids", ids) + .executeUpdate(); + + totalDeleted += deleted; + em.clear(); + } + log.info("[PURGE][COMMENT] cutoff={}, deleted={}", cutoff, totalDeleted); + return totalDeleted; + } + +} From dfe4f6d7ef3aa4d9e01a79d16c9d5e378affced6 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Tue, 28 Oct 2025 11:26:13 +0900 Subject: [PATCH 054/178] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20DTO=208?= =?UTF-8?q?=EA=B0=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comments/dto/CommentActivityDto.java | 15 ++++++++ .../monew_api/comments/dto/CommentDto.java | 29 +++++++++++++++ .../comments/dto/CommentLikeActivityDto.java | 16 +++++++++ .../comments/dto/CommentLikeDto.java | 17 +++++++++ .../comments/dto/CommentRegisterRequest.java | 35 +++++++++++++++++++ .../comments/dto/CommentUpdateRequest.java | 11 ++++++ .../dto/CursorPageResponseCommentDto.java | 14 ++++++++ .../monew_api/comments/entity/Comment.java | 3 ++ .../comments/entity/CommentLike.java | 4 +-- 9 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentUpdateRequest.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java new file mode 100644 index 0000000..64c4244 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java @@ -0,0 +1,15 @@ +package com.monew.monew_api.comments.dto; + +import java.time.LocalDateTime; + +public record CommentActivityDto( + String id, + String articleId, + String articleTitle, + String userId, + String userNickname, + String content, + int likeCount, + LocalDateTime createdAt +) { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java new file mode 100644 index 0000000..b2d2b33 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java @@ -0,0 +1,29 @@ +package com.monew.monew_api.comments.dto; + +import com.monew.monew_api.comments.entity.Comment; + +public record CommentDto( + String id, + String articleId, + String userId, + String userNickname, + String content, + int likeCount, + boolean likedByMe, + String createdAt +) { + + public static CommentDto from(Comment comment, boolean likedByMe) { + return new CommentDto( + String.valueOf(comment.getId()), + String.valueOf(comment.getArticleId()), + String.valueOf(comment.getUser().getId()), + comment.getUser().getNickname(), + comment.getContent(), + comment.getLikeCount(), + likedByMe, + comment.getCreatedAt().toString() + ); + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java new file mode 100644 index 0000000..e69beb3 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java @@ -0,0 +1,16 @@ +package com.monew.monew_api.comments.dto; + +import java.time.LocalDateTime; + +public record CommentLikeActivityDto( + String id, + LocalDateTime createdAt, + String commentId, + String articleId, + String articleTitle, + String commentUserId, + String commentUserNickname, + String commentContent, + int commentLikeCount, + LocalDateTime commentCreatedAt +) {} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java new file mode 100644 index 0000000..8d76036 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.comments.dto; + +import java.time.LocalDateTime; + +public record CommentLikeDto( + String id, + String likedBy, + LocalDateTime createdAt, + String commentId, + String articleId, + String commentUserId, + String commentUserNickname, + String commentContent, + int commentLikeCount, + LocalDateTime commentCreatedAt +) { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java new file mode 100644 index 0000000..ff14ba4 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java @@ -0,0 +1,35 @@ +package com.monew.monew_api.comments.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record CommentRegisterRequest( + @NotNull(message = "기사 ID는 필수입니다.") + String articleId, + + @NotNull(message = "사용자 ID는 필수입니다.") + String userId, + + @NotBlank(message = "댓글 내용을 작성해주세요.") + @Size(max = 500, message = "댓글은 최대 500자까지 작성 가능합니다.") + String content +) { + + public Long getArticleIdAsLong() { + try { + return Long.parseLong(articleId); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("잘못된 기사 ID 형식입니다."); + } + } + + public Long getUserIdAsLong() { + try { + return Long.parseLong(userId); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("잘못된 사용자 ID 형식입니다."); + } + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentUpdateRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentUpdateRequest.java new file mode 100644 index 0000000..c978813 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentUpdateRequest.java @@ -0,0 +1,11 @@ +package com.monew.monew_api.comments.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CommentUpdateRequest( + @NotBlank(message = "댓글 내용을 작성해주세요.") + @Size(max = 500, message = "댓글은 최대 500자까지 작성 가능합니다.") + String content +) { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java new file mode 100644 index 0000000..b2c98d9 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java @@ -0,0 +1,14 @@ +package com.monew.monew_api.comments.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record CursorPageResponseCommentDto( + List content, + String nextCursor, + LocalDateTime nextAfter, + int size, + long totalElements, + boolean hasNext +) { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java index 6538743..01cf092 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java @@ -75,4 +75,7 @@ public void decreaseLike() { public Long getUserId() { return this.user != null ? this.user.getId() : null; } + public String getUserIdAsString() { + return String.valueOf(getUserId()); + } } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java index 88a35f5..7068e6b 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java @@ -36,8 +36,8 @@ public class CommentLike extends BaseCreatedEntity { @JoinColumn(name="user_id", nullable=false) private User user; - // @ManyToOne(fetch = LAZY) @JoinColumn(name="comment_id", nullable=false) - // private Comment comment; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="comment_id", nullable=false) + private Comment comment; @Column(name = "comment_id", nullable = false) private Long commentId; From b0dd75cfae92568bd77908eb981efe0708b3eed1 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Tue, 28 Oct 2025 13:31:45 +0900 Subject: [PATCH 055/178] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20ErrorCode?= =?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 --- .../monew/monew_api/common/exception/ErrorCode.java | 5 +++++ .../comment/CommentAlreadyLikedException.java | 10 ++++++++++ .../comment/CommentArticleNotFoundException.java | 10 ++++++++++ .../exception/comment/CommentForbiddenException.java | 11 +++++++++++ .../exception/comment/CommentNotLikedException.java | 10 ++++++++++ .../comment/CommentUserNotFoundException.java | 10 ++++++++++ 6 files changed, 56 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentAlreadyLikedException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentArticleNotFoundException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentForbiddenException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotLikedException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentUserNotFoundException.java diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java index 2535714..7140881 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java @@ -22,6 +22,11 @@ public enum ErrorCode { // 댓글 - COMMENT COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "댓글 정보를 찾을 수 없습니다."), + COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN.value(), "댓글 수정 또는 삭제 권한이 없습니다."), + COMMENT_ALREADY_LIKED(HttpStatus.CONFLICT.value(), "이미 좋아요한 댓글입니다."), + COMMENT_NOT_LIKED(HttpStatus.BAD_REQUEST.value(), "좋아요하지 않은 댓글은 취소할 수 없습니다."), + COMMENT_USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "댓글 작성자를 찾을 수 없습니다."), + COMMENT_ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "댓글이 연결된 기사를 찾을 수 없습니다."), // 알림 - NOTIFICATION NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "알림 정보를 찾을 수 없습니다."), diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentAlreadyLikedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentAlreadyLikedException.java new file mode 100644 index 0000000..5574141 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentAlreadyLikedException.java @@ -0,0 +1,10 @@ +package com.monew.monew_api.common.exception.comment; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +public class CommentAlreadyLikedException extends BaseException { + public CommentAlreadyLikedException() { + super(ErrorCode.COMMENT_ALREADY_LIKED); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentArticleNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentArticleNotFoundException.java new file mode 100644 index 0000000..86ef31c --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentArticleNotFoundException.java @@ -0,0 +1,10 @@ +package com.monew.monew_api.common.exception.comment; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +public class CommentArticleNotFoundException extends BaseException { + public CommentArticleNotFoundException() { + super(ErrorCode.COMMENT_ARTICLE_NOT_FOUND); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentForbiddenException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentForbiddenException.java new file mode 100644 index 0000000..f08b222 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentForbiddenException.java @@ -0,0 +1,11 @@ +package com.monew.monew_api.common.exception.comment; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +public class CommentForbiddenException extends BaseException { + public CommentForbiddenException() { + super(ErrorCode.COMMENT_FORBIDDEN); + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotLikedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotLikedException.java new file mode 100644 index 0000000..38202cf --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotLikedException.java @@ -0,0 +1,10 @@ +package com.monew.monew_api.common.exception.comment; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +public class CommentNotLikedException extends BaseException { + public CommentNotLikedException() { + super(ErrorCode.COMMENT_NOT_LIKED); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentUserNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentUserNotFoundException.java new file mode 100644 index 0000000..ea481c3 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentUserNotFoundException.java @@ -0,0 +1,10 @@ +package com.monew.monew_api.common.exception.comment; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +public class CommentUserNotFoundException extends BaseException { + public CommentUserNotFoundException() { + super(ErrorCode.COMMENT_USER_NOT_FOUND); + } +} From d0446bc4b6e85d2566999d9fe0ec3bfee39e7244 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Tue, 28 Oct 2025 13:33:13 +0900 Subject: [PATCH 056/178] =?UTF-8?q?feat:=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=EC=97=90=20article=20=EC=97=B0=EA=B4=80=20=EA=B4=80=EA=B3=84?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20DTO,=20Repository=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 --- .../controller/CommentController.java | 118 +++++++------- .../comments/dto/CommentActivityDto.java | 18 ++- .../monew_api/comments/dto/CommentDto.java | 2 +- .../comments/dto/CommentLikeActivityDto.java | 25 ++- .../comments/dto/CommentLikeDto.java | 39 ++++- .../comments/dto/CommentRegisterRequest.java | 11 +- .../comments/dto/CommentRequestDto.java | 28 ---- .../comments/dto/CommentResponseDto.java | 27 ---- .../comments/dto/CommentUpdateRequest.java | 2 +- .../dto/CursorPageResponseCommentDto.java | 3 +- .../monew_api/comments/entity/Comment.java | 42 +++-- .../comments/entity/CommentLike.java | 22 ++- .../repository/CommentLikeRepository.java | 4 +- .../impl/CommentRepositoryImpl.java | 25 +-- .../comments/service/CommentService.java | 147 ++++++++++-------- 15 files changed, 278 insertions(+), 235 deletions(-) delete mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRequestDto.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentResponseDto.java diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java index 23757df..0accd3a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java @@ -6,19 +6,9 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; -import com.monew.monew_api.comments.dto.CommentRequestDto; -import com.monew.monew_api.comments.dto.CommentResponseDto; +import com.monew.monew_api.comments.dto.*; import com.monew.monew_api.comments.service.CommentService; import jakarta.validation.Valid; @@ -26,76 +16,80 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/api") +@RequestMapping("/api/comments") public class CommentController { private final CommentService commentService; - @PostMapping("/comments") - public ResponseEntity writeComment( - @RequestHeader("MoNew-Request-User-ID") Long userId, - @Valid @RequestBody CommentRequestDto request + @PostMapping + public ResponseEntity register( + @RequestHeader("Monew-Request-User-ID") String userId, + @Valid @RequestBody CommentRegisterRequest request ) { - Long savedId = commentService.write( - userId, - request.getArticleIdAsLong(), - request.getContent() - ); - return ResponseEntity.status(HttpStatus.CREATED) - .body(String.valueOf(savedId)); + Long savedId = commentService.register(request.withUserId(userId)); + CommentDto response = commentService.findById(savedId, userId); + return ResponseEntity.status(HttpStatus.CREATED).body(response); } - @GetMapping("/articles/{articleId}/comments") - public ResponseEntity> getCommentsByArticle( - @PathVariable Long articleId, - @RequestParam(required = false, defaultValue = "10") Integer limit, - @RequestParam(required = false, defaultValue = "created") String sort, - @RequestParam(required = false) Long cursorId, - @RequestParam(required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime cursorCreatedAt, - @RequestParam(required = false) Integer cursorLikeCount + @PatchMapping("/{commentId}") + public ResponseEntity update( + @RequestHeader("Monew-Request-User-ID") String userId, + @PathVariable String commentId, + @Valid @RequestBody CommentUpdateRequest request ) { - - List comments = commentService.getCommentsByArticleIdWithCursor( - articleId, limit, sort, cursorId, cursorCreatedAt, cursorLikeCount - ); - return ResponseEntity.ok(comments); + commentService.update(Long.parseLong(userId), Long.parseLong(commentId), request); + CommentDto updated = commentService.findById(Long.parseLong(commentId), userId); + return ResponseEntity.ok(updated); } - @PatchMapping("/comments/{commentId}") - public ResponseEntity updateComment( - @RequestHeader("MoNew-Request-User-ID") Long userId, - @PathVariable Long commentId, - @Valid @RequestBody CommentRequestDto request + @DeleteMapping("/{commentId}") + public ResponseEntity delete( + @RequestHeader("Monew-Request-User-ID") String userId, + @PathVariable String commentId ) { - commentService.update(userId, commentId, request.getContent()); + commentService.delete(Long.parseLong(userId), Long.parseLong(commentId)); return ResponseEntity.noContent().build(); } - @DeleteMapping("/comments/{commentId}") - public ResponseEntity deleteComment( - @RequestHeader("MoNew-Request-User-ID") Long userId, - @PathVariable Long commentId + @PostMapping("/{commentId}/comment-likes") + public ResponseEntity like( + @RequestHeader("Monew-Request-User-ID") String userId, + @PathVariable String commentId ) { - commentService.delete(userId, commentId); - return ResponseEntity.noContent().build(); + commentService.like(Long.parseLong(userId), Long.parseLong(commentId)); + CommentLikeDto response = commentService.findLike(Long.parseLong(commentId), Long.parseLong(userId)); + return ResponseEntity.ok(response); } - @PostMapping("/comments/{commentId}/like") - public ResponseEntity likeComment( - @RequestHeader("MoNew-Request-User-ID") Long userId, - @PathVariable Long commentId + @DeleteMapping("/{commentId}/comment-likes") + public ResponseEntity disLike( + @RequestHeader("Monew-Request-User-ID") String userId, + @PathVariable String commentId ) { - commentService.like(userId, commentId); - return ResponseEntity.ok().build(); + commentService.disLike(Long.parseLong(userId), Long.parseLong(commentId)); + return ResponseEntity.noContent().build(); } - @DeleteMapping("/comments/{commentId}/like") - public ResponseEntity unlikeComment( - @RequestHeader("MoNew-Request-User-ID") Long userId, - @PathVariable Long commentId + @GetMapping + public ResponseEntity> findAll( + @RequestHeader("Monew-Request-User-ID") String userId, + @RequestParam String articleId, + @RequestParam String orderBy, + @RequestParam String direction, + @RequestParam(required = false) String cursor, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime after, + @RequestParam int limit ) { - commentService.unlike(userId, commentId); - return ResponseEntity.noContent().build(); + List comments = commentService.findAll( + Long.parseLong(articleId), + limit, + orderBy, + direction, + cursor != null ? Long.parseLong(cursor) : null, + after, + Long.parseLong(userId) + ); + + return ResponseEntity.ok(comments); } } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java index 64c4244..119d173 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java @@ -2,6 +2,8 @@ import java.time.LocalDateTime; +import com.monew.monew_api.comments.entity.Comment; + public record CommentActivityDto( String id, String articleId, @@ -10,6 +12,20 @@ public record CommentActivityDto( String userNickname, String content, int likeCount, - LocalDateTime createdAt + String createdAt ) { + + public static CommentActivityDto from(Comment comment) { + return new CommentActivityDto( + String.valueOf(comment.getId()), + String.valueOf(comment.getArticle().getId()), + comment.getArticle().getTitle(), + String.valueOf(comment.getUser().getId()), + comment.getUser().getNickname(), + comment.getContent(), + comment.getLikeCount(), + comment.getCreatedAt().toString() + ); + } + } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java index b2d2b33..5272e21 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java @@ -16,7 +16,7 @@ public record CommentDto( public static CommentDto from(Comment comment, boolean likedByMe) { return new CommentDto( String.valueOf(comment.getId()), - String.valueOf(comment.getArticleId()), + String.valueOf(comment.getArticle().getId()), String.valueOf(comment.getUser().getId()), comment.getUser().getNickname(), comment.getContent(), diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java index e69beb3..c768f7d 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java @@ -2,9 +2,10 @@ import java.time.LocalDateTime; +import com.monew.monew_api.comments.entity.CommentLike; + public record CommentLikeActivityDto( String id, - LocalDateTime createdAt, String commentId, String articleId, String articleTitle, @@ -12,5 +13,23 @@ public record CommentLikeActivityDto( String commentUserNickname, String commentContent, int commentLikeCount, - LocalDateTime commentCreatedAt -) {} + String commentCreatedAt, + String createdAt +) { + + public static CommentLikeActivityDto from(CommentLike like) { + return new CommentLikeActivityDto( + String.valueOf(like.getId()), + String.valueOf(like.getComment().getId()), + String.valueOf(like.getComment().getArticleId()), + like.getComment().getArticle().getTitle(), + String.valueOf(like.getComment().getUserId()), + like.getComment().getUser().getNickname(), + like.getComment().getContent(), + like.getComment().getLikeCount(), + like.getComment().getCreatedAt().toString(), + like.getCreatedAt().toString() + ); + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java index 8d76036..b8b520d 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java @@ -2,16 +2,49 @@ import java.time.LocalDateTime; +import com.monew.monew_api.comments.entity.CommentLike; + public record CommentLikeDto( String id, - String likedBy, - LocalDateTime createdAt, String commentId, String articleId, + String likedBy, String commentUserId, String commentUserNickname, String commentContent, int commentLikeCount, - LocalDateTime commentCreatedAt + String commentCreatedAt, + String createdAt ) { + + public static CommentLikeDto from(CommentLike like) { + return new CommentLikeDto( + String.valueOf(like.getId()), + String.valueOf(like.getComment().getId()), + String.valueOf(like.getComment().getArticleId()), + String.valueOf(like.getUser().getId()), + String.valueOf(like.getComment().getUserId()), + like.getComment().getUser().getNickname(), + like.getComment().getContent(), + like.getComment().getLikeCount(), + like.getComment().getCreatedAt().toString(), + like.getCreatedAt().toString() + ); + } + + public static CommentLikeDto of(Long commentId, Long userId, boolean liked) { + return new CommentLikeDto( + null, // id는 단건 조회에선 사용하지 않음 + String.valueOf(commentId), + null, // articleId도 필요 없으면 null + String.valueOf(userId), // likedBy + null, // commentUserId + null, // commentUserNickname + null, // commentContent + -1, // commentLikeCount + null, // commentCreatedAt + LocalDateTime.now().toString() // createdAt은 현재 시각 또는 null + ); + } + } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java index ff14ba4..c25d47c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java @@ -11,16 +11,20 @@ public record CommentRegisterRequest( @NotNull(message = "사용자 ID는 필수입니다.") String userId, - @NotBlank(message = "댓글 내용을 작성해주세요.") + @NotBlank(message = "댓글 내용을 입력해주세요.") @Size(max = 500, message = "댓글은 최대 500자까지 작성 가능합니다.") String content ) { + public CommentRegisterRequest withUserId(String userId) { + return new CommentRegisterRequest(this.articleId, this.content, userId); + } + public Long getArticleIdAsLong() { try { return Long.parseLong(articleId); } catch (NumberFormatException e) { - throw new IllegalArgumentException("잘못된 기사 ID 형식입니다."); + throw new IllegalArgumentException("잘못된 기사 ID 형식입니다: " + articleId); } } @@ -28,8 +32,7 @@ public Long getUserIdAsLong() { try { return Long.parseLong(userId); } catch (NumberFormatException e) { - throw new IllegalArgumentException("잘못된 사용자 ID 형식입니다."); + throw new IllegalArgumentException("잘못된 사용자 ID 형식입니다: " + userId); } } - } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRequestDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRequestDto.java deleted file mode 100644 index bb3cb12..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRequestDto.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.monew.monew_api.comments.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class CommentRequestDto { - - @NotNull(message = "articleId는 필수입니다.") - private String articleId; - - @NotBlank(message = "댓글 내용을 작성해주세요.") - @Size(max = 500, message = "댓글은 최대 500자까지 작성 가능합니다.") - private String content; - - public Long getArticleIdAsLong() { - try { - return Long.parseLong(articleId); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("잘못된 ID 형식입니다: " + articleId); - } - } - -} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentResponseDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentResponseDto.java deleted file mode 100644 index 693e279..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentResponseDto.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.monew.monew_api.comments.dto; - -import com.monew.monew_api.comments.entity.Comment; - -public record CommentResponseDto( - String id, - String articleId, - String userId, - String content, - int likeCount, - String createdAt, - String updatedAt -) { - - public static CommentResponseDto from(Comment comment) { - return new CommentResponseDto( - String.valueOf(comment.getId()), - String.valueOf(comment.getArticleId()), - String.valueOf(comment.getUserId()), - comment.getContent(), - comment.getLikeCount(), - comment.getCreatedAt().toString(), - comment.getUpdatedAt().toString() - ); - } - -} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentUpdateRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentUpdateRequest.java index c978813..744ba53 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentUpdateRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentUpdateRequest.java @@ -4,7 +4,7 @@ import jakarta.validation.constraints.Size; public record CommentUpdateRequest( - @NotBlank(message = "댓글 내용을 작성해주세요.") + @NotBlank(message = "댓글 내용을 입력해주세요.") @Size(max = 500, message = "댓글은 최대 500자까지 작성 가능합니다.") String content ) { diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java index b2c98d9..1978d2f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java @@ -1,12 +1,13 @@ package com.monew.monew_api.comments.dto; import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.List; public record CursorPageResponseCommentDto( List content, String nextCursor, - LocalDateTime nextAfter, + ZonedDateTime nextAfter, int size, long totalElements, boolean hasNext diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java index 01cf092..33201cf 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java @@ -3,6 +3,7 @@ import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.Where; +import com.monew.monew_api.article.entity.Article; import com.monew.monew_api.common.entity.BaseTimeEntity; import com.monew.monew_api.domain.user.User; @@ -37,12 +38,9 @@ public class Comment extends BaseTimeEntity { @JoinColumn(name = "user_id", nullable = false) private User user; - // @ManyToOne(fetch = FetchType.LAZY) - // @JoinColumn(name = "article_id", nullable = false) - // private Article article; - - @Column(name = "article_id", nullable = false) - private Long articleId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "article_id", nullable = false) + private Article article; @Size(max = 500) @NotBlank @@ -55,27 +53,37 @@ public class Comment extends BaseTimeEntity { @Column(name = "like_count", nullable = false) private int likeCount = 0; - private Comment(User user, Long articleId, String content) { + private Comment(User user, Article articleId, String content) { this.user = user; - this.articleId = articleId; + this.article = articleId; this.content = content; } - public static Comment of(User user, Long articleId, String content) { - return new Comment(user, articleId, content); + public static Comment of(User user, Article article, String content) { + return new Comment(user, article, content); } - public boolean isOwnedBy(Long userId) { return this.user != null && this.user.getId().equals(userId); } - public void updateContent(String content) { this.content = content; } + public void updateContent(String content) { + this.content = content; + } + + public void increaseLike() { + this.likeCount++; + } - public void increaseLike() { this.likeCount += 1; } public void decreaseLike() { - if (this.likeCount > 0) this.likeCount -= 1; + if (this.likeCount > 0) this.likeCount--; } + + public boolean isOwnedBy(Long userId) { + return this.user != null && this.user.getId().equals(userId); + } + public Long getUserId() { - return this.user != null ? this.user.getId() : null; + return user != null ? user.getId() : null; } - public String getUserIdAsString() { - return String.valueOf(getUserId()); + + public Long getArticleId() { + return article != null ? article.getId() : null; } } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java index 7068e6b..f0ba96f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java @@ -3,7 +3,6 @@ import com.monew.monew_api.common.entity.BaseCreatedEntity; import com.monew.monew_api.domain.user.User; -import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.Index; @@ -29,33 +28,30 @@ } ) - public class CommentLike extends BaseCreatedEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="user_id", nullable=false) private User user; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="comment_id", nullable=false) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="comment_id", nullable=false) private Comment comment; - @Column(name = "comment_id", nullable = false) - private Long commentId; - - private CommentLike(User userId, Long commentId) { - this.user = userId; - this.commentId = commentId; + private CommentLike(User user, Comment comment) { + this.user = user; + this.comment = comment; } - public static CommentLike of(User user, Long commentId) { - return new CommentLike(user, commentId); + public static CommentLike of(User user, Comment comment) { + return new CommentLike(user, comment); } public boolean isByUser(Long userId) { - return this.user != null && this.user.getId().equals(userId); + return user != null && user.getId().equals(userId); } public boolean isForComment(Long commentId) { - return this.commentId.equals(commentId); + return comment != null && comment.getId().equals(commentId); } } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java index f097036..95c0821 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java @@ -6,8 +6,8 @@ public interface CommentLikeRepository extends JpaRepository { - boolean existsByCommentIdAndUser_Id(Long commentId, Long userId); + boolean existsByComment_IdAndUser_Id(Long commentId, Long userId); - void deleteByCommentIdAndUser_Id(Long commentId, Long userId); + void deleteByComment_IdAndUser_Id(Long commentId, Long userId); } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java index 2ba0343..2255fcd 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java @@ -24,7 +24,7 @@ public class CommentRepositoryImpl implements CommentRepositoryCustom { @Override public List findCommentsByArticleIdWithCursor(Long articleId, Long cursorId, int limit, String sort) { OrderSpecifier orderBy = "like".equalsIgnoreCase(sort) ? c.likeCount.desc() : c.createdAt.desc(); - BooleanExpression byArticle = c.articleId.eq(articleId); + BooleanExpression byArticle = articleIdEq(articleId); BooleanExpression ltCursor = cursorId != null ? c.id.lt(cursorId) : null; return jpaQueryFactory @@ -39,21 +39,21 @@ public List findCommentsByArticleIdWithCursor(Long articleId, Long curs public List findPageByArticleIdOrderByCreatedAtDesc(Long articleId, Long cursorId, LocalDateTime cursorCreatedAt, int limit) { - BooleanExpression byArticle = c.articleId.eq(articleId); - BooleanExpression afterCursor = buildCreatedAtCursor(cursorId, cursorCreatedAt); + BooleanExpression byArticle = articleIdEq(articleId); + BooleanExpression afterCursor = buildCreatedAtCursor(cursorId, cursorCreatedAt); - return jpaQueryFactory - .selectFrom(c) - .where(byArticle, afterCursor) - .orderBy(c.createdAt.desc(), c.id.desc()) - .limit(limit) - .fetch(); + return jpaQueryFactory + .selectFrom(c) + .where(byArticle, afterCursor) + .orderBy(c.createdAt.desc(), c.id.desc()) + .limit(limit) + .fetch(); } @Override public List findPageByArticleIdOrderByLikeCountDesc(Long articleId, Long cursorId, Integer cursorLikeCount, int limit) { - BooleanExpression byArticle = c.articleId.eq(articleId); + BooleanExpression byArticle = articleIdEq(articleId); BooleanExpression afterCursor = buildLikeCountCursor(cursorId, cursorLikeCount); return jpaQueryFactory @@ -64,6 +64,11 @@ public List findPageByArticleIdOrderByLikeCountDesc(Long articleId, Lon .fetch(); } + // ✅ article.id 기반 비교로 변경 + private BooleanExpression articleIdEq(Long articleId) { + return c.article.id.eq(articleId); + } + private BooleanExpression buildCreatedAtCursor(Long cursorId, LocalDateTime cursorCreatedAt) { if (cursorId == null || cursorCreatedAt == null) return null; diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index d26f4e9..e82750e 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -8,15 +8,17 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_api.comments.dto.*; +import com.monew.monew_api.comments.entity.*; import com.monew.monew_api.comments.event.CommentCreatedEvent; import com.monew.monew_api.comments.event.CommentLikedEvent; -import com.monew.monew_api.comments.dto.CommentResponseDto; -import com.monew.monew_api.comments.entity.Comment; -import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.comments.repository.CommentLikeRepository; import com.monew.monew_api.comments.repository.CommentRepository; import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; +import com.monew.monew_api.common.exception.comment.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,105 +31,126 @@ public class CommentService { private final CommentRepository commentRepository; private final CommentLikeRepository commentLikeRepository; - private final ApplicationEventPublisher eventPublisher; private final UserRepository userRepository; - - private static final String SORT_CREATED = "created"; - private static final String SORT_LIKE = "like"; + private final ArticleRepository articleRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional - public Long write(Long userId, Long articleId, String content) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")); - Comment comment = Comment.of(user, articleId, content); + public Long register(CommentRegisterRequest request) { + User user = getUserById(request.getUserIdAsLong()); + Article article = getArticleById(request.getArticleIdAsLong()); + + Comment comment = Comment.of(user, article, request.content()); Comment saved = commentRepository.save(comment); - log.info("[COMMENT][CREATE] userId={}, articleId={}, commentId={}", userId, articleId, saved.getId()); - // 댓글 생성 시 이벤트 발행 (알림/활동로그) - eventPublisher.publishEvent(new CommentCreatedEvent(saved.getId(), userId, articleId, saved.getCreatedAt())); + log.info("[COMMENT][CREATE] userId={}, articleId={}, commentId={}", + user.getId(), article.getId(), saved.getId()); + + eventPublisher.publishEvent( + new CommentCreatedEvent(saved.getId(), user.getId(), article.getId(), saved.getCreatedAt()) + ); return saved.getId(); } @Transactional - public void update(Long userId, Long commentId, String content) { - Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다.")); - validateOwner(userId, comment); - log.info("[COMMENT][UPDATE] userId={}, commentId={}, contentLength={}", userId, commentId, content.length()); - comment.updateContent(content); - } + public void update(Long userId, Long commentId, CommentUpdateRequest request) { + Comment comment = getCommentById(commentId); + validateOwnership(comment, userId); - private void validateOwner(Long userId, Comment comment) { - if (!comment.isOwnedBy(userId)) { - throw new IllegalStateException("작성자만 수행할 수 있습니다."); - } + comment.updateContent(request.content()); + log.info("[COMMENT][UPDATE] userId={}, commentId={}, contentLength={}", + userId, commentId, request.content().length()); } @Transactional public void delete(Long userId, Long commentId) { - Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다.")); - validateOwner(userId, comment); - log.info("[COMMENT][DELETE] userId={}, commentId={}", userId, commentId); + Comment comment = getCommentById(commentId); + validateOwnership(comment, userId); commentRepository.delete(comment); + log.info("[COMMENT][DELETE] userId={}, commentId={}", userId, commentId); } @Transactional public void like(Long userId, Long commentId) { - Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다.")); - - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")); + User user = getUserById(userId); + Comment comment = getCommentById(commentId); try { - commentLikeRepository.save(CommentLike.of(user, commentId)); + commentLikeRepository.save(CommentLike.of(user, comment)); comment.increaseLike(); log.info("[COMMENT][LIKE] userId={}, commentId={}", userId, commentId); eventPublisher.publishEvent( - new CommentLikedEvent(comment.getId(), comment.getUser().getId(), userId, LocalDateTime.now()) + new CommentLikedEvent(comment.getId(), comment.getUserId(), userId, LocalDateTime.now()) ); } catch (DataIntegrityViolationException e) { - return; + throw new CommentAlreadyLikedException(); } } @Transactional - public void unlike(Long userId, Long commentId) { - Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다.")); - boolean liked = commentLikeRepository.existsByCommentIdAndUser_Id(commentId, userId); - if (!liked) { - return; - } - commentLikeRepository.deleteByCommentIdAndUser_Id(commentId, userId); - log.info("[COMMENT][UNLIKE] userId={}, commentId={}", userId, commentId); + public void disLike(Long userId, Long commentId) { + boolean liked = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, userId); + if (!liked) throw new CommentNotLikedException(); + + commentLikeRepository.deleteByComment_IdAndUser_Id(commentId, userId); + Comment comment = getCommentById(commentId); comment.decreaseLike(); + + log.info("[COMMENT][DISLIKE] userId={}, commentId={}", userId, commentId); } - public List getCommentsByArticleIdWithCursor( + public List findAll( Long articleId, int limit, - String sort, - Long cursorId, - LocalDateTime cursorCreatedAt, - Integer cursorLikeCount + String orderBy, + String direction, + Long cursor, + LocalDateTime after, + Long requestUserId ) { - String s = (sort == null) ? SORT_CREATED : sort.toLowerCase(); + List page = orderBy.equalsIgnoreCase("like") + ? commentRepository.findPageByArticleIdOrderByLikeCountDesc(articleId, cursor, null, limit) + : commentRepository.findPageByArticleIdOrderByCreatedAtDesc(articleId, cursor, after, limit); + + return page.stream() + .map(comment -> { + boolean likedByMe = commentLikeRepository.existsByComment_IdAndUser_Id(comment.getId(), requestUserId); + return CommentDto.from(comment, likedByMe); + }) + .toList(); + } - List page; - if (SORT_LIKE.equals(s)) { - page = commentRepository.findPageByArticleIdOrderByLikeCountDesc( - articleId, cursorId, cursorLikeCount, limit - ); - } else { - page = commentRepository.findPageByArticleIdOrderByCreatedAtDesc( - articleId, cursorId, cursorCreatedAt, limit - ); + public CommentDto findById(Long commentId, String userId) { + Comment comment = getCommentById(commentId); + boolean likedByMe = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, Long.parseLong(userId)); + return CommentDto.from(comment, likedByMe); + } + + public CommentLikeDto findLike(Long commentId, Long userId) { + boolean liked = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, userId); + return CommentLikeDto.of(commentId, userId, liked); + } + + private void validateOwnership(Comment comment, Long userId) { + if (!comment.isOwnedBy(userId)) { + throw new CommentForbiddenException(); } + } + + private Comment getCommentById(Long commentId) { + return commentRepository.findById(commentId) + .orElseThrow(CommentNotFoundException::new); + } + + private User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(CommentUserNotFoundException::new); + } - return page.stream().map(CommentResponseDto::from).toList(); + private Article getArticleById(Long articleId) { + return articleRepository.findById(articleId) + .orElseThrow(CommentArticleNotFoundException::new); } } From e4a7c393709267233b4b23ad4c05724713fd5c8c Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Tue, 28 Oct 2025 17:46:01 +0900 Subject: [PATCH 057/178] =?UTF-8?q?refactor:=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=BD=94=EB=93=9C=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EC=A7=81=20=EC=9D=BC=EB=B6=80=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 --- .../controller/CommentController.java | 166 +++++++++++++----- .../comments/dto/CommentActivityDto.java | 2 - .../comments/dto/CommentLikeActivityDto.java | 2 - .../comments/dto/CommentLikeDto.java | 18 +- .../comments/dto/CommentRegisterRequest.java | 9 +- .../dto/CursorPageResponseCommentDto.java | 1 - .../monew_api/comments/entity/Comment.java | 4 +- .../monew_api/comments/mapper/UuidMapper.java | 68 ------- .../repository/CommentLikeRepository.java | 6 + .../repository/CommentRepository.java | 18 ++ .../repository/CommentRepositoryCustom.java | 5 - .../impl/CommentRepositoryImpl.java | 25 +-- .../comments/service/CommentService.java | 161 ++++++++++++----- .../monew_api/common/exception/ErrorCode.java | 2 + .../CommentInvalidArticleIdException.java | 12 ++ .../CommentInvalidUserIdException.java | 14 ++ .../comment/CommentNotFoundException.java | 4 - .../comments/service/CommentPurgeService.java | 4 + 18 files changed, 318 insertions(+), 203 deletions(-) delete mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/mapper/UuidMapper.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidArticleIdException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidUserIdException.java diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java index 0accd3a..a9bc7f5 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java @@ -1,95 +1,175 @@ package com.monew.monew_api.comments.controller; import java.time.LocalDateTime; -import java.util.List; -import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; -import com.monew.monew_api.comments.dto.*; +import com.monew.monew_api.comments.dto.CommentDto; +import com.monew.monew_api.comments.dto.CommentLikeDto; +import com.monew.monew_api.comments.dto.CommentRegisterRequest; +import com.monew.monew_api.comments.dto.CommentUpdateRequest; +import com.monew.monew_api.comments.dto.CursorPageResponseCommentDto; import com.monew.monew_api.comments.service.CommentService; +import com.monew.monew_api.common.exception.comment.CommentInvalidArticleIdException; +import com.monew.monew_api.common.exception.comment.CommentInvalidUserIdException; +import com.monew.monew_api.common.exception.comment.CommentNotFoundException; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController -@RequiredArgsConstructor @RequestMapping("/api/comments") +@RequiredArgsConstructor +@Validated public class CommentController { private final CommentService commentService; + @GetMapping + public ResponseEntity findAll( + @RequestHeader("Monew-Request-User-ID") String userIdHeader, + @RequestParam(required = false) String articleId, + @RequestParam String orderBy, + @RequestParam String direction, + @RequestParam(required = false) String cursor, + @RequestParam(required = false) String after, + @RequestParam int limit + ) { + Long aid = parseNullableArticleId(articleId); + Long uid = parseUserId(userIdHeader); + + Long cursorId = null; + LocalDateTime cursorCreatedAt = parseNullableDateTime(after); + Integer cursorLikeCount = null; + + if (cursor != null && !cursor.isBlank()) { + if ("likeCount".equalsIgnoreCase(orderBy)) { + String[] parts = cursor.split(":"); + if (parts.length == 2) { + cursorLikeCount = safeParseInt(parts[0]); + cursorId = safeParseLong(parts[1]); + } else { + + } + } else { + cursorId = safeParseLong(cursor); + } + } + + CursorPageResponseCommentDto page = + commentService.findAll(aid, limit, orderBy, cursorId, cursorCreatedAt, cursorLikeCount, uid); + + return ResponseEntity.ok(page); + } + @PostMapping public ResponseEntity register( - @RequestHeader("Monew-Request-User-ID") String userId, + @RequestHeader("Monew-Request-User-ID") String userIdHeader, @Valid @RequestBody CommentRegisterRequest request ) { - Long savedId = commentService.register(request.withUserId(userId)); - CommentDto response = commentService.findById(savedId, userId); - return ResponseEntity.status(HttpStatus.CREATED).body(response); + CommentRegisterRequest fixed = request.withUserId(userIdHeader); + CommentDto dto = commentService.register(fixed); + return ResponseEntity.status(HttpStatus.CREATED).body(dto); } @PatchMapping("/{commentId}") public ResponseEntity update( - @RequestHeader("Monew-Request-User-ID") String userId, + @RequestHeader("Monew-Request-User-ID") String userIdHeader, @PathVariable String commentId, @Valid @RequestBody CommentUpdateRequest request ) { - commentService.update(Long.parseLong(userId), Long.parseLong(commentId), request); - CommentDto updated = commentService.findById(Long.parseLong(commentId), userId); - return ResponseEntity.ok(updated); + Long userId = parseUserId(userIdHeader); + Long cid = parseCommentId(commentId); + CommentDto dto = commentService.update(userId, cid, request); + return ResponseEntity.ok(dto); } @DeleteMapping("/{commentId}") public ResponseEntity delete( - @RequestHeader("Monew-Request-User-ID") String userId, + @RequestHeader("Monew-Request-User-ID") String userIdHeader, @PathVariable String commentId ) { - commentService.delete(Long.parseLong(userId), Long.parseLong(commentId)); + Long userId = parseUserId(userIdHeader); + Long cid = parseCommentId(commentId); + commentService.delete(userId, cid); return ResponseEntity.noContent().build(); } @PostMapping("/{commentId}/comment-likes") public ResponseEntity like( - @RequestHeader("Monew-Request-User-ID") String userId, + @RequestHeader("Monew-Request-User-ID") String userIdHeader, @PathVariable String commentId ) { - commentService.like(Long.parseLong(userId), Long.parseLong(commentId)); - CommentLikeDto response = commentService.findLike(Long.parseLong(commentId), Long.parseLong(userId)); - return ResponseEntity.ok(response); + Long userId = parseUserId(userIdHeader); + Long cid = parseCommentId(commentId); + CommentLikeDto dto = commentService.like(userId, cid); + return ResponseEntity.ok(dto); } @DeleteMapping("/{commentId}/comment-likes") - public ResponseEntity disLike( - @RequestHeader("Monew-Request-User-ID") String userId, + public ResponseEntity dislike( + @RequestHeader("Monew-Request-User-ID") String userIdHeader, @PathVariable String commentId ) { - commentService.disLike(Long.parseLong(userId), Long.parseLong(commentId)); + Long userId = parseUserId(userIdHeader); + Long cid = parseCommentId(commentId); + commentService.dislike(userId, cid); return ResponseEntity.noContent().build(); } - @GetMapping - public ResponseEntity> findAll( - @RequestHeader("Monew-Request-User-ID") String userId, - @RequestParam String articleId, - @RequestParam String orderBy, - @RequestParam String direction, - @RequestParam(required = false) String cursor, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime after, - @RequestParam int limit - ) { - List comments = commentService.findAll( - Long.parseLong(articleId), - limit, - orderBy, - direction, - cursor != null ? Long.parseLong(cursor) : null, - after, - Long.parseLong(userId) - ); - - return ResponseEntity.ok(comments); + @DeleteMapping("/{commentId}/hard") + public ResponseEntity hardDelete(@PathVariable String commentId) { + Long cid = parseCommentId(commentId); + commentService.hardDelete(cid); + return ResponseEntity.noContent().build(); + } + + private Long parseUserId(String userId) { + try { + return Long.parseLong(userId); + } catch (Exception e) { + throw new CommentInvalidUserIdException(userId); + } + } + + private Long parseCommentId(String commentId) { + try { + return Long.parseLong(commentId); + } catch (Exception e) { + throw new CommentNotFoundException(); + } + } + + private Long parseNullableArticleId(String articleId) { + if (articleId == null || articleId.isBlank()) return null; + try { + return Long.parseLong(articleId); + } catch (Exception e) { + throw new CommentInvalidArticleIdException(articleId); + } + } + + private LocalDateTime parseNullableDateTime(String text) { + return (text == null || text.isBlank()) ? null : LocalDateTime.parse(text); + } + + private Long safeParseLong(String s) { + try { return Long.parseLong(s); } catch (Exception e) { return null; } + } + + private Integer safeParseInt(String s) { + try { return Integer.parseInt(s); } catch (Exception e) { return null; } } } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java index 119d173..85dfeeb 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java @@ -1,7 +1,5 @@ package com.monew.monew_api.comments.dto; -import java.time.LocalDateTime; - import com.monew.monew_api.comments.entity.Comment; public record CommentActivityDto( diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java index c768f7d..6da48ad 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java @@ -1,7 +1,5 @@ package com.monew.monew_api.comments.dto; -import java.time.LocalDateTime; - import com.monew.monew_api.comments.entity.CommentLike; public record CommentLikeActivityDto( diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java index b8b520d..a735cf4 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java @@ -32,18 +32,16 @@ public static CommentLikeDto from(CommentLike like) { ); } - public static CommentLikeDto of(Long commentId, Long userId, boolean liked) { + public static CommentLikeDto of(Long commentId, Long userId) { return new CommentLikeDto( - null, // id는 단건 조회에선 사용하지 않음 + null, String.valueOf(commentId), - null, // articleId도 필요 없으면 null - String.valueOf(userId), // likedBy - null, // commentUserId - null, // commentUserNickname - null, // commentContent - -1, // commentLikeCount - null, // commentCreatedAt - LocalDateTime.now().toString() // createdAt은 현재 시각 또는 null + null, + String.valueOf(userId), + null, null, null, + -1, + null, + LocalDateTime.now().toString() ); } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java index c25d47c..0037b01 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java @@ -1,5 +1,8 @@ package com.monew.monew_api.comments.dto; +import com.monew.monew_api.common.exception.comment.CommentInvalidArticleIdException; +import com.monew.monew_api.common.exception.comment.CommentInvalidUserIdException; + import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -17,14 +20,14 @@ public record CommentRegisterRequest( ) { public CommentRegisterRequest withUserId(String userId) { - return new CommentRegisterRequest(this.articleId, this.content, userId); + return new CommentRegisterRequest(this.articleId, userId, this.content); } public Long getArticleIdAsLong() { try { return Long.parseLong(articleId); } catch (NumberFormatException e) { - throw new IllegalArgumentException("잘못된 기사 ID 형식입니다: " + articleId); + throw new CommentInvalidArticleIdException(articleId); } } @@ -32,7 +35,7 @@ public Long getUserIdAsLong() { try { return Long.parseLong(userId); } catch (NumberFormatException e) { - throw new IllegalArgumentException("잘못된 사용자 ID 형식입니다: " + userId); + throw new CommentInvalidUserIdException(userId); } } } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java index 1978d2f..8216b1b 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java @@ -1,6 +1,5 @@ package com.monew.monew_api.comments.dto; -import java.time.LocalDateTime; import java.time.ZonedDateTime; import java.util.List; diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java index 33201cf..fe316bb 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java @@ -53,9 +53,9 @@ public class Comment extends BaseTimeEntity { @Column(name = "like_count", nullable = false) private int likeCount = 0; - private Comment(User user, Article articleId, String content) { + private Comment(User user, Article article, String content) { this.user = user; - this.article = articleId; + this.article = article; this.content = content; } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/mapper/UuidMapper.java b/monew-api/src/main/java/com/monew/monew_api/comments/mapper/UuidMapper.java deleted file mode 100644 index 1bb4e28..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/comments/mapper/UuidMapper.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.monew.monew_api.comments.mapper; - -import java.nio.ByteBuffer; -import java.util.Base64; -import java.util.UUID; - -public final class UuidMapper { - - private UuidMapper() { - // Utility class - } - - /** - * UUID(String) → Long(BigInt) - * - * 프론트에서 전달된 UUID를 내부 DB에서 사용하는 Long으로 변환 - */ - public static Long toLong(String uuidString) { - if (uuidString == null || uuidString.isBlank()) { - return null; - } - - UUID uuid = UUID.fromString(uuidString); - long msb = uuid.getMostSignificantBits(); - long lsb = uuid.getLeastSignificantBits(); - - // msb + lsb를 합쳐서 Long으로 변환 (충돌 최소화) - // 64bit에 맞추기 위해 XOR 사용 - return Math.abs(msb ^ lsb); - } - - /** - * Long(BigInt) → UUID(String) - * - * DB ID(Long)를 프론트로 보낼 때 UUID 형태 문자열로 변환 - */ - public static String toUuid(Long id) { - if (id == null) { - return null; - } - - // 단방향 매핑이므로 실제 UUID가 아니라 가상 UUID 생성 - UUID fakeUuid = new UUID(id, 0L); - return fakeUuid.toString(); - } - - /** - * Long(BigInt) → Base64 짧은 토큰 (선택적 사용) - * - * 프론트에서 짧은 키로 쓸 수 있음 (선택 사항) - */ - public static String toBase64(Long id) { - if (id == null) return null; - ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES); - buffer.putLong(id); - return Base64.getUrlEncoder().withoutPadding().encodeToString(buffer.array()); - } - - /** - * Base64 → Long(BigInt) - */ - public static Long fromBase64(String encoded) { - if (encoded == null || encoded.isBlank()) return null; - byte[] bytes = Base64.getUrlDecoder().decode(encoded); - ByteBuffer buffer = ByteBuffer.wrap(bytes); - return buffer.getLong(); - } -} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java index 95c0821..d449a95 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java @@ -1,5 +1,8 @@ package com.monew.monew_api.comments.repository; +import java.util.Collection; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; import com.monew.monew_api.comments.entity.CommentLike; @@ -10,4 +13,7 @@ public interface CommentLikeRepository extends JpaRepository { void deleteByComment_IdAndUser_Id(Long commentId, Long userId); + // N+1 회피용: 특정 사용자 + 여러 댓글 ID에 대한 좋아요 목록 + List findByUser_IdAndComment_IdIn(Long userId, Collection commentIds); + } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java index 97089f3..29ad841 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java @@ -1,9 +1,27 @@ package com.monew.monew_api.comments.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.monew.monew_api.comments.entity.Comment; public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Comment c set c.likeCount = c.likeCount + 1 where c.id = :id") + int incLikeCount(@Param("id") Long id); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update Comment c + set c.likeCount = case when c.likeCount > 0 then c.likeCount - 1 else 0 end + where c.id = :id + """) + int decLikeCount(@Param("id") Long id); + + @Modifying + @Query(value = "DELETE FROM comments WHERE id = :id", nativeQuery = true) + void hardDeleteById(@Param("id") Long id); } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java index a843209..cf75cc5 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java @@ -6,11 +6,6 @@ import com.monew.monew_api.comments.entity.Comment; public interface CommentRepositoryCustom { - List findCommentsByArticleIdWithCursor( - Long articleId, - Long cursorId, - int limit, - String sort); List findPageByArticleIdOrderByCreatedAtDesc( Long articleId, diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java index 2255fcd..375f36c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java @@ -21,20 +21,6 @@ public class CommentRepositoryImpl implements CommentRepositoryCustom { private final JPAQueryFactory jpaQueryFactory; private final QComment c = QComment.comment; - @Override - public List findCommentsByArticleIdWithCursor(Long articleId, Long cursorId, int limit, String sort) { - OrderSpecifier orderBy = "like".equalsIgnoreCase(sort) ? c.likeCount.desc() : c.createdAt.desc(); - BooleanExpression byArticle = articleIdEq(articleId); - BooleanExpression ltCursor = cursorId != null ? c.id.lt(cursorId) : null; - - return jpaQueryFactory - .selectFrom(c) - .where(byArticle, ltCursor) - .orderBy(orderBy, c.id.desc()) - .limit(limit) - .fetch(); - } - @Override public List findPageByArticleIdOrderByCreatedAtDesc(Long articleId, Long cursorId, LocalDateTime cursorCreatedAt, int limit) { @@ -44,9 +30,11 @@ public List findPageByArticleIdOrderByCreatedAtDesc(Long articleId, Lon return jpaQueryFactory .selectFrom(c) + .leftJoin(c.user).fetchJoin() + .leftJoin(c.article).fetchJoin() .where(byArticle, afterCursor) .orderBy(c.createdAt.desc(), c.id.desc()) - .limit(limit) + .limit(limit + 1) .fetch(); } @@ -58,15 +46,16 @@ public List findPageByArticleIdOrderByLikeCountDesc(Long articleId, Lon return jpaQueryFactory .selectFrom(c) + .leftJoin(c.user).fetchJoin() + .leftJoin(c.article).fetchJoin() .where(byArticle, afterCursor) .orderBy(c.likeCount.desc(), c.id.desc()) - .limit(limit) + .limit(limit + 1) .fetch(); } - // ✅ article.id 기반 비교로 변경 private BooleanExpression articleIdEq(Long articleId) { - return c.article.id.eq(articleId); + return articleId != null ? c.article.id.eq(articleId) : null; } private BooleanExpression buildCreatedAtCursor(Long cursorId, LocalDateTime cursorCreatedAt) { diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index e82750e..137dbe0 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -1,7 +1,11 @@ package com.monew.monew_api.comments.service; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; @@ -10,16 +14,28 @@ import com.monew.monew_api.article.entity.Article; import com.monew.monew_api.article.repository.ArticleRepository; -import com.monew.monew_api.comments.dto.*; -import com.monew.monew_api.comments.entity.*; +import com.monew.monew_api.comments.dto.CommentDto; +import com.monew.monew_api.comments.dto.CommentLikeDto; +import com.monew.monew_api.comments.dto.CommentRegisterRequest; +import com.monew.monew_api.comments.dto.CommentUpdateRequest; +import com.monew.monew_api.comments.dto.CursorPageResponseCommentDto; +import com.monew.monew_api.comments.entity.Comment; +import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.comments.event.CommentCreatedEvent; import com.monew.monew_api.comments.event.CommentLikedEvent; import com.monew.monew_api.comments.repository.CommentLikeRepository; import com.monew.monew_api.comments.repository.CommentRepository; +import com.monew.monew_api.common.exception.comment.CommentAlreadyLikedException; +import com.monew.monew_api.common.exception.comment.CommentArticleNotFoundException; +import com.monew.monew_api.common.exception.comment.CommentForbiddenException; +import com.monew.monew_api.common.exception.comment.CommentNotFoundException; +import com.monew.monew_api.common.exception.comment.CommentNotLikedException; +import com.monew.monew_api.common.exception.comment.CommentUserNotFoundException; import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; -import com.monew.monew_api.common.exception.comment.*; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,6 +45,8 @@ @Transactional(readOnly = true) public class CommentService { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final CommentRepository commentRepository; private final CommentLikeRepository commentLikeRepository; private final UserRepository userRepository; @@ -36,13 +54,11 @@ public class CommentService { private final ApplicationEventPublisher eventPublisher; @Transactional - public Long register(CommentRegisterRequest request) { + public CommentDto register(CommentRegisterRequest request) { User user = getUserById(request.getUserIdAsLong()); Article article = getArticleById(request.getArticleIdAsLong()); - Comment comment = Comment.of(user, article, request.content()); - Comment saved = commentRepository.save(comment); - + Comment saved = commentRepository.save(Comment.of(user, article, request.content())); log.info("[COMMENT][CREATE] userId={}, articleId={}, commentId={}", user.getId(), article.getId(), saved.getId()); @@ -50,107 +66,162 @@ public Long register(CommentRegisterRequest request) { new CommentCreatedEvent(saved.getId(), user.getId(), article.getId(), saved.getCreatedAt()) ); - return saved.getId(); + return CommentDto.from(saved, false); } @Transactional - public void update(Long userId, Long commentId, CommentUpdateRequest request) { + public CommentDto update(Long userId, Long commentId, CommentUpdateRequest request) { Comment comment = getCommentById(commentId); validateOwnership(comment, userId); comment.updateContent(request.content()); log.info("[COMMENT][UPDATE] userId={}, commentId={}, contentLength={}", userId, commentId, request.content().length()); + + boolean likedByMe = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, userId); + return CommentDto.from(comment, likedByMe); } @Transactional public void delete(Long userId, Long commentId) { Comment comment = getCommentById(commentId); validateOwnership(comment, userId); + commentRepository.delete(comment); log.info("[COMMENT][DELETE] userId={}, commentId={}", userId, commentId); } @Transactional - public void like(Long userId, Long commentId) { + public CommentLikeDto like(Long userId, Long commentId) { User user = getUserById(userId); Comment comment = getCommentById(commentId); + CommentLike saved; try { - commentLikeRepository.save(CommentLike.of(user, comment)); - comment.increaseLike(); - - log.info("[COMMENT][LIKE] userId={}, commentId={}", userId, commentId); - eventPublisher.publishEvent( - new CommentLikedEvent(comment.getId(), comment.getUserId(), userId, LocalDateTime.now()) - ); + saved = commentLikeRepository.save(CommentLike.of(user, comment)); } catch (DataIntegrityViolationException e) { throw new CommentAlreadyLikedException(); } + + comment.increaseLike(); + + eventPublisher.publishEvent( + new CommentLikedEvent(comment.getId(), comment.getUserId(), userId, LocalDateTime.now()) + ); + + return CommentLikeDto.from(saved); } @Transactional - public void disLike(Long userId, Long commentId) { + public void dislike(Long userId, Long commentId) { // void 반환 boolean liked = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, userId); if (!liked) throw new CommentNotLikedException(); commentLikeRepository.deleteByComment_IdAndUser_Id(commentId, userId); - Comment comment = getCommentById(commentId); - comment.decreaseLike(); + commentRepository.decLikeCount(commentId); log.info("[COMMENT][DISLIKE] userId={}, commentId={}", userId, commentId); } - public List findAll( + public CursorPageResponseCommentDto findAll( Long articleId, - int limit, + int size, String orderBy, - String direction, - Long cursor, - LocalDateTime after, + Long cursorId, + LocalDateTime cursorCreatedAt, + Integer cursorLikeCount, Long requestUserId ) { - List page = orderBy.equalsIgnoreCase("like") - ? commentRepository.findPageByArticleIdOrderByLikeCountDesc(articleId, cursor, null, limit) - : commentRepository.findPageByArticleIdOrderByCreatedAtDesc(articleId, cursor, after, limit); - - return page.stream() - .map(comment -> { - boolean likedByMe = commentLikeRepository.existsByComment_IdAndUser_Id(comment.getId(), requestUserId); - return CommentDto.from(comment, likedByMe); - }) + final boolean orderByLike = "likeCount".equalsIgnoreCase(orderBy); + + List page = orderByLike + ? commentRepository.findPageByArticleIdOrderByLikeCountDesc(articleId, cursorId, cursorLikeCount, size) + : commentRepository.findPageByArticleIdOrderByCreatedAtDesc(articleId, cursorId, cursorCreatedAt, size); + + boolean hasNext = page.size() > size; + if (hasNext) + page = page.subList(0, size); + + Set likedCommentIds = requestUserId == null || page.isEmpty() + ? Set.of() + : commentLikeRepository + .findByUser_IdAndComment_IdIn(requestUserId, + page.stream().map(Comment::getId).toList()) + .stream() + .map(cl -> cl.getComment().getId()) + .collect(Collectors.toSet()); + + List content = page.stream() + .map(c -> CommentDto.from(c, likedCommentIds.contains(c.getId()))) .toList(); + + String nextCursor = null; + if (hasNext) { + Comment last = page.get(page.size() - 1); + if (orderByLike) { + nextCursor = last.getLikeCount() + ":" + last.getId(); + } else { + nextCursor = String.valueOf(last.getId()); + } + } + + ZonedDateTime nextAfter = null; + if (hasNext) { + LocalDateTime lastCreated = page.get(page.size() - 1).getCreatedAt(); + nextAfter = lastCreated.atZone(KST); + } + + return new CursorPageResponseCommentDto( + content, + nextCursor, + nextAfter, + content.size(), + -1L, + hasNext + ); + } + + @PersistenceContext + private EntityManager entityManager; + + @Transactional + public void hardDelete(Long commentId) { + if (!commentRepository.existsById(commentId)) { + throw new CommentNotFoundException(); + } + commentRepository.hardDeleteById(commentId); + log.info("[COMMENT][HARD_DELETE] commentId={}", commentId); } public CommentDto findById(Long commentId, String userId) { Comment comment = getCommentById(commentId); - boolean likedByMe = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, Long.parseLong(userId)); + boolean likedByMe = false; + if (userId != null && !userId.isBlank()) { + likedByMe = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, Long.parseLong(userId)); + } return CommentDto.from(comment, likedByMe); } public CommentLikeDto findLike(Long commentId, Long userId) { - boolean liked = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, userId); - return CommentLikeDto.of(commentId, userId, liked); + return CommentLikeDto.of(commentId, userId); } + // === 내부 유틸 === + private void validateOwnership(Comment comment, Long userId) { - if (!comment.isOwnedBy(userId)) { + if (!comment.isOwnedBy(userId)) throw new CommentForbiddenException(); - } } private Comment getCommentById(Long commentId) { - return commentRepository.findById(commentId) - .orElseThrow(CommentNotFoundException::new); + return commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new); } private User getUserById(Long userId) { - return userRepository.findById(userId) - .orElseThrow(CommentUserNotFoundException::new); + return userRepository.findById(userId).orElseThrow(CommentUserNotFoundException::new); } private Article getArticleById(Long articleId) { - return articleRepository.findById(articleId) - .orElseThrow(CommentArticleNotFoundException::new); + return articleRepository.findById(articleId).orElseThrow(CommentArticleNotFoundException::new); } } diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java index 7140881..ce97b6f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java @@ -27,6 +27,8 @@ public enum ErrorCode { COMMENT_NOT_LIKED(HttpStatus.BAD_REQUEST.value(), "좋아요하지 않은 댓글은 취소할 수 없습니다."), COMMENT_USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "댓글 작성자를 찾을 수 없습니다."), COMMENT_ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "댓글이 연결된 기사를 찾을 수 없습니다."), + COMMENT_INVALID_USER_ID(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 사용자 ID 형식입니다."), + COMMENT_INVALID_ARTICLE_ID(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 기사 ID 형식입니다."), // 알림 - NOTIFICATION NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "알림 정보를 찾을 수 없습니다."), diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidArticleIdException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidArticleIdException.java new file mode 100644 index 0000000..eda5feb --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidArticleIdException.java @@ -0,0 +1,12 @@ +package com.monew.monew_api.common.exception.comment; + +import java.util.Map; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +public class CommentInvalidArticleIdException extends BaseException { + public CommentInvalidArticleIdException(String invalidValue) { + super(ErrorCode.COMMENT_INVALID_ARTICLE_ID, Map.of("articleId", invalidValue)); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidUserIdException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidUserIdException.java new file mode 100644 index 0000000..0b5e303 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidUserIdException.java @@ -0,0 +1,14 @@ +package com.monew.monew_api.common.exception.comment; + +import java.util.Map; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +public class CommentInvalidUserIdException extends BaseException { + + public CommentInvalidUserIdException(String invalidValue) { + super(ErrorCode.COMMENT_INVALID_USER_ID, Map.of("userId", invalidValue)); + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotFoundException.java index 0e2e98b..a6527d7 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotFoundException.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotFoundException.java @@ -10,8 +10,4 @@ public class CommentNotFoundException extends BaseException { public CommentNotFoundException() { super(ErrorCode.COMMENT_NOT_FOUND); } - - public CommentNotFoundException(Map details) { - super(ErrorCode.COMMENT_NOT_FOUND, details); - } } diff --git a/monew-batch/src/main/java/com/monew/monew_batch/comments/service/CommentPurgeService.java b/monew-batch/src/main/java/com/monew/monew_batch/comments/service/CommentPurgeService.java index 782d70a..0ef9a35 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/comments/service/CommentPurgeService.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/comments/service/CommentPurgeService.java @@ -35,6 +35,10 @@ public int purge(LocalDateTime cutoff) { if (ids.isEmpty()) break; + em.createQuery("delete from CommentLike cl where cl.comment.id in :ids") + .setParameter("ids", ids) + .executeUpdate(); + int deleted = em.createQuery("delete from Comment c where c.id in :ids") .setParameter("ids", ids) .executeUpdate(); From 8a11e40b469a29e25611e25271b8c71db34c315d Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Tue, 28 Oct 2025 23:58:22 +0900 Subject: [PATCH 058/178] =?UTF-8?q?refactor:=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EC=9D=BC?= =?UTF-8?q?=EB=B6=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monew-api/src/main/resources/static/assets/index-BBLciFoK.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monew-api/src/main/resources/static/assets/index-BBLciFoK.js b/monew-api/src/main/resources/static/assets/index-BBLciFoK.js index 5a10cb1..77a93e5 100644 --- a/monew-api/src/main/resources/static/assets/index-BBLciFoK.js +++ b/monew-api/src/main/resources/static/assets/index-BBLciFoK.js @@ -77,5 +77,5 @@ Please change the parent to p(!d),className:`w-full border rounded-lg mt-1.5 bg-white ${q[o]} border-gray-200 focus:border-cyan-500 outline-none text-left flex items-center gap-2`,children:[m.jsx("span",{className:`text-16-m ${a?"text-gray-900":"text-gray-400"}`,children:a||i}),a&&f&&m.jsx("span",{className:"text-14-m text-gray-500",children:f})]}),d&&m.jsxs("div",{className:"absolute z-50 mt-2 bg-white border border-gray-200 rounded-lg shadow-lg p-4 w-[280px]",children:[m.jsxs("div",{className:"flex items-center justify-between mb-4",children:[m.jsx("button",{type:"button",onClick:T,className:"p-1 hover:bg-gray-100 rounded",children:m.jsx("svg",{className:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:m.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M15 19l-7-7 7-7"})})}),m.jsx("div",{className:"text-16-b text-gray-900",children:Ta(g,"yyyy년 M월")}),m.jsx("button",{type:"button",onClick:M,className:"p-1 hover:bg-gray-100 rounded",children:m.jsx("svg",{className:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:m.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M9 5l7 7-7 7"})})})]}),m.jsx("div",{className:"mb-2",children:m.jsx("div",{className:"flex gap-1 justify-between mb-2",children:["일","월","화","수","목","금","토"].map(A=>m.jsx("div",{className:"h-9 w-9 flex items-center justify-center text-14-m text-gray-600 font-semibold",children:A},A))})}),m.jsx("div",{className:"flex flex-col gap-1",children:C()})]})]})}function Hl({isOpen:a,onClose:i,children:r,width:s="w-[502px]",noPadding:o=!1,disableClose:f=!1}){const d=b.useRef(null);return Xs(d,i,a,f),b.useEffect(()=>(a?document.body.style.overflow="hidden":document.body.style.overflow="unset",()=>{document.body.style.overflow="unset"}),[a]),a?rv.createPortal(m.jsx("div",{className:"z-50 fixed inset-0 bg-black/50 flex items-center justify-center",role:"dialog","aria-modal":"true",children:m.jsxs("div",{ref:d,className:`${s} max-h-[90vh] overflow-y-auto h-auto rounded-2xl ${o?"":"p-8"} gap-2.5 bg-white relative`,onClick:p=>p.stopPropagation(),children:[m.jsx("button",{className:"absolute top-9 right-7 hover:text-gray-700",onClick:i,"aria-label":"close",children:m.jsx("img",{src:kh,alt:"닫기",className:"w-6 h-6"})}),r]})}),document.body):null}const Ih="data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2016%2016'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M8%209.72973L11.8333%206L13%207.13514L8%2012L3%207.13513L4.16667%206L8%209.72973Z'%20fill='%231F2937'/%3e%3c/svg%3e";function j5({label:a,onClick:i}){return m.jsx("li",{className:"h-11 p-3 gap-0.5 bg-white font-pretendard font-medium text-sm leading-5 hover:bg-gray-100 truncate",onClick:i,children:m.jsx("span",{children:a})})}function ef({items:a,onChange:i,className:r}){const s=o=>{i(o)};return m.jsx("div",{className:`absolute box-border bg-white border border-gray-200 rounded-lg overflow-hidden ${r}`,children:m.jsx("ul",{className:"py-1 max-h-60 overflow-y-auto cursor-pointer",children:a.map((o,f)=>m.jsx(j5,{label:o,onClick:()=>s(o)},f))})})}function Ol({placeholder:a="선택하세요",items:i,value:r,onChange:s,className:o="w-full h-10",noBorder:f=!1,textClassName:d="text-14-m",noBackground:p=!1}){const[g,y]=b.useState(!1),v=b.useRef(null);b.useEffect(()=>{const R=T=>{v.current&&!v.current.contains(T.target)&&y(!1)};return document.addEventListener("mousedown",R),()=>{document.removeEventListener("mousedown",R)}},[]);const w=()=>{y(!g)},N=R=>{s(R),y(!1)};return m.jsxs("div",{ref:v,className:`relative ${o}`,children:[m.jsx("button",{type:"button",className:`${f?"":"border rounded-lg border-gray-200"} ${p?"":"bg-white"} py-2.5 px-3 cursor-pointer w-full h-full focus:outline-none`,onClick:w,children:m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsx("p",{className:`${d} ${r?f?"text-cyan-600":"text-gray-900":"text-gray-400"}`,children:r||a}),m.jsx("img",{src:Ih,className:`transform transition-transform duration-200 ${g?"rotate-180":""} ${f?"ml-3":""}`,alt:"chevron"})]})}),g&&m.jsx("div",{className:"absolute top-full left-0 right-0 z-10",children:m.jsx(ef,{items:i,onChange:N,className:"w-full"})})]})}const e1="data:image/svg+xml,%3csvg%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20fill-rule='evenodd'%20clip-rule='evenodd'%20d='M4.45084%206.5193C2.64063%208.31949%202.49043%2011.1977%204.19174%2013.0999C4.90111%2013.8931%205.64486%2014.6967%206.30532%2015.3535C7.87526%2016.9147%2010.2793%2018.944%2011.3937%2019.8697C11.5692%2020.0154%2011.7867%2020.0802%2011.9996%2020.0666C12.2126%2020.0803%2012.4303%2020.0156%2012.6059%2019.8697C13.7204%2018.9442%2016.1243%2016.9152%2017.6942%2015.3541C18.3548%2014.6971%2019.0988%2013.8932%2019.8083%2013.0998C21.5096%2011.1975%2021.3593%208.31939%2019.5492%206.51927C17.6038%204.5847%2014.4498%204.58473%2012.5044%206.51933L12%207.02098L11.4954%206.51924C9.55013%204.58473%206.39617%204.58476%204.45084%206.5193Z'%20fill='%23D1D5DB'/%3e%3c/svg%3e",R5="data:image/svg+xml,%3csvg%20width='20'%20height='20'%20viewBox='0%200%2020%2020'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3ccircle%20cx='16'%20cy='10'%20r='1.5'%20transform='rotate(90%2016%2010)'%20fill='%23989FAC'/%3e%3ccircle%20cx='10.5'%20cy='10'%20r='1.5'%20transform='rotate(90%2010.5%2010)'%20fill='%23989FAC'/%3e%3ccircle%20cx='5'%20cy='10'%20r='1.5'%20transform='rotate(90%205%2010)'%20fill='%23989FAC'/%3e%3c/svg%3e";function A5({userNickname:a,createdAt:i,likeCount:r,content:s,isLiked:o,onLikeClick:f,onEditSave:d,onDelete:p,commentId:g,isMyComment:y,className:v}){const[w,N]=b.useState(s),[R,T]=b.useState(!1),[M,C]=b.useState(!1),q=b.useRef(null);Xs(q,()=>C(!1),M);const A=()=>{f(g)},Y=()=>{C(!M)},Q=()=>{T(!1),N(s)},$=()=>{w.trim()&&(d(g,w.trim()),T(!1))},ee=J=>{J==="수정하기"?(T(!0),N(s)):J==="삭제하기"&&p(g)};return m.jsxs("div",{className:`w-full h-auto border-gray-300 py-4 px-4 bg-gray-100 rounded-lg ${v||""}`,children:[m.jsxs("div",{className:"flex justify-between pr-1 gap-2 mb-2.5",children:[m.jsxs("div",{className:"gap-1 flex items-center",children:[m.jsx("span",{className:"text-14-m text-gray-500",children:a}),m.jsx("span",{className:"text-14-m text-gray-500 ",children:"·"}),m.jsx("span",{className:"text-14-m text-gray-500",children:j3(i,{addSuffix:!0,locale:dh})}),y&&m.jsx("span",{className:"ml-1 text-14-m text-cyan-500",children:"내 댓글"})]}),m.jsxs("div",{className:"flex items-center gap-2",children:[y&&!R&&m.jsxs("button",{ref:q,onClick:()=>C(!M),children:[m.jsx("img",{src:R5,className:"w-5 h-5",alt:"케밥",onClick:Y}),M&&m.jsx(ef,{items:["수정하기","삭제하기"],onChange:ee})]}),m.jsxs("button",{onClick:A,className:"flex justify-center items-center gap-2",children:[o?m.jsx("img",{src:Qc,className:"w-6 h-6",alt:"활성화 하트"}):m.jsx("img",{src:e1,className:"w-6 h-6",alt:"비활성화 하트"}),m.jsx("p",{className:"text-14-r text-gray-500",children:r})]})]})]}),y&&R?m.jsxs("div",{className:"flex items-center gap-2.5 w-full",children:[m.jsx(Tt,{inputSize:"sm",value:w,onChange:J=>N(J.target.value),className:"flex-1"}),m.jsx(Fe,{variant:"tertiary",size:"sm",className:"w-16 mt-1",onClick:Q,children:"취소"}),m.jsx(Fe,{size:"sm",className:"w-16 mt-1",onClick:$,children:"수정"})]}):m.jsx("div",{children:m.jsx("p",{className:"text-16-r text-gray-700",children:s})})]})}async function Rm(a,i){const{data:r}=await Ye.get("/comments",{params:a,headers:{"Monew-Request-User-ID":i}});return r}async function U5(a){const{data:i}=await Ye.post("/comments",a);return i}async function z5(a,i){const{data:r}=await Ye.post(`/comments/${a}/comment-likes`,void 0,{headers:{"Monew-Request-User-ID":i}});return r}async function L5(a,i,r){const{data:s}=await Ye.patch(`/comments/${a}`,i,{headers:{"Monew-Request-User-ID":r}});return s}async function H5(a,i){await Ye.delete(`/comments/${a}/comment-likes`,{headers:{"Monew-Request-User-ID":i}})}async function B5(a){await Ye.delete(`/comments/${a}`)}function t1({src:a,label:i}){return m.jsxs("div",{className:"flex gap-1.5 items-center w-fit",children:[a&&m.jsx("div",{className:"p-0.5 bg-gray-200 rounded-full",children:m.jsx("img",{src:a,className:"w-5 h-5 rounded-full"})}),m.jsx("p",{className:"font-pretendard font-semibold text-sm leading-5 text-gray-500",children:i})]})}const n1="data:image/svg+xml,%3csvg%20width='20'%20height='20'%20viewBox='0%200%2020%2020'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M6%2011.25H15V9.75H6V11.25ZM5%208.5H15V7H5V8.5ZM18%2018L15%2015H3.5C3.0875%2015%202.73438%2014.8531%202.44063%2014.5594C2.14688%2014.2656%202%2013.9125%202%2013.5V4.5C2%204.0875%202.14688%203.73438%202.44063%203.44063C2.73438%203.14688%203.0875%203%203.5%203H16.5C16.9125%203%2017.2656%203.14688%2017.5594%203.44063C17.8531%203.73438%2018%204.0875%2018%204.5V18ZM3.5%2013.5H15.625L16.5%2014.375V4.5H3.5V13.5Z'%20fill='%236B7280'/%3e%3c/svg%3e";function a1(){const[a,i]=b.useState(!1),[r,s]=b.useState(null);return{isOpen:a,openModal:d=>{s(d),i(!0)},onClose:()=>{i(!1),s(null)},initialData:r}}function l1({isOpen:a,onClose:i,onConfirm:r,title:s,message:o,cancelText:f="취소",confirmText:d="확인"}){const p=async()=>{await r(),i()};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("div",{className:"w-full p-6 text-center",children:[m.jsx("h2",{className:"text-20-b text-gray-900 mb-4",children:s}),m.jsx("p",{className:"text-16-r text-gray-600 mb-8 leading-relaxed",children:o}),m.jsxs("div",{className:"flex gap-4 pt-4",children:[m.jsx(Fe,{variant:"secondary",onClick:i,className:"min-w-[100px] flex-1 px-6",children:f}),m.jsx(Fe,{variant:"primary",onClick:p,className:"min-w-[100px] flex-1 px-6",children:d})]})]})})}function i1({articleId:a,onClose:i}){const[r,s]=b.useState(null),o=["등록순","좋아요순"],f=5,[d,p]=b.useState("createdAt"),g=d==="createdAt"?"등록순":"좋아요순",y="DESC",[v,w]=b.useState([]),[N,R]=b.useState(""),[T,M]=b.useState(!1),[C,q]=b.useState(!1),[A,Y]=b.useState(null),Q=b.useRef(null),$=b.useRef(null),{isOpen:ee,openModal:J,onClose:F,initialData:ae}=a1(),{userId:re}=ta();b.useEffect(()=>{a&&D5(a,re).then(D=>{s(D),D.viewedByMe||N5(a,re)})},[a,re]);const fe=b.useCallback(async()=>{if(M(!0),!!r)try{const D={articleId:r.id,orderBy:d,direction:y,limit:f},k=await Rm(D,re);w(k.content),q(k.hasNext),Y(k.nextCursor)}catch(D){console.error(D)}finally{M(!1)}},[r,d,re]),oe=b.useCallback(async()=>{if(!(!r||!C||!re||T)){M(!0);try{const D={articleId:r.id,orderBy:d,direction:y,limit:f,cursor:A||void 0},k=await Rm(D,re);w(Z=>[...Z,...k.content]),q(k.hasNext),Y(k.nextCursor)}catch(D){console.error(D)}finally{M(!1)}}},[d,y,f,A,r,C,T,re]);b.useEffect(()=>{r&&fe()},[fe,r]),b.useEffect(()=>{if(!T)return Q.current&&Q.current.disconnect(),Q.current=new IntersectionObserver(D=>{D[0].isIntersecting&&C&&oe()},{threshold:.8}),$.current&&Q.current.observe($.current),()=>{Q.current&&Q.current.disconnect()}},[C,T,oe]);const _e=()=>{i()},Ue=()=>{r&&window.open(r.sourceUrl,"_blank","noopener,noreferrer")},Se=D=>{D==="등록순"?p("createdAt"):D==="좋아요순"&&p("likeCount")},B=async D=>{try{const k=v.find(Z=>Z.id===D);if(!k)return;k.likedByMe?await H5(D,re):await z5(D,re),await fe()}catch(k){console.error(k)}},K=async(D,k)=>{try{await L5(D,{content:k},re),fe(),ce.success("댓글이 수정되었습니다.")}catch(Z){console.error(Z),ce.error("댓글 수정 중 오류가 발생했습니다.")}},W=async D=>{if(!(!r||!D.trim()))try{const k={articleId:r.id,userId:re,content:D.trim()};await U5(k),R(""),await fe(),ce.success("댓글 작성 완료")}catch(k){console.error(k)}},pe=async D=>{J({title:"댓글 삭제",message:"정말 삭제하시겠습니까?",onConfirm:async()=>{try{await B5(D),ce.success("댓글이 삭제되었습니다."),await fe()}catch(k){console.error(k),ce.error("댓글 삭제 중 오류가 발생했습니다.")}},confirmText:"삭제",cancelText:"취소"})};if(!r)return null;const E=Ta(r.publishDate,"yyyy.MM.dd");return m.jsxs(m.Fragment,{children:[m.jsxs(Hl,{isOpen:r!==null,onClose:_e,width:"w-[894px]",noPadding:!0,disableClose:ee,children:[m.jsxs("div",{className:"h-auto rounded-tr-3xl rounded-tl-3xl pt-10 px-10 pb-6 bg-white",children:[m.jsx("div",{className:"text-20-b text-gray-900 mb-2",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:r.title}})}),m.jsxs("div",{className:"flex items-center gap-4 pb-6 mb-6 border-b border-gray-200",children:[m.jsx(t1,{label:r.source}),m.jsxs("div",{className:"flex items-center gap-3",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:E}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:"읽음"}),m.jsx("span",{className:"text-14-r text-gray-400",children:r.viewCount})]}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("img",{src:n1,className:"w-5 h-5",alt:"댓글"}),m.jsx("span",{className:"text-14-r text-gray-400",children:r.commentCount})]})]})]}),m.jsx("div",{className:"text-18-r text-gray-500 mb-6",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:r.summary}})}),m.jsx("div",{className:"mt-4 mb-10 border-b-gray-200",children:m.jsx(Fe,{size:"sm",className:"w-[162px]",variant:"secondary",onClick:Ue,children:"전체 기사 보러가기 →"})})]}),m.jsxs("div",{className:"rounded-br-3xl rounded-bl-3xl pt-3 px-10 pb-8 bg-gray-100",children:[m.jsx("div",{className:"mb-2 w-[110px]",children:m.jsx(Ol,{items:o,value:g,onChange:Se,placeholder:"등록순",noBorder:!0,textClassName:"text-14-m text-gray-400",noBackground:!0})}),m.jsxs("div",{className:"flex items-center gap-2.5 mb-2",children:[m.jsx(Tt,{placeholder:"2025.01.01 부터",className:"flex-1",value:N,onChange:D=>R(D.target.value)}),m.jsx(Fe,{className:"w-[92px]",onClick:()=>W(N),children:"댓글 작성"})]}),m.jsx("div",{children:v.map((D,k)=>m.jsx("div",{ref:k===v.length-1?$:null,children:m.jsx(A5,{userNickname:D.userNickname,createdAt:new Date(D.createdAt),likeCount:D.likeCount,content:D.content,isLiked:D.likedByMe,onLikeClick:B,onEditSave:K,commentId:D.id,isMyComment:D.userId===re,onDelete:pe})},D.id))})]})]}),ae&&m.jsx(l1,{isOpen:ee,onClose:F,onConfirm:ae.onConfirm,title:ae.title,message:ae.message,confirmText:ae.confirmText,cancelText:ae.cancelText})]})}function k5({isOpen:a,onClose:i,onSave:r}){const[s,o]=b.useState(""),[f,d]=b.useState(""),p=s.trim()!==""&&f.trim()!=="";b.useEffect(()=>{a||(o(""),d(""))},[a]);const g=y=>{y.preventDefault(),p&&(r({from:s,to:f}),i())};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("form",{onSubmit:g,className:"w-[438px] h-auto gap-10",children:[m.jsx("h2",{className:"text-24-sb mb-10",children:"기사 복구하기"}),m.jsx(Tt,{label:"날짜",value:s,placeholder:"2025.01.01 부터",onChange:y=>o(y.target.value),className:"mb-2"}),m.jsx(Tt,{value:f,placeholder:"2025.01.01 까지",onChange:y=>d(y.target.value),className:"mb-12"}),m.jsx(Fe,{className:"w-full",disabled:!p,type:"submit",children:"복구하기"})]})})}const q5="data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2016%2016'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M7.14697%201.14844C10.4595%201.14863%2013.1458%203.83391%2013.146%207.14648C13.146%208.51468%2012.6858%209.77484%2011.9146%2010.7842L14.9907%2013.8594L14.4243%2014.4248L13.8589%2014.9912L10.7837%2011.915C9.77453%2012.686%208.51479%2013.1454%207.14697%2013.1455C3.83421%2013.1454%201.14893%2010.4593%201.14893%207.14648C1.14912%203.83386%203.83433%201.14856%207.14697%201.14844ZM7.14697%202.74805C4.71799%202.74817%202.74873%204.71752%202.74854%207.14648C2.74854%209.57561%204.71787%2011.5448%207.14697%2011.5449C9.57601%2011.5447%2011.5454%209.57557%2011.5454%207.14648C11.5452%204.71756%209.57589%202.74824%207.14697%202.74805Z'%20fill='%231F2937'/%3e%3c/svg%3e";function r1({width:a="w-3xs",height:i="h-[40px]",containerClassName:r="",className:s="",onSearch:o,onKeyDown:f,...d}){const p=b.useRef(null),g=()=>{const v=p.current?.value||"";o&&v.trim()&&(o(v.trim()),p.current&&(p.current.value=""))},y=v=>{v.key==="Enter"&&o&&!v.nativeEvent.isComposing&&(v.preventDefault(),g()),f?.(v)};return m.jsxs("div",{className:`${a} ${i} px-4 py-2.5 border bg-white border-gray-300 rounded-[100px] flex items-center justify-between gap-2.5 ${r}`,children:[m.jsx("input",{ref:p,type:"text",placeholder:"검색어를 입력해주세요",className:`flex-1 outline-none font-pretendard placeholder:text-sm placeholder:text-gray-400 placeholder:font-normal placeholder:leading-5 ${s}`,onKeyDown:y,...d}),m.jsx("button",{type:"button",onClick:g,"aria-label":"검색",children:m.jsx("img",{src:q5,alt:"검색",className:"w-4 h-4"})})]})}function Y5({items:a,values:i,onChange:r,className:s="w-full h-10",placeholder:o="선택하세요"}){const[f,d]=b.useState(!1),p=b.useRef(null);b.useEffect(()=>{const w=N=>{p.current&&!p.current.contains(N.target)&&d(!1)};return document.addEventListener("mousedown",w),()=>{document.removeEventListener("mousedown",w)}},[]);const g=()=>{d(!f)},y=w=>{const N=i.includes(w)?i.filter(R=>R!==w):[...i,w];r(N)},v=i.length>0?`${i.length}개 선택됨`:o;return m.jsxs("div",{ref:p,className:`relative ${s}`,children:[m.jsx("button",{type:"button",className:"border rounded-lg border-gray-200 bg-white py-2.5 px-3 cursor-pointer w-full h-full focus:outline-none",onClick:g,children:m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsx("p",{className:`text-14-m ${i.length===0?"text-gray-400":"text-gray-900"}`,children:v}),m.jsx("img",{src:Ih,className:`transform transition-transform duration-200 ${f?"rotate-180":""}`,alt:"chevron"})]})}),f&&m.jsx("div",{className:"absolute top-full left-0 right-0 z-10",children:m.jsx("div",{className:"absolute box-border bg-white border border-gray-200 rounded-lg overflow-hidden w-full",children:m.jsx("ul",{className:"py-1 max-h-60 overflow-y-auto",children:a.map(w=>m.jsx("li",{className:"h-11 p-3 gap-0.5 bg-white font-pretendard font-medium text-sm leading-5 hover:bg-gray-100 cursor-pointer",children:m.jsxs("label",{className:"flex items-center gap-2 cursor-pointer",children:[m.jsx("input",{type:"checkbox",checked:i.includes(w),onChange:()=>y(w),className:"w-4 h-4 rounded border-gray-300 text-cyan-600 focus:ring-cyan-500 cursor-pointer"}),m.jsx("span",{children:w})]})},w))})})})]})}function s1({article:a,onClick:i}){const r=Ta(a.publishDate,"yyyy.MM.dd");return m.jsx(m.Fragment,{children:m.jsx("div",{className:"max-w-4xl w-auto min-h-48 h-auto cursor-pointer",onClick:i,children:m.jsxs("div",{className:"my-6 mx-1",children:[m.jsx("div",{className:"text-20-b text-gray-900 mb-2",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:a.title}})}),m.jsx("div",{className:"text-18-r text-gray-500 mb-6",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:a.summary}})}),m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsx(t1,{label:a.source}),m.jsxs("div",{className:"flex items-center gap-3",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:r}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:"읽음"}),m.jsx("span",{className:"text-14-r text-gray-400",children:a.viewCount})]}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("img",{src:n1,className:"w-5 h-5",alt:"댓글"}),m.jsx("span",{className:"text-14-r text-gray-400",children:a.commentCount})]})]})]})]})})})}function u1(){const[a,i]=b.useState(!1),[r,s]=b.useState(null);return{isOpen:a,openModal:d=>{s(d),i(!0)},onClose:()=>{i(!1),s(null)},initialData:r}}function V5(){const[a,i]=b.useState(!1);return{isOpen:a,openModal:()=>i(!0),onClose:()=>i(!1)}}async function o1(a){const{data:i}=await Ye.get(`/user-activities/${a}`);return i}function Am(){const[a,i]=Ls(),{articleId:r}=fg(),o=Wt().state?.article,{openModal:f,onClose:d,initialData:p}=u1(),g=["게시일","조회수","댓글수"],y=["내림차순","오름차순"],v=a.get("orderBy")||"publishDate",w=v==="publishDate"||v==="commentCount"||v==="viewCount"?v:"publishDate",N=a.get("direction")||"DESC",R=N==="ASC"||N==="DESC"?N:"DESC",T=a.get("limit")||"10",M=a.get("keyword")||"",C=a.get("interestId")||"",q=a.get("sourceIn")||"",[A,Y]=b.useState(),Q=new Date,$=z3(Q),[ee,J]=b.useState(Ta($,"yyyy.MM.dd")),[F,ae]=b.useState(Ta(Q,"yyyy.MM.dd")),re=a.get("publishDateFrom")||"",fe=a.get("publishDateTo")||"",[oe,_e]=b.useState(!1),[Ue,Se]=b.useState(!1),[B,K]=b.useState(null),W=b.useRef(null),pe=b.useRef(null),[E,D]=b.useState([]),[k,Z]=b.useState([]),[P,xe]=b.useState([]),[se,at]=b.useState([]),ze=Oa(),{userId:dt}=ta(),{isOpen:Bl,openModal:Ra,onClose:Aa}=V5(),kl={게시일:"publishDate",조회수:"viewCount",댓글수:"commentCount"},Ua={publishDate:"게시일",viewCount:"조회수",commentCount:"댓글수"},[ql,lr]=b.useState(Ua[w]||"게시일"),[gt,ir]=b.useState(R==="DESC"?"내림차순":"오름차순"),Dn=b.useRef(!0),za=b.useCallback(async()=>{_e(!0);try{const I=q?q.split(","):[],he={keyword:M,interestId:C,publishDateFrom:re,publishDateTo:fe,orderBy:w,direction:R,limit:parseInt(T),sourceIn:I.length>0?I:void 0},ge=await Mm(he,dt);Se(ge.hasNext),K(ge.nextCursor),D(ge.content)}catch(I){console.error(I)}finally{_e(!1)}},[M,C,w,R,re,fe,T,dt,q]),na=b.useCallback(async()=>{if(!(!Ue||!dt||oe)){_e(!0);try{const I=q?q.split(","):[],he={keyword:M,interestId:C,publishDateFrom:re,publishDateTo:fe,orderBy:w,direction:R,limit:parseInt(T),cursor:B||void 0,sourceIn:I.length>0?I:void 0},ge=await Mm(he,dt);D(lt=>[...lt,...ge.content]),Se(ge.hasNext),K(ge.nextCursor)}catch(I){console.error(I)}finally{_e(!1)}}},[w,R,re,fe,T,M,C,dt,Ue,oe,B,q]),rr=b.useCallback(async()=>{try{const he=(await o1(dt)).subscriptions.map(ge=>({id:ge.interestId,name:ge.interestName,keywords:ge.interestKeywords,subscriberCount:ge.interestSubscriberCount,subscribedByMe:!0}));Z(he)}catch(I){console.error(I)}},[]),Yl=b.useCallback(async()=>{try{const I=await O5();if(xe(I),q){const he=q.split(",");at(he)}else I.length>0&&at(I.slice(0,1))}catch(I){console.error(I)}},[q]);b.useEffect(()=>{Yl()},[Yl]),b.useEffect(()=>{if(!oe)return W.current&&W.current.disconnect(),W.current=new IntersectionObserver(I=>{I[0].isIntersecting&&Ue&&na()},{threshold:.8}),pe.current&&W.current.observe(pe.current),()=>{W.current&&W.current.disconnect()}},[Ue,oe,na]);const $s=b.useMemo(()=>k.map(I=>I.name),[k]),Js=I=>{const he=k.find(ge=>ge.name===I);he&&(Y(he),K(null),Se(!1))};b.useEffect(()=>{A&&i(I=>{const he=new URLSearchParams(I);return he.set("interestId",A.id),he})},[A]);const aa=I=>{lr(I)},mt=I=>{ir(I)},It=I=>{at(I)},ht=I=>{(I??"")!==M&&(i(he=>{const ge=new URLSearchParams(he);return I?ge.set("keyword",I):ge.delete("keyword"),ge.delete("interestId"),ge}),Y(void 0),K(null),Se(!1))};b.useEffect(()=>{if(Dn.current)return;const I=kl[ql];w!==I&&(i(he=>{const ge=new URLSearchParams(he);return ge.set("orderBy",I),ge}),K(null),Se(!1))},[ql]),b.useEffect(()=>{if(Dn.current)return;const I=gt==="오름차순"?"ASC":"DESC";R!==I&&(i(he=>{const ge=new URLSearchParams(he);return ge.set("direction",I),ge}),K(null),Se(!1))},[gt]),b.useEffect(()=>{if(!Dn.current&&se.length>0){const I=se.join(",");q!==I&&(i(he=>{const ge=new URLSearchParams(he);return ge.set("sourceIn",I),ge}),K(null),Se(!1))}},[se]),b.useEffect(()=>{if(Dn.current){Dn.current=!1;return}const I=ee?`${ee.replace(/\./g,"-")}T00:00:00`:"",he=F?`${F.replace(/\./g,"-")}T23:59:59`:"";(re!==I||fe!==he)&&(i(ge=>{const lt=new URLSearchParams(ge);return ee?lt.set("publishDateFrom",I):lt.delete("publishDateFrom"),F?lt.set("publishDateTo",he):lt.delete("publishDateTo"),lt}),K(null),Se(!1))},[ee,F]),b.useEffect(()=>{rr()},[rr]),b.useEffect(()=>{if(k.length>0&&C){const I=k.find(he=>he.id===C);I&&Y(I)}else C||Y(void 0)},[k,C]),b.useEffect(()=>{lr(Ua[w]||"게시일"),ir(R==="DESC"?"내림차순":"오름차순"),za()},[za,w,R]),b.useEffect(()=>{if(r){if(o&&o.id===r)Vl(o);else if(E.length>0){const I=E.find(he=>he.id===r);I&&Vl(I)}}},[r,E,o]);const Fs=I=>{try{const he={from:`${I.from.replace(/\./g,"-")}T00:00:00`,to:`${I.to.replace(/\./g,"-")}T23:59:59`};M5(he),za(),ce.success("기사가 복구되었습니다."),Aa()}catch(he){console.error(he);const ge=he,lt=ge.response?.data?.message||ge.message||"오류가 발생했습니다.";ce.error(lt)}},Vl=b.useCallback(I=>{f(I)},[f]);return m.jsxs("div",{className:"flex gap-12 justify-center",children:[m.jsxs("div",{className:"max-w-3xs min-h-[564px] h-auto",children:[m.jsx("div",{className:"mb-6",children:m.jsx(r1,{height:"h-11",onSearch:ht})}),m.jsxs("div",{className:"h-auto mb-6 border border-gray-200 rounded-2xl px-4 pt-4 pb-6 bg-white",children:[m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"정렬"}),m.jsx("div",{className:"min-h-10",children:m.jsx(Ol,{items:g,value:ql,onChange:aa,className:"mb-6 h-10"})}),m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"정렬 방향"}),m.jsx(Ol,{items:y,value:gt,onChange:mt,className:"mb-6 h-10"}),m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"출처"}),m.jsx(Y5,{items:P,values:se,onChange:It,className:"mb-6"}),m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"날짜"}),m.jsx(jm,{value:ee,placeholder:"시작 날짜",onChange:J,className:"mb-2",inputSize:"sm",label:"부터"}),m.jsx(jm,{value:F,placeholder:"종료 날짜",onChange:ae,className:"mb-4",inputSize:"sm",label:"까지"})]}),m.jsx(Fe,{className:"w-full",variant:"secondary",size:"sm",onClick:Ra,children:"기사 복구하기"}),m.jsx(k5,{isOpen:Bl,onClose:Aa,onSave:Fs})]}),m.jsxs("div",{className:"min-w-48",children:[M?m.jsxs("div",{className:"flex gap-4 items-center mb-8",children:[m.jsx("div",{className:"text-24-b text-cyan-600",children:M}),m.jsx("div",{className:"text-24-b text-gray-900",children:"관련 기사 목록"})]}):k.length>0?m.jsxs("div",{className:"flex gap-4 items-baseline mb-8",children:[m.jsx("div",{className:"min-w-[157px]",children:m.jsx(Ol,{items:$s,value:A?.name,onChange:Js,placeholder:"관심사 선택",noBorder:!0,textClassName:"text-24-b",noBackground:!0})}),m.jsx("div",{className:"text-24-b text-gray-900",children:"관련 기사 목록"})]}):m.jsx("div",{className:"text-24-b text-gray-900",children:"관련 기사 목록"}),E.length===0?m.jsx("div",{className:"min-w-[894px] w-full flex flex-col justify-center min-h-72 items-center mt-30",children:C?m.jsx(In,{message:"관련된 기사가 없습니다."}):m.jsxs("div",{className:"flex flex-col items-center justify-center gap-6",children:[m.jsx(In,{message:"관심사를 등록하면 맞춤 기사를 확인하실 수 있어요."}),m.jsx(Fe,{onClick:()=>ze("/interests"),className:"w-[160px]",size:"sm",children:"관심사 등록하기"})]})}):m.jsx("div",{children:E.map((I,he)=>m.jsx("div",{className:"min-w-2xs",ref:he===E.length-1?pe:null,children:m.jsx(s1,{article:I,onClick:()=>Vl(I)})},I.id))})]}),p&&m.jsx(i1,{onClose:d,articleId:p.id})]})}const X5="data:image/svg+xml,%3csvg%20width='20'%20height='20'%20viewBox='0%200%2020%2020'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M3%2010H17'%20stroke='white'%20stroke-width='2'%20stroke-linecap='round'/%3e%3cpath%20d='M10%2017V3'%20stroke='white'%20stroke-width='2'%20stroke-linecap='round'/%3e%3c/svg%3e",G5="data:image/svg+xml,%3csvg%20width='32'%20height='32'%20viewBox='0%200%2032%2032'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3ccircle%20cx='24'%20cy='16'%20r='2'%20transform='rotate(90%2024%2016)'%20fill='%23989FAC'/%3e%3ccircle%20cx='16'%20cy='16'%20r='2'%20transform='rotate(90%2016%2016)'%20fill='%23989FAC'/%3e%3ccircle%20cx='8'%20cy='16'%20r='2'%20transform='rotate(90%208%2016)'%20fill='%23989FAC'/%3e%3c/svg%3e",Z5="data:image/svg+xml,%3csvg%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3ccircle%20cx='12'%20cy='9'%20r='3'%20fill='%23D1D5DB'/%3e%3cpath%20d='M7%2017C7%2014.7909%208.79086%2013%2011%2013H13C15.2091%2013%2017%2014.7909%2017%2017V18C17%2018.5523%2016.5523%2019%2016%2019H8C7.44772%2019%207%2018.5523%207%2018V17Z'%20fill='%23D1D5DB'/%3e%3c/svg%3e",Q5="data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2016%2016'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M6.10417%2012.4375L2.0625%208.41667L3.125%207.33333L6.10417%2010.3125L12.875%203.5625L13.9375%204.625L6.10417%2012.4375Z'%20fill='%2300BCD4'/%3e%3c/svg%3e";function K5(){const[a,i]=b.useState(!1),[r,s]=b.useState();return{isOpen:a,openModal:d=>{s(d),i(!0)},onClose:()=>i(!1),initialData:r}}function As({label:a,onRemove:i}){return m.jsxs("div",{className:"w-fit h-8 px-2 py-1 rounded-lg bg-gray-100 flex items-center gap-1",children:[m.jsx("p",{className:"text-16-m text-gray-500",children:a}),i&&m.jsx("button",{type:"button",onClick:i,className:"w-4 h-4 flex items-center justify-center text-gray-400 hover:text-gray-600 cursor-pointer text-sm","aria-label":"태그 삭제",children:"×"})]})}function $5({isOpen:a,onClose:i,onSave:r,initialData:s}){const[o,f]=b.useState(""),[d,p]=b.useState([]),g=d.length>0;b.useEffect(()=>{a&&s?(p(s.keywords),f("")):a||(f(""),p([]))},[a,s]);const y=()=>{o.trim()!==""&&!d.includes(o.trim())?(p(N=>[...N,o.trim()]),f("")):d.includes(o.trim())&&ce.error("동일한 키워드는 등록할 수 없습니다.")},v=N=>{p(R=>R.filter(T=>T!==N))},w=N=>{N.preventDefault(),g&&(r(d),i())};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("form",{onSubmit:w,className:"w-[438px] h-auto gap-10",children:[m.jsx("h2",{className:"text-24-sb mb-10",children:"관심사 수정"}),m.jsxs("div",{className:"mb-1.5 flex gap-2",children:[m.jsx(Tt,{label:"키워드",placeholder:"키워드를 추가해주세요",value:o,onChange:N=>f(N.target.value),className:"flex-1"}),m.jsx(Fe,{className:"px-4 mt-8 whitespace-nowrap",onClick:y,type:"button",children:"키워드 추가"})]}),d.length>0&&m.jsx("div",{className:"max-h-32 overflow-y-auto p-3 mb-10",children:m.jsx("div",{className:"flex flex-wrap gap-2",children:d?.map((N,R)=>m.jsx(As,{label:N,onRemove:()=>v(N)},R))})}),m.jsx(Fe,{className:"w-full",disabled:!g,type:"submit",children:"수정하기"})]})})}function J5({interestId:a,name:i,keywords:r,subscriberCount:s,isSubscribed:o=!1,onSubscribeClick:f,onSaveKeyword:d,onDeleteInterest:p}){const[g,y]=b.useState(!1),v=b.useRef(null),{isOpen:w,openModal:N,onClose:R,initialData:T}=K5(),{isOpen:M,openModal:C,onClose:q,initialData:A}=a1();Xs(v,()=>y(!1),g);const Y=()=>{f(a,o)},Q=ee=>{ee==="키워드 수정"?N({keywords:r}):ee==="관심사 삭제"&&C({title:"관심사 삭제",message:`'${i}' 관심사를 정말 삭제하시겠습니까? + `,children:Ta(F,"d")},F.toString())),F=th(F,1)}ee.push(m.jsx("div",{className:"flex gap-1 justify-between",children:J},F.toString())),J=[]}return ee},q={sm:"min-h-10 py-1.5 px-3",md:"min-h-14 py-4 px-5"};return m.jsxs("div",{className:s,ref:v,children:[m.jsxs("button",{type:"button",id:w,onClick:()=>p(!d),className:`w-full border rounded-lg mt-1.5 bg-white ${q[o]} border-gray-200 focus:border-cyan-500 outline-none text-left flex items-center gap-2`,children:[m.jsx("span",{className:`text-16-m ${a?"text-gray-900":"text-gray-400"}`,children:a||i}),a&&f&&m.jsx("span",{className:"text-14-m text-gray-500",children:f})]}),d&&m.jsxs("div",{className:"absolute z-50 mt-2 bg-white border border-gray-200 rounded-lg shadow-lg p-4 w-[280px]",children:[m.jsxs("div",{className:"flex items-center justify-between mb-4",children:[m.jsx("button",{type:"button",onClick:T,className:"p-1 hover:bg-gray-100 rounded",children:m.jsx("svg",{className:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:m.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M15 19l-7-7 7-7"})})}),m.jsx("div",{className:"text-16-b text-gray-900",children:Ta(g,"yyyy년 M월")}),m.jsx("button",{type:"button",onClick:M,className:"p-1 hover:bg-gray-100 rounded",children:m.jsx("svg",{className:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:m.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M9 5l7 7-7 7"})})})]}),m.jsx("div",{className:"mb-2",children:m.jsx("div",{className:"flex gap-1 justify-between mb-2",children:["일","월","화","수","목","금","토"].map(A=>m.jsx("div",{className:"h-9 w-9 flex items-center justify-center text-14-m text-gray-600 font-semibold",children:A},A))})}),m.jsx("div",{className:"flex flex-col gap-1",children:C()})]})]})}function Hl({isOpen:a,onClose:i,children:r,width:s="w-[502px]",noPadding:o=!1,disableClose:f=!1}){const d=b.useRef(null);return Xs(d,i,a,f),b.useEffect(()=>(a?document.body.style.overflow="hidden":document.body.style.overflow="unset",()=>{document.body.style.overflow="unset"}),[a]),a?rv.createPortal(m.jsx("div",{className:"z-50 fixed inset-0 bg-black/50 flex items-center justify-center",role:"dialog","aria-modal":"true",children:m.jsxs("div",{ref:d,className:`${s} max-h-[90vh] overflow-y-auto h-auto rounded-2xl ${o?"":"p-8"} gap-2.5 bg-white relative`,onClick:p=>p.stopPropagation(),children:[m.jsx("button",{className:"absolute top-9 right-7 hover:text-gray-700",onClick:i,"aria-label":"close",children:m.jsx("img",{src:kh,alt:"닫기",className:"w-6 h-6"})}),r]})}),document.body):null}const Ih="data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2016%2016'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M8%209.72973L11.8333%206L13%207.13514L8%2012L3%207.13513L4.16667%206L8%209.72973Z'%20fill='%231F2937'/%3e%3c/svg%3e";function j5({label:a,onClick:i}){return m.jsx("li",{className:"h-11 p-3 gap-0.5 bg-white font-pretendard font-medium text-sm leading-5 hover:bg-gray-100 truncate",onClick:i,children:m.jsx("span",{children:a})})}function ef({items:a,onChange:i,className:r}){const s=o=>{i(o)};return m.jsx("div",{className:`absolute box-border bg-white border border-gray-200 rounded-lg overflow-hidden ${r}`,children:m.jsx("ul",{className:"py-1 max-h-60 overflow-y-auto cursor-pointer",children:a.map((o,f)=>m.jsx(j5,{label:o,onClick:()=>s(o)},f))})})}function Ol({placeholder:a="선택하세요",items:i,value:r,onChange:s,className:o="w-full h-10",noBorder:f=!1,textClassName:d="text-14-m",noBackground:p=!1}){const[g,y]=b.useState(!1),v=b.useRef(null);b.useEffect(()=>{const R=T=>{v.current&&!v.current.contains(T.target)&&y(!1)};return document.addEventListener("mousedown",R),()=>{document.removeEventListener("mousedown",R)}},[]);const w=()=>{y(!g)},N=R=>{s(R),y(!1)};return m.jsxs("div",{ref:v,className:`relative ${o}`,children:[m.jsx("button",{type:"button",className:`${f?"":"border rounded-lg border-gray-200"} ${p?"":"bg-white"} py-2.5 px-3 cursor-pointer w-full h-full focus:outline-none`,onClick:w,children:m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsx("p",{className:`${d} ${r?f?"text-cyan-600":"text-gray-900":"text-gray-400"}`,children:r||a}),m.jsx("img",{src:Ih,className:`transform transition-transform duration-200 ${g?"rotate-180":""} ${f?"ml-3":""}`,alt:"chevron"})]})}),g&&m.jsx("div",{className:"absolute top-full left-0 right-0 z-10",children:m.jsx(ef,{items:i,onChange:N,className:"w-full"})})]})}const e1="data:image/svg+xml,%3csvg%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20fill-rule='evenodd'%20clip-rule='evenodd'%20d='M4.45084%206.5193C2.64063%208.31949%202.49043%2011.1977%204.19174%2013.0999C4.90111%2013.8931%205.64486%2014.6967%206.30532%2015.3535C7.87526%2016.9147%2010.2793%2018.944%2011.3937%2019.8697C11.5692%2020.0154%2011.7867%2020.0802%2011.9996%2020.0666C12.2126%2020.0803%2012.4303%2020.0156%2012.6059%2019.8697C13.7204%2018.9442%2016.1243%2016.9152%2017.6942%2015.3541C18.3548%2014.6971%2019.0988%2013.8932%2019.8083%2013.0998C21.5096%2011.1975%2021.3593%208.31939%2019.5492%206.51927C17.6038%204.5847%2014.4498%204.58473%2012.5044%206.51933L12%207.02098L11.4954%206.51924C9.55013%204.58473%206.39617%204.58476%204.45084%206.5193Z'%20fill='%23D1D5DB'/%3e%3c/svg%3e",R5="data:image/svg+xml,%3csvg%20width='20'%20height='20'%20viewBox='0%200%2020%2020'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3ccircle%20cx='16'%20cy='10'%20r='1.5'%20transform='rotate(90%2016%2010)'%20fill='%23989FAC'/%3e%3ccircle%20cx='10.5'%20cy='10'%20r='1.5'%20transform='rotate(90%2010.5%2010)'%20fill='%23989FAC'/%3e%3ccircle%20cx='5'%20cy='10'%20r='1.5'%20transform='rotate(90%205%2010)'%20fill='%23989FAC'/%3e%3c/svg%3e";function A5({userNickname:a,createdAt:i,likeCount:r,content:s,isLiked:o,onLikeClick:f,onEditSave:d,onDelete:p,commentId:g,isMyComment:y,className:v}){const[w,N]=b.useState(s),[R,T]=b.useState(!1),[M,C]=b.useState(!1),q=b.useRef(null);Xs(q,()=>C(!1),M);const A=()=>{f(g)},Y=()=>{C(!M)},Q=()=>{T(!1),N(s)},$=()=>{w.trim()&&(d(g,w.trim()),T(!1))},ee=J=>{J==="수정하기"?(T(!0),N(s)):J==="삭제하기"&&p(g)};return m.jsxs("div",{className:`w-full h-auto border-gray-300 py-4 px-4 bg-gray-100 rounded-lg ${v||""}`,children:[m.jsxs("div",{className:"flex justify-between pr-1 gap-2 mb-2.5",children:[m.jsxs("div",{className:"gap-1 flex items-center",children:[m.jsx("span",{className:"text-14-m text-gray-500",children:a}),m.jsx("span",{className:"text-14-m text-gray-500 ",children:"·"}),m.jsx("span",{className:"text-14-m text-gray-500",children:j3(i,{addSuffix:!0,locale:dh})}),y&&m.jsx("span",{className:"ml-1 text-14-m text-cyan-500",children:"내 댓글"})]}),m.jsxs("div",{className:"flex items-center gap-2",children:[y&&!R&&m.jsxs("button",{ref:q,onClick:()=>C(!M),children:[m.jsx("img",{src:R5,className:"w-5 h-5",alt:"케밥",onClick:Y}),M&&m.jsx(ef,{items:["수정하기","삭제하기"],onChange:ee})]}),m.jsxs("button",{onClick:A,className:"flex justify-center items-center gap-2",children:[o?m.jsx("img",{src:Qc,className:"w-6 h-6",alt:"활성화 하트"}):m.jsx("img",{src:e1,className:"w-6 h-6",alt:"비활성화 하트"}),m.jsx("p",{className:"text-14-r text-gray-500",children:r})]})]})]}),y&&R?m.jsxs("div",{className:"flex items-center gap-2.5 w-full",children:[m.jsx(Tt,{inputSize:"sm",value:w,onChange:J=>N(J.target.value),className:"flex-1"}),m.jsx(Fe,{variant:"tertiary",size:"sm",className:"w-16 mt-1",onClick:Q,children:"취소"}),m.jsx(Fe,{size:"sm",className:"w-16 mt-1",onClick:$,children:"수정"})]}):m.jsx("div",{children:m.jsx("p",{className:"text-16-r text-gray-700",children:s})})]})}async function Rm(a,i){const{data:r}=await Ye.get("/comments",{params:a,headers:{"Monew-Request-User-ID":i}});return r}async function U5(a){const{data:i}=await Ye.post("/comments",a);return i}async function z5(a,i){const{data:r}=await Ye.post(`/comments/${a}/comment-likes`,void 0,{headers:{"Monew-Request-User-ID":i}});return r}async function L5(a,i,r){const{data:s}=await Ye.patch(`/comments/${a}`,i,{headers:{"Monew-Request-User-ID":r}});return s}async function H5(a,i){await Ye.delete(`/comments/${a}/comment-likes`,{headers:{"Monew-Request-User-ID":i}})}async function B5(a){await Ye.delete(`/comments/${a}`)}function t1({src:a,label:i}){return m.jsxs("div",{className:"flex gap-1.5 items-center w-fit",children:[a&&m.jsx("div",{className:"p-0.5 bg-gray-200 rounded-full",children:m.jsx("img",{src:a,className:"w-5 h-5 rounded-full"})}),m.jsx("p",{className:"font-pretendard font-semibold text-sm leading-5 text-gray-500",children:i})]})}const n1="data:image/svg+xml,%3csvg%20width='20'%20height='20'%20viewBox='0%200%2020%2020'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M6%2011.25H15V9.75H6V11.25ZM5%208.5H15V7H5V8.5ZM18%2018L15%2015H3.5C3.0875%2015%202.73438%2014.8531%202.44063%2014.5594C2.14688%2014.2656%202%2013.9125%202%2013.5V4.5C2%204.0875%202.14688%203.73438%202.44063%203.44063C2.73438%203.14688%203.0875%203%203.5%203H16.5C16.9125%203%2017.2656%203.14688%2017.5594%203.44063C17.8531%203.73438%2018%204.0875%2018%204.5V18ZM3.5%2013.5H15.625L16.5%2014.375V4.5H3.5V13.5Z'%20fill='%236B7280'/%3e%3c/svg%3e";function a1(){const[a,i]=b.useState(!1),[r,s]=b.useState(null);return{isOpen:a,openModal:d=>{s(d),i(!0)},onClose:()=>{i(!1),s(null)},initialData:r}}function l1({isOpen:a,onClose:i,onConfirm:r,title:s,message:o,cancelText:f="취소",confirmText:d="확인"}){const p=async()=>{await r(),i()};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("div",{className:"w-full p-6 text-center",children:[m.jsx("h2",{className:"text-20-b text-gray-900 mb-4",children:s}),m.jsx("p",{className:"text-16-r text-gray-600 mb-8 leading-relaxed",children:o}),m.jsxs("div",{className:"flex gap-4 pt-4",children:[m.jsx(Fe,{variant:"secondary",onClick:i,className:"min-w-[100px] flex-1 px-6",children:f}),m.jsx(Fe,{variant:"primary",onClick:p,className:"min-w-[100px] flex-1 px-6",children:d})]})]})})}function i1({articleId:a,onClose:i}){const[r,s]=b.useState(null),o=["등록순","좋아요순"],f=5,[d,p]=b.useState("createdAt"),g=d==="createdAt"?"등록순":"좋아요순",y="DESC",[v,w]=b.useState([]),[N,R]=b.useState(""),[T,M]=b.useState(!1),[C,q]=b.useState(!1),[A,Y]=b.useState(null),Q=b.useRef(null),$=b.useRef(null),{isOpen:ee,openModal:J,onClose:F,initialData:ae}=a1(),{userId:re}=ta();b.useEffect(()=>{a&&D5(a,re).then(D=>{s(D),D.viewedByMe||N5(a,re)})},[a,re]);const fe=b.useCallback(async()=>{if(M(!0),!!r)try{const D={articleId:r.id,orderBy:d,direction:y,limit:f},k=await Rm(D,re);w(k.content),q(k.hasNext),Y(k.nextCursor)}catch(D){console.error(D)}finally{M(!1)}},[r,d,re]),oe=b.useCallback(async()=>{if(!(!r||!C||!re||T)){M(!0);try{const D={articleId:r.id,orderBy:d,direction:y,limit:f,cursor:A||void 0},k=await Rm(D,re);w(Z=>[...Z,...k.content]),q(k.hasNext),Y(k.nextCursor)}catch(D){console.error(D)}finally{M(!1)}}},[d,y,f,A,r,C,T,re]);b.useEffect(()=>{r&&fe()},[fe,r]),b.useEffect(()=>{if(!T)return Q.current&&Q.current.disconnect(),Q.current=new IntersectionObserver(D=>{D[0].isIntersecting&&C&&oe()},{threshold:.8}),$.current&&Q.current.observe($.current),()=>{Q.current&&Q.current.disconnect()}},[C,T,oe]);const _e=()=>{i()},Ue=()=>{r&&window.open(r.sourceUrl,"_blank","noopener,noreferrer")},Se=D=>{D==="등록순"?p("createdAt"):D==="좋아요순"&&p("likeCount")},B=async D=>{try{const k=v.find(Z=>Z.id===D);if(!k)return;k.likedByMe?await H5(D,re):await z5(D,re),await fe()}catch(k){console.error(k)}},K=async(D,k)=>{try{await L5(D,{content:k},re),fe(),ce.success("댓글이 수정되었습니다.")}catch(Z){console.error(Z),ce.error("댓글 수정 중 오류가 발생했습니다.")}},W=async D=>{if(!(!r||!D.trim()))try{const k={articleId:r.id,userId:re,content:D.trim()};await U5(k),R(""),await fe(),ce.success("댓글 작성 완료")}catch(k){console.error(k)}},pe=async D=>{J({title:"댓글 삭제",message:"정말 삭제하시겠습니까?",onConfirm:async()=>{try{await B5(D),ce.success("댓글이 삭제되었습니다."),await fe()}catch(k){console.error(k),ce.error("댓글 삭제 중 오류가 발생했습니다.")}},confirmText:"삭제",cancelText:"취소"})};if(!r)return null;const E=Ta(r.publishDate,"yyyy.MM.dd");return m.jsxs(m.Fragment,{children:[m.jsxs(Hl,{isOpen:r!==null,onClose:_e,width:"w-[894px]",noPadding:!0,disableClose:ee,children:[m.jsxs("div",{className:"h-auto rounded-tr-3xl rounded-tl-3xl pt-10 px-10 pb-6 bg-white",children:[m.jsx("div",{className:"text-20-b text-gray-900 mb-2",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:r.title}})}),m.jsxs("div",{className:"flex items-center gap-4 pb-6 mb-6 border-b border-gray-200",children:[m.jsx(t1,{label:r.source}),m.jsxs("div",{className:"flex items-center gap-3",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:E}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:"읽음"}),m.jsx("span",{className:"text-14-r text-gray-400",children:r.viewCount})]}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("img",{src:n1,className:"w-5 h-5",alt:"댓글"}),m.jsx("span",{className:"text-14-r text-gray-400",children:r.commentCount})]})]})]}),m.jsx("div",{className:"text-18-r text-gray-500 mb-6",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:r.summary}})}),m.jsx("div",{className:"mt-4 mb-10 border-b-gray-200",children:m.jsx(Fe,{size:"sm",className:"w-[162px]",variant:"secondary",onClick:Ue,children:"전체 기사 보러가기 →"})})]}),m.jsxs("div",{className:"rounded-br-3xl rounded-bl-3xl pt-3 px-10 pb-8 bg-gray-100",children:[m.jsx("div",{className:"mb-2 w-[110px]",children:m.jsx(Ol,{items:o,value:g,onChange:Se,placeholder:"등록순",noBorder:!0,textClassName:"text-14-m text-gray-400",noBackground:!0})}),m.jsxs("div",{className:"flex items-center gap-2.5 mb-2",children:[m.jsx(Tt,{placeholder:"2025.01.01 부터",className:"flex-1",value:N,onChange:D=>R(D.target.value)}),m.jsx(Fe,{className:"w-[92px]",onClick:()=>W(N),children:"댓글 작성"})]}),m.jsx("div",{children:v.map((D,k)=>m.jsx("div",{ref:k===v.length-1?$:null,children:m.jsx(A5,{userNickname:D.userNickname,createdAt:new Date(D.createdAt),likeCount:D.likeCount,content:D.content,isLiked:D.likedByMe,onLikeClick:B,onEditSave:K,commentId:D.id,isMyComment:D.isMyComment,onDelete:pe})},D.id))})]})]}),ae&&m.jsx(l1,{isOpen:ee,onClose:F,onConfirm:ae.onConfirm,title:ae.title,message:ae.message,confirmText:ae.confirmText,cancelText:ae.cancelText})]})}function k5({isOpen:a,onClose:i,onSave:r}){const[s,o]=b.useState(""),[f,d]=b.useState(""),p=s.trim()!==""&&f.trim()!=="";b.useEffect(()=>{a||(o(""),d(""))},[a]);const g=y=>{y.preventDefault(),p&&(r({from:s,to:f}),i())};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("form",{onSubmit:g,className:"w-[438px] h-auto gap-10",children:[m.jsx("h2",{className:"text-24-sb mb-10",children:"기사 복구하기"}),m.jsx(Tt,{label:"날짜",value:s,placeholder:"2025.01.01 부터",onChange:y=>o(y.target.value),className:"mb-2"}),m.jsx(Tt,{value:f,placeholder:"2025.01.01 까지",onChange:y=>d(y.target.value),className:"mb-12"}),m.jsx(Fe,{className:"w-full",disabled:!p,type:"submit",children:"복구하기"})]})})}const q5="data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2016%2016'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M7.14697%201.14844C10.4595%201.14863%2013.1458%203.83391%2013.146%207.14648C13.146%208.51468%2012.6858%209.77484%2011.9146%2010.7842L14.9907%2013.8594L14.4243%2014.4248L13.8589%2014.9912L10.7837%2011.915C9.77453%2012.686%208.51479%2013.1454%207.14697%2013.1455C3.83421%2013.1454%201.14893%2010.4593%201.14893%207.14648C1.14912%203.83386%203.83433%201.14856%207.14697%201.14844ZM7.14697%202.74805C4.71799%202.74817%202.74873%204.71752%202.74854%207.14648C2.74854%209.57561%204.71787%2011.5448%207.14697%2011.5449C9.57601%2011.5447%2011.5454%209.57557%2011.5454%207.14648C11.5452%204.71756%209.57589%202.74824%207.14697%202.74805Z'%20fill='%231F2937'/%3e%3c/svg%3e";function r1({width:a="w-3xs",height:i="h-[40px]",containerClassName:r="",className:s="",onSearch:o,onKeyDown:f,...d}){const p=b.useRef(null),g=()=>{const v=p.current?.value||"";o&&v.trim()&&(o(v.trim()),p.current&&(p.current.value=""))},y=v=>{v.key==="Enter"&&o&&!v.nativeEvent.isComposing&&(v.preventDefault(),g()),f?.(v)};return m.jsxs("div",{className:`${a} ${i} px-4 py-2.5 border bg-white border-gray-300 rounded-[100px] flex items-center justify-between gap-2.5 ${r}`,children:[m.jsx("input",{ref:p,type:"text",placeholder:"검색어를 입력해주세요",className:`flex-1 outline-none font-pretendard placeholder:text-sm placeholder:text-gray-400 placeholder:font-normal placeholder:leading-5 ${s}`,onKeyDown:y,...d}),m.jsx("button",{type:"button",onClick:g,"aria-label":"검색",children:m.jsx("img",{src:q5,alt:"검색",className:"w-4 h-4"})})]})}function Y5({items:a,values:i,onChange:r,className:s="w-full h-10",placeholder:o="선택하세요"}){const[f,d]=b.useState(!1),p=b.useRef(null);b.useEffect(()=>{const w=N=>{p.current&&!p.current.contains(N.target)&&d(!1)};return document.addEventListener("mousedown",w),()=>{document.removeEventListener("mousedown",w)}},[]);const g=()=>{d(!f)},y=w=>{const N=i.includes(w)?i.filter(R=>R!==w):[...i,w];r(N)},v=i.length>0?`${i.length}개 선택됨`:o;return m.jsxs("div",{ref:p,className:`relative ${s}`,children:[m.jsx("button",{type:"button",className:"border rounded-lg border-gray-200 bg-white py-2.5 px-3 cursor-pointer w-full h-full focus:outline-none",onClick:g,children:m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsx("p",{className:`text-14-m ${i.length===0?"text-gray-400":"text-gray-900"}`,children:v}),m.jsx("img",{src:Ih,className:`transform transition-transform duration-200 ${f?"rotate-180":""}`,alt:"chevron"})]})}),f&&m.jsx("div",{className:"absolute top-full left-0 right-0 z-10",children:m.jsx("div",{className:"absolute box-border bg-white border border-gray-200 rounded-lg overflow-hidden w-full",children:m.jsx("ul",{className:"py-1 max-h-60 overflow-y-auto",children:a.map(w=>m.jsx("li",{className:"h-11 p-3 gap-0.5 bg-white font-pretendard font-medium text-sm leading-5 hover:bg-gray-100 cursor-pointer",children:m.jsxs("label",{className:"flex items-center gap-2 cursor-pointer",children:[m.jsx("input",{type:"checkbox",checked:i.includes(w),onChange:()=>y(w),className:"w-4 h-4 rounded border-gray-300 text-cyan-600 focus:ring-cyan-500 cursor-pointer"}),m.jsx("span",{children:w})]})},w))})})})]})}function s1({article:a,onClick:i}){const r=Ta(a.publishDate,"yyyy.MM.dd");return m.jsx(m.Fragment,{children:m.jsx("div",{className:"max-w-4xl w-auto min-h-48 h-auto cursor-pointer",onClick:i,children:m.jsxs("div",{className:"my-6 mx-1",children:[m.jsx("div",{className:"text-20-b text-gray-900 mb-2",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:a.title}})}),m.jsx("div",{className:"text-18-r text-gray-500 mb-6",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:a.summary}})}),m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsx(t1,{label:a.source}),m.jsxs("div",{className:"flex items-center gap-3",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:r}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:"읽음"}),m.jsx("span",{className:"text-14-r text-gray-400",children:a.viewCount})]}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("img",{src:n1,className:"w-5 h-5",alt:"댓글"}),m.jsx("span",{className:"text-14-r text-gray-400",children:a.commentCount})]})]})]})]})})})}function u1(){const[a,i]=b.useState(!1),[r,s]=b.useState(null);return{isOpen:a,openModal:d=>{s(d),i(!0)},onClose:()=>{i(!1),s(null)},initialData:r}}function V5(){const[a,i]=b.useState(!1);return{isOpen:a,openModal:()=>i(!0),onClose:()=>i(!1)}}async function o1(a){const{data:i}=await Ye.get(`/user-activities/${a}`);return i}function Am(){const[a,i]=Ls(),{articleId:r}=fg(),o=Wt().state?.article,{openModal:f,onClose:d,initialData:p}=u1(),g=["게시일","조회수","댓글수"],y=["내림차순","오름차순"],v=a.get("orderBy")||"publishDate",w=v==="publishDate"||v==="commentCount"||v==="viewCount"?v:"publishDate",N=a.get("direction")||"DESC",R=N==="ASC"||N==="DESC"?N:"DESC",T=a.get("limit")||"10",M=a.get("keyword")||"",C=a.get("interestId")||"",q=a.get("sourceIn")||"",[A,Y]=b.useState(),Q=new Date,$=z3(Q),[ee,J]=b.useState(Ta($,"yyyy.MM.dd")),[F,ae]=b.useState(Ta(Q,"yyyy.MM.dd")),re=a.get("publishDateFrom")||"",fe=a.get("publishDateTo")||"",[oe,_e]=b.useState(!1),[Ue,Se]=b.useState(!1),[B,K]=b.useState(null),W=b.useRef(null),pe=b.useRef(null),[E,D]=b.useState([]),[k,Z]=b.useState([]),[P,xe]=b.useState([]),[se,at]=b.useState([]),ze=Oa(),{userId:dt}=ta(),{isOpen:Bl,openModal:Ra,onClose:Aa}=V5(),kl={게시일:"publishDate",조회수:"viewCount",댓글수:"commentCount"},Ua={publishDate:"게시일",viewCount:"조회수",commentCount:"댓글수"},[ql,lr]=b.useState(Ua[w]||"게시일"),[gt,ir]=b.useState(R==="DESC"?"내림차순":"오름차순"),Dn=b.useRef(!0),za=b.useCallback(async()=>{_e(!0);try{const I=q?q.split(","):[],he={keyword:M,interestId:C,publishDateFrom:re,publishDateTo:fe,orderBy:w,direction:R,limit:parseInt(T),sourceIn:I.length>0?I:void 0},ge=await Mm(he,dt);Se(ge.hasNext),K(ge.nextCursor),D(ge.content)}catch(I){console.error(I)}finally{_e(!1)}},[M,C,w,R,re,fe,T,dt,q]),na=b.useCallback(async()=>{if(!(!Ue||!dt||oe)){_e(!0);try{const I=q?q.split(","):[],he={keyword:M,interestId:C,publishDateFrom:re,publishDateTo:fe,orderBy:w,direction:R,limit:parseInt(T),cursor:B||void 0,sourceIn:I.length>0?I:void 0},ge=await Mm(he,dt);D(lt=>[...lt,...ge.content]),Se(ge.hasNext),K(ge.nextCursor)}catch(I){console.error(I)}finally{_e(!1)}}},[w,R,re,fe,T,M,C,dt,Ue,oe,B,q]),rr=b.useCallback(async()=>{try{const he=(await o1(dt)).subscriptions.map(ge=>({id:ge.interestId,name:ge.interestName,keywords:ge.interestKeywords,subscriberCount:ge.interestSubscriberCount,subscribedByMe:!0}));Z(he)}catch(I){console.error(I)}},[]),Yl=b.useCallback(async()=>{try{const I=await O5();if(xe(I),q){const he=q.split(",");at(he)}else I.length>0&&at(I.slice(0,1))}catch(I){console.error(I)}},[q]);b.useEffect(()=>{Yl()},[Yl]),b.useEffect(()=>{if(!oe)return W.current&&W.current.disconnect(),W.current=new IntersectionObserver(I=>{I[0].isIntersecting&&Ue&&na()},{threshold:.8}),pe.current&&W.current.observe(pe.current),()=>{W.current&&W.current.disconnect()}},[Ue,oe,na]);const $s=b.useMemo(()=>k.map(I=>I.name),[k]),Js=I=>{const he=k.find(ge=>ge.name===I);he&&(Y(he),K(null),Se(!1))};b.useEffect(()=>{A&&i(I=>{const he=new URLSearchParams(I);return he.set("interestId",A.id),he})},[A]);const aa=I=>{lr(I)},mt=I=>{ir(I)},It=I=>{at(I)},ht=I=>{(I??"")!==M&&(i(he=>{const ge=new URLSearchParams(he);return I?ge.set("keyword",I):ge.delete("keyword"),ge.delete("interestId"),ge}),Y(void 0),K(null),Se(!1))};b.useEffect(()=>{if(Dn.current)return;const I=kl[ql];w!==I&&(i(he=>{const ge=new URLSearchParams(he);return ge.set("orderBy",I),ge}),K(null),Se(!1))},[ql]),b.useEffect(()=>{if(Dn.current)return;const I=gt==="오름차순"?"ASC":"DESC";R!==I&&(i(he=>{const ge=new URLSearchParams(he);return ge.set("direction",I),ge}),K(null),Se(!1))},[gt]),b.useEffect(()=>{if(!Dn.current&&se.length>0){const I=se.join(",");q!==I&&(i(he=>{const ge=new URLSearchParams(he);return ge.set("sourceIn",I),ge}),K(null),Se(!1))}},[se]),b.useEffect(()=>{if(Dn.current){Dn.current=!1;return}const I=ee?`${ee.replace(/\./g,"-")}T00:00:00`:"",he=F?`${F.replace(/\./g,"-")}T23:59:59`:"";(re!==I||fe!==he)&&(i(ge=>{const lt=new URLSearchParams(ge);return ee?lt.set("publishDateFrom",I):lt.delete("publishDateFrom"),F?lt.set("publishDateTo",he):lt.delete("publishDateTo"),lt}),K(null),Se(!1))},[ee,F]),b.useEffect(()=>{rr()},[rr]),b.useEffect(()=>{if(k.length>0&&C){const I=k.find(he=>he.id===C);I&&Y(I)}else C||Y(void 0)},[k,C]),b.useEffect(()=>{lr(Ua[w]||"게시일"),ir(R==="DESC"?"내림차순":"오름차순"),za()},[za,w,R]),b.useEffect(()=>{if(r){if(o&&o.id===r)Vl(o);else if(E.length>0){const I=E.find(he=>he.id===r);I&&Vl(I)}}},[r,E,o]);const Fs=I=>{try{const he={from:`${I.from.replace(/\./g,"-")}T00:00:00`,to:`${I.to.replace(/\./g,"-")}T23:59:59`};M5(he),za(),ce.success("기사가 복구되었습니다."),Aa()}catch(he){console.error(he);const ge=he,lt=ge.response?.data?.message||ge.message||"오류가 발생했습니다.";ce.error(lt)}},Vl=b.useCallback(I=>{f(I)},[f]);return m.jsxs("div",{className:"flex gap-12 justify-center",children:[m.jsxs("div",{className:"max-w-3xs min-h-[564px] h-auto",children:[m.jsx("div",{className:"mb-6",children:m.jsx(r1,{height:"h-11",onSearch:ht})}),m.jsxs("div",{className:"h-auto mb-6 border border-gray-200 rounded-2xl px-4 pt-4 pb-6 bg-white",children:[m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"정렬"}),m.jsx("div",{className:"min-h-10",children:m.jsx(Ol,{items:g,value:ql,onChange:aa,className:"mb-6 h-10"})}),m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"정렬 방향"}),m.jsx(Ol,{items:y,value:gt,onChange:mt,className:"mb-6 h-10"}),m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"출처"}),m.jsx(Y5,{items:P,values:se,onChange:It,className:"mb-6"}),m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"날짜"}),m.jsx(jm,{value:ee,placeholder:"시작 날짜",onChange:J,className:"mb-2",inputSize:"sm",label:"부터"}),m.jsx(jm,{value:F,placeholder:"종료 날짜",onChange:ae,className:"mb-4",inputSize:"sm",label:"까지"})]}),m.jsx(Fe,{className:"w-full",variant:"secondary",size:"sm",onClick:Ra,children:"기사 복구하기"}),m.jsx(k5,{isOpen:Bl,onClose:Aa,onSave:Fs})]}),m.jsxs("div",{className:"min-w-48",children:[M?m.jsxs("div",{className:"flex gap-4 items-center mb-8",children:[m.jsx("div",{className:"text-24-b text-cyan-600",children:M}),m.jsx("div",{className:"text-24-b text-gray-900",children:"관련 기사 목록"})]}):k.length>0?m.jsxs("div",{className:"flex gap-4 items-baseline mb-8",children:[m.jsx("div",{className:"min-w-[157px]",children:m.jsx(Ol,{items:$s,value:A?.name,onChange:Js,placeholder:"관심사 선택",noBorder:!0,textClassName:"text-24-b",noBackground:!0})}),m.jsx("div",{className:"text-24-b text-gray-900",children:"관련 기사 목록"})]}):m.jsx("div",{className:"text-24-b text-gray-900",children:"관련 기사 목록"}),E.length===0?m.jsx("div",{className:"min-w-[894px] w-full flex flex-col justify-center min-h-72 items-center mt-30",children:C?m.jsx(In,{message:"관련된 기사가 없습니다."}):m.jsxs("div",{className:"flex flex-col items-center justify-center gap-6",children:[m.jsx(In,{message:"관심사를 등록하면 맞춤 기사를 확인하실 수 있어요."}),m.jsx(Fe,{onClick:()=>ze("/interests"),className:"w-[160px]",size:"sm",children:"관심사 등록하기"})]})}):m.jsx("div",{children:E.map((I,he)=>m.jsx("div",{className:"min-w-2xs",ref:he===E.length-1?pe:null,children:m.jsx(s1,{article:I,onClick:()=>Vl(I)})},I.id))})]}),p&&m.jsx(i1,{onClose:d,articleId:p.id})]})}const X5="data:image/svg+xml,%3csvg%20width='20'%20height='20'%20viewBox='0%200%2020%2020'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M3%2010H17'%20stroke='white'%20stroke-width='2'%20stroke-linecap='round'/%3e%3cpath%20d='M10%2017V3'%20stroke='white'%20stroke-width='2'%20stroke-linecap='round'/%3e%3c/svg%3e",G5="data:image/svg+xml,%3csvg%20width='32'%20height='32'%20viewBox='0%200%2032%2032'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3ccircle%20cx='24'%20cy='16'%20r='2'%20transform='rotate(90%2024%2016)'%20fill='%23989FAC'/%3e%3ccircle%20cx='16'%20cy='16'%20r='2'%20transform='rotate(90%2016%2016)'%20fill='%23989FAC'/%3e%3ccircle%20cx='8'%20cy='16'%20r='2'%20transform='rotate(90%208%2016)'%20fill='%23989FAC'/%3e%3c/svg%3e",Z5="data:image/svg+xml,%3csvg%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3ccircle%20cx='12'%20cy='9'%20r='3'%20fill='%23D1D5DB'/%3e%3cpath%20d='M7%2017C7%2014.7909%208.79086%2013%2011%2013H13C15.2091%2013%2017%2014.7909%2017%2017V18C17%2018.5523%2016.5523%2019%2016%2019H8C7.44772%2019%207%2018.5523%207%2018V17Z'%20fill='%23D1D5DB'/%3e%3c/svg%3e",Q5="data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2016%2016'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M6.10417%2012.4375L2.0625%208.41667L3.125%207.33333L6.10417%2010.3125L12.875%203.5625L13.9375%204.625L6.10417%2012.4375Z'%20fill='%2300BCD4'/%3e%3c/svg%3e";function K5(){const[a,i]=b.useState(!1),[r,s]=b.useState();return{isOpen:a,openModal:d=>{s(d),i(!0)},onClose:()=>i(!1),initialData:r}}function As({label:a,onRemove:i}){return m.jsxs("div",{className:"w-fit h-8 px-2 py-1 rounded-lg bg-gray-100 flex items-center gap-1",children:[m.jsx("p",{className:"text-16-m text-gray-500",children:a}),i&&m.jsx("button",{type:"button",onClick:i,className:"w-4 h-4 flex items-center justify-center text-gray-400 hover:text-gray-600 cursor-pointer text-sm","aria-label":"태그 삭제",children:"×"})]})}function $5({isOpen:a,onClose:i,onSave:r,initialData:s}){const[o,f]=b.useState(""),[d,p]=b.useState([]),g=d.length>0;b.useEffect(()=>{a&&s?(p(s.keywords),f("")):a||(f(""),p([]))},[a,s]);const y=()=>{o.trim()!==""&&!d.includes(o.trim())?(p(N=>[...N,o.trim()]),f("")):d.includes(o.trim())&&ce.error("동일한 키워드는 등록할 수 없습니다.")},v=N=>{p(R=>R.filter(T=>T!==N))},w=N=>{N.preventDefault(),g&&(r(d),i())};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("form",{onSubmit:w,className:"w-[438px] h-auto gap-10",children:[m.jsx("h2",{className:"text-24-sb mb-10",children:"관심사 수정"}),m.jsxs("div",{className:"mb-1.5 flex gap-2",children:[m.jsx(Tt,{label:"키워드",placeholder:"키워드를 추가해주세요",value:o,onChange:N=>f(N.target.value),className:"flex-1"}),m.jsx(Fe,{className:"px-4 mt-8 whitespace-nowrap",onClick:y,type:"button",children:"키워드 추가"})]}),d.length>0&&m.jsx("div",{className:"max-h-32 overflow-y-auto p-3 mb-10",children:m.jsx("div",{className:"flex flex-wrap gap-2",children:d?.map((N,R)=>m.jsx(As,{label:N,onRemove:()=>v(N)},R))})}),m.jsx(Fe,{className:"w-full",disabled:!g,type:"submit",children:"수정하기"})]})})}function J5({interestId:a,name:i,keywords:r,subscriberCount:s,isSubscribed:o=!1,onSubscribeClick:f,onSaveKeyword:d,onDeleteInterest:p}){const[g,y]=b.useState(!1),v=b.useRef(null),{isOpen:w,openModal:N,onClose:R,initialData:T}=K5(),{isOpen:M,openModal:C,onClose:q,initialData:A}=a1();Xs(v,()=>y(!1),g);const Y=()=>{f(a,o)},Q=ee=>{ee==="키워드 수정"?N({keywords:r}):ee==="관심사 삭제"&&C({title:"관심사 삭제",message:`'${i}' 관심사를 정말 삭제하시겠습니까? 삭제된 관심사는 복구할 수 없습니다.`,confirmText:"삭제",cancelText:"취소",onConfirm:()=>p(a)}),y(!1)},$=ee=>{d(a,ee),R()};return m.jsxs("div",{className:"w-full h-[232px] border border-gray-200 rounded-2xl p-6 bg-white flex flex-col",children:[m.jsxs("div",{className:"flex justify-between items-center mb-4",children:[m.jsx("h2",{className:"text-20-b text-gray-900",children:i}),m.jsxs("button",{className:"relative",onClick:()=>y(!g),ref:v,children:[m.jsx("img",{src:G5,className:"w-8 h-8",alt:"케밥"}),g&&m.jsx(ef,{items:["키워드 수정","관심사 삭제"],onChange:Q,className:"right-0 top-7 z-10 min-w-32"})]})]}),m.jsx("div",{className:"flex flex-wrap gap-2 mb-6 flex-1 overflow-y-auto",children:r.map((ee,J)=>m.jsx("div",{className:"rounded-lg py-1 px-2 bg-gray-100 text-16-m text-gray-500 h-fit",children:ee},J))}),m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsxs("div",{className:"flex items-center justify-center",children:[m.jsx("img",{src:Z5,className:"w-6 h-6",alt:"사람모양"}),m.jsxs("span",{className:"text-14-r text-gray-500",children:[s," 구독자"]})]}),o?m.jsxs(Fe,{variant:"secondary",size:"sm",className:"flex gap-1 min-w-[91px]",onClick:Y,children:[m.jsx("img",{src:Q5,className:"w-4 h-4",alt:"체크"}),"구독 중"]}):m.jsx(Fe,{className:"min-w-[91px]",size:"sm",onClick:Y,children:"구독하기"})]}),m.jsx($5,{isOpen:w,onClose:R,onSave:$,initialData:T}),A&&m.jsx(l1,{isOpen:M,onClose:q,onConfirm:A.onConfirm,title:A.title,message:A.message,confirmText:A.confirmText,cancelText:A.cancelText})]})}async function Um(a,i){const{data:r}=await Ye.get("/interests",{params:a,headers:{"Monew-Request-User-ID":i}});return r}async function F5(a){const{data:i}=await Ye.post("/interests",a);return i}async function W5(a,i){const{data:r}=await Ye.post(`/interests/${a}/subscriptions`,void 0,{headers:{"Monew-Request-User-ID":i}});return r}async function P5(a,i){const{data:r}=await Ye.patch(`/interests/${a}`,i);return r}async function I5(a,i){await Ye.delete(`/interests/${a}/subscriptions`,{headers:{"Monew-Request-User-ID":i}})}async function e9(a){await Ye.delete(`/interests/${a}`)}function t9({isOpen:a,onClose:i,onSave:r}){const[s,o]=b.useState(""),[f,d]=b.useState([]),[p,g]=b.useState(""),y=s.trim()!==""&&f.length>0;b.useEffect(()=>{a||(o(""),g(""))},[a]);const v=R=>{R.preventDefault(),y&&(r({name:s,keywords:f}),o(""),g(""),d([]),i())},w=()=>{p.trim()!==""&&!f.includes(p.trim())?(d(R=>[...R,p.trim()]),g("")):f.includes(p.trim())&&ce.error("동일한 키워드는 등록할 수 없습니다.")},N=R=>{d(T=>T.filter(M=>M!==R))};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("form",{onSubmit:v,className:"w-[438px] h-auto gap-10",children:[m.jsx("h2",{className:"text-24-sb mb-10",children:"관심사 등록"}),m.jsx(Tt,{label:"관심사 이름",placeholder:"관심사 이름을 입력해주세요",value:s,onChange:R=>o(R.target.value),className:"mb-6"}),m.jsxs("div",{className:"mb-1.5 flex gap-2",children:[m.jsx(Tt,{label:"키워드",placeholder:"키워드를 추가해주세요",value:p,onChange:R=>g(R.target.value),className:"flex-1"}),m.jsx(Fe,{className:"px-4 mt-8 whitespace-nowrap",onClick:w,type:"button",children:"키워드 추가"})]}),m.jsx("p",{className:"px-1 gap-2.5 text-14-r text-gray-500 mb-16",children:"*설정한 키워드 기준으로 뉴스를 자동 수집합니다"}),f.length>0&&m.jsx("div",{className:"max-h-20 overflow-y-auto flex flex-wrap gap-2 mb-10",children:f?.map((R,T)=>m.jsx(As,{label:R,onRemove:()=>N(R)},T))}),m.jsx(Fe,{className:"w-full",disabled:!y,type:"submit",children:"등록하기"})]})})}function n9(){const[a,i]=b.useState(!1);return{isOpen:a,openModal:()=>i(!0),onClose:()=>i(!1)}}function a9(){const[a,i]=b.useState([]),r=["이름","구독자수"],s=["내림차순","오름차순"],[o,f]=Ls(),[d,p]=b.useState(!1),[g,y]=b.useState(!1),[v,w]=b.useState(null),N=b.useRef(null),R=b.useRef(null),T=o.get("keyword")||"",M=o.get("orderBy"),C=M==="name"||M==="subscriberCount"?M:"name",q=o.get("direction"),A=q==="ASC"||q==="DESC"?q:"DESC",[Y,Q]=b.useState("이름"),[$,ee]=b.useState("내림차순"),J=o.get("limit")||"6",{userId:F}=ta(),{isOpen:ae,openModal:re,onClose:fe}=n9(),oe=b.useCallback(async()=>{if(F){p(!0);try{const D={keyword:T,orderBy:C,direction:A,limit:parseInt(J)},k=await Um(D,F);i(k.content),y(k.hasNext),w(k.nextCursor)}catch(D){console.error("API 에러:",D)}finally{p(!1)}}},[T,C,A,J,F]),_e=b.useCallback(async()=>{if(!(!F||!g||d)){p(!0);try{const D={keyword:T,orderBy:C,direction:A,limit:parseInt(J),cursor:v||void 0},k=await Um(D,F);i(Z=>[...Z,...k.content]),y(k.hasNext),w(k.nextCursor)}catch(D){console.error("API 에러:",D)}finally{p(!1)}}},[T,C,A,J,F,g,d,v]);b.useEffect(()=>{if(!d)return N.current&&N.current.disconnect(),N.current=new IntersectionObserver(D=>{D[0].isIntersecting&&g&&_e()},{threshold:.8}),R.current&&N.current.observe(R.current),()=>{N.current&&N.current.disconnect()}},[g,d,_e]),b.useEffect(()=>{Q(C==="name"?"이름":"구독자수"),ee(A==="DESC"?"내림차순":"오름차순"),oe()},[oe,C,A]);const Ue=async(D,k)=>{if(F)try{k?await I5(D,F):await W5(D,F),i(Z=>Z.map(P=>P.id===D?{...P,subscribedByMe:!k,subscriberCount:k?P.subscriberCount-1:P.subscriberCount+1}:P))}catch(Z){console.error(Z),ce.error(k?"구독 취소 실패":"구독 실패")}},Se=async D=>{try{await F5(D),oe(),fe()}catch(k){if(console.error(k),k&&typeof k=="object"&&"response"in k){console.error(k);const Z=k,P=Z.response?.data?.message||Z.message||"오류가 발생했습니다.";ce.error(P)}}},B=async(D,k)=>{if(F)try{await P5(D,{keywords:k}),await oe(),ce.success("키워드 수정 성공")}catch(Z){console.error(Z),ce.error("키워드 수정 실패")}},K=async D=>{try{await e9(D),await oe()}catch(k){console.error(k),ce.error("관심 목록 제거 실패")}},W=D=>{(D??"")!==T&&f(k=>{const Z=new URLSearchParams(k);return D?Z.set("keyword",D):Z.delete("keyword"),Z})},pe=D=>{const k=D==="이름"?"name":"subscriberCount";f(Z=>{const P=new URLSearchParams(Z);return P.set("orderBy",k),P})},E=D=>{const k=D==="오름차순"?"ASC":"DESC";f(Z=>{const P=new URLSearchParams(Z);return P.set("direction",k),P})};return m.jsx("div",{children:m.jsxs("div",{className:"w-[1200px] mx-auto",children:[m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsxs("div",{className:"text-left",children:[m.jsx("h1",{className:"text-24-b text-gray-900",children:"관심사 목록"}),m.jsx("p",{className:"text-16-m text-gray-500 mt-3",children:"구독 중인 관심사를 관리하고, 새로운 관심사를 등록해보세요."})]}),m.jsxs(Fe,{className:"min-w-[186px] flex justify-center items-center",onClick:re,children:[m.jsx("img",{src:X5,className:"w-5 h-5",alt:"추가"}),"관심사 등록"]}),m.jsx(t9,{isOpen:ae,onClose:fe,onSave:Se})]}),m.jsxs("div",{className:"flex justify-between items-center mt-10",children:[m.jsxs("div",{className:"flex justify-center items-center gap-3 ",children:[m.jsx(Ol,{items:r,value:Y,onChange:pe,className:"w-24 h-10"}),m.jsx(Ol,{items:s,value:$,onChange:E,className:"w-24 h-10"})]}),m.jsx(r1,{width:"w-[304px]",onSearch:W})]}),m.jsxs("div",{className:"mt-4 min-w-2xs",children:[a.length===0?m.jsx("div",{className:"flex justify-center items-center min-h-[200px] mt-30",children:T?m.jsx(In,{message:"검색 결과가 없습니다."}):m.jsx(In,{message:"아직 등록한 관심사가 없습니다."})}):m.jsx("div",{className:"mt-4 grid grid-cols-3 gap-4",children:a.map((D,k)=>m.jsx("div",{className:"w-[386px] h-[232px]",ref:k===a.length-1?R:null,children:m.jsx(J5,{interestId:D.id,name:D.name,keywords:D.keywords,subscriberCount:D.subscriberCount,isSubscribed:D.subscribedByMe,onSubscribeClick:Ue,onSaveKeyword:B,onDeleteInterest:K})},D.id))}),d&&m.jsx(Nn,{className:"h-[232px] mx-4"})]})]})})}const l9="data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2016%2016'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M3.5999%2012.4004H4.4499L10.6999%206.15039L9.8499%205.30039L3.5999%2011.5504V12.4004ZM2.3999%2013.6004V11.0504L10.6999%202.75039C10.8221%202.62817%2010.9546%202.53928%2011.0972%202.48372C11.2398%202.42817%2011.3898%202.40039%2011.5472%202.40039C11.7046%202.40039%2011.8555%202.42817%2011.9999%202.48372C12.1443%202.53928%2012.2777%202.62817%2012.3999%202.75039L13.2499%203.60039C13.3721%203.72261%2013.461%203.85595%2013.5166%204.00039C13.5721%204.14483%2013.5999%204.29228%2013.5999%204.44272C13.5999%204.60328%2013.572%204.75628%2013.5162%204.90172C13.4605%205.04717%2013.3717%205.18006%2013.2499%205.30039L4.9499%2013.6004H2.3999ZM10.2674%205.73289L9.8499%205.30039L10.6999%206.15039L10.2674%205.73289Z'%20fill='%23989FAC'/%3e%3c/svg%3e";function i9({isOpen:a,onClose:i,user:r,onUpdated:s}){const[o,f]=b.useState(""),[d,p]=b.useState(!1),g=o.trim()!=="";b.useEffect(()=>{a&&f(r?.nickname??"")},[a,r]);const y=async v=>{if(v.preventDefault(),!(!g||d))try{p(!0);const w=await g4(r.id,{nickname:o});$i.write(w),s?.({userId:r.id,nickname:o,updatedUser:w}),ce.success("닉네임이 수정되었습니다."),i()}catch(w){Ic(w)}finally{p(!1)}};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("form",{onSubmit:y,className:"w-full max-w-[438px] h-auto gap-10",children:[m.jsx("h2",{className:"text-24-sb mb-10",children:"닉네임 수정"}),m.jsx(Tt,{label:"닉네임",value:o,onChange:v=>f(v.target.value),className:"mb-12",disabled:d}),m.jsx(Fe,{className:"w-full",disabled:!g||d,type:"submit",children:d?m.jsx(Nn,{className:"mx-4"}):"수정하기"})]})})}function r9(){const[a,i]=b.useState(!1),[r,s]=b.useState();return{isOpen:a,openModal:d=>{s(d),i(!0)},onClose:()=>i(!1),initialData:r}}function s9(){const a=Pc(),{isOpen:i,openModal:r,onClose:s}=r9();if(!a)return null;const o=()=>{r(a.id)};return m.jsxs(m.Fragment,{children:[m.jsx("button",{type:"button","aria-label":"닉네임 수정",onClick:o,disabled:!a,children:m.jsx("img",{src:l9,alt:""})}),i&&m.jsx(i9,{isOpen:i,onClose:s,user:a})]})}function u9(){const{userName:a,userEmail:i}=ta();return m.jsxs("div",{className:"flex flex-col w-[260px] h-[100px] bg-white rounded-2xl border border-gray-200 p-6",children:[m.jsxs("div",{className:"flex w-full gap-1.5",children:[m.jsx("p",{className:"text-black text-18-sb",children:a}),m.jsx(s9,{})]}),m.jsx("span",{className:"text-[#9EA5B0] text-16-r",children:i})]})}const o9={subscriptions:"subscriptions",recentComments:"comments",likedComments:"commentLikes",viewedArticles:"articleViews"},c9={subscriptions:"구독 목록을 불러오지 못했습니다.",recentComments:"최근 작성한 댓글을 불러오지 못했습니다.",likedComments:"좋아요한 댓글을 불러오지 못했습니다.",viewedArticles:"최근 본 기사를 불러오지 못했습니다."};function Ks(a,i=10){const{userId:r}=ta(),[s,o]=b.useState([]),[f,d]=b.useState(0),[p,g]=b.useState(!0),[y,v]=b.useState(null);b.useEffect(()=>{let N=!0;return(async()=>{v(null),g(!0);try{const R=await o1(r),T=o9[a],M=R[T]??[];if(!N)return;d(M.length),o(M.slice(0,i))}catch{if(!N)return;v(c9[a])}finally{N&&g(!1)}})(),()=>{N=!1}},[r,a,i]);const w=!p&&s?.length===0;return{items:s,totalCount:f,error:y,loading:p,empty:w}}const f9="data:image/svg+xml,%3csvg%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M12.6%2012L8%207.4L9.4%206L15.4%2012L9.4%2018L8%2016.6L12.6%2012Z'%20fill='%231F2937'/%3e%3c/svg%3e";function d9({name:a,keywords:i,maxTags:r=8}){const s=(i??[]).filter(Boolean),o=s.slice(0,r),f=s.length>o.length;return m.jsxs("div",{className:"flex flex-col w-[212px] gap-3 my-5 max-h-[168px] bg-tranparent",children:[m.jsx("p",{className:"text-16-sb text-black",children:a}),m.jsxs("div",{className:"mt-3 flex flex-wrap gap-2",children:[o.map(d=>m.jsx(As,{label:d},d)),f&&m.jsx(As,{label:"…"})]})]})}function m9(){const{items:a,totalCount:i,error:r,loading:s,empty:o}=Ks("subscriptions",10);return r?m.jsx("div",{children:m.jsx("p",{className:"text-14-r text-error",children:r})}):s?m.jsx(Nn,{height:"132px"}):m.jsxs("aside",{"aria-labelledby":"subs-heading",className:"w-[260px] min-h-0 rounded-2xl p-6 bg-white border border-gray-200",children:[m.jsxs("div",{className:"flex justify-between items-center w-full",children:[m.jsxs("h2",{id:"subs-heading",className:"text-18-b text-gray-900",children:["총",m.jsxs("span",{className:"text-cyan-600",children:[i,"개"]}),"의 관심사 구독중"]}),m.jsx(ea,{to:Ut.INTERESTS,"aria-label":"interests",children:m.jsx("img",{src:f9,alt:""})})]}),!o&&m.jsx("div",{className:"mt-6 h-[1px] w-[212px] bg-gray-200"}),!o&&m.jsx("ul",{className:"flex flex-col divide-y divide-gray-200",children:a.map(f=>m.jsx("li",{children:m.jsx(d9,{name:f.interestName,keywords:f.interestKeywords})},f.interestId))})]})}const h9={recent:"최근 작성한 댓글",liked:"좋아요한 댓글",viewed:"최근 본 기사"};function y9(){const[a]=Ls(),i=a.get("tab")??Ki;return Zc.includes(i)?i:Ki}function p9(){const a=y9();return m.jsx("nav",{"aria-label":"활동내역 탭",className:"w-[895px] min-h-[66px]",children:m.jsx("div",{className:"grid grid-cols-3 gap-2 rounded-lg bg-gray-100 p-2",children:Zc.map(i=>{const r=a===i;return m.jsx(ea,{to:uv(i),"aria-current":r?"page":void 0,className:["inline-flex justify-center w-full rounded-lg py-3 transition","text-16-m",r?"bg-white text-black text-16-sb":"text-gray-500 hover:bg-gray-200"].join(" "),children:h9[i]},i)})})})}const g9="data:image/svg+xml,%3csvg%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M14%2021L12.575%2019.575L16.175%2016H5V4H7V14H16.175L12.575%2010.4L13.975%208.975L20%2015L14%2021Z'%20fill='%23989FAC'/%3e%3c/svg%3e";function c1({mode:a="recent",id:i,articleTitle:r,content:s,likeCount:o,createdAt:f,isLiked:d,onLikeClick:p}){const g=()=>{p?.(i)};return m.jsxs("div",{className:"w-full max-w-[895px] h-auto px-2 py-8 bg-transparent border-none",children:[m.jsxs("div",{className:"flex mb-4 items-center",children:[m.jsxs("div",{className:"flex",children:[m.jsx("p",{className:"text-16-m text-cyan-600",children:r}),m.jsx("span",{className:"text-16-r",children:a==="recent"?"에 남긴 댓글":"에 좋아요한 댓글"})]}),m.jsxs("div",{className:"flex gap-1 ml-1",children:[m.jsx("span",{className:"text-14-m text-gray-500",children:"·"}),m.jsx("span",{className:"text-14-m text-gray-500",children:mh(f)})]})]}),m.jsxs("div",{className:"flex items-center justify-between",children:[m.jsxs("div",{className:"flex items-center gap-4",children:[m.jsx("img",{src:g9,className:"w-6 h-6",alt:"답글"}),m.jsx("span",{className:"text-18-sb line-clamp-3",children:s})]}),m.jsxs("button",{onClick:g,className:"flex justify-center items-center gap-2 shrink-0",children:[d?m.jsx("img",{src:Qc,className:"w-6 h-6",alt:"활성화 하트"}):m.jsx("img",{src:e1,className:"w-6 h-6",alt:"비활성화 하트"}),m.jsx("p",{className:"text-14-r text-gray-500",children:o})]})]})]})}function v9(){const{items:a,error:i,loading:r,empty:s}=Ks("recentComments",10);return i?m.jsx("p",{className:"text-14-r text-error",children:i}):r?m.jsx(Nn,{height:"132px"}):s?m.jsx("div",{className:"min-h-[600px]",children:m.jsx(In,{message:"아직 작성한 댓글이 없습니다."})}):m.jsx("ul",{className:"flex flex-col divide-y divide-gray-300",children:a.map(o=>{const f={id:o.id,articleId:o.articleId,articleTitle:o.articleTitle,userId:o.userId,userNickname:o.userNickname,content:o.content,likeCount:o.likeCount,createdAt:o.createdAt};return m.jsx("li",{children:m.jsx(c1,{mode:"recent",isLiked:!1,...f})},o.id)})})}function x9(){const{items:a,error:i,loading:r,empty:s}=Ks("likedComments",10);return i?m.jsx("p",{className:"text-14-r text-error",children:i}):r?m.jsx(Nn,{height:"132px"}):s?m.jsx("div",{className:"min-h-[600px]",children:m.jsx(In,{message:"아직 좋아요한 댓글이 없습니다."})}):m.jsx("ul",{className:"flex flex-col gap-4 divide-y divide-gray-300",children:a.map(o=>{const f={id:o.commentId,articleId:o.articleId,articleTitle:o.articleTitle,userId:o.commentUserId,userNickname:o.commentUserNickname,content:o.commentContent,likeCount:o.commentLikeCount,createdAt:o.commentCreatedAt};return m.jsx("li",{children:m.jsx(c1,{mode:"liked",isLiked:!0,...f})},o.id)})})}function b9(){const{items:a,error:i,loading:r,empty:s}=Ks("viewedArticles",10),{openModal:o,onClose:f,initialData:d}=u1();if(i)return m.jsx("p",{className:"text-14-r text-error",children:i});if(r)return m.jsx(Nn,{height:"132px"});if(s)return m.jsx("div",{className:"min-h-[600px]",children:m.jsx(In,{message:"최근 본 기사가 없습니다."})});const p=g=>{o(g)};return m.jsxs(m.Fragment,{children:[m.jsx("ul",{className:"flex flex-col gap-4 divide-y divide-gray-300",children:a.map(g=>{const y={id:g.articleId,title:g.articleTitle,summary:g.articleSummary,source:g.source,sourceUrl:g.sourceUrl,publishDate:g.articlePublishedDate,viewCount:g.articleViewCount,commentCount:g.articleCommentCount,viewedByMe:!0};return m.jsx("li",{children:m.jsx(s1,{article:y,onClick:()=>p(y)})},g.id)})}),d&&m.jsx(i1,{onClose:f,articleId:d.id})]})}function w9(){const[a,i]=Ls(),r=a.get("tab")??Ki,s=Zc.includes(r);return b.useEffect(()=>{s||i({tab:Ki},{replace:!0})},[s,i]),m.jsxs("div",{className:"flex justify-center gap-10 w-full",children:[m.jsxs("div",{className:"flex flex-col gap-4",children:[m.jsx(u9,{}),m.jsx(m9,{})]}),m.jsxs("div",{className:"flex flex-col",children:[m.jsx(p9,{}),m.jsxs("div",{className:"mt-2 flex flex-col gap-4 h-full",children:[r==="recent"&&m.jsx(v9,{}),r==="liked"&&m.jsx(x9,{}),r==="viewed"&&m.jsx(b9,{})]})]})]})}const S9="data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20id='_레이어_1'%20data-name='레이어%201'%20xmlns='http://www.w3.org/2000/svg'%20width='30'%20height='25.37'%20viewBox='0%200%2030%2025.37'%3e%3cdefs%3e%3cstyle%3e%20.cls-1%20{%20fill:%20%23d1d5db;%20}%20.cls-2%20{%20fill:%20%23989fac;%20}%20%3c/style%3e%3c/defs%3e%3cpath%20class='cls-1'%20d='M22.28,0c.85,0,1.54,.69,1.54,1.54V3.65H5.57c-1.06,0-1.92,.86-1.92,1.92v11.14h-.06s-.09,.02-.12,.04L.31,19.33c-.13,.1-.31,.01-.31-.15V1.54C0,.69,.69,0,1.54,0H22.28Z'/%3e%3cpath%20class='cls-2'%20d='M28.46,5.99c.85,0,1.54,.69,1.54,1.54v6.91h-10.37v1.73h10.37v1.73h-13.64v1.73h13.64v1.54c0,.85-.69,1.54-1.54,1.54H9.78s-.09,.02-.12,.04l-3.15,2.58c-.13,.1-.31,.01-.31-.15V7.53c0-.85,.69-1.54,1.54-1.54H28.46Z'/%3e%3c/svg%3e";function T9(){return m.jsxs("div",{className:"flex flex-col items-center justify-center gap-4 min-h-[inherit]",children:[m.jsx("img",{src:S9,alt:"gray monew logo",className:"h-[60px]"}),m.jsx("p",{className:"sm:text-20-m text-16-m",children:"404 Not Found"})]})}const E9=()=>{const a=Pc(),i=Wt();return a?m.jsx(Vc,{}):m.jsx(Cg,{to:"/login",replace:!0,state:{from:i}})};function C9(){return m.jsxs(Ng,{children:[m.jsx(Zt,{element:m.jsx(E9,{}),children:m.jsxs(Zt,{element:m.jsx(r5,{}),children:[m.jsx(Zt,{path:"/articles",element:m.jsx(Am,{})}),m.jsx(Zt,{path:"/articles/:articleId",element:m.jsx(Am,{})}),m.jsx(Zt,{path:"/interests",element:m.jsx(a9,{})}),m.jsx(Zt,{path:"/activities",element:m.jsx(w9,{})})]})}),m.jsxs(Zt,{element:m.jsx(s5,{}),children:[m.jsx(Zt,{path:"/",element:m.jsx(v5,{})}),m.jsx(Zt,{path:"/login",element:m.jsx(E5,{})}),m.jsx(Zt,{path:"/signup",element:m.jsx(_5,{})}),m.jsx(Zt,{path:"*",element:m.jsx(T9,{})})]})]})}function _9(){return m.jsx(e5,{hideProgressBar:!0,closeButton:!1,limit:2,position:"bottom-center",autoClose:3e3,newestOnTop:!0,transition:P4,draggable:!0,pauseOnHover:!0,pauseOnFocusLoss:!0})}Ap.createRoot(document.getElementById("root")).render(m.jsxs(Wg,{children:[m.jsx(_9,{}),m.jsx(C9,{})]})); From 6b23529424bef942f86109a75c1f664820b8a1d1 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Tue, 28 Oct 2025 23:59:22 +0900 Subject: [PATCH 059/178] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95/=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=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 --- .../controller/CommentController.java | 79 ++++---------- .../comments/dto/CommentActivityDto.java | 29 ----- .../monew_api/comments/dto/CommentDto.java | 17 +++ .../comments/dto/CommentLikeActivityDto.java | 33 ------ .../comments/dto/CommentRegisterRequest.java | 25 +---- .../impl/CommentRepositoryImpl.java | 101 ++++++++++++++---- .../comments/service/CommentService.java | 47 ++++---- 7 files changed, 141 insertions(+), 190 deletions(-) delete mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java index a9bc7f5..3146eab 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java @@ -22,9 +22,6 @@ import com.monew.monew_api.comments.dto.CommentUpdateRequest; import com.monew.monew_api.comments.dto.CursorPageResponseCommentDto; import com.monew.monew_api.comments.service.CommentService; -import com.monew.monew_api.common.exception.comment.CommentInvalidArticleIdException; -import com.monew.monew_api.common.exception.comment.CommentInvalidUserIdException; -import com.monew.monew_api.common.exception.comment.CommentNotFoundException; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -39,17 +36,14 @@ public class CommentController { @GetMapping public ResponseEntity findAll( - @RequestHeader("Monew-Request-User-ID") String userIdHeader, - @RequestParam(required = false) String articleId, + @RequestHeader("Monew-Request-User-ID") Long userIdHeader, + @RequestParam(required = false) Long articleId, @RequestParam String orderBy, @RequestParam String direction, @RequestParam(required = false) String cursor, @RequestParam(required = false) String after, @RequestParam int limit ) { - Long aid = parseNullableArticleId(articleId); - Long uid = parseUserId(userIdHeader); - Long cursorId = null; LocalDateTime cursorCreatedAt = parseNullableDateTime(after); Integer cursorLikeCount = null; @@ -69,98 +63,63 @@ public ResponseEntity findAll( } CursorPageResponseCommentDto page = - commentService.findAll(aid, limit, orderBy, cursorId, cursorCreatedAt, cursorLikeCount, uid); + commentService.findAll(articleId, limit, orderBy, cursorId, cursorCreatedAt, cursorLikeCount, userIdHeader); return ResponseEntity.ok(page); } @PostMapping public ResponseEntity register( - @RequestHeader("Monew-Request-User-ID") String userIdHeader, @Valid @RequestBody CommentRegisterRequest request ) { - CommentRegisterRequest fixed = request.withUserId(userIdHeader); - CommentDto dto = commentService.register(fixed); + CommentDto dto = commentService.register(request); return ResponseEntity.status(HttpStatus.CREATED).body(dto); } @PatchMapping("/{commentId}") public ResponseEntity update( - @RequestHeader("Monew-Request-User-ID") String userIdHeader, - @PathVariable String commentId, + @RequestHeader("Monew-Request-User-ID") Long userIdHeader, + @PathVariable Long commentId, @Valid @RequestBody CommentUpdateRequest request ) { - Long userId = parseUserId(userIdHeader); - Long cid = parseCommentId(commentId); - CommentDto dto = commentService.update(userId, cid, request); + CommentDto dto = commentService.update(userIdHeader, commentId, request); return ResponseEntity.ok(dto); } @DeleteMapping("/{commentId}") public ResponseEntity delete( - @RequestHeader("Monew-Request-User-ID") String userIdHeader, - @PathVariable String commentId + // @RequestHeader("Monew-Request-User-ID") Long userIdHeader, + @PathVariable Long commentId ) { - Long userId = parseUserId(userIdHeader); - Long cid = parseCommentId(commentId); - commentService.delete(userId, cid); + // commentService.delete(userIdHeader, commentId); + commentService.delete(commentId); return ResponseEntity.noContent().build(); } @PostMapping("/{commentId}/comment-likes") public ResponseEntity like( - @RequestHeader("Monew-Request-User-ID") String userIdHeader, - @PathVariable String commentId + @RequestHeader("Monew-Request-User-ID") Long userIdHeader, + @PathVariable Long commentId ) { - Long userId = parseUserId(userIdHeader); - Long cid = parseCommentId(commentId); - CommentLikeDto dto = commentService.like(userId, cid); + CommentLikeDto dto = commentService.like(userIdHeader, commentId); return ResponseEntity.ok(dto); } @DeleteMapping("/{commentId}/comment-likes") public ResponseEntity dislike( - @RequestHeader("Monew-Request-User-ID") String userIdHeader, - @PathVariable String commentId + @RequestHeader("Monew-Request-User-ID") Long userIdHeader, + @PathVariable Long commentId ) { - Long userId = parseUserId(userIdHeader); - Long cid = parseCommentId(commentId); - commentService.dislike(userId, cid); + commentService.dislike(userIdHeader, commentId); return ResponseEntity.noContent().build(); } @DeleteMapping("/{commentId}/hard") - public ResponseEntity hardDelete(@PathVariable String commentId) { - Long cid = parseCommentId(commentId); - commentService.hardDelete(cid); + public ResponseEntity hardDelete(@PathVariable Long commentId) { + commentService.hardDelete(commentId); return ResponseEntity.noContent().build(); } - private Long parseUserId(String userId) { - try { - return Long.parseLong(userId); - } catch (Exception e) { - throw new CommentInvalidUserIdException(userId); - } - } - - private Long parseCommentId(String commentId) { - try { - return Long.parseLong(commentId); - } catch (Exception e) { - throw new CommentNotFoundException(); - } - } - - private Long parseNullableArticleId(String articleId) { - if (articleId == null || articleId.isBlank()) return null; - try { - return Long.parseLong(articleId); - } catch (Exception e) { - throw new CommentInvalidArticleIdException(articleId); - } - } - private LocalDateTime parseNullableDateTime(String text) { return (text == null || text.isBlank()) ? null : LocalDateTime.parse(text); } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java deleted file mode 100644 index 85dfeeb..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentActivityDto.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.monew.monew_api.comments.dto; - -import com.monew.monew_api.comments.entity.Comment; - -public record CommentActivityDto( - String id, - String articleId, - String articleTitle, - String userId, - String userNickname, - String content, - int likeCount, - String createdAt -) { - - public static CommentActivityDto from(Comment comment) { - return new CommentActivityDto( - String.valueOf(comment.getId()), - String.valueOf(comment.getArticle().getId()), - comment.getArticle().getTitle(), - String.valueOf(comment.getUser().getId()), - comment.getUser().getNickname(), - comment.getContent(), - comment.getLikeCount(), - comment.getCreatedAt().toString() - ); - } - -} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java index 5272e21..3bb2c06 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java @@ -10,6 +10,7 @@ public record CommentDto( String content, int likeCount, boolean likedByMe, + boolean isMyComment, String createdAt ) { @@ -22,8 +23,24 @@ public static CommentDto from(Comment comment, boolean likedByMe) { comment.getContent(), comment.getLikeCount(), likedByMe, + false, comment.getCreatedAt().toString() ); } + public static CommentDto from(Comment comment, boolean likedByMe, Long requestUserId) { + boolean isMyComment = comment.getUser().getId().equals(requestUserId); + + return new CommentDto( + String.valueOf(comment.getId()), + String.valueOf(comment.getArticle().getId()), + String.valueOf(comment.getUser().getId()), + comment.getUser().getNickname(), + comment.getContent(), + comment.getLikeCount(), + likedByMe, + isMyComment, + comment.getCreatedAt().toString() + ); + } } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java deleted file mode 100644 index 6da48ad..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeActivityDto.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.monew.monew_api.comments.dto; - -import com.monew.monew_api.comments.entity.CommentLike; - -public record CommentLikeActivityDto( - String id, - String commentId, - String articleId, - String articleTitle, - String commentUserId, - String commentUserNickname, - String commentContent, - int commentLikeCount, - String commentCreatedAt, - String createdAt -) { - - public static CommentLikeActivityDto from(CommentLike like) { - return new CommentLikeActivityDto( - String.valueOf(like.getId()), - String.valueOf(like.getComment().getId()), - String.valueOf(like.getComment().getArticleId()), - like.getComment().getArticle().getTitle(), - String.valueOf(like.getComment().getUserId()), - like.getComment().getUser().getNickname(), - like.getComment().getContent(), - like.getComment().getLikeCount(), - like.getComment().getCreatedAt().toString(), - like.getCreatedAt().toString() - ); - } - -} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java index 0037b01..9df99e7 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java @@ -11,31 +11,10 @@ public record CommentRegisterRequest( @NotNull(message = "기사 ID는 필수입니다.") String articleId, - @NotNull(message = "사용자 ID는 필수입니다.") - String userId, + Long userId, @NotBlank(message = "댓글 내용을 입력해주세요.") @Size(max = 500, message = "댓글은 최대 500자까지 작성 가능합니다.") String content ) { - - public CommentRegisterRequest withUserId(String userId) { - return new CommentRegisterRequest(this.articleId, userId, this.content); - } - - public Long getArticleIdAsLong() { - try { - return Long.parseLong(articleId); - } catch (NumberFormatException e) { - throw new CommentInvalidArticleIdException(articleId); - } - } - - public Long getUserIdAsLong() { - try { - return Long.parseLong(userId); - } catch (NumberFormatException e) { - throw new CommentInvalidUserIdException(userId); - } - } -} +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java index 375f36c..83f78a2 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java @@ -8,7 +8,7 @@ import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.QComment; import com.monew.monew_api.comments.repository.CommentRepositoryCustom; -import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -25,51 +25,110 @@ public class CommentRepositoryImpl implements CommentRepositoryCustom { public List findPageByArticleIdOrderByCreatedAtDesc(Long articleId, Long cursorId, LocalDateTime cursorCreatedAt, int limit) { - BooleanExpression byArticle = articleIdEq(articleId); - BooleanExpression afterCursor = buildCreatedAtCursor(cursorId, cursorCreatedAt); + QComment c = QComment.comment; + + BooleanBuilder where = new BooleanBuilder(); + if (articleId != null) { + where.and(c.article.id.eq(articleId)); + } + + BooleanExpression cursorExpr = buildCreatedAtCursor(c, cursorId, cursorCreatedAt); + if (cursorExpr != null) { + where.and(cursorExpr); + } return jpaQueryFactory .selectFrom(c) - .leftJoin(c.user).fetchJoin() - .leftJoin(c.article).fetchJoin() - .where(byArticle, afterCursor) - .orderBy(c.createdAt.desc(), c.id.desc()) - .limit(limit + 1) + .where(where) + .orderBy( + c.createdAt.desc(), + c.id.desc() + ) + .limit(limit + 1L) .fetch(); } @Override public List findPageByArticleIdOrderByLikeCountDesc(Long articleId, Long cursorId, Integer cursorLikeCount, int limit) { - BooleanExpression byArticle = articleIdEq(articleId); - BooleanExpression afterCursor = buildLikeCountCursor(cursorId, cursorLikeCount); + + QComment c = QComment.comment; + + BooleanBuilder where = new BooleanBuilder(); + if (articleId != null) { + where.and(c.article.id.eq(articleId)); + } + + BooleanExpression cursorExpr = buildLikeCountCursor(c, cursorId, cursorLikeCount); + if (cursorExpr != null) { + where.and(cursorExpr); + } return jpaQueryFactory .selectFrom(c) - .leftJoin(c.user).fetchJoin() - .leftJoin(c.article).fetchJoin() - .where(byArticle, afterCursor) - .orderBy(c.likeCount.desc(), c.id.desc()) - .limit(limit + 1) + .where(where) + .orderBy( + c.likeCount.desc(), + c.id.desc() + ) + .limit(limit + 1L) .fetch(); + } private BooleanExpression articleIdEq(Long articleId) { return articleId != null ? c.article.id.eq(articleId) : null; } - private BooleanExpression buildCreatedAtCursor(Long cursorId, LocalDateTime cursorCreatedAt) { - if (cursorId == null || cursorCreatedAt == null) return null; + + + private BooleanExpression buildCreatedAtCursor( + QComment c, + Long cursorId, + LocalDateTime cursorCreatedAt + ) { + if (cursorId == null && cursorCreatedAt == null) { + return null; + } + + if (cursorId != null && cursorCreatedAt == null) { + return c.id.lt(cursorId); + } + + if (cursorId == null && cursorCreatedAt != null) { + + return c.createdAt.lt(cursorCreatedAt); + } return c.createdAt.lt(cursorCreatedAt) - .or(c.createdAt.eq(cursorCreatedAt).and(c.id.lt(cursorId))); + .or( + c.createdAt.eq(cursorCreatedAt) + .and(c.id.lt(cursorId)) + ); } - private BooleanExpression buildLikeCountCursor(Long cursorId, Integer cursorLikeCount) { - if (cursorId == null || cursorLikeCount == null) return null; + private BooleanExpression buildLikeCountCursor( + QComment c, + Long cursorId, + Integer cursorLikeCount + ) { + if (cursorId == null && cursorLikeCount == null) { + return null; + } + + if (cursorLikeCount != null && cursorId == null) { + return c.likeCount.lt(cursorLikeCount); + } + + if (cursorLikeCount == null && cursorId != null) { + return c.id.lt(cursorId); + } return c.likeCount.lt(cursorLikeCount) - .or(c.likeCount.eq(cursorLikeCount).and(c.id.lt(cursorId))); + .or( + c.likeCount.eq(cursorLikeCount) + .and(c.id.lt(cursorId)) + ); } } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index 137dbe0..6d3c7f1 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -34,8 +34,6 @@ import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -83,12 +81,11 @@ public CommentDto update(Long userId, Long commentId, CommentUpdateRequest reque } @Transactional - public void delete(Long userId, Long commentId) { + public void delete(Long commentId) { Comment comment = getCommentById(commentId); - validateOwnership(comment, userId); commentRepository.delete(comment); - log.info("[COMMENT][DELETE] userId={}, commentId={}", userId, commentId); + log.info("[COMMENT][DELETE] commentId={}", commentId); } @Transactional @@ -113,9 +110,10 @@ public CommentLikeDto like(Long userId, Long commentId) { } @Transactional - public void dislike(Long userId, Long commentId) { // void 반환 + public void dislike(Long userId, Long commentId) { boolean liked = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, userId); - if (!liked) throw new CommentNotLikedException(); + if (!liked) + throw new CommentNotLikedException(); commentLikeRepository.deleteByComment_IdAndUser_Id(commentId, userId); commentRepository.decLikeCount(commentId); @@ -132,6 +130,9 @@ public CursorPageResponseCommentDto findAll( Integer cursorLikeCount, Long requestUserId ) { + + log.info("=== [DEBUG] Service에 전달된 requestUserId = {} ===", requestUserId); + final boolean orderByLike = "likeCount".equalsIgnoreCase(orderBy); List page = orderByLike @@ -142,6 +143,14 @@ public CursorPageResponseCommentDto findAll( if (hasNext) page = page.subList(0, size); + + page.forEach(comment -> { + log.info("[DEBUG] 댓글 ID={}, 작성자 userId={}, 내용={}", + comment.getId(), + comment.getUser().getId(), + comment.getContent().substring(0, Math.min(10, comment.getContent().length()))); + }); + Set likedCommentIds = requestUserId == null || page.isEmpty() ? Set.of() : commentLikeRepository @@ -152,9 +161,15 @@ public CursorPageResponseCommentDto findAll( .collect(Collectors.toSet()); List content = page.stream() - .map(c -> CommentDto.from(c, likedCommentIds.contains(c.getId()))) + .map(c -> CommentDto.from(c, likedCommentIds.contains(c.getId()), + requestUserId)) .toList(); + content.forEach(dto -> { + log.info("[DEBUG] 응답 DTO - commentId={}, userId={}, isMyComment={}, likedByMe={}", + dto.id(), dto.userId(), dto.isMyComment(), dto.likedByMe()); + }); + String nextCursor = null; if (hasNext) { Comment last = page.get(page.size() - 1); @@ -181,9 +196,6 @@ public CursorPageResponseCommentDto findAll( ); } - @PersistenceContext - private EntityManager entityManager; - @Transactional public void hardDelete(Long commentId) { if (!commentRepository.existsById(commentId)) { @@ -193,19 +205,6 @@ public void hardDelete(Long commentId) { log.info("[COMMENT][HARD_DELETE] commentId={}", commentId); } - public CommentDto findById(Long commentId, String userId) { - Comment comment = getCommentById(commentId); - boolean likedByMe = false; - if (userId != null && !userId.isBlank()) { - likedByMe = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, Long.parseLong(userId)); - } - return CommentDto.from(comment, likedByMe); - } - - public CommentLikeDto findLike(Long commentId, Long userId) { - return CommentLikeDto.of(commentId, userId); - } - // === 내부 유틸 === private void validateOwnership(Comment comment, Long userId) { From 48984b78570a6802b3983632228e7b6e898ab007 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Wed, 29 Oct 2025 00:00:51 +0900 Subject: [PATCH 060/178] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95/=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=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 --- .../monew/monew_api/comments/dto/CommentRegisterRequest.java | 2 +- .../com/monew/monew_api/comments/service/CommentService.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java index 9df99e7..b6aadd7 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java @@ -9,7 +9,7 @@ public record CommentRegisterRequest( @NotNull(message = "기사 ID는 필수입니다.") - String articleId, + Long articleId, Long userId, diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index 6d3c7f1..b67a178 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -53,8 +53,8 @@ public class CommentService { @Transactional public CommentDto register(CommentRegisterRequest request) { - User user = getUserById(request.getUserIdAsLong()); - Article article = getArticleById(request.getArticleIdAsLong()); + User user = getUserById(request.userId()); + Article article = getArticleById(request.articleId()); Comment saved = commentRepository.save(Comment.of(user, article, request.content())); log.info("[COMMENT][CREATE] userId={}, articleId={}, commentId={}", From d44d6b29eccdf69ab64db0d2d7cc03a8561cdcc6 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Wed, 29 Oct 2025 09:15:58 +0900 Subject: [PATCH 061/178] =?UTF-8?q?refactor:=20=EB=8C=93=EA=B8=80=20DTO?= =?UTF-8?q?=EC=97=90=EC=84=9C=20id=20=ED=83=80=EC=9E=85=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=EB=B0=8F=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EC=BD=94=EB=93=9C=20=EC=9B=90=EB=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CommentController.java | 6 +---- .../monew_api/comments/dto/CommentDto.java | 18 +++++++------- .../comments/dto/CommentLikeDto.java | 24 +++++++++---------- .../comments/dto/CommentRegisterRequest.java | 3 --- .../repository/CommentRepository.java | 4 ---- .../impl/CommentRepositoryImpl.java | 6 ----- .../resources/static/assets/index-BBLciFoK.js | 2 +- 7 files changed, 23 insertions(+), 40 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java index 3146eab..0739db9 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java @@ -39,7 +39,7 @@ public ResponseEntity findAll( @RequestHeader("Monew-Request-User-ID") Long userIdHeader, @RequestParam(required = false) Long articleId, @RequestParam String orderBy, - @RequestParam String direction, + @RequestParam(required = false) String direction, @RequestParam(required = false) String cursor, @RequestParam(required = false) String after, @RequestParam int limit @@ -54,8 +54,6 @@ public ResponseEntity findAll( if (parts.length == 2) { cursorLikeCount = safeParseInt(parts[0]); cursorId = safeParseLong(parts[1]); - } else { - } } else { cursorId = safeParseLong(cursor); @@ -88,10 +86,8 @@ public ResponseEntity update( @DeleteMapping("/{commentId}") public ResponseEntity delete( - // @RequestHeader("Monew-Request-User-ID") Long userIdHeader, @PathVariable Long commentId ) { - // commentService.delete(userIdHeader, commentId); commentService.delete(commentId); return ResponseEntity.noContent().build(); } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java index 3bb2c06..b588040 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java @@ -3,9 +3,9 @@ import com.monew.monew_api.comments.entity.Comment; public record CommentDto( - String id, - String articleId, - String userId, + Long id, + Long articleId, + Long userId, String userNickname, String content, int likeCount, @@ -16,9 +16,9 @@ public record CommentDto( public static CommentDto from(Comment comment, boolean likedByMe) { return new CommentDto( - String.valueOf(comment.getId()), - String.valueOf(comment.getArticle().getId()), - String.valueOf(comment.getUser().getId()), + comment.getId(), + comment.getArticle().getId(), + comment.getUser().getId(), comment.getUser().getNickname(), comment.getContent(), comment.getLikeCount(), @@ -32,9 +32,9 @@ public static CommentDto from(Comment comment, boolean likedByMe, Long requestUs boolean isMyComment = comment.getUser().getId().equals(requestUserId); return new CommentDto( - String.valueOf(comment.getId()), - String.valueOf(comment.getArticle().getId()), - String.valueOf(comment.getUser().getId()), + comment.getId(), + comment.getArticle().getId(), + comment.getUser().getId(), comment.getUser().getNickname(), comment.getContent(), comment.getLikeCount(), diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java index a735cf4..a170ad0 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java @@ -5,11 +5,11 @@ import com.monew.monew_api.comments.entity.CommentLike; public record CommentLikeDto( - String id, - String commentId, - String articleId, - String likedBy, - String commentUserId, + Long id, + Long commentId, + Long articleId, + Long likedBy, + Long commentUserId, String commentUserNickname, String commentContent, int commentLikeCount, @@ -19,11 +19,11 @@ public record CommentLikeDto( public static CommentLikeDto from(CommentLike like) { return new CommentLikeDto( - String.valueOf(like.getId()), - String.valueOf(like.getComment().getId()), - String.valueOf(like.getComment().getArticleId()), - String.valueOf(like.getUser().getId()), - String.valueOf(like.getComment().getUserId()), + like.getId(), + like.getComment().getId(), + like.getComment().getArticleId(), + like.getUser().getId(), + like.getComment().getUserId(), like.getComment().getUser().getNickname(), like.getComment().getContent(), like.getComment().getLikeCount(), @@ -35,9 +35,9 @@ public static CommentLikeDto from(CommentLike like) { public static CommentLikeDto of(Long commentId, Long userId) { return new CommentLikeDto( null, - String.valueOf(commentId), + commentId, null, - String.valueOf(userId), + userId, null, null, null, -1, null, diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java index b6aadd7..af3f3fc 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java @@ -1,8 +1,5 @@ package com.monew.monew_api.comments.dto; -import com.monew.monew_api.common.exception.comment.CommentInvalidArticleIdException; -import com.monew.monew_api.common.exception.comment.CommentInvalidUserIdException; - import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java index 29ad841..df37294 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java @@ -9,10 +9,6 @@ public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query("update Comment c set c.likeCount = c.likeCount + 1 where c.id = :id") - int incLikeCount(@Param("id") Long id); - @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" update Comment c diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java index 83f78a2..5aaa070 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java @@ -76,12 +76,6 @@ public List findPageByArticleIdOrderByLikeCountDesc(Long articleId, Lon } - private BooleanExpression articleIdEq(Long articleId) { - return articleId != null ? c.article.id.eq(articleId) : null; - } - - - private BooleanExpression buildCreatedAtCursor( QComment c, Long cursorId, diff --git a/monew-api/src/main/resources/static/assets/index-BBLciFoK.js b/monew-api/src/main/resources/static/assets/index-BBLciFoK.js index 77a93e5..5a10cb1 100644 --- a/monew-api/src/main/resources/static/assets/index-BBLciFoK.js +++ b/monew-api/src/main/resources/static/assets/index-BBLciFoK.js @@ -77,5 +77,5 @@ Please change the parent to p(!d),className:`w-full border rounded-lg mt-1.5 bg-white ${q[o]} border-gray-200 focus:border-cyan-500 outline-none text-left flex items-center gap-2`,children:[m.jsx("span",{className:`text-16-m ${a?"text-gray-900":"text-gray-400"}`,children:a||i}),a&&f&&m.jsx("span",{className:"text-14-m text-gray-500",children:f})]}),d&&m.jsxs("div",{className:"absolute z-50 mt-2 bg-white border border-gray-200 rounded-lg shadow-lg p-4 w-[280px]",children:[m.jsxs("div",{className:"flex items-center justify-between mb-4",children:[m.jsx("button",{type:"button",onClick:T,className:"p-1 hover:bg-gray-100 rounded",children:m.jsx("svg",{className:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:m.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M15 19l-7-7 7-7"})})}),m.jsx("div",{className:"text-16-b text-gray-900",children:Ta(g,"yyyy년 M월")}),m.jsx("button",{type:"button",onClick:M,className:"p-1 hover:bg-gray-100 rounded",children:m.jsx("svg",{className:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:m.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M9 5l7 7-7 7"})})})]}),m.jsx("div",{className:"mb-2",children:m.jsx("div",{className:"flex gap-1 justify-between mb-2",children:["일","월","화","수","목","금","토"].map(A=>m.jsx("div",{className:"h-9 w-9 flex items-center justify-center text-14-m text-gray-600 font-semibold",children:A},A))})}),m.jsx("div",{className:"flex flex-col gap-1",children:C()})]})]})}function Hl({isOpen:a,onClose:i,children:r,width:s="w-[502px]",noPadding:o=!1,disableClose:f=!1}){const d=b.useRef(null);return Xs(d,i,a,f),b.useEffect(()=>(a?document.body.style.overflow="hidden":document.body.style.overflow="unset",()=>{document.body.style.overflow="unset"}),[a]),a?rv.createPortal(m.jsx("div",{className:"z-50 fixed inset-0 bg-black/50 flex items-center justify-center",role:"dialog","aria-modal":"true",children:m.jsxs("div",{ref:d,className:`${s} max-h-[90vh] overflow-y-auto h-auto rounded-2xl ${o?"":"p-8"} gap-2.5 bg-white relative`,onClick:p=>p.stopPropagation(),children:[m.jsx("button",{className:"absolute top-9 right-7 hover:text-gray-700",onClick:i,"aria-label":"close",children:m.jsx("img",{src:kh,alt:"닫기",className:"w-6 h-6"})}),r]})}),document.body):null}const Ih="data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2016%2016'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M8%209.72973L11.8333%206L13%207.13514L8%2012L3%207.13513L4.16667%206L8%209.72973Z'%20fill='%231F2937'/%3e%3c/svg%3e";function j5({label:a,onClick:i}){return m.jsx("li",{className:"h-11 p-3 gap-0.5 bg-white font-pretendard font-medium text-sm leading-5 hover:bg-gray-100 truncate",onClick:i,children:m.jsx("span",{children:a})})}function ef({items:a,onChange:i,className:r}){const s=o=>{i(o)};return m.jsx("div",{className:`absolute box-border bg-white border border-gray-200 rounded-lg overflow-hidden ${r}`,children:m.jsx("ul",{className:"py-1 max-h-60 overflow-y-auto cursor-pointer",children:a.map((o,f)=>m.jsx(j5,{label:o,onClick:()=>s(o)},f))})})}function Ol({placeholder:a="선택하세요",items:i,value:r,onChange:s,className:o="w-full h-10",noBorder:f=!1,textClassName:d="text-14-m",noBackground:p=!1}){const[g,y]=b.useState(!1),v=b.useRef(null);b.useEffect(()=>{const R=T=>{v.current&&!v.current.contains(T.target)&&y(!1)};return document.addEventListener("mousedown",R),()=>{document.removeEventListener("mousedown",R)}},[]);const w=()=>{y(!g)},N=R=>{s(R),y(!1)};return m.jsxs("div",{ref:v,className:`relative ${o}`,children:[m.jsx("button",{type:"button",className:`${f?"":"border rounded-lg border-gray-200"} ${p?"":"bg-white"} py-2.5 px-3 cursor-pointer w-full h-full focus:outline-none`,onClick:w,children:m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsx("p",{className:`${d} ${r?f?"text-cyan-600":"text-gray-900":"text-gray-400"}`,children:r||a}),m.jsx("img",{src:Ih,className:`transform transition-transform duration-200 ${g?"rotate-180":""} ${f?"ml-3":""}`,alt:"chevron"})]})}),g&&m.jsx("div",{className:"absolute top-full left-0 right-0 z-10",children:m.jsx(ef,{items:i,onChange:N,className:"w-full"})})]})}const e1="data:image/svg+xml,%3csvg%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20fill-rule='evenodd'%20clip-rule='evenodd'%20d='M4.45084%206.5193C2.64063%208.31949%202.49043%2011.1977%204.19174%2013.0999C4.90111%2013.8931%205.64486%2014.6967%206.30532%2015.3535C7.87526%2016.9147%2010.2793%2018.944%2011.3937%2019.8697C11.5692%2020.0154%2011.7867%2020.0802%2011.9996%2020.0666C12.2126%2020.0803%2012.4303%2020.0156%2012.6059%2019.8697C13.7204%2018.9442%2016.1243%2016.9152%2017.6942%2015.3541C18.3548%2014.6971%2019.0988%2013.8932%2019.8083%2013.0998C21.5096%2011.1975%2021.3593%208.31939%2019.5492%206.51927C17.6038%204.5847%2014.4498%204.58473%2012.5044%206.51933L12%207.02098L11.4954%206.51924C9.55013%204.58473%206.39617%204.58476%204.45084%206.5193Z'%20fill='%23D1D5DB'/%3e%3c/svg%3e",R5="data:image/svg+xml,%3csvg%20width='20'%20height='20'%20viewBox='0%200%2020%2020'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3ccircle%20cx='16'%20cy='10'%20r='1.5'%20transform='rotate(90%2016%2010)'%20fill='%23989FAC'/%3e%3ccircle%20cx='10.5'%20cy='10'%20r='1.5'%20transform='rotate(90%2010.5%2010)'%20fill='%23989FAC'/%3e%3ccircle%20cx='5'%20cy='10'%20r='1.5'%20transform='rotate(90%205%2010)'%20fill='%23989FAC'/%3e%3c/svg%3e";function A5({userNickname:a,createdAt:i,likeCount:r,content:s,isLiked:o,onLikeClick:f,onEditSave:d,onDelete:p,commentId:g,isMyComment:y,className:v}){const[w,N]=b.useState(s),[R,T]=b.useState(!1),[M,C]=b.useState(!1),q=b.useRef(null);Xs(q,()=>C(!1),M);const A=()=>{f(g)},Y=()=>{C(!M)},Q=()=>{T(!1),N(s)},$=()=>{w.trim()&&(d(g,w.trim()),T(!1))},ee=J=>{J==="수정하기"?(T(!0),N(s)):J==="삭제하기"&&p(g)};return m.jsxs("div",{className:`w-full h-auto border-gray-300 py-4 px-4 bg-gray-100 rounded-lg ${v||""}`,children:[m.jsxs("div",{className:"flex justify-between pr-1 gap-2 mb-2.5",children:[m.jsxs("div",{className:"gap-1 flex items-center",children:[m.jsx("span",{className:"text-14-m text-gray-500",children:a}),m.jsx("span",{className:"text-14-m text-gray-500 ",children:"·"}),m.jsx("span",{className:"text-14-m text-gray-500",children:j3(i,{addSuffix:!0,locale:dh})}),y&&m.jsx("span",{className:"ml-1 text-14-m text-cyan-500",children:"내 댓글"})]}),m.jsxs("div",{className:"flex items-center gap-2",children:[y&&!R&&m.jsxs("button",{ref:q,onClick:()=>C(!M),children:[m.jsx("img",{src:R5,className:"w-5 h-5",alt:"케밥",onClick:Y}),M&&m.jsx(ef,{items:["수정하기","삭제하기"],onChange:ee})]}),m.jsxs("button",{onClick:A,className:"flex justify-center items-center gap-2",children:[o?m.jsx("img",{src:Qc,className:"w-6 h-6",alt:"활성화 하트"}):m.jsx("img",{src:e1,className:"w-6 h-6",alt:"비활성화 하트"}),m.jsx("p",{className:"text-14-r text-gray-500",children:r})]})]})]}),y&&R?m.jsxs("div",{className:"flex items-center gap-2.5 w-full",children:[m.jsx(Tt,{inputSize:"sm",value:w,onChange:J=>N(J.target.value),className:"flex-1"}),m.jsx(Fe,{variant:"tertiary",size:"sm",className:"w-16 mt-1",onClick:Q,children:"취소"}),m.jsx(Fe,{size:"sm",className:"w-16 mt-1",onClick:$,children:"수정"})]}):m.jsx("div",{children:m.jsx("p",{className:"text-16-r text-gray-700",children:s})})]})}async function Rm(a,i){const{data:r}=await Ye.get("/comments",{params:a,headers:{"Monew-Request-User-ID":i}});return r}async function U5(a){const{data:i}=await Ye.post("/comments",a);return i}async function z5(a,i){const{data:r}=await Ye.post(`/comments/${a}/comment-likes`,void 0,{headers:{"Monew-Request-User-ID":i}});return r}async function L5(a,i,r){const{data:s}=await Ye.patch(`/comments/${a}`,i,{headers:{"Monew-Request-User-ID":r}});return s}async function H5(a,i){await Ye.delete(`/comments/${a}/comment-likes`,{headers:{"Monew-Request-User-ID":i}})}async function B5(a){await Ye.delete(`/comments/${a}`)}function t1({src:a,label:i}){return m.jsxs("div",{className:"flex gap-1.5 items-center w-fit",children:[a&&m.jsx("div",{className:"p-0.5 bg-gray-200 rounded-full",children:m.jsx("img",{src:a,className:"w-5 h-5 rounded-full"})}),m.jsx("p",{className:"font-pretendard font-semibold text-sm leading-5 text-gray-500",children:i})]})}const n1="data:image/svg+xml,%3csvg%20width='20'%20height='20'%20viewBox='0%200%2020%2020'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M6%2011.25H15V9.75H6V11.25ZM5%208.5H15V7H5V8.5ZM18%2018L15%2015H3.5C3.0875%2015%202.73438%2014.8531%202.44063%2014.5594C2.14688%2014.2656%202%2013.9125%202%2013.5V4.5C2%204.0875%202.14688%203.73438%202.44063%203.44063C2.73438%203.14688%203.0875%203%203.5%203H16.5C16.9125%203%2017.2656%203.14688%2017.5594%203.44063C17.8531%203.73438%2018%204.0875%2018%204.5V18ZM3.5%2013.5H15.625L16.5%2014.375V4.5H3.5V13.5Z'%20fill='%236B7280'/%3e%3c/svg%3e";function a1(){const[a,i]=b.useState(!1),[r,s]=b.useState(null);return{isOpen:a,openModal:d=>{s(d),i(!0)},onClose:()=>{i(!1),s(null)},initialData:r}}function l1({isOpen:a,onClose:i,onConfirm:r,title:s,message:o,cancelText:f="취소",confirmText:d="확인"}){const p=async()=>{await r(),i()};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("div",{className:"w-full p-6 text-center",children:[m.jsx("h2",{className:"text-20-b text-gray-900 mb-4",children:s}),m.jsx("p",{className:"text-16-r text-gray-600 mb-8 leading-relaxed",children:o}),m.jsxs("div",{className:"flex gap-4 pt-4",children:[m.jsx(Fe,{variant:"secondary",onClick:i,className:"min-w-[100px] flex-1 px-6",children:f}),m.jsx(Fe,{variant:"primary",onClick:p,className:"min-w-[100px] flex-1 px-6",children:d})]})]})})}function i1({articleId:a,onClose:i}){const[r,s]=b.useState(null),o=["등록순","좋아요순"],f=5,[d,p]=b.useState("createdAt"),g=d==="createdAt"?"등록순":"좋아요순",y="DESC",[v,w]=b.useState([]),[N,R]=b.useState(""),[T,M]=b.useState(!1),[C,q]=b.useState(!1),[A,Y]=b.useState(null),Q=b.useRef(null),$=b.useRef(null),{isOpen:ee,openModal:J,onClose:F,initialData:ae}=a1(),{userId:re}=ta();b.useEffect(()=>{a&&D5(a,re).then(D=>{s(D),D.viewedByMe||N5(a,re)})},[a,re]);const fe=b.useCallback(async()=>{if(M(!0),!!r)try{const D={articleId:r.id,orderBy:d,direction:y,limit:f},k=await Rm(D,re);w(k.content),q(k.hasNext),Y(k.nextCursor)}catch(D){console.error(D)}finally{M(!1)}},[r,d,re]),oe=b.useCallback(async()=>{if(!(!r||!C||!re||T)){M(!0);try{const D={articleId:r.id,orderBy:d,direction:y,limit:f,cursor:A||void 0},k=await Rm(D,re);w(Z=>[...Z,...k.content]),q(k.hasNext),Y(k.nextCursor)}catch(D){console.error(D)}finally{M(!1)}}},[d,y,f,A,r,C,T,re]);b.useEffect(()=>{r&&fe()},[fe,r]),b.useEffect(()=>{if(!T)return Q.current&&Q.current.disconnect(),Q.current=new IntersectionObserver(D=>{D[0].isIntersecting&&C&&oe()},{threshold:.8}),$.current&&Q.current.observe($.current),()=>{Q.current&&Q.current.disconnect()}},[C,T,oe]);const _e=()=>{i()},Ue=()=>{r&&window.open(r.sourceUrl,"_blank","noopener,noreferrer")},Se=D=>{D==="등록순"?p("createdAt"):D==="좋아요순"&&p("likeCount")},B=async D=>{try{const k=v.find(Z=>Z.id===D);if(!k)return;k.likedByMe?await H5(D,re):await z5(D,re),await fe()}catch(k){console.error(k)}},K=async(D,k)=>{try{await L5(D,{content:k},re),fe(),ce.success("댓글이 수정되었습니다.")}catch(Z){console.error(Z),ce.error("댓글 수정 중 오류가 발생했습니다.")}},W=async D=>{if(!(!r||!D.trim()))try{const k={articleId:r.id,userId:re,content:D.trim()};await U5(k),R(""),await fe(),ce.success("댓글 작성 완료")}catch(k){console.error(k)}},pe=async D=>{J({title:"댓글 삭제",message:"정말 삭제하시겠습니까?",onConfirm:async()=>{try{await B5(D),ce.success("댓글이 삭제되었습니다."),await fe()}catch(k){console.error(k),ce.error("댓글 삭제 중 오류가 발생했습니다.")}},confirmText:"삭제",cancelText:"취소"})};if(!r)return null;const E=Ta(r.publishDate,"yyyy.MM.dd");return m.jsxs(m.Fragment,{children:[m.jsxs(Hl,{isOpen:r!==null,onClose:_e,width:"w-[894px]",noPadding:!0,disableClose:ee,children:[m.jsxs("div",{className:"h-auto rounded-tr-3xl rounded-tl-3xl pt-10 px-10 pb-6 bg-white",children:[m.jsx("div",{className:"text-20-b text-gray-900 mb-2",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:r.title}})}),m.jsxs("div",{className:"flex items-center gap-4 pb-6 mb-6 border-b border-gray-200",children:[m.jsx(t1,{label:r.source}),m.jsxs("div",{className:"flex items-center gap-3",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:E}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:"읽음"}),m.jsx("span",{className:"text-14-r text-gray-400",children:r.viewCount})]}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("img",{src:n1,className:"w-5 h-5",alt:"댓글"}),m.jsx("span",{className:"text-14-r text-gray-400",children:r.commentCount})]})]})]}),m.jsx("div",{className:"text-18-r text-gray-500 mb-6",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:r.summary}})}),m.jsx("div",{className:"mt-4 mb-10 border-b-gray-200",children:m.jsx(Fe,{size:"sm",className:"w-[162px]",variant:"secondary",onClick:Ue,children:"전체 기사 보러가기 →"})})]}),m.jsxs("div",{className:"rounded-br-3xl rounded-bl-3xl pt-3 px-10 pb-8 bg-gray-100",children:[m.jsx("div",{className:"mb-2 w-[110px]",children:m.jsx(Ol,{items:o,value:g,onChange:Se,placeholder:"등록순",noBorder:!0,textClassName:"text-14-m text-gray-400",noBackground:!0})}),m.jsxs("div",{className:"flex items-center gap-2.5 mb-2",children:[m.jsx(Tt,{placeholder:"2025.01.01 부터",className:"flex-1",value:N,onChange:D=>R(D.target.value)}),m.jsx(Fe,{className:"w-[92px]",onClick:()=>W(N),children:"댓글 작성"})]}),m.jsx("div",{children:v.map((D,k)=>m.jsx("div",{ref:k===v.length-1?$:null,children:m.jsx(A5,{userNickname:D.userNickname,createdAt:new Date(D.createdAt),likeCount:D.likeCount,content:D.content,isLiked:D.likedByMe,onLikeClick:B,onEditSave:K,commentId:D.id,isMyComment:D.isMyComment,onDelete:pe})},D.id))})]})]}),ae&&m.jsx(l1,{isOpen:ee,onClose:F,onConfirm:ae.onConfirm,title:ae.title,message:ae.message,confirmText:ae.confirmText,cancelText:ae.cancelText})]})}function k5({isOpen:a,onClose:i,onSave:r}){const[s,o]=b.useState(""),[f,d]=b.useState(""),p=s.trim()!==""&&f.trim()!=="";b.useEffect(()=>{a||(o(""),d(""))},[a]);const g=y=>{y.preventDefault(),p&&(r({from:s,to:f}),i())};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("form",{onSubmit:g,className:"w-[438px] h-auto gap-10",children:[m.jsx("h2",{className:"text-24-sb mb-10",children:"기사 복구하기"}),m.jsx(Tt,{label:"날짜",value:s,placeholder:"2025.01.01 부터",onChange:y=>o(y.target.value),className:"mb-2"}),m.jsx(Tt,{value:f,placeholder:"2025.01.01 까지",onChange:y=>d(y.target.value),className:"mb-12"}),m.jsx(Fe,{className:"w-full",disabled:!p,type:"submit",children:"복구하기"})]})})}const q5="data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2016%2016'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M7.14697%201.14844C10.4595%201.14863%2013.1458%203.83391%2013.146%207.14648C13.146%208.51468%2012.6858%209.77484%2011.9146%2010.7842L14.9907%2013.8594L14.4243%2014.4248L13.8589%2014.9912L10.7837%2011.915C9.77453%2012.686%208.51479%2013.1454%207.14697%2013.1455C3.83421%2013.1454%201.14893%2010.4593%201.14893%207.14648C1.14912%203.83386%203.83433%201.14856%207.14697%201.14844ZM7.14697%202.74805C4.71799%202.74817%202.74873%204.71752%202.74854%207.14648C2.74854%209.57561%204.71787%2011.5448%207.14697%2011.5449C9.57601%2011.5447%2011.5454%209.57557%2011.5454%207.14648C11.5452%204.71756%209.57589%202.74824%207.14697%202.74805Z'%20fill='%231F2937'/%3e%3c/svg%3e";function r1({width:a="w-3xs",height:i="h-[40px]",containerClassName:r="",className:s="",onSearch:o,onKeyDown:f,...d}){const p=b.useRef(null),g=()=>{const v=p.current?.value||"";o&&v.trim()&&(o(v.trim()),p.current&&(p.current.value=""))},y=v=>{v.key==="Enter"&&o&&!v.nativeEvent.isComposing&&(v.preventDefault(),g()),f?.(v)};return m.jsxs("div",{className:`${a} ${i} px-4 py-2.5 border bg-white border-gray-300 rounded-[100px] flex items-center justify-between gap-2.5 ${r}`,children:[m.jsx("input",{ref:p,type:"text",placeholder:"검색어를 입력해주세요",className:`flex-1 outline-none font-pretendard placeholder:text-sm placeholder:text-gray-400 placeholder:font-normal placeholder:leading-5 ${s}`,onKeyDown:y,...d}),m.jsx("button",{type:"button",onClick:g,"aria-label":"검색",children:m.jsx("img",{src:q5,alt:"검색",className:"w-4 h-4"})})]})}function Y5({items:a,values:i,onChange:r,className:s="w-full h-10",placeholder:o="선택하세요"}){const[f,d]=b.useState(!1),p=b.useRef(null);b.useEffect(()=>{const w=N=>{p.current&&!p.current.contains(N.target)&&d(!1)};return document.addEventListener("mousedown",w),()=>{document.removeEventListener("mousedown",w)}},[]);const g=()=>{d(!f)},y=w=>{const N=i.includes(w)?i.filter(R=>R!==w):[...i,w];r(N)},v=i.length>0?`${i.length}개 선택됨`:o;return m.jsxs("div",{ref:p,className:`relative ${s}`,children:[m.jsx("button",{type:"button",className:"border rounded-lg border-gray-200 bg-white py-2.5 px-3 cursor-pointer w-full h-full focus:outline-none",onClick:g,children:m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsx("p",{className:`text-14-m ${i.length===0?"text-gray-400":"text-gray-900"}`,children:v}),m.jsx("img",{src:Ih,className:`transform transition-transform duration-200 ${f?"rotate-180":""}`,alt:"chevron"})]})}),f&&m.jsx("div",{className:"absolute top-full left-0 right-0 z-10",children:m.jsx("div",{className:"absolute box-border bg-white border border-gray-200 rounded-lg overflow-hidden w-full",children:m.jsx("ul",{className:"py-1 max-h-60 overflow-y-auto",children:a.map(w=>m.jsx("li",{className:"h-11 p-3 gap-0.5 bg-white font-pretendard font-medium text-sm leading-5 hover:bg-gray-100 cursor-pointer",children:m.jsxs("label",{className:"flex items-center gap-2 cursor-pointer",children:[m.jsx("input",{type:"checkbox",checked:i.includes(w),onChange:()=>y(w),className:"w-4 h-4 rounded border-gray-300 text-cyan-600 focus:ring-cyan-500 cursor-pointer"}),m.jsx("span",{children:w})]})},w))})})})]})}function s1({article:a,onClick:i}){const r=Ta(a.publishDate,"yyyy.MM.dd");return m.jsx(m.Fragment,{children:m.jsx("div",{className:"max-w-4xl w-auto min-h-48 h-auto cursor-pointer",onClick:i,children:m.jsxs("div",{className:"my-6 mx-1",children:[m.jsx("div",{className:"text-20-b text-gray-900 mb-2",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:a.title}})}),m.jsx("div",{className:"text-18-r text-gray-500 mb-6",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:a.summary}})}),m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsx(t1,{label:a.source}),m.jsxs("div",{className:"flex items-center gap-3",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:r}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:"읽음"}),m.jsx("span",{className:"text-14-r text-gray-400",children:a.viewCount})]}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("img",{src:n1,className:"w-5 h-5",alt:"댓글"}),m.jsx("span",{className:"text-14-r text-gray-400",children:a.commentCount})]})]})]})]})})})}function u1(){const[a,i]=b.useState(!1),[r,s]=b.useState(null);return{isOpen:a,openModal:d=>{s(d),i(!0)},onClose:()=>{i(!1),s(null)},initialData:r}}function V5(){const[a,i]=b.useState(!1);return{isOpen:a,openModal:()=>i(!0),onClose:()=>i(!1)}}async function o1(a){const{data:i}=await Ye.get(`/user-activities/${a}`);return i}function Am(){const[a,i]=Ls(),{articleId:r}=fg(),o=Wt().state?.article,{openModal:f,onClose:d,initialData:p}=u1(),g=["게시일","조회수","댓글수"],y=["내림차순","오름차순"],v=a.get("orderBy")||"publishDate",w=v==="publishDate"||v==="commentCount"||v==="viewCount"?v:"publishDate",N=a.get("direction")||"DESC",R=N==="ASC"||N==="DESC"?N:"DESC",T=a.get("limit")||"10",M=a.get("keyword")||"",C=a.get("interestId")||"",q=a.get("sourceIn")||"",[A,Y]=b.useState(),Q=new Date,$=z3(Q),[ee,J]=b.useState(Ta($,"yyyy.MM.dd")),[F,ae]=b.useState(Ta(Q,"yyyy.MM.dd")),re=a.get("publishDateFrom")||"",fe=a.get("publishDateTo")||"",[oe,_e]=b.useState(!1),[Ue,Se]=b.useState(!1),[B,K]=b.useState(null),W=b.useRef(null),pe=b.useRef(null),[E,D]=b.useState([]),[k,Z]=b.useState([]),[P,xe]=b.useState([]),[se,at]=b.useState([]),ze=Oa(),{userId:dt}=ta(),{isOpen:Bl,openModal:Ra,onClose:Aa}=V5(),kl={게시일:"publishDate",조회수:"viewCount",댓글수:"commentCount"},Ua={publishDate:"게시일",viewCount:"조회수",commentCount:"댓글수"},[ql,lr]=b.useState(Ua[w]||"게시일"),[gt,ir]=b.useState(R==="DESC"?"내림차순":"오름차순"),Dn=b.useRef(!0),za=b.useCallback(async()=>{_e(!0);try{const I=q?q.split(","):[],he={keyword:M,interestId:C,publishDateFrom:re,publishDateTo:fe,orderBy:w,direction:R,limit:parseInt(T),sourceIn:I.length>0?I:void 0},ge=await Mm(he,dt);Se(ge.hasNext),K(ge.nextCursor),D(ge.content)}catch(I){console.error(I)}finally{_e(!1)}},[M,C,w,R,re,fe,T,dt,q]),na=b.useCallback(async()=>{if(!(!Ue||!dt||oe)){_e(!0);try{const I=q?q.split(","):[],he={keyword:M,interestId:C,publishDateFrom:re,publishDateTo:fe,orderBy:w,direction:R,limit:parseInt(T),cursor:B||void 0,sourceIn:I.length>0?I:void 0},ge=await Mm(he,dt);D(lt=>[...lt,...ge.content]),Se(ge.hasNext),K(ge.nextCursor)}catch(I){console.error(I)}finally{_e(!1)}}},[w,R,re,fe,T,M,C,dt,Ue,oe,B,q]),rr=b.useCallback(async()=>{try{const he=(await o1(dt)).subscriptions.map(ge=>({id:ge.interestId,name:ge.interestName,keywords:ge.interestKeywords,subscriberCount:ge.interestSubscriberCount,subscribedByMe:!0}));Z(he)}catch(I){console.error(I)}},[]),Yl=b.useCallback(async()=>{try{const I=await O5();if(xe(I),q){const he=q.split(",");at(he)}else I.length>0&&at(I.slice(0,1))}catch(I){console.error(I)}},[q]);b.useEffect(()=>{Yl()},[Yl]),b.useEffect(()=>{if(!oe)return W.current&&W.current.disconnect(),W.current=new IntersectionObserver(I=>{I[0].isIntersecting&&Ue&&na()},{threshold:.8}),pe.current&&W.current.observe(pe.current),()=>{W.current&&W.current.disconnect()}},[Ue,oe,na]);const $s=b.useMemo(()=>k.map(I=>I.name),[k]),Js=I=>{const he=k.find(ge=>ge.name===I);he&&(Y(he),K(null),Se(!1))};b.useEffect(()=>{A&&i(I=>{const he=new URLSearchParams(I);return he.set("interestId",A.id),he})},[A]);const aa=I=>{lr(I)},mt=I=>{ir(I)},It=I=>{at(I)},ht=I=>{(I??"")!==M&&(i(he=>{const ge=new URLSearchParams(he);return I?ge.set("keyword",I):ge.delete("keyword"),ge.delete("interestId"),ge}),Y(void 0),K(null),Se(!1))};b.useEffect(()=>{if(Dn.current)return;const I=kl[ql];w!==I&&(i(he=>{const ge=new URLSearchParams(he);return ge.set("orderBy",I),ge}),K(null),Se(!1))},[ql]),b.useEffect(()=>{if(Dn.current)return;const I=gt==="오름차순"?"ASC":"DESC";R!==I&&(i(he=>{const ge=new URLSearchParams(he);return ge.set("direction",I),ge}),K(null),Se(!1))},[gt]),b.useEffect(()=>{if(!Dn.current&&se.length>0){const I=se.join(",");q!==I&&(i(he=>{const ge=new URLSearchParams(he);return ge.set("sourceIn",I),ge}),K(null),Se(!1))}},[se]),b.useEffect(()=>{if(Dn.current){Dn.current=!1;return}const I=ee?`${ee.replace(/\./g,"-")}T00:00:00`:"",he=F?`${F.replace(/\./g,"-")}T23:59:59`:"";(re!==I||fe!==he)&&(i(ge=>{const lt=new URLSearchParams(ge);return ee?lt.set("publishDateFrom",I):lt.delete("publishDateFrom"),F?lt.set("publishDateTo",he):lt.delete("publishDateTo"),lt}),K(null),Se(!1))},[ee,F]),b.useEffect(()=>{rr()},[rr]),b.useEffect(()=>{if(k.length>0&&C){const I=k.find(he=>he.id===C);I&&Y(I)}else C||Y(void 0)},[k,C]),b.useEffect(()=>{lr(Ua[w]||"게시일"),ir(R==="DESC"?"내림차순":"오름차순"),za()},[za,w,R]),b.useEffect(()=>{if(r){if(o&&o.id===r)Vl(o);else if(E.length>0){const I=E.find(he=>he.id===r);I&&Vl(I)}}},[r,E,o]);const Fs=I=>{try{const he={from:`${I.from.replace(/\./g,"-")}T00:00:00`,to:`${I.to.replace(/\./g,"-")}T23:59:59`};M5(he),za(),ce.success("기사가 복구되었습니다."),Aa()}catch(he){console.error(he);const ge=he,lt=ge.response?.data?.message||ge.message||"오류가 발생했습니다.";ce.error(lt)}},Vl=b.useCallback(I=>{f(I)},[f]);return m.jsxs("div",{className:"flex gap-12 justify-center",children:[m.jsxs("div",{className:"max-w-3xs min-h-[564px] h-auto",children:[m.jsx("div",{className:"mb-6",children:m.jsx(r1,{height:"h-11",onSearch:ht})}),m.jsxs("div",{className:"h-auto mb-6 border border-gray-200 rounded-2xl px-4 pt-4 pb-6 bg-white",children:[m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"정렬"}),m.jsx("div",{className:"min-h-10",children:m.jsx(Ol,{items:g,value:ql,onChange:aa,className:"mb-6 h-10"})}),m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"정렬 방향"}),m.jsx(Ol,{items:y,value:gt,onChange:mt,className:"mb-6 h-10"}),m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"출처"}),m.jsx(Y5,{items:P,values:se,onChange:It,className:"mb-6"}),m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"날짜"}),m.jsx(jm,{value:ee,placeholder:"시작 날짜",onChange:J,className:"mb-2",inputSize:"sm",label:"부터"}),m.jsx(jm,{value:F,placeholder:"종료 날짜",onChange:ae,className:"mb-4",inputSize:"sm",label:"까지"})]}),m.jsx(Fe,{className:"w-full",variant:"secondary",size:"sm",onClick:Ra,children:"기사 복구하기"}),m.jsx(k5,{isOpen:Bl,onClose:Aa,onSave:Fs})]}),m.jsxs("div",{className:"min-w-48",children:[M?m.jsxs("div",{className:"flex gap-4 items-center mb-8",children:[m.jsx("div",{className:"text-24-b text-cyan-600",children:M}),m.jsx("div",{className:"text-24-b text-gray-900",children:"관련 기사 목록"})]}):k.length>0?m.jsxs("div",{className:"flex gap-4 items-baseline mb-8",children:[m.jsx("div",{className:"min-w-[157px]",children:m.jsx(Ol,{items:$s,value:A?.name,onChange:Js,placeholder:"관심사 선택",noBorder:!0,textClassName:"text-24-b",noBackground:!0})}),m.jsx("div",{className:"text-24-b text-gray-900",children:"관련 기사 목록"})]}):m.jsx("div",{className:"text-24-b text-gray-900",children:"관련 기사 목록"}),E.length===0?m.jsx("div",{className:"min-w-[894px] w-full flex flex-col justify-center min-h-72 items-center mt-30",children:C?m.jsx(In,{message:"관련된 기사가 없습니다."}):m.jsxs("div",{className:"flex flex-col items-center justify-center gap-6",children:[m.jsx(In,{message:"관심사를 등록하면 맞춤 기사를 확인하실 수 있어요."}),m.jsx(Fe,{onClick:()=>ze("/interests"),className:"w-[160px]",size:"sm",children:"관심사 등록하기"})]})}):m.jsx("div",{children:E.map((I,he)=>m.jsx("div",{className:"min-w-2xs",ref:he===E.length-1?pe:null,children:m.jsx(s1,{article:I,onClick:()=>Vl(I)})},I.id))})]}),p&&m.jsx(i1,{onClose:d,articleId:p.id})]})}const X5="data:image/svg+xml,%3csvg%20width='20'%20height='20'%20viewBox='0%200%2020%2020'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M3%2010H17'%20stroke='white'%20stroke-width='2'%20stroke-linecap='round'/%3e%3cpath%20d='M10%2017V3'%20stroke='white'%20stroke-width='2'%20stroke-linecap='round'/%3e%3c/svg%3e",G5="data:image/svg+xml,%3csvg%20width='32'%20height='32'%20viewBox='0%200%2032%2032'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3ccircle%20cx='24'%20cy='16'%20r='2'%20transform='rotate(90%2024%2016)'%20fill='%23989FAC'/%3e%3ccircle%20cx='16'%20cy='16'%20r='2'%20transform='rotate(90%2016%2016)'%20fill='%23989FAC'/%3e%3ccircle%20cx='8'%20cy='16'%20r='2'%20transform='rotate(90%208%2016)'%20fill='%23989FAC'/%3e%3c/svg%3e",Z5="data:image/svg+xml,%3csvg%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3ccircle%20cx='12'%20cy='9'%20r='3'%20fill='%23D1D5DB'/%3e%3cpath%20d='M7%2017C7%2014.7909%208.79086%2013%2011%2013H13C15.2091%2013%2017%2014.7909%2017%2017V18C17%2018.5523%2016.5523%2019%2016%2019H8C7.44772%2019%207%2018.5523%207%2018V17Z'%20fill='%23D1D5DB'/%3e%3c/svg%3e",Q5="data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2016%2016'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M6.10417%2012.4375L2.0625%208.41667L3.125%207.33333L6.10417%2010.3125L12.875%203.5625L13.9375%204.625L6.10417%2012.4375Z'%20fill='%2300BCD4'/%3e%3c/svg%3e";function K5(){const[a,i]=b.useState(!1),[r,s]=b.useState();return{isOpen:a,openModal:d=>{s(d),i(!0)},onClose:()=>i(!1),initialData:r}}function As({label:a,onRemove:i}){return m.jsxs("div",{className:"w-fit h-8 px-2 py-1 rounded-lg bg-gray-100 flex items-center gap-1",children:[m.jsx("p",{className:"text-16-m text-gray-500",children:a}),i&&m.jsx("button",{type:"button",onClick:i,className:"w-4 h-4 flex items-center justify-center text-gray-400 hover:text-gray-600 cursor-pointer text-sm","aria-label":"태그 삭제",children:"×"})]})}function $5({isOpen:a,onClose:i,onSave:r,initialData:s}){const[o,f]=b.useState(""),[d,p]=b.useState([]),g=d.length>0;b.useEffect(()=>{a&&s?(p(s.keywords),f("")):a||(f(""),p([]))},[a,s]);const y=()=>{o.trim()!==""&&!d.includes(o.trim())?(p(N=>[...N,o.trim()]),f("")):d.includes(o.trim())&&ce.error("동일한 키워드는 등록할 수 없습니다.")},v=N=>{p(R=>R.filter(T=>T!==N))},w=N=>{N.preventDefault(),g&&(r(d),i())};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("form",{onSubmit:w,className:"w-[438px] h-auto gap-10",children:[m.jsx("h2",{className:"text-24-sb mb-10",children:"관심사 수정"}),m.jsxs("div",{className:"mb-1.5 flex gap-2",children:[m.jsx(Tt,{label:"키워드",placeholder:"키워드를 추가해주세요",value:o,onChange:N=>f(N.target.value),className:"flex-1"}),m.jsx(Fe,{className:"px-4 mt-8 whitespace-nowrap",onClick:y,type:"button",children:"키워드 추가"})]}),d.length>0&&m.jsx("div",{className:"max-h-32 overflow-y-auto p-3 mb-10",children:m.jsx("div",{className:"flex flex-wrap gap-2",children:d?.map((N,R)=>m.jsx(As,{label:N,onRemove:()=>v(N)},R))})}),m.jsx(Fe,{className:"w-full",disabled:!g,type:"submit",children:"수정하기"})]})})}function J5({interestId:a,name:i,keywords:r,subscriberCount:s,isSubscribed:o=!1,onSubscribeClick:f,onSaveKeyword:d,onDeleteInterest:p}){const[g,y]=b.useState(!1),v=b.useRef(null),{isOpen:w,openModal:N,onClose:R,initialData:T}=K5(),{isOpen:M,openModal:C,onClose:q,initialData:A}=a1();Xs(v,()=>y(!1),g);const Y=()=>{f(a,o)},Q=ee=>{ee==="키워드 수정"?N({keywords:r}):ee==="관심사 삭제"&&C({title:"관심사 삭제",message:`'${i}' 관심사를 정말 삭제하시겠습니까? + `,children:Ta(F,"d")},F.toString())),F=th(F,1)}ee.push(m.jsx("div",{className:"flex gap-1 justify-between",children:J},F.toString())),J=[]}return ee},q={sm:"min-h-10 py-1.5 px-3",md:"min-h-14 py-4 px-5"};return m.jsxs("div",{className:s,ref:v,children:[m.jsxs("button",{type:"button",id:w,onClick:()=>p(!d),className:`w-full border rounded-lg mt-1.5 bg-white ${q[o]} border-gray-200 focus:border-cyan-500 outline-none text-left flex items-center gap-2`,children:[m.jsx("span",{className:`text-16-m ${a?"text-gray-900":"text-gray-400"}`,children:a||i}),a&&f&&m.jsx("span",{className:"text-14-m text-gray-500",children:f})]}),d&&m.jsxs("div",{className:"absolute z-50 mt-2 bg-white border border-gray-200 rounded-lg shadow-lg p-4 w-[280px]",children:[m.jsxs("div",{className:"flex items-center justify-between mb-4",children:[m.jsx("button",{type:"button",onClick:T,className:"p-1 hover:bg-gray-100 rounded",children:m.jsx("svg",{className:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:m.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M15 19l-7-7 7-7"})})}),m.jsx("div",{className:"text-16-b text-gray-900",children:Ta(g,"yyyy년 M월")}),m.jsx("button",{type:"button",onClick:M,className:"p-1 hover:bg-gray-100 rounded",children:m.jsx("svg",{className:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:m.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M9 5l7 7-7 7"})})})]}),m.jsx("div",{className:"mb-2",children:m.jsx("div",{className:"flex gap-1 justify-between mb-2",children:["일","월","화","수","목","금","토"].map(A=>m.jsx("div",{className:"h-9 w-9 flex items-center justify-center text-14-m text-gray-600 font-semibold",children:A},A))})}),m.jsx("div",{className:"flex flex-col gap-1",children:C()})]})]})}function Hl({isOpen:a,onClose:i,children:r,width:s="w-[502px]",noPadding:o=!1,disableClose:f=!1}){const d=b.useRef(null);return Xs(d,i,a,f),b.useEffect(()=>(a?document.body.style.overflow="hidden":document.body.style.overflow="unset",()=>{document.body.style.overflow="unset"}),[a]),a?rv.createPortal(m.jsx("div",{className:"z-50 fixed inset-0 bg-black/50 flex items-center justify-center",role:"dialog","aria-modal":"true",children:m.jsxs("div",{ref:d,className:`${s} max-h-[90vh] overflow-y-auto h-auto rounded-2xl ${o?"":"p-8"} gap-2.5 bg-white relative`,onClick:p=>p.stopPropagation(),children:[m.jsx("button",{className:"absolute top-9 right-7 hover:text-gray-700",onClick:i,"aria-label":"close",children:m.jsx("img",{src:kh,alt:"닫기",className:"w-6 h-6"})}),r]})}),document.body):null}const Ih="data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2016%2016'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M8%209.72973L11.8333%206L13%207.13514L8%2012L3%207.13513L4.16667%206L8%209.72973Z'%20fill='%231F2937'/%3e%3c/svg%3e";function j5({label:a,onClick:i}){return m.jsx("li",{className:"h-11 p-3 gap-0.5 bg-white font-pretendard font-medium text-sm leading-5 hover:bg-gray-100 truncate",onClick:i,children:m.jsx("span",{children:a})})}function ef({items:a,onChange:i,className:r}){const s=o=>{i(o)};return m.jsx("div",{className:`absolute box-border bg-white border border-gray-200 rounded-lg overflow-hidden ${r}`,children:m.jsx("ul",{className:"py-1 max-h-60 overflow-y-auto cursor-pointer",children:a.map((o,f)=>m.jsx(j5,{label:o,onClick:()=>s(o)},f))})})}function Ol({placeholder:a="선택하세요",items:i,value:r,onChange:s,className:o="w-full h-10",noBorder:f=!1,textClassName:d="text-14-m",noBackground:p=!1}){const[g,y]=b.useState(!1),v=b.useRef(null);b.useEffect(()=>{const R=T=>{v.current&&!v.current.contains(T.target)&&y(!1)};return document.addEventListener("mousedown",R),()=>{document.removeEventListener("mousedown",R)}},[]);const w=()=>{y(!g)},N=R=>{s(R),y(!1)};return m.jsxs("div",{ref:v,className:`relative ${o}`,children:[m.jsx("button",{type:"button",className:`${f?"":"border rounded-lg border-gray-200"} ${p?"":"bg-white"} py-2.5 px-3 cursor-pointer w-full h-full focus:outline-none`,onClick:w,children:m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsx("p",{className:`${d} ${r?f?"text-cyan-600":"text-gray-900":"text-gray-400"}`,children:r||a}),m.jsx("img",{src:Ih,className:`transform transition-transform duration-200 ${g?"rotate-180":""} ${f?"ml-3":""}`,alt:"chevron"})]})}),g&&m.jsx("div",{className:"absolute top-full left-0 right-0 z-10",children:m.jsx(ef,{items:i,onChange:N,className:"w-full"})})]})}const e1="data:image/svg+xml,%3csvg%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20fill-rule='evenodd'%20clip-rule='evenodd'%20d='M4.45084%206.5193C2.64063%208.31949%202.49043%2011.1977%204.19174%2013.0999C4.90111%2013.8931%205.64486%2014.6967%206.30532%2015.3535C7.87526%2016.9147%2010.2793%2018.944%2011.3937%2019.8697C11.5692%2020.0154%2011.7867%2020.0802%2011.9996%2020.0666C12.2126%2020.0803%2012.4303%2020.0156%2012.6059%2019.8697C13.7204%2018.9442%2016.1243%2016.9152%2017.6942%2015.3541C18.3548%2014.6971%2019.0988%2013.8932%2019.8083%2013.0998C21.5096%2011.1975%2021.3593%208.31939%2019.5492%206.51927C17.6038%204.5847%2014.4498%204.58473%2012.5044%206.51933L12%207.02098L11.4954%206.51924C9.55013%204.58473%206.39617%204.58476%204.45084%206.5193Z'%20fill='%23D1D5DB'/%3e%3c/svg%3e",R5="data:image/svg+xml,%3csvg%20width='20'%20height='20'%20viewBox='0%200%2020%2020'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3ccircle%20cx='16'%20cy='10'%20r='1.5'%20transform='rotate(90%2016%2010)'%20fill='%23989FAC'/%3e%3ccircle%20cx='10.5'%20cy='10'%20r='1.5'%20transform='rotate(90%2010.5%2010)'%20fill='%23989FAC'/%3e%3ccircle%20cx='5'%20cy='10'%20r='1.5'%20transform='rotate(90%205%2010)'%20fill='%23989FAC'/%3e%3c/svg%3e";function A5({userNickname:a,createdAt:i,likeCount:r,content:s,isLiked:o,onLikeClick:f,onEditSave:d,onDelete:p,commentId:g,isMyComment:y,className:v}){const[w,N]=b.useState(s),[R,T]=b.useState(!1),[M,C]=b.useState(!1),q=b.useRef(null);Xs(q,()=>C(!1),M);const A=()=>{f(g)},Y=()=>{C(!M)},Q=()=>{T(!1),N(s)},$=()=>{w.trim()&&(d(g,w.trim()),T(!1))},ee=J=>{J==="수정하기"?(T(!0),N(s)):J==="삭제하기"&&p(g)};return m.jsxs("div",{className:`w-full h-auto border-gray-300 py-4 px-4 bg-gray-100 rounded-lg ${v||""}`,children:[m.jsxs("div",{className:"flex justify-between pr-1 gap-2 mb-2.5",children:[m.jsxs("div",{className:"gap-1 flex items-center",children:[m.jsx("span",{className:"text-14-m text-gray-500",children:a}),m.jsx("span",{className:"text-14-m text-gray-500 ",children:"·"}),m.jsx("span",{className:"text-14-m text-gray-500",children:j3(i,{addSuffix:!0,locale:dh})}),y&&m.jsx("span",{className:"ml-1 text-14-m text-cyan-500",children:"내 댓글"})]}),m.jsxs("div",{className:"flex items-center gap-2",children:[y&&!R&&m.jsxs("button",{ref:q,onClick:()=>C(!M),children:[m.jsx("img",{src:R5,className:"w-5 h-5",alt:"케밥",onClick:Y}),M&&m.jsx(ef,{items:["수정하기","삭제하기"],onChange:ee})]}),m.jsxs("button",{onClick:A,className:"flex justify-center items-center gap-2",children:[o?m.jsx("img",{src:Qc,className:"w-6 h-6",alt:"활성화 하트"}):m.jsx("img",{src:e1,className:"w-6 h-6",alt:"비활성화 하트"}),m.jsx("p",{className:"text-14-r text-gray-500",children:r})]})]})]}),y&&R?m.jsxs("div",{className:"flex items-center gap-2.5 w-full",children:[m.jsx(Tt,{inputSize:"sm",value:w,onChange:J=>N(J.target.value),className:"flex-1"}),m.jsx(Fe,{variant:"tertiary",size:"sm",className:"w-16 mt-1",onClick:Q,children:"취소"}),m.jsx(Fe,{size:"sm",className:"w-16 mt-1",onClick:$,children:"수정"})]}):m.jsx("div",{children:m.jsx("p",{className:"text-16-r text-gray-700",children:s})})]})}async function Rm(a,i){const{data:r}=await Ye.get("/comments",{params:a,headers:{"Monew-Request-User-ID":i}});return r}async function U5(a){const{data:i}=await Ye.post("/comments",a);return i}async function z5(a,i){const{data:r}=await Ye.post(`/comments/${a}/comment-likes`,void 0,{headers:{"Monew-Request-User-ID":i}});return r}async function L5(a,i,r){const{data:s}=await Ye.patch(`/comments/${a}`,i,{headers:{"Monew-Request-User-ID":r}});return s}async function H5(a,i){await Ye.delete(`/comments/${a}/comment-likes`,{headers:{"Monew-Request-User-ID":i}})}async function B5(a){await Ye.delete(`/comments/${a}`)}function t1({src:a,label:i}){return m.jsxs("div",{className:"flex gap-1.5 items-center w-fit",children:[a&&m.jsx("div",{className:"p-0.5 bg-gray-200 rounded-full",children:m.jsx("img",{src:a,className:"w-5 h-5 rounded-full"})}),m.jsx("p",{className:"font-pretendard font-semibold text-sm leading-5 text-gray-500",children:i})]})}const n1="data:image/svg+xml,%3csvg%20width='20'%20height='20'%20viewBox='0%200%2020%2020'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M6%2011.25H15V9.75H6V11.25ZM5%208.5H15V7H5V8.5ZM18%2018L15%2015H3.5C3.0875%2015%202.73438%2014.8531%202.44063%2014.5594C2.14688%2014.2656%202%2013.9125%202%2013.5V4.5C2%204.0875%202.14688%203.73438%202.44063%203.44063C2.73438%203.14688%203.0875%203%203.5%203H16.5C16.9125%203%2017.2656%203.14688%2017.5594%203.44063C17.8531%203.73438%2018%204.0875%2018%204.5V18ZM3.5%2013.5H15.625L16.5%2014.375V4.5H3.5V13.5Z'%20fill='%236B7280'/%3e%3c/svg%3e";function a1(){const[a,i]=b.useState(!1),[r,s]=b.useState(null);return{isOpen:a,openModal:d=>{s(d),i(!0)},onClose:()=>{i(!1),s(null)},initialData:r}}function l1({isOpen:a,onClose:i,onConfirm:r,title:s,message:o,cancelText:f="취소",confirmText:d="확인"}){const p=async()=>{await r(),i()};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("div",{className:"w-full p-6 text-center",children:[m.jsx("h2",{className:"text-20-b text-gray-900 mb-4",children:s}),m.jsx("p",{className:"text-16-r text-gray-600 mb-8 leading-relaxed",children:o}),m.jsxs("div",{className:"flex gap-4 pt-4",children:[m.jsx(Fe,{variant:"secondary",onClick:i,className:"min-w-[100px] flex-1 px-6",children:f}),m.jsx(Fe,{variant:"primary",onClick:p,className:"min-w-[100px] flex-1 px-6",children:d})]})]})})}function i1({articleId:a,onClose:i}){const[r,s]=b.useState(null),o=["등록순","좋아요순"],f=5,[d,p]=b.useState("createdAt"),g=d==="createdAt"?"등록순":"좋아요순",y="DESC",[v,w]=b.useState([]),[N,R]=b.useState(""),[T,M]=b.useState(!1),[C,q]=b.useState(!1),[A,Y]=b.useState(null),Q=b.useRef(null),$=b.useRef(null),{isOpen:ee,openModal:J,onClose:F,initialData:ae}=a1(),{userId:re}=ta();b.useEffect(()=>{a&&D5(a,re).then(D=>{s(D),D.viewedByMe||N5(a,re)})},[a,re]);const fe=b.useCallback(async()=>{if(M(!0),!!r)try{const D={articleId:r.id,orderBy:d,direction:y,limit:f},k=await Rm(D,re);w(k.content),q(k.hasNext),Y(k.nextCursor)}catch(D){console.error(D)}finally{M(!1)}},[r,d,re]),oe=b.useCallback(async()=>{if(!(!r||!C||!re||T)){M(!0);try{const D={articleId:r.id,orderBy:d,direction:y,limit:f,cursor:A||void 0},k=await Rm(D,re);w(Z=>[...Z,...k.content]),q(k.hasNext),Y(k.nextCursor)}catch(D){console.error(D)}finally{M(!1)}}},[d,y,f,A,r,C,T,re]);b.useEffect(()=>{r&&fe()},[fe,r]),b.useEffect(()=>{if(!T)return Q.current&&Q.current.disconnect(),Q.current=new IntersectionObserver(D=>{D[0].isIntersecting&&C&&oe()},{threshold:.8}),$.current&&Q.current.observe($.current),()=>{Q.current&&Q.current.disconnect()}},[C,T,oe]);const _e=()=>{i()},Ue=()=>{r&&window.open(r.sourceUrl,"_blank","noopener,noreferrer")},Se=D=>{D==="등록순"?p("createdAt"):D==="좋아요순"&&p("likeCount")},B=async D=>{try{const k=v.find(Z=>Z.id===D);if(!k)return;k.likedByMe?await H5(D,re):await z5(D,re),await fe()}catch(k){console.error(k)}},K=async(D,k)=>{try{await L5(D,{content:k},re),fe(),ce.success("댓글이 수정되었습니다.")}catch(Z){console.error(Z),ce.error("댓글 수정 중 오류가 발생했습니다.")}},W=async D=>{if(!(!r||!D.trim()))try{const k={articleId:r.id,userId:re,content:D.trim()};await U5(k),R(""),await fe(),ce.success("댓글 작성 완료")}catch(k){console.error(k)}},pe=async D=>{J({title:"댓글 삭제",message:"정말 삭제하시겠습니까?",onConfirm:async()=>{try{await B5(D),ce.success("댓글이 삭제되었습니다."),await fe()}catch(k){console.error(k),ce.error("댓글 삭제 중 오류가 발생했습니다.")}},confirmText:"삭제",cancelText:"취소"})};if(!r)return null;const E=Ta(r.publishDate,"yyyy.MM.dd");return m.jsxs(m.Fragment,{children:[m.jsxs(Hl,{isOpen:r!==null,onClose:_e,width:"w-[894px]",noPadding:!0,disableClose:ee,children:[m.jsxs("div",{className:"h-auto rounded-tr-3xl rounded-tl-3xl pt-10 px-10 pb-6 bg-white",children:[m.jsx("div",{className:"text-20-b text-gray-900 mb-2",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:r.title}})}),m.jsxs("div",{className:"flex items-center gap-4 pb-6 mb-6 border-b border-gray-200",children:[m.jsx(t1,{label:r.source}),m.jsxs("div",{className:"flex items-center gap-3",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:E}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:"읽음"}),m.jsx("span",{className:"text-14-r text-gray-400",children:r.viewCount})]}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("img",{src:n1,className:"w-5 h-5",alt:"댓글"}),m.jsx("span",{className:"text-14-r text-gray-400",children:r.commentCount})]})]})]}),m.jsx("div",{className:"text-18-r text-gray-500 mb-6",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:r.summary}})}),m.jsx("div",{className:"mt-4 mb-10 border-b-gray-200",children:m.jsx(Fe,{size:"sm",className:"w-[162px]",variant:"secondary",onClick:Ue,children:"전체 기사 보러가기 →"})})]}),m.jsxs("div",{className:"rounded-br-3xl rounded-bl-3xl pt-3 px-10 pb-8 bg-gray-100",children:[m.jsx("div",{className:"mb-2 w-[110px]",children:m.jsx(Ol,{items:o,value:g,onChange:Se,placeholder:"등록순",noBorder:!0,textClassName:"text-14-m text-gray-400",noBackground:!0})}),m.jsxs("div",{className:"flex items-center gap-2.5 mb-2",children:[m.jsx(Tt,{placeholder:"2025.01.01 부터",className:"flex-1",value:N,onChange:D=>R(D.target.value)}),m.jsx(Fe,{className:"w-[92px]",onClick:()=>W(N),children:"댓글 작성"})]}),m.jsx("div",{children:v.map((D,k)=>m.jsx("div",{ref:k===v.length-1?$:null,children:m.jsx(A5,{userNickname:D.userNickname,createdAt:new Date(D.createdAt),likeCount:D.likeCount,content:D.content,isLiked:D.likedByMe,onLikeClick:B,onEditSave:K,commentId:D.id,isMyComment:D.userId===re,onDelete:pe})},D.id))})]})]}),ae&&m.jsx(l1,{isOpen:ee,onClose:F,onConfirm:ae.onConfirm,title:ae.title,message:ae.message,confirmText:ae.confirmText,cancelText:ae.cancelText})]})}function k5({isOpen:a,onClose:i,onSave:r}){const[s,o]=b.useState(""),[f,d]=b.useState(""),p=s.trim()!==""&&f.trim()!=="";b.useEffect(()=>{a||(o(""),d(""))},[a]);const g=y=>{y.preventDefault(),p&&(r({from:s,to:f}),i())};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("form",{onSubmit:g,className:"w-[438px] h-auto gap-10",children:[m.jsx("h2",{className:"text-24-sb mb-10",children:"기사 복구하기"}),m.jsx(Tt,{label:"날짜",value:s,placeholder:"2025.01.01 부터",onChange:y=>o(y.target.value),className:"mb-2"}),m.jsx(Tt,{value:f,placeholder:"2025.01.01 까지",onChange:y=>d(y.target.value),className:"mb-12"}),m.jsx(Fe,{className:"w-full",disabled:!p,type:"submit",children:"복구하기"})]})})}const q5="data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2016%2016'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M7.14697%201.14844C10.4595%201.14863%2013.1458%203.83391%2013.146%207.14648C13.146%208.51468%2012.6858%209.77484%2011.9146%2010.7842L14.9907%2013.8594L14.4243%2014.4248L13.8589%2014.9912L10.7837%2011.915C9.77453%2012.686%208.51479%2013.1454%207.14697%2013.1455C3.83421%2013.1454%201.14893%2010.4593%201.14893%207.14648C1.14912%203.83386%203.83433%201.14856%207.14697%201.14844ZM7.14697%202.74805C4.71799%202.74817%202.74873%204.71752%202.74854%207.14648C2.74854%209.57561%204.71787%2011.5448%207.14697%2011.5449C9.57601%2011.5447%2011.5454%209.57557%2011.5454%207.14648C11.5452%204.71756%209.57589%202.74824%207.14697%202.74805Z'%20fill='%231F2937'/%3e%3c/svg%3e";function r1({width:a="w-3xs",height:i="h-[40px]",containerClassName:r="",className:s="",onSearch:o,onKeyDown:f,...d}){const p=b.useRef(null),g=()=>{const v=p.current?.value||"";o&&v.trim()&&(o(v.trim()),p.current&&(p.current.value=""))},y=v=>{v.key==="Enter"&&o&&!v.nativeEvent.isComposing&&(v.preventDefault(),g()),f?.(v)};return m.jsxs("div",{className:`${a} ${i} px-4 py-2.5 border bg-white border-gray-300 rounded-[100px] flex items-center justify-between gap-2.5 ${r}`,children:[m.jsx("input",{ref:p,type:"text",placeholder:"검색어를 입력해주세요",className:`flex-1 outline-none font-pretendard placeholder:text-sm placeholder:text-gray-400 placeholder:font-normal placeholder:leading-5 ${s}`,onKeyDown:y,...d}),m.jsx("button",{type:"button",onClick:g,"aria-label":"검색",children:m.jsx("img",{src:q5,alt:"검색",className:"w-4 h-4"})})]})}function Y5({items:a,values:i,onChange:r,className:s="w-full h-10",placeholder:o="선택하세요"}){const[f,d]=b.useState(!1),p=b.useRef(null);b.useEffect(()=>{const w=N=>{p.current&&!p.current.contains(N.target)&&d(!1)};return document.addEventListener("mousedown",w),()=>{document.removeEventListener("mousedown",w)}},[]);const g=()=>{d(!f)},y=w=>{const N=i.includes(w)?i.filter(R=>R!==w):[...i,w];r(N)},v=i.length>0?`${i.length}개 선택됨`:o;return m.jsxs("div",{ref:p,className:`relative ${s}`,children:[m.jsx("button",{type:"button",className:"border rounded-lg border-gray-200 bg-white py-2.5 px-3 cursor-pointer w-full h-full focus:outline-none",onClick:g,children:m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsx("p",{className:`text-14-m ${i.length===0?"text-gray-400":"text-gray-900"}`,children:v}),m.jsx("img",{src:Ih,className:`transform transition-transform duration-200 ${f?"rotate-180":""}`,alt:"chevron"})]})}),f&&m.jsx("div",{className:"absolute top-full left-0 right-0 z-10",children:m.jsx("div",{className:"absolute box-border bg-white border border-gray-200 rounded-lg overflow-hidden w-full",children:m.jsx("ul",{className:"py-1 max-h-60 overflow-y-auto",children:a.map(w=>m.jsx("li",{className:"h-11 p-3 gap-0.5 bg-white font-pretendard font-medium text-sm leading-5 hover:bg-gray-100 cursor-pointer",children:m.jsxs("label",{className:"flex items-center gap-2 cursor-pointer",children:[m.jsx("input",{type:"checkbox",checked:i.includes(w),onChange:()=>y(w),className:"w-4 h-4 rounded border-gray-300 text-cyan-600 focus:ring-cyan-500 cursor-pointer"}),m.jsx("span",{children:w})]})},w))})})})]})}function s1({article:a,onClick:i}){const r=Ta(a.publishDate,"yyyy.MM.dd");return m.jsx(m.Fragment,{children:m.jsx("div",{className:"max-w-4xl w-auto min-h-48 h-auto cursor-pointer",onClick:i,children:m.jsxs("div",{className:"my-6 mx-1",children:[m.jsx("div",{className:"text-20-b text-gray-900 mb-2",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:a.title}})}),m.jsx("div",{className:"text-18-r text-gray-500 mb-6",children:m.jsx("span",{dangerouslySetInnerHTML:{__html:a.summary}})}),m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsx(t1,{label:a.source}),m.jsxs("div",{className:"flex items-center gap-3",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:r}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("span",{className:"text-14-r text-gray-400",children:"읽음"}),m.jsx("span",{className:"text-14-r text-gray-400",children:a.viewCount})]}),m.jsx("span",{className:"text-gray-300",children:"|"}),m.jsxs("div",{className:"flex items-center gap-1",children:[m.jsx("img",{src:n1,className:"w-5 h-5",alt:"댓글"}),m.jsx("span",{className:"text-14-r text-gray-400",children:a.commentCount})]})]})]})]})})})}function u1(){const[a,i]=b.useState(!1),[r,s]=b.useState(null);return{isOpen:a,openModal:d=>{s(d),i(!0)},onClose:()=>{i(!1),s(null)},initialData:r}}function V5(){const[a,i]=b.useState(!1);return{isOpen:a,openModal:()=>i(!0),onClose:()=>i(!1)}}async function o1(a){const{data:i}=await Ye.get(`/user-activities/${a}`);return i}function Am(){const[a,i]=Ls(),{articleId:r}=fg(),o=Wt().state?.article,{openModal:f,onClose:d,initialData:p}=u1(),g=["게시일","조회수","댓글수"],y=["내림차순","오름차순"],v=a.get("orderBy")||"publishDate",w=v==="publishDate"||v==="commentCount"||v==="viewCount"?v:"publishDate",N=a.get("direction")||"DESC",R=N==="ASC"||N==="DESC"?N:"DESC",T=a.get("limit")||"10",M=a.get("keyword")||"",C=a.get("interestId")||"",q=a.get("sourceIn")||"",[A,Y]=b.useState(),Q=new Date,$=z3(Q),[ee,J]=b.useState(Ta($,"yyyy.MM.dd")),[F,ae]=b.useState(Ta(Q,"yyyy.MM.dd")),re=a.get("publishDateFrom")||"",fe=a.get("publishDateTo")||"",[oe,_e]=b.useState(!1),[Ue,Se]=b.useState(!1),[B,K]=b.useState(null),W=b.useRef(null),pe=b.useRef(null),[E,D]=b.useState([]),[k,Z]=b.useState([]),[P,xe]=b.useState([]),[se,at]=b.useState([]),ze=Oa(),{userId:dt}=ta(),{isOpen:Bl,openModal:Ra,onClose:Aa}=V5(),kl={게시일:"publishDate",조회수:"viewCount",댓글수:"commentCount"},Ua={publishDate:"게시일",viewCount:"조회수",commentCount:"댓글수"},[ql,lr]=b.useState(Ua[w]||"게시일"),[gt,ir]=b.useState(R==="DESC"?"내림차순":"오름차순"),Dn=b.useRef(!0),za=b.useCallback(async()=>{_e(!0);try{const I=q?q.split(","):[],he={keyword:M,interestId:C,publishDateFrom:re,publishDateTo:fe,orderBy:w,direction:R,limit:parseInt(T),sourceIn:I.length>0?I:void 0},ge=await Mm(he,dt);Se(ge.hasNext),K(ge.nextCursor),D(ge.content)}catch(I){console.error(I)}finally{_e(!1)}},[M,C,w,R,re,fe,T,dt,q]),na=b.useCallback(async()=>{if(!(!Ue||!dt||oe)){_e(!0);try{const I=q?q.split(","):[],he={keyword:M,interestId:C,publishDateFrom:re,publishDateTo:fe,orderBy:w,direction:R,limit:parseInt(T),cursor:B||void 0,sourceIn:I.length>0?I:void 0},ge=await Mm(he,dt);D(lt=>[...lt,...ge.content]),Se(ge.hasNext),K(ge.nextCursor)}catch(I){console.error(I)}finally{_e(!1)}}},[w,R,re,fe,T,M,C,dt,Ue,oe,B,q]),rr=b.useCallback(async()=>{try{const he=(await o1(dt)).subscriptions.map(ge=>({id:ge.interestId,name:ge.interestName,keywords:ge.interestKeywords,subscriberCount:ge.interestSubscriberCount,subscribedByMe:!0}));Z(he)}catch(I){console.error(I)}},[]),Yl=b.useCallback(async()=>{try{const I=await O5();if(xe(I),q){const he=q.split(",");at(he)}else I.length>0&&at(I.slice(0,1))}catch(I){console.error(I)}},[q]);b.useEffect(()=>{Yl()},[Yl]),b.useEffect(()=>{if(!oe)return W.current&&W.current.disconnect(),W.current=new IntersectionObserver(I=>{I[0].isIntersecting&&Ue&&na()},{threshold:.8}),pe.current&&W.current.observe(pe.current),()=>{W.current&&W.current.disconnect()}},[Ue,oe,na]);const $s=b.useMemo(()=>k.map(I=>I.name),[k]),Js=I=>{const he=k.find(ge=>ge.name===I);he&&(Y(he),K(null),Se(!1))};b.useEffect(()=>{A&&i(I=>{const he=new URLSearchParams(I);return he.set("interestId",A.id),he})},[A]);const aa=I=>{lr(I)},mt=I=>{ir(I)},It=I=>{at(I)},ht=I=>{(I??"")!==M&&(i(he=>{const ge=new URLSearchParams(he);return I?ge.set("keyword",I):ge.delete("keyword"),ge.delete("interestId"),ge}),Y(void 0),K(null),Se(!1))};b.useEffect(()=>{if(Dn.current)return;const I=kl[ql];w!==I&&(i(he=>{const ge=new URLSearchParams(he);return ge.set("orderBy",I),ge}),K(null),Se(!1))},[ql]),b.useEffect(()=>{if(Dn.current)return;const I=gt==="오름차순"?"ASC":"DESC";R!==I&&(i(he=>{const ge=new URLSearchParams(he);return ge.set("direction",I),ge}),K(null),Se(!1))},[gt]),b.useEffect(()=>{if(!Dn.current&&se.length>0){const I=se.join(",");q!==I&&(i(he=>{const ge=new URLSearchParams(he);return ge.set("sourceIn",I),ge}),K(null),Se(!1))}},[se]),b.useEffect(()=>{if(Dn.current){Dn.current=!1;return}const I=ee?`${ee.replace(/\./g,"-")}T00:00:00`:"",he=F?`${F.replace(/\./g,"-")}T23:59:59`:"";(re!==I||fe!==he)&&(i(ge=>{const lt=new URLSearchParams(ge);return ee?lt.set("publishDateFrom",I):lt.delete("publishDateFrom"),F?lt.set("publishDateTo",he):lt.delete("publishDateTo"),lt}),K(null),Se(!1))},[ee,F]),b.useEffect(()=>{rr()},[rr]),b.useEffect(()=>{if(k.length>0&&C){const I=k.find(he=>he.id===C);I&&Y(I)}else C||Y(void 0)},[k,C]),b.useEffect(()=>{lr(Ua[w]||"게시일"),ir(R==="DESC"?"내림차순":"오름차순"),za()},[za,w,R]),b.useEffect(()=>{if(r){if(o&&o.id===r)Vl(o);else if(E.length>0){const I=E.find(he=>he.id===r);I&&Vl(I)}}},[r,E,o]);const Fs=I=>{try{const he={from:`${I.from.replace(/\./g,"-")}T00:00:00`,to:`${I.to.replace(/\./g,"-")}T23:59:59`};M5(he),za(),ce.success("기사가 복구되었습니다."),Aa()}catch(he){console.error(he);const ge=he,lt=ge.response?.data?.message||ge.message||"오류가 발생했습니다.";ce.error(lt)}},Vl=b.useCallback(I=>{f(I)},[f]);return m.jsxs("div",{className:"flex gap-12 justify-center",children:[m.jsxs("div",{className:"max-w-3xs min-h-[564px] h-auto",children:[m.jsx("div",{className:"mb-6",children:m.jsx(r1,{height:"h-11",onSearch:ht})}),m.jsxs("div",{className:"h-auto mb-6 border border-gray-200 rounded-2xl px-4 pt-4 pb-6 bg-white",children:[m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"정렬"}),m.jsx("div",{className:"min-h-10",children:m.jsx(Ol,{items:g,value:ql,onChange:aa,className:"mb-6 h-10"})}),m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"정렬 방향"}),m.jsx(Ol,{items:y,value:gt,onChange:mt,className:"mb-6 h-10"}),m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"출처"}),m.jsx(Y5,{items:P,values:se,onChange:It,className:"mb-6"}),m.jsx("div",{className:"text-14-m text-gray-900 mb-2",children:"날짜"}),m.jsx(jm,{value:ee,placeholder:"시작 날짜",onChange:J,className:"mb-2",inputSize:"sm",label:"부터"}),m.jsx(jm,{value:F,placeholder:"종료 날짜",onChange:ae,className:"mb-4",inputSize:"sm",label:"까지"})]}),m.jsx(Fe,{className:"w-full",variant:"secondary",size:"sm",onClick:Ra,children:"기사 복구하기"}),m.jsx(k5,{isOpen:Bl,onClose:Aa,onSave:Fs})]}),m.jsxs("div",{className:"min-w-48",children:[M?m.jsxs("div",{className:"flex gap-4 items-center mb-8",children:[m.jsx("div",{className:"text-24-b text-cyan-600",children:M}),m.jsx("div",{className:"text-24-b text-gray-900",children:"관련 기사 목록"})]}):k.length>0?m.jsxs("div",{className:"flex gap-4 items-baseline mb-8",children:[m.jsx("div",{className:"min-w-[157px]",children:m.jsx(Ol,{items:$s,value:A?.name,onChange:Js,placeholder:"관심사 선택",noBorder:!0,textClassName:"text-24-b",noBackground:!0})}),m.jsx("div",{className:"text-24-b text-gray-900",children:"관련 기사 목록"})]}):m.jsx("div",{className:"text-24-b text-gray-900",children:"관련 기사 목록"}),E.length===0?m.jsx("div",{className:"min-w-[894px] w-full flex flex-col justify-center min-h-72 items-center mt-30",children:C?m.jsx(In,{message:"관련된 기사가 없습니다."}):m.jsxs("div",{className:"flex flex-col items-center justify-center gap-6",children:[m.jsx(In,{message:"관심사를 등록하면 맞춤 기사를 확인하실 수 있어요."}),m.jsx(Fe,{onClick:()=>ze("/interests"),className:"w-[160px]",size:"sm",children:"관심사 등록하기"})]})}):m.jsx("div",{children:E.map((I,he)=>m.jsx("div",{className:"min-w-2xs",ref:he===E.length-1?pe:null,children:m.jsx(s1,{article:I,onClick:()=>Vl(I)})},I.id))})]}),p&&m.jsx(i1,{onClose:d,articleId:p.id})]})}const X5="data:image/svg+xml,%3csvg%20width='20'%20height='20'%20viewBox='0%200%2020%2020'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M3%2010H17'%20stroke='white'%20stroke-width='2'%20stroke-linecap='round'/%3e%3cpath%20d='M10%2017V3'%20stroke='white'%20stroke-width='2'%20stroke-linecap='round'/%3e%3c/svg%3e",G5="data:image/svg+xml,%3csvg%20width='32'%20height='32'%20viewBox='0%200%2032%2032'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3ccircle%20cx='24'%20cy='16'%20r='2'%20transform='rotate(90%2024%2016)'%20fill='%23989FAC'/%3e%3ccircle%20cx='16'%20cy='16'%20r='2'%20transform='rotate(90%2016%2016)'%20fill='%23989FAC'/%3e%3ccircle%20cx='8'%20cy='16'%20r='2'%20transform='rotate(90%208%2016)'%20fill='%23989FAC'/%3e%3c/svg%3e",Z5="data:image/svg+xml,%3csvg%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3ccircle%20cx='12'%20cy='9'%20r='3'%20fill='%23D1D5DB'/%3e%3cpath%20d='M7%2017C7%2014.7909%208.79086%2013%2011%2013H13C15.2091%2013%2017%2014.7909%2017%2017V18C17%2018.5523%2016.5523%2019%2016%2019H8C7.44772%2019%207%2018.5523%207%2018V17Z'%20fill='%23D1D5DB'/%3e%3c/svg%3e",Q5="data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2016%2016'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M6.10417%2012.4375L2.0625%208.41667L3.125%207.33333L6.10417%2010.3125L12.875%203.5625L13.9375%204.625L6.10417%2012.4375Z'%20fill='%2300BCD4'/%3e%3c/svg%3e";function K5(){const[a,i]=b.useState(!1),[r,s]=b.useState();return{isOpen:a,openModal:d=>{s(d),i(!0)},onClose:()=>i(!1),initialData:r}}function As({label:a,onRemove:i}){return m.jsxs("div",{className:"w-fit h-8 px-2 py-1 rounded-lg bg-gray-100 flex items-center gap-1",children:[m.jsx("p",{className:"text-16-m text-gray-500",children:a}),i&&m.jsx("button",{type:"button",onClick:i,className:"w-4 h-4 flex items-center justify-center text-gray-400 hover:text-gray-600 cursor-pointer text-sm","aria-label":"태그 삭제",children:"×"})]})}function $5({isOpen:a,onClose:i,onSave:r,initialData:s}){const[o,f]=b.useState(""),[d,p]=b.useState([]),g=d.length>0;b.useEffect(()=>{a&&s?(p(s.keywords),f("")):a||(f(""),p([]))},[a,s]);const y=()=>{o.trim()!==""&&!d.includes(o.trim())?(p(N=>[...N,o.trim()]),f("")):d.includes(o.trim())&&ce.error("동일한 키워드는 등록할 수 없습니다.")},v=N=>{p(R=>R.filter(T=>T!==N))},w=N=>{N.preventDefault(),g&&(r(d),i())};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("form",{onSubmit:w,className:"w-[438px] h-auto gap-10",children:[m.jsx("h2",{className:"text-24-sb mb-10",children:"관심사 수정"}),m.jsxs("div",{className:"mb-1.5 flex gap-2",children:[m.jsx(Tt,{label:"키워드",placeholder:"키워드를 추가해주세요",value:o,onChange:N=>f(N.target.value),className:"flex-1"}),m.jsx(Fe,{className:"px-4 mt-8 whitespace-nowrap",onClick:y,type:"button",children:"키워드 추가"})]}),d.length>0&&m.jsx("div",{className:"max-h-32 overflow-y-auto p-3 mb-10",children:m.jsx("div",{className:"flex flex-wrap gap-2",children:d?.map((N,R)=>m.jsx(As,{label:N,onRemove:()=>v(N)},R))})}),m.jsx(Fe,{className:"w-full",disabled:!g,type:"submit",children:"수정하기"})]})})}function J5({interestId:a,name:i,keywords:r,subscriberCount:s,isSubscribed:o=!1,onSubscribeClick:f,onSaveKeyword:d,onDeleteInterest:p}){const[g,y]=b.useState(!1),v=b.useRef(null),{isOpen:w,openModal:N,onClose:R,initialData:T}=K5(),{isOpen:M,openModal:C,onClose:q,initialData:A}=a1();Xs(v,()=>y(!1),g);const Y=()=>{f(a,o)},Q=ee=>{ee==="키워드 수정"?N({keywords:r}):ee==="관심사 삭제"&&C({title:"관심사 삭제",message:`'${i}' 관심사를 정말 삭제하시겠습니까? 삭제된 관심사는 복구할 수 없습니다.`,confirmText:"삭제",cancelText:"취소",onConfirm:()=>p(a)}),y(!1)},$=ee=>{d(a,ee),R()};return m.jsxs("div",{className:"w-full h-[232px] border border-gray-200 rounded-2xl p-6 bg-white flex flex-col",children:[m.jsxs("div",{className:"flex justify-between items-center mb-4",children:[m.jsx("h2",{className:"text-20-b text-gray-900",children:i}),m.jsxs("button",{className:"relative",onClick:()=>y(!g),ref:v,children:[m.jsx("img",{src:G5,className:"w-8 h-8",alt:"케밥"}),g&&m.jsx(ef,{items:["키워드 수정","관심사 삭제"],onChange:Q,className:"right-0 top-7 z-10 min-w-32"})]})]}),m.jsx("div",{className:"flex flex-wrap gap-2 mb-6 flex-1 overflow-y-auto",children:r.map((ee,J)=>m.jsx("div",{className:"rounded-lg py-1 px-2 bg-gray-100 text-16-m text-gray-500 h-fit",children:ee},J))}),m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsxs("div",{className:"flex items-center justify-center",children:[m.jsx("img",{src:Z5,className:"w-6 h-6",alt:"사람모양"}),m.jsxs("span",{className:"text-14-r text-gray-500",children:[s," 구독자"]})]}),o?m.jsxs(Fe,{variant:"secondary",size:"sm",className:"flex gap-1 min-w-[91px]",onClick:Y,children:[m.jsx("img",{src:Q5,className:"w-4 h-4",alt:"체크"}),"구독 중"]}):m.jsx(Fe,{className:"min-w-[91px]",size:"sm",onClick:Y,children:"구독하기"})]}),m.jsx($5,{isOpen:w,onClose:R,onSave:$,initialData:T}),A&&m.jsx(l1,{isOpen:M,onClose:q,onConfirm:A.onConfirm,title:A.title,message:A.message,confirmText:A.confirmText,cancelText:A.cancelText})]})}async function Um(a,i){const{data:r}=await Ye.get("/interests",{params:a,headers:{"Monew-Request-User-ID":i}});return r}async function F5(a){const{data:i}=await Ye.post("/interests",a);return i}async function W5(a,i){const{data:r}=await Ye.post(`/interests/${a}/subscriptions`,void 0,{headers:{"Monew-Request-User-ID":i}});return r}async function P5(a,i){const{data:r}=await Ye.patch(`/interests/${a}`,i);return r}async function I5(a,i){await Ye.delete(`/interests/${a}/subscriptions`,{headers:{"Monew-Request-User-ID":i}})}async function e9(a){await Ye.delete(`/interests/${a}`)}function t9({isOpen:a,onClose:i,onSave:r}){const[s,o]=b.useState(""),[f,d]=b.useState([]),[p,g]=b.useState(""),y=s.trim()!==""&&f.length>0;b.useEffect(()=>{a||(o(""),g(""))},[a]);const v=R=>{R.preventDefault(),y&&(r({name:s,keywords:f}),o(""),g(""),d([]),i())},w=()=>{p.trim()!==""&&!f.includes(p.trim())?(d(R=>[...R,p.trim()]),g("")):f.includes(p.trim())&&ce.error("동일한 키워드는 등록할 수 없습니다.")},N=R=>{d(T=>T.filter(M=>M!==R))};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("form",{onSubmit:v,className:"w-[438px] h-auto gap-10",children:[m.jsx("h2",{className:"text-24-sb mb-10",children:"관심사 등록"}),m.jsx(Tt,{label:"관심사 이름",placeholder:"관심사 이름을 입력해주세요",value:s,onChange:R=>o(R.target.value),className:"mb-6"}),m.jsxs("div",{className:"mb-1.5 flex gap-2",children:[m.jsx(Tt,{label:"키워드",placeholder:"키워드를 추가해주세요",value:p,onChange:R=>g(R.target.value),className:"flex-1"}),m.jsx(Fe,{className:"px-4 mt-8 whitespace-nowrap",onClick:w,type:"button",children:"키워드 추가"})]}),m.jsx("p",{className:"px-1 gap-2.5 text-14-r text-gray-500 mb-16",children:"*설정한 키워드 기준으로 뉴스를 자동 수집합니다"}),f.length>0&&m.jsx("div",{className:"max-h-20 overflow-y-auto flex flex-wrap gap-2 mb-10",children:f?.map((R,T)=>m.jsx(As,{label:R,onRemove:()=>N(R)},T))}),m.jsx(Fe,{className:"w-full",disabled:!y,type:"submit",children:"등록하기"})]})})}function n9(){const[a,i]=b.useState(!1);return{isOpen:a,openModal:()=>i(!0),onClose:()=>i(!1)}}function a9(){const[a,i]=b.useState([]),r=["이름","구독자수"],s=["내림차순","오름차순"],[o,f]=Ls(),[d,p]=b.useState(!1),[g,y]=b.useState(!1),[v,w]=b.useState(null),N=b.useRef(null),R=b.useRef(null),T=o.get("keyword")||"",M=o.get("orderBy"),C=M==="name"||M==="subscriberCount"?M:"name",q=o.get("direction"),A=q==="ASC"||q==="DESC"?q:"DESC",[Y,Q]=b.useState("이름"),[$,ee]=b.useState("내림차순"),J=o.get("limit")||"6",{userId:F}=ta(),{isOpen:ae,openModal:re,onClose:fe}=n9(),oe=b.useCallback(async()=>{if(F){p(!0);try{const D={keyword:T,orderBy:C,direction:A,limit:parseInt(J)},k=await Um(D,F);i(k.content),y(k.hasNext),w(k.nextCursor)}catch(D){console.error("API 에러:",D)}finally{p(!1)}}},[T,C,A,J,F]),_e=b.useCallback(async()=>{if(!(!F||!g||d)){p(!0);try{const D={keyword:T,orderBy:C,direction:A,limit:parseInt(J),cursor:v||void 0},k=await Um(D,F);i(Z=>[...Z,...k.content]),y(k.hasNext),w(k.nextCursor)}catch(D){console.error("API 에러:",D)}finally{p(!1)}}},[T,C,A,J,F,g,d,v]);b.useEffect(()=>{if(!d)return N.current&&N.current.disconnect(),N.current=new IntersectionObserver(D=>{D[0].isIntersecting&&g&&_e()},{threshold:.8}),R.current&&N.current.observe(R.current),()=>{N.current&&N.current.disconnect()}},[g,d,_e]),b.useEffect(()=>{Q(C==="name"?"이름":"구독자수"),ee(A==="DESC"?"내림차순":"오름차순"),oe()},[oe,C,A]);const Ue=async(D,k)=>{if(F)try{k?await I5(D,F):await W5(D,F),i(Z=>Z.map(P=>P.id===D?{...P,subscribedByMe:!k,subscriberCount:k?P.subscriberCount-1:P.subscriberCount+1}:P))}catch(Z){console.error(Z),ce.error(k?"구독 취소 실패":"구독 실패")}},Se=async D=>{try{await F5(D),oe(),fe()}catch(k){if(console.error(k),k&&typeof k=="object"&&"response"in k){console.error(k);const Z=k,P=Z.response?.data?.message||Z.message||"오류가 발생했습니다.";ce.error(P)}}},B=async(D,k)=>{if(F)try{await P5(D,{keywords:k}),await oe(),ce.success("키워드 수정 성공")}catch(Z){console.error(Z),ce.error("키워드 수정 실패")}},K=async D=>{try{await e9(D),await oe()}catch(k){console.error(k),ce.error("관심 목록 제거 실패")}},W=D=>{(D??"")!==T&&f(k=>{const Z=new URLSearchParams(k);return D?Z.set("keyword",D):Z.delete("keyword"),Z})},pe=D=>{const k=D==="이름"?"name":"subscriberCount";f(Z=>{const P=new URLSearchParams(Z);return P.set("orderBy",k),P})},E=D=>{const k=D==="오름차순"?"ASC":"DESC";f(Z=>{const P=new URLSearchParams(Z);return P.set("direction",k),P})};return m.jsx("div",{children:m.jsxs("div",{className:"w-[1200px] mx-auto",children:[m.jsxs("div",{className:"flex justify-between items-center",children:[m.jsxs("div",{className:"text-left",children:[m.jsx("h1",{className:"text-24-b text-gray-900",children:"관심사 목록"}),m.jsx("p",{className:"text-16-m text-gray-500 mt-3",children:"구독 중인 관심사를 관리하고, 새로운 관심사를 등록해보세요."})]}),m.jsxs(Fe,{className:"min-w-[186px] flex justify-center items-center",onClick:re,children:[m.jsx("img",{src:X5,className:"w-5 h-5",alt:"추가"}),"관심사 등록"]}),m.jsx(t9,{isOpen:ae,onClose:fe,onSave:Se})]}),m.jsxs("div",{className:"flex justify-between items-center mt-10",children:[m.jsxs("div",{className:"flex justify-center items-center gap-3 ",children:[m.jsx(Ol,{items:r,value:Y,onChange:pe,className:"w-24 h-10"}),m.jsx(Ol,{items:s,value:$,onChange:E,className:"w-24 h-10"})]}),m.jsx(r1,{width:"w-[304px]",onSearch:W})]}),m.jsxs("div",{className:"mt-4 min-w-2xs",children:[a.length===0?m.jsx("div",{className:"flex justify-center items-center min-h-[200px] mt-30",children:T?m.jsx(In,{message:"검색 결과가 없습니다."}):m.jsx(In,{message:"아직 등록한 관심사가 없습니다."})}):m.jsx("div",{className:"mt-4 grid grid-cols-3 gap-4",children:a.map((D,k)=>m.jsx("div",{className:"w-[386px] h-[232px]",ref:k===a.length-1?R:null,children:m.jsx(J5,{interestId:D.id,name:D.name,keywords:D.keywords,subscriberCount:D.subscriberCount,isSubscribed:D.subscribedByMe,onSubscribeClick:Ue,onSaveKeyword:B,onDeleteInterest:K})},D.id))}),d&&m.jsx(Nn,{className:"h-[232px] mx-4"})]})]})})}const l9="data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2016%2016'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M3.5999%2012.4004H4.4499L10.6999%206.15039L9.8499%205.30039L3.5999%2011.5504V12.4004ZM2.3999%2013.6004V11.0504L10.6999%202.75039C10.8221%202.62817%2010.9546%202.53928%2011.0972%202.48372C11.2398%202.42817%2011.3898%202.40039%2011.5472%202.40039C11.7046%202.40039%2011.8555%202.42817%2011.9999%202.48372C12.1443%202.53928%2012.2777%202.62817%2012.3999%202.75039L13.2499%203.60039C13.3721%203.72261%2013.461%203.85595%2013.5166%204.00039C13.5721%204.14483%2013.5999%204.29228%2013.5999%204.44272C13.5999%204.60328%2013.572%204.75628%2013.5162%204.90172C13.4605%205.04717%2013.3717%205.18006%2013.2499%205.30039L4.9499%2013.6004H2.3999ZM10.2674%205.73289L9.8499%205.30039L10.6999%206.15039L10.2674%205.73289Z'%20fill='%23989FAC'/%3e%3c/svg%3e";function i9({isOpen:a,onClose:i,user:r,onUpdated:s}){const[o,f]=b.useState(""),[d,p]=b.useState(!1),g=o.trim()!=="";b.useEffect(()=>{a&&f(r?.nickname??"")},[a,r]);const y=async v=>{if(v.preventDefault(),!(!g||d))try{p(!0);const w=await g4(r.id,{nickname:o});$i.write(w),s?.({userId:r.id,nickname:o,updatedUser:w}),ce.success("닉네임이 수정되었습니다."),i()}catch(w){Ic(w)}finally{p(!1)}};return m.jsx(Hl,{isOpen:a,onClose:i,children:m.jsxs("form",{onSubmit:y,className:"w-full max-w-[438px] h-auto gap-10",children:[m.jsx("h2",{className:"text-24-sb mb-10",children:"닉네임 수정"}),m.jsx(Tt,{label:"닉네임",value:o,onChange:v=>f(v.target.value),className:"mb-12",disabled:d}),m.jsx(Fe,{className:"w-full",disabled:!g||d,type:"submit",children:d?m.jsx(Nn,{className:"mx-4"}):"수정하기"})]})})}function r9(){const[a,i]=b.useState(!1),[r,s]=b.useState();return{isOpen:a,openModal:d=>{s(d),i(!0)},onClose:()=>i(!1),initialData:r}}function s9(){const a=Pc(),{isOpen:i,openModal:r,onClose:s}=r9();if(!a)return null;const o=()=>{r(a.id)};return m.jsxs(m.Fragment,{children:[m.jsx("button",{type:"button","aria-label":"닉네임 수정",onClick:o,disabled:!a,children:m.jsx("img",{src:l9,alt:""})}),i&&m.jsx(i9,{isOpen:i,onClose:s,user:a})]})}function u9(){const{userName:a,userEmail:i}=ta();return m.jsxs("div",{className:"flex flex-col w-[260px] h-[100px] bg-white rounded-2xl border border-gray-200 p-6",children:[m.jsxs("div",{className:"flex w-full gap-1.5",children:[m.jsx("p",{className:"text-black text-18-sb",children:a}),m.jsx(s9,{})]}),m.jsx("span",{className:"text-[#9EA5B0] text-16-r",children:i})]})}const o9={subscriptions:"subscriptions",recentComments:"comments",likedComments:"commentLikes",viewedArticles:"articleViews"},c9={subscriptions:"구독 목록을 불러오지 못했습니다.",recentComments:"최근 작성한 댓글을 불러오지 못했습니다.",likedComments:"좋아요한 댓글을 불러오지 못했습니다.",viewedArticles:"최근 본 기사를 불러오지 못했습니다."};function Ks(a,i=10){const{userId:r}=ta(),[s,o]=b.useState([]),[f,d]=b.useState(0),[p,g]=b.useState(!0),[y,v]=b.useState(null);b.useEffect(()=>{let N=!0;return(async()=>{v(null),g(!0);try{const R=await o1(r),T=o9[a],M=R[T]??[];if(!N)return;d(M.length),o(M.slice(0,i))}catch{if(!N)return;v(c9[a])}finally{N&&g(!1)}})(),()=>{N=!1}},[r,a,i]);const w=!p&&s?.length===0;return{items:s,totalCount:f,error:y,loading:p,empty:w}}const f9="data:image/svg+xml,%3csvg%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M12.6%2012L8%207.4L9.4%206L15.4%2012L9.4%2018L8%2016.6L12.6%2012Z'%20fill='%231F2937'/%3e%3c/svg%3e";function d9({name:a,keywords:i,maxTags:r=8}){const s=(i??[]).filter(Boolean),o=s.slice(0,r),f=s.length>o.length;return m.jsxs("div",{className:"flex flex-col w-[212px] gap-3 my-5 max-h-[168px] bg-tranparent",children:[m.jsx("p",{className:"text-16-sb text-black",children:a}),m.jsxs("div",{className:"mt-3 flex flex-wrap gap-2",children:[o.map(d=>m.jsx(As,{label:d},d)),f&&m.jsx(As,{label:"…"})]})]})}function m9(){const{items:a,totalCount:i,error:r,loading:s,empty:o}=Ks("subscriptions",10);return r?m.jsx("div",{children:m.jsx("p",{className:"text-14-r text-error",children:r})}):s?m.jsx(Nn,{height:"132px"}):m.jsxs("aside",{"aria-labelledby":"subs-heading",className:"w-[260px] min-h-0 rounded-2xl p-6 bg-white border border-gray-200",children:[m.jsxs("div",{className:"flex justify-between items-center w-full",children:[m.jsxs("h2",{id:"subs-heading",className:"text-18-b text-gray-900",children:["총",m.jsxs("span",{className:"text-cyan-600",children:[i,"개"]}),"의 관심사 구독중"]}),m.jsx(ea,{to:Ut.INTERESTS,"aria-label":"interests",children:m.jsx("img",{src:f9,alt:""})})]}),!o&&m.jsx("div",{className:"mt-6 h-[1px] w-[212px] bg-gray-200"}),!o&&m.jsx("ul",{className:"flex flex-col divide-y divide-gray-200",children:a.map(f=>m.jsx("li",{children:m.jsx(d9,{name:f.interestName,keywords:f.interestKeywords})},f.interestId))})]})}const h9={recent:"최근 작성한 댓글",liked:"좋아요한 댓글",viewed:"최근 본 기사"};function y9(){const[a]=Ls(),i=a.get("tab")??Ki;return Zc.includes(i)?i:Ki}function p9(){const a=y9();return m.jsx("nav",{"aria-label":"활동내역 탭",className:"w-[895px] min-h-[66px]",children:m.jsx("div",{className:"grid grid-cols-3 gap-2 rounded-lg bg-gray-100 p-2",children:Zc.map(i=>{const r=a===i;return m.jsx(ea,{to:uv(i),"aria-current":r?"page":void 0,className:["inline-flex justify-center w-full rounded-lg py-3 transition","text-16-m",r?"bg-white text-black text-16-sb":"text-gray-500 hover:bg-gray-200"].join(" "),children:h9[i]},i)})})})}const g9="data:image/svg+xml,%3csvg%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M14%2021L12.575%2019.575L16.175%2016H5V4H7V14H16.175L12.575%2010.4L13.975%208.975L20%2015L14%2021Z'%20fill='%23989FAC'/%3e%3c/svg%3e";function c1({mode:a="recent",id:i,articleTitle:r,content:s,likeCount:o,createdAt:f,isLiked:d,onLikeClick:p}){const g=()=>{p?.(i)};return m.jsxs("div",{className:"w-full max-w-[895px] h-auto px-2 py-8 bg-transparent border-none",children:[m.jsxs("div",{className:"flex mb-4 items-center",children:[m.jsxs("div",{className:"flex",children:[m.jsx("p",{className:"text-16-m text-cyan-600",children:r}),m.jsx("span",{className:"text-16-r",children:a==="recent"?"에 남긴 댓글":"에 좋아요한 댓글"})]}),m.jsxs("div",{className:"flex gap-1 ml-1",children:[m.jsx("span",{className:"text-14-m text-gray-500",children:"·"}),m.jsx("span",{className:"text-14-m text-gray-500",children:mh(f)})]})]}),m.jsxs("div",{className:"flex items-center justify-between",children:[m.jsxs("div",{className:"flex items-center gap-4",children:[m.jsx("img",{src:g9,className:"w-6 h-6",alt:"답글"}),m.jsx("span",{className:"text-18-sb line-clamp-3",children:s})]}),m.jsxs("button",{onClick:g,className:"flex justify-center items-center gap-2 shrink-0",children:[d?m.jsx("img",{src:Qc,className:"w-6 h-6",alt:"활성화 하트"}):m.jsx("img",{src:e1,className:"w-6 h-6",alt:"비활성화 하트"}),m.jsx("p",{className:"text-14-r text-gray-500",children:o})]})]})]})}function v9(){const{items:a,error:i,loading:r,empty:s}=Ks("recentComments",10);return i?m.jsx("p",{className:"text-14-r text-error",children:i}):r?m.jsx(Nn,{height:"132px"}):s?m.jsx("div",{className:"min-h-[600px]",children:m.jsx(In,{message:"아직 작성한 댓글이 없습니다."})}):m.jsx("ul",{className:"flex flex-col divide-y divide-gray-300",children:a.map(o=>{const f={id:o.id,articleId:o.articleId,articleTitle:o.articleTitle,userId:o.userId,userNickname:o.userNickname,content:o.content,likeCount:o.likeCount,createdAt:o.createdAt};return m.jsx("li",{children:m.jsx(c1,{mode:"recent",isLiked:!1,...f})},o.id)})})}function x9(){const{items:a,error:i,loading:r,empty:s}=Ks("likedComments",10);return i?m.jsx("p",{className:"text-14-r text-error",children:i}):r?m.jsx(Nn,{height:"132px"}):s?m.jsx("div",{className:"min-h-[600px]",children:m.jsx(In,{message:"아직 좋아요한 댓글이 없습니다."})}):m.jsx("ul",{className:"flex flex-col gap-4 divide-y divide-gray-300",children:a.map(o=>{const f={id:o.commentId,articleId:o.articleId,articleTitle:o.articleTitle,userId:o.commentUserId,userNickname:o.commentUserNickname,content:o.commentContent,likeCount:o.commentLikeCount,createdAt:o.commentCreatedAt};return m.jsx("li",{children:m.jsx(c1,{mode:"liked",isLiked:!0,...f})},o.id)})})}function b9(){const{items:a,error:i,loading:r,empty:s}=Ks("viewedArticles",10),{openModal:o,onClose:f,initialData:d}=u1();if(i)return m.jsx("p",{className:"text-14-r text-error",children:i});if(r)return m.jsx(Nn,{height:"132px"});if(s)return m.jsx("div",{className:"min-h-[600px]",children:m.jsx(In,{message:"최근 본 기사가 없습니다."})});const p=g=>{o(g)};return m.jsxs(m.Fragment,{children:[m.jsx("ul",{className:"flex flex-col gap-4 divide-y divide-gray-300",children:a.map(g=>{const y={id:g.articleId,title:g.articleTitle,summary:g.articleSummary,source:g.source,sourceUrl:g.sourceUrl,publishDate:g.articlePublishedDate,viewCount:g.articleViewCount,commentCount:g.articleCommentCount,viewedByMe:!0};return m.jsx("li",{children:m.jsx(s1,{article:y,onClick:()=>p(y)})},g.id)})}),d&&m.jsx(i1,{onClose:f,articleId:d.id})]})}function w9(){const[a,i]=Ls(),r=a.get("tab")??Ki,s=Zc.includes(r);return b.useEffect(()=>{s||i({tab:Ki},{replace:!0})},[s,i]),m.jsxs("div",{className:"flex justify-center gap-10 w-full",children:[m.jsxs("div",{className:"flex flex-col gap-4",children:[m.jsx(u9,{}),m.jsx(m9,{})]}),m.jsxs("div",{className:"flex flex-col",children:[m.jsx(p9,{}),m.jsxs("div",{className:"mt-2 flex flex-col gap-4 h-full",children:[r==="recent"&&m.jsx(v9,{}),r==="liked"&&m.jsx(x9,{}),r==="viewed"&&m.jsx(b9,{})]})]})]})}const S9="data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20id='_레이어_1'%20data-name='레이어%201'%20xmlns='http://www.w3.org/2000/svg'%20width='30'%20height='25.37'%20viewBox='0%200%2030%2025.37'%3e%3cdefs%3e%3cstyle%3e%20.cls-1%20{%20fill:%20%23d1d5db;%20}%20.cls-2%20{%20fill:%20%23989fac;%20}%20%3c/style%3e%3c/defs%3e%3cpath%20class='cls-1'%20d='M22.28,0c.85,0,1.54,.69,1.54,1.54V3.65H5.57c-1.06,0-1.92,.86-1.92,1.92v11.14h-.06s-.09,.02-.12,.04L.31,19.33c-.13,.1-.31,.01-.31-.15V1.54C0,.69,.69,0,1.54,0H22.28Z'/%3e%3cpath%20class='cls-2'%20d='M28.46,5.99c.85,0,1.54,.69,1.54,1.54v6.91h-10.37v1.73h10.37v1.73h-13.64v1.73h13.64v1.54c0,.85-.69,1.54-1.54,1.54H9.78s-.09,.02-.12,.04l-3.15,2.58c-.13,.1-.31,.01-.31-.15V7.53c0-.85,.69-1.54,1.54-1.54H28.46Z'/%3e%3c/svg%3e";function T9(){return m.jsxs("div",{className:"flex flex-col items-center justify-center gap-4 min-h-[inherit]",children:[m.jsx("img",{src:S9,alt:"gray monew logo",className:"h-[60px]"}),m.jsx("p",{className:"sm:text-20-m text-16-m",children:"404 Not Found"})]})}const E9=()=>{const a=Pc(),i=Wt();return a?m.jsx(Vc,{}):m.jsx(Cg,{to:"/login",replace:!0,state:{from:i}})};function C9(){return m.jsxs(Ng,{children:[m.jsx(Zt,{element:m.jsx(E9,{}),children:m.jsxs(Zt,{element:m.jsx(r5,{}),children:[m.jsx(Zt,{path:"/articles",element:m.jsx(Am,{})}),m.jsx(Zt,{path:"/articles/:articleId",element:m.jsx(Am,{})}),m.jsx(Zt,{path:"/interests",element:m.jsx(a9,{})}),m.jsx(Zt,{path:"/activities",element:m.jsx(w9,{})})]})}),m.jsxs(Zt,{element:m.jsx(s5,{}),children:[m.jsx(Zt,{path:"/",element:m.jsx(v5,{})}),m.jsx(Zt,{path:"/login",element:m.jsx(E5,{})}),m.jsx(Zt,{path:"/signup",element:m.jsx(_5,{})}),m.jsx(Zt,{path:"*",element:m.jsx(T9,{})})]})]})}function _9(){return m.jsx(e5,{hideProgressBar:!0,closeButton:!1,limit:2,position:"bottom-center",autoClose:3e3,newestOnTop:!0,transition:P4,draggable:!0,pauseOnHover:!0,pauseOnFocusLoss:!0})}Ap.createRoot(document.getElementById("root")).render(m.jsxs(Wg,{children:[m.jsx(_9,{}),m.jsx(C9,{})]})); From a2800f6447e8fe6ba47142ebea0a6965945c25d3 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Thu, 30 Oct 2025 11:50:34 +0900 Subject: [PATCH 062/178] =?UTF-8?q?refactor:=20=EB=8C=93=EA=B8=80=20Commen?= =?UTF-8?q?tController,=20CommentService,=20CommentRepositoryImpl,=20Comme?= =?UTF-8?q?ntSearchRequest=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CommentController.java | 106 +++++---- .../monew_api/comments/dto/CommentDto.java | 26 +-- .../comments/dto/CommentLikeDto.java | 14 -- .../comments/dto/CommentRegisterRequest.java | 1 + .../comments/dto/CommentSearchRequest.java | 33 +++ .../repository/CommentLikeRepository.java | 7 +- .../repository/CommentRepository.java | 14 +- .../repository/CommentRepositoryCustom.java | 22 +- .../impl/CommentRepositoryImpl.java | 207 ++++++++++++------ .../comments/service/CommentService.java | 152 +++++-------- .../Comment/CommentServiceHardDeleteTest.java | 75 +++++++ .../comments/CommentPurgeJobConfig.java | 48 ---- .../comments/CommentPurgeProperties.java | 11 - .../comments/CommentPurgeScheduler.java | 32 --- .../comments/service/CommentPurgeService.java | 53 ----- 15 files changed, 371 insertions(+), 430 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentSearchRequest.java create mode 100644 monew-api/src/test/java/com/monew/monew_api/Comment/CommentServiceHardDeleteTest.java delete mode 100644 monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeJobConfig.java delete mode 100644 monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeProperties.java delete mode 100644 monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeScheduler.java delete mode 100644 monew-batch/src/main/java/com/monew/monew_batch/comments/service/CommentPurgeService.java diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java index 0739db9..9a632e0 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java @@ -1,130 +1,122 @@ package com.monew.monew_api.comments.controller; -import java.time.LocalDateTime; - import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.monew.monew_api.comments.dto.CommentDto; import com.monew.monew_api.comments.dto.CommentLikeDto; import com.monew.monew_api.comments.dto.CommentRegisterRequest; +import com.monew.monew_api.comments.dto.CommentSearchRequest; import com.monew.monew_api.comments.dto.CommentUpdateRequest; import com.monew.monew_api.comments.dto.CursorPageResponseCommentDto; import com.monew.monew_api.comments.service.CommentService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RestController @RequestMapping("/api/comments") @RequiredArgsConstructor @Validated public class CommentController { + private static final String REQUEST_HEADER_USER_ID = "MoNew-Request-User-ID"; private final CommentService commentService; + // 댓글 조회 @GetMapping public ResponseEntity findAll( - @RequestHeader("Monew-Request-User-ID") Long userIdHeader, - @RequestParam(required = false) Long articleId, - @RequestParam String orderBy, - @RequestParam(required = false) String direction, - @RequestParam(required = false) String cursor, - @RequestParam(required = false) String after, - @RequestParam int limit + @RequestHeader(REQUEST_HEADER_USER_ID) Long userId, + @ModelAttribute CommentSearchRequest request ) { - Long cursorId = null; - LocalDateTime cursorCreatedAt = parseNullableDateTime(after); - Integer cursorLikeCount = null; - - if (cursor != null && !cursor.isBlank()) { - if ("likeCount".equalsIgnoreCase(orderBy)) { - String[] parts = cursor.split(":"); - if (parts.length == 2) { - cursorLikeCount = safeParseInt(parts[0]); - cursorId = safeParseLong(parts[1]); - } - } else { - cursorId = safeParseLong(cursor); - } - } - - CursorPageResponseCommentDto page = - commentService.findAll(articleId, limit, orderBy, cursorId, cursorCreatedAt, cursorLikeCount, userIdHeader); - - return ResponseEntity.ok(page); + log.info("[CommentController] GET /api/comments - userId={}, request={}", userId, request); + CursorPageResponseCommentDto response = commentService.findAll(userId, request); + return ResponseEntity.ok(response); } + // 댓글 작성 @PostMapping public ResponseEntity register( @Valid @RequestBody CommentRegisterRequest request ) { + log.info("[CommentController] POST /api/comments - register request={}", request); CommentDto dto = commentService.register(request); + log.info("[CommentController] POST /api/comments - created commentId={}", dto.id()); return ResponseEntity.status(HttpStatus.CREATED).body(dto); } + // 댓글 수정 @PatchMapping("/{commentId}") public ResponseEntity update( - @RequestHeader("Monew-Request-User-ID") Long userIdHeader, + @RequestHeader(REQUEST_HEADER_USER_ID) Long userId, @PathVariable Long commentId, @Valid @RequestBody CommentUpdateRequest request ) { - CommentDto dto = commentService.update(userIdHeader, commentId, request); + log.info("[CommentController] PATCH /api/comments/{} - userId={}, request={}", commentId, userId, request); + CommentDto dto = commentService.update(userId, commentId, request); + log.info("[CommentController] PATCH /api/comments/{} - updated", commentId); return ResponseEntity.ok(dto); } - @DeleteMapping("/{commentId}") - public ResponseEntity delete( - @PathVariable Long commentId - ) { - commentService.delete(commentId); - return ResponseEntity.noContent().build(); - } - + // 댓글 좋아요 @PostMapping("/{commentId}/comment-likes") public ResponseEntity like( - @RequestHeader("Monew-Request-User-ID") Long userIdHeader, + @RequestHeader(REQUEST_HEADER_USER_ID) Long userId, @PathVariable Long commentId ) { - CommentLikeDto dto = commentService.like(userIdHeader, commentId); + log.info("[CommentController] POST /api/comments/{}/comment-likes - like request, userId={}" + , commentId, userId); + CommentLikeDto dto = commentService.like(userId, commentId); + log.info("[CommentController] POST /api/comments/{}/comment-likes - like success, likeId={}" + , commentId, dto.id()); return ResponseEntity.ok(dto); } + // 댓글 좋아요 삭제 @DeleteMapping("/{commentId}/comment-likes") public ResponseEntity dislike( - @RequestHeader("Monew-Request-User-ID") Long userIdHeader, + @RequestHeader(REQUEST_HEADER_USER_ID) Long userId, @PathVariable Long commentId ) { - commentService.dislike(userIdHeader, commentId); + log.info("[CommentController] DELETE /api/comments/{}/comment-likes - dislike request, userId={}" + , commentId, userId); + commentService.dislike(userId, commentId); + log.info("[CommentController] DELETE /api/comments/{}/comment-likes - dislike success" + , commentId); return ResponseEntity.noContent().build(); } - @DeleteMapping("/{commentId}/hard") - public ResponseEntity hardDelete(@PathVariable Long commentId) { - commentService.hardDelete(commentId); + // 댓글 논리 삭제 + @DeleteMapping("/{commentId}") + public ResponseEntity delete( + @PathVariable Long commentId + ) { + log.info("[CommentController] DELETE /api/comments/{} - soft delete request", commentId); + commentService.delete(commentId); + log.info("[CommentController] DELETE /api/comments/{} - soft delete success", commentId); return ResponseEntity.noContent().build(); } - private LocalDateTime parseNullableDateTime(String text) { - return (text == null || text.isBlank()) ? null : LocalDateTime.parse(text); - } - - private Long safeParseLong(String s) { - try { return Long.parseLong(s); } catch (Exception e) { return null; } - } - - private Integer safeParseInt(String s) { - try { return Integer.parseInt(s); } catch (Exception e) { return null; } + // 댓글 물리 삭제 + @DeleteMapping("/{commentId}/hard") + public ResponseEntity hardDelete( + @PathVariable Long commentId) { + log.info("[CommentController] DELETE /api/comments/{}/hard - hard delete request", commentId); + commentService.hardDelete(commentId); + log.info("[CommentController] DELETE /api/comments/{}/hard - hard delete success", commentId); + return ResponseEntity.noContent().build(); } } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java index b588040..5a37d4c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java @@ -1,46 +1,32 @@ package com.monew.monew_api.comments.dto; import com.monew.monew_api.comments.entity.Comment; +import com.querydsl.core.annotations.QueryProjection; public record CommentDto( Long id, - Long articleId, Long userId, + Long articleId, String userNickname, String content, int likeCount, boolean likedByMe, - boolean isMyComment, String createdAt ) { - + @QueryProjection // QCommentDto를 생성 + public CommentDto { + } public static CommentDto from(Comment comment, boolean likedByMe) { return new CommentDto( comment.getId(), - comment.getArticle().getId(), comment.getUser().getId(), - comment.getUser().getNickname(), - comment.getContent(), - comment.getLikeCount(), - likedByMe, - false, - comment.getCreatedAt().toString() - ); - } - - public static CommentDto from(Comment comment, boolean likedByMe, Long requestUserId) { - boolean isMyComment = comment.getUser().getId().equals(requestUserId); - - return new CommentDto( - comment.getId(), comment.getArticle().getId(), - comment.getUser().getId(), comment.getUser().getNickname(), comment.getContent(), comment.getLikeCount(), likedByMe, - isMyComment, comment.getCreatedAt().toString() ); } + } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java index a170ad0..abf9b20 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java @@ -31,18 +31,4 @@ public static CommentLikeDto from(CommentLike like) { like.getCreatedAt().toString() ); } - - public static CommentLikeDto of(Long commentId, Long userId) { - return new CommentLikeDto( - null, - commentId, - null, - userId, - null, null, null, - -1, - null, - LocalDateTime.now().toString() - ); - } - } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java index af3f3fc..3a0b76a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java @@ -8,6 +8,7 @@ public record CommentRegisterRequest( @NotNull(message = "기사 ID는 필수입니다.") Long articleId, + @NotNull(message = "유저 ID는 필수입니다.") Long userId, @NotBlank(message = "댓글 내용을 입력해주세요.") diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentSearchRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentSearchRequest.java new file mode 100644 index 0000000..de1374f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentSearchRequest.java @@ -0,0 +1,33 @@ +package com.monew.monew_api.comments.dto; + +import java.time.LocalDateTime; + +import org.springframework.format.annotation.DateTimeFormat; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CommentSearchRequest { + + private Long articleId; + + @Pattern(regexp = "createdAt|likeCount", message = "orderBy는 'createdAt' 또는 'likeCount'만 가능합니다.") + private String orderBy = "createdAt"; + + private String direction = "DESC"; + + private String cursor; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime after; + + @Min(value = 1, message = "limit은 1 이상이어야 합니다.") + @Max(value = 50, message = "limit은 최대 50까지만 가능합니다.") + private int limit = 10; + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java index d449a95..544a1e1 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java @@ -7,13 +7,12 @@ import com.monew.monew_api.comments.entity.CommentLike; -public interface CommentLikeRepository extends JpaRepository { +public interface CommentLikeRepository extends JpaRepository { + // 좋아요 중복 확인 boolean existsByComment_IdAndUser_Id(Long commentId, Long userId); + // 자신 좋아요 취소 void deleteByComment_IdAndUser_Id(Long commentId, Long userId); - // N+1 회피용: 특정 사용자 + 여러 댓글 ID에 대한 좋아요 목록 - List findByUser_IdAndComment_IdIn(Long userId, Collection commentIds); - } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java index df37294..6c7fb9a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java @@ -9,15 +9,17 @@ public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { + // 좋아요 취소 @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" - update Comment c - set c.likeCount = case when c.likeCount > 0 then c.likeCount - 1 else 0 end - where c.id = :id - """) + update Comment c + set c.likeCount = case when c.likeCount > 0 then c.likeCount - 1 else 0 end + where c.id = :id + """) int decLikeCount(@Param("id") Long id); + // 댓글 물리 삭제 @Modifying - @Query(value = "DELETE FROM comments WHERE id = :id", nativeQuery = true) - void hardDeleteById(@Param("id") Long id); + @Query(value = "DELETE FROM comments WHERE id = :id AND is_deleted = true", nativeQuery = true) + int hardDeleteById(@Param("id") Long id); } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java index cf75cc5..2a507f8 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java @@ -1,24 +1,18 @@ package com.monew.monew_api.comments.repository; import java.time.LocalDateTime; -import java.util.List; -import com.monew.monew_api.comments.entity.Comment; +import com.monew.monew_api.comments.dto.CursorPageResponseCommentDto; public interface CommentRepositoryCustom { - List findPageByArticleIdOrderByCreatedAtDesc( + // 댓글 조회 + CursorPageResponseCommentDto searchComments( Long articleId, - Long cursorId, - LocalDateTime cursorCreatedAt, - int limit + String orderBy, + String cursor, + LocalDateTime after, + int limit, + Long userId ); - - List findPageByArticleIdOrderByLikeCountDesc( - Long articleId, - Long cursorId, - Integer cursorLikeCount, - int limit - ); - } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java index 5aaa070..67e0887 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java @@ -1,128 +1,193 @@ package com.monew.monew_api.comments.repository.impl; +import static com.monew.monew_api.comments.entity.QComment.*; + import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; import org.springframework.stereotype.Repository; -import com.monew.monew_api.comments.entity.Comment; +import com.monew.monew_api.comments.dto.CommentDto; +import com.monew.monew_api.comments.dto.CursorPageResponseCommentDto; +import com.monew.monew_api.comments.dto.QCommentDto; import com.monew.monew_api.comments.entity.QComment; +import com.monew.monew_api.comments.entity.QCommentLike; import com.monew.monew_api.comments.repository.CommentRepositoryCustom; -import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Repository @RequiredArgsConstructor public class CommentRepositoryImpl implements CommentRepositoryCustom { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private final JPAQueryFactory jpaQueryFactory; - private final QComment c = QComment.comment; @Override - public List findPageByArticleIdOrderByCreatedAtDesc(Long articleId, Long cursorId, - LocalDateTime cursorCreatedAt, int limit) { + public CursorPageResponseCommentDto searchComments( + Long articleId, String orderBy, String cursor, + LocalDateTime after, int limit, Long userId) { + + QCommentLike cl = QCommentLike.commentLike; + + // DTO로 바로 조회 (서브쿼리로 좋아요 여부 포함) + List comments = jpaQueryFactory + .select(new QCommentDto( + comment.id, + comment.user.id, + comment.article.id, + comment.user.nickname, + comment.content, + comment.likeCount, + JPAExpressions // 좋아요 여부 확인하는 서브쿼리 + .selectOne() + .from(cl) + .where( + cl.comment.id.eq(comment.id) + .and(cl.user.id.eq(userId)) + ) + .exists(), + comment.createdAt.stringValue() + )) + .from(comment) + .where( + articleIdEq(articleId), + cursorCondition(orderBy, cursor, after) + ) + .orderBy(orderSpecifiers(comment, orderBy)) + .limit(limit + 1L) + .fetch(); - QComment c = QComment.comment; + log.info("[조회 결과] 총 {}개 댓글 조회됨", comments.size()); - BooleanBuilder where = new BooleanBuilder(); - if (articleId != null) { - where.and(c.article.id.eq(articleId)); + // hasNext 계산 + boolean hasNext = comments.size() > limit; + if (hasNext) { + comments.remove(limit); } - BooleanExpression cursorExpr = buildCreatedAtCursor(c, cursorId, cursorCreatedAt); - if (cursorExpr != null) { - where.and(cursorExpr); - } + // nextCursor, nextAfter 생성 + String nextCursor = null; + ZonedDateTime nextAfter = null; - return jpaQueryFactory - .selectFrom(c) - .where(where) - .orderBy( - c.createdAt.desc(), - c.id.desc() - ) - .limit(limit + 1L) - .fetch(); - } + if (hasNext && !comments.isEmpty()) { + CommentDto last = comments.get(comments.size() - 1); - @Override - public List findPageByArticleIdOrderByLikeCountDesc(Long articleId, Long cursorId, Integer cursorLikeCount, - int limit) { - - QComment c = QComment.comment; + if ("likeCount".equalsIgnoreCase(orderBy)) { + nextCursor = last.likeCount() + ":" + last.id(); + } else { + nextCursor = String.valueOf(last.id()); + } - BooleanBuilder where = new BooleanBuilder(); - if (articleId != null) { - where.and(c.article.id.eq(articleId)); - } + // after는 입력받은 값을 그대로 유지 (시간 필터 고정) + nextAfter = after != null ? after.atZone(KST) : null; - BooleanExpression cursorExpr = buildLikeCountCursor(c, cursorId, cursorLikeCount); - if (cursorExpr != null) { - where.and(cursorExpr); } - return jpaQueryFactory - .selectFrom(c) - .where(where) - .orderBy( - c.likeCount.desc(), - c.id.desc() - ) - .limit(limit + 1L) - .fetch(); + return new CursorPageResponseCommentDto( + comments, + nextCursor, + nextAfter, + comments.size(), + -1L, + hasNext + ); + } + // 커서 조건 분배(최신순, 인기순) + private BooleanExpression cursorCondition(String orderBy, String cursor, LocalDateTime after) { + if ("likeCount".equalsIgnoreCase(orderBy)) { + return buildLikeCountCursor(cursor, after); + } else { + return buildCreatedAtCursor(cursor, after); + } } + // createdAt 기준 커서 조건 (최신순) private BooleanExpression buildCreatedAtCursor( - QComment c, - Long cursorId, - LocalDateTime cursorCreatedAt + String cursor, LocalDateTime after ) { - if (cursorId == null && cursorCreatedAt == null) { + Long cursorId = parseLongCursor(cursor); + // 둘 다 없으면 조건 없음 + if (cursorId == null && after == null) { return null; } - if (cursorId != null && cursorCreatedAt == null) { - return c.id.lt(cursorId); + // cursor만 있을 때 + if (cursorId != null && after == null) { + return comment.id.lt(cursorId); } - if (cursorId == null && cursorCreatedAt != null) { - - return c.createdAt.lt(cursorCreatedAt); + // after만 있을 때 + if (cursorId == null) { + return comment.createdAt.lt(after); } - return c.createdAt.lt(cursorCreatedAt) + // 둘 다 있을 때 + return comment.createdAt.lt(after) .or( - c.createdAt.eq(cursorCreatedAt) - .and(c.id.lt(cursorId)) + comment.createdAt.eq(after) + .and(comment.id.lt(cursorId)) ); } - private BooleanExpression buildLikeCountCursor( - QComment c, - Long cursorId, - Integer cursorLikeCount - ) { - if (cursorId == null && cursorLikeCount == null) { + // likeCount 기준 커서 조건 + private BooleanExpression buildLikeCountCursor(String cursor, LocalDateTime after) { + + if (cursor == null || cursor.isBlank()) { return null; } - if (cursorLikeCount != null && cursorId == null) { - return c.likeCount.lt(cursorLikeCount); + String[] parts = cursor.split(":"); + if (parts.length != 2) { + return null; } - if (cursorLikeCount == null && cursorId != null) { - return c.id.lt(cursorId); + try { + Integer likeCount = Integer.parseInt(parts[0]); + Long id = Long.parseLong(parts[1]); + + return comment.likeCount.lt(likeCount) + .or( + comment.likeCount.eq(likeCount) + .and(comment.id.lt(id)) + ); + } catch (NumberFormatException e) { + return null; } + } - return c.likeCount.lt(cursorLikeCount) - .or( - c.likeCount.eq(cursorLikeCount) - .and(c.id.lt(cursorId)) - ); + // Long 타입 커서 파싱 + private Long parseLongCursor(String cursor) { + if (cursor == null || cursor.isBlank()) { + return null; + } + try { + return Long.parseLong(cursor); + } catch (NumberFormatException e) { + return null; + } } + // 게시글 필터 조건 + private BooleanExpression articleIdEq(Long articleId) { + return articleId == null ? null : comment.article.id.eq(articleId); + } + + // 정렬 컬럼 선택 + private OrderSpecifier[] orderSpecifiers(QComment c, String orderBy) { + if ("likeCount".equalsIgnoreCase(orderBy)) { + return new OrderSpecifier[] {c.likeCount.desc(), c.id.desc()}; + } + return new OrderSpecifier[] {c.createdAt.desc(), c.id.desc()}; + } } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index b67a178..fda57b9 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -2,13 +2,8 @@ import java.time.LocalDateTime; import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +12,7 @@ import com.monew.monew_api.comments.dto.CommentDto; import com.monew.monew_api.comments.dto.CommentLikeDto; import com.monew.monew_api.comments.dto.CommentRegisterRequest; +import com.monew.monew_api.comments.dto.CommentSearchRequest; import com.monew.monew_api.comments.dto.CommentUpdateRequest; import com.monew.monew_api.comments.dto.CursorPageResponseCommentDto; import com.monew.monew_api.comments.entity.Comment; @@ -25,7 +21,6 @@ import com.monew.monew_api.comments.event.CommentLikedEvent; import com.monew.monew_api.comments.repository.CommentLikeRepository; import com.monew.monew_api.comments.repository.CommentRepository; -import com.monew.monew_api.common.exception.comment.CommentAlreadyLikedException; import com.monew.monew_api.common.exception.comment.CommentArticleNotFoundException; import com.monew.monew_api.common.exception.comment.CommentForbiddenException; import com.monew.monew_api.common.exception.comment.CommentNotFoundException; @@ -43,16 +38,16 @@ @Transactional(readOnly = true) public class CommentService { - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private final CommentRepository commentRepository; private final CommentLikeRepository commentLikeRepository; private final UserRepository userRepository; private final ArticleRepository articleRepository; private final ApplicationEventPublisher eventPublisher; + // 댓글 작성 @Transactional public CommentDto register(CommentRegisterRequest request) { + log.info("[COMMENT][CREATE][START] userId={}, articleId={}", request.userId(), request.articleId()); User user = getUserById(request.userId()); Article article = getArticleById(request.articleId()); @@ -67,8 +62,10 @@ public CommentDto register(CommentRegisterRequest request) { return CommentDto.from(saved, false); } + // 댓글 수정 @Transactional public CommentDto update(Long userId, Long commentId, CommentUpdateRequest request) { + log.info("[COMMENT][UPDATE][START] userId={}, commentId={}", userId, commentId); Comment comment = getCommentById(commentId); validateOwnership(comment, userId); @@ -80,37 +77,27 @@ public CommentDto update(Long userId, Long commentId, CommentUpdateRequest reque return CommentDto.from(comment, likedByMe); } - @Transactional - public void delete(Long commentId) { - Comment comment = getCommentById(commentId); - - commentRepository.delete(comment); - log.info("[COMMENT][DELETE] commentId={}", commentId); - } - + // 댓글 좋아요 @Transactional public CommentLikeDto like(Long userId, Long commentId) { + log.info("[COMMENT][LIKE] 좋아요 요청 시작 - userId={}, commentId={}", userId, commentId); User user = getUserById(userId); Comment comment = getCommentById(commentId); - - CommentLike saved; - try { - saved = commentLikeRepository.save(CommentLike.of(user, comment)); - } catch (DataIntegrityViolationException e) { - throw new CommentAlreadyLikedException(); - } - + log.info("[COMMENT][LIKE] 엔티티 조회 완료 - user={}, comment={}", user.getId(), comment.getId()); + CommentLike saved = commentLikeRepository.save(CommentLike.of(user, comment)); comment.increaseLike(); eventPublisher.publishEvent( new CommentLikedEvent(comment.getId(), comment.getUserId(), userId, LocalDateTime.now()) ); - + log.info("[COMMENT][LIKE] userId={}, commentId={}", userId, commentId); return CommentLikeDto.from(saved); } + // 댓글 좋아요 삭제 @Transactional public void dislike(Long userId, Long commentId) { + log.info("[COMMENT][DISLIKE][START] userId={}, commentId={}", userId, commentId); boolean liked = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, userId); if (!liked) throw new CommentNotLikedException(); @@ -121,105 +108,70 @@ public void dislike(Long userId, Long commentId) { log.info("[COMMENT][DISLIKE] userId={}, commentId={}", userId, commentId); } - public CursorPageResponseCommentDto findAll( - Long articleId, - int size, - String orderBy, - Long cursorId, - LocalDateTime cursorCreatedAt, - Integer cursorLikeCount, - Long requestUserId - ) { - - log.info("=== [DEBUG] Service에 전달된 requestUserId = {} ===", requestUserId); - - final boolean orderByLike = "likeCount".equalsIgnoreCase(orderBy); - - List page = orderByLike - ? commentRepository.findPageByArticleIdOrderByLikeCountDesc(articleId, cursorId, cursorLikeCount, size) - : commentRepository.findPageByArticleIdOrderByCreatedAtDesc(articleId, cursorId, cursorCreatedAt, size); - - boolean hasNext = page.size() > size; - if (hasNext) - page = page.subList(0, size); - - - page.forEach(comment -> { - log.info("[DEBUG] 댓글 ID={}, 작성자 userId={}, 내용={}", - comment.getId(), - comment.getUser().getId(), - comment.getContent().substring(0, Math.min(10, comment.getContent().length()))); - }); - - Set likedCommentIds = requestUserId == null || page.isEmpty() - ? Set.of() - : commentLikeRepository - .findByUser_IdAndComment_IdIn(requestUserId, - page.stream().map(Comment::getId).toList()) - .stream() - .map(cl -> cl.getComment().getId()) - .collect(Collectors.toSet()); - - List content = page.stream() - .map(c -> CommentDto.from(c, likedCommentIds.contains(c.getId()), - requestUserId)) - .toList(); - - content.forEach(dto -> { - log.info("[DEBUG] 응답 DTO - commentId={}, userId={}, isMyComment={}, likedByMe={}", - dto.id(), dto.userId(), dto.isMyComment(), dto.likedByMe()); - }); - - String nextCursor = null; - if (hasNext) { - Comment last = page.get(page.size() - 1); - if (orderByLike) { - nextCursor = last.getLikeCount() + ":" + last.getId(); - } else { - nextCursor = String.valueOf(last.getId()); - } - } - - ZonedDateTime nextAfter = null; - if (hasNext) { - LocalDateTime lastCreated = page.get(page.size() - 1).getCreatedAt(); - nextAfter = lastCreated.atZone(KST); - } + // 댓글 논리 삭제 + @Transactional + public void delete(Long commentId) { + log.info("[COMMENT][DELETE][START] commentId={}", commentId); + Comment comment = getCommentById(commentId); - return new CursorPageResponseCommentDto( - content, - nextCursor, - nextAfter, - content.size(), - -1L, - hasNext - ); + commentRepository.delete(comment); + log.info("[COMMENT][DELETE] commentId={}", commentId); } + // 댓글 물리 삭제 @Transactional public void hardDelete(Long commentId) { - if (!commentRepository.existsById(commentId)) { + log.info("[COMMENT][HARD_DELETE][START] commentId={}", commentId); + int deletedCount = commentRepository.hardDeleteById(commentId); + // 0 = 실패, 1 = 성공 + if (deletedCount == 0) { throw new CommentNotFoundException(); } - commentRepository.hardDeleteById(commentId); - log.info("[COMMENT][HARD_DELETE] commentId={}", commentId); + log.info("[COMMENT][HARD_DELETE] commentId={}, deletedCount={}", commentId, deletedCount); + } + + // 댓글 전체 조회 + public CursorPageResponseCommentDto findAll( + Long userId, CommentSearchRequest request + ) { + log.info("[COMMENT][FIND_ALL][START] userId={}, articleId={}, orderBy={}, cursor={}, after={}, limit={}", + userId, + request.getArticleId(), + request.getOrderBy(), + request.getCursor(), + request.getAfter(), + request.getLimit() + ); + + return commentRepository.searchComments( + request.getArticleId(), + request.getOrderBy(), + request.getCursor(), + request.getAfter(), + request.getLimit(), + userId + ); } // === 내부 유틸 === + // 작성자 확인 private void validateOwnership(Comment comment, Long userId) { if (!comment.isOwnedBy(userId)) throw new CommentForbiddenException(); } + // commentId 확인 private Comment getCommentById(Long commentId) { return commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new); } + // userId 확인 private User getUserById(Long userId) { return userRepository.findById(userId).orElseThrow(CommentUserNotFoundException::new); } + // articleId 확인 private Article getArticleById(Long articleId) { return articleRepository.findById(articleId).orElseThrow(CommentArticleNotFoundException::new); } diff --git a/monew-api/src/test/java/com/monew/monew_api/Comment/CommentServiceHardDeleteTest.java b/monew-api/src/test/java/com/monew/monew_api/Comment/CommentServiceHardDeleteTest.java new file mode 100644 index 0000000..7884b0b --- /dev/null +++ b/monew-api/src/test/java/com/monew/monew_api/Comment/CommentServiceHardDeleteTest.java @@ -0,0 +1,75 @@ +package com.monew.monew_api.Comment; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.monew.monew_api.comments.repository.CommentRepository; +import com.monew.monew_api.comments.service.CommentService; +import com.monew.monew_api.common.exception.comment.CommentNotFoundException; + +/** + * 물리 삭제 단위 테스트 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("CommentService - hardDelete") +public class CommentServiceHardDeleteTest { + + @InjectMocks + private CommentService commentService; + + @Mock + private CommentRepository commentRepository; + + // 물리 삭제 성공 - is_deleted=true인 댓글 + @Test + void hardDelete_Success_DeletedComment() { + + // given + Long commentId = 1L; + given(commentRepository.hardDeleteById(commentId)).willReturn(1); // 1개 삭제됨 + + // when + commentService.hardDelete(commentId); + + // then + then(commentRepository).should().hardDeleteById(commentId); + } + + // 물리 삭제 실패 - 존재하지 않는 댓글 + @Test + void hardDelete_CommentNotFound() { + + // given + Long commentId = 999L; + given(commentRepository.hardDeleteById(commentId)).willReturn(0); // 삭제된 row 없음 + + // when & then + assertThatThrownBy(() -> commentService.hardDelete(commentId)) + .isInstanceOf(CommentNotFoundException.class); + + then(commentRepository).should().hardDeleteById(commentId); + } + + // 물리 삭제 실패 - is_deleted=false인 댓글 (논리 삭제 안됨) + @Test + void hardDelete_NotDeletedComment() { + + // given + Long commentId = 2L; + // is_deleted=false 삭제 조건에 안 맞음 → 0개 삭제 + given(commentRepository.hardDeleteById(commentId)).willReturn(0); + + // when & then + assertThatThrownBy(() -> commentService.hardDelete(commentId)) + .isInstanceOf(CommentNotFoundException.class); + + then(commentRepository).should().hardDeleteById(commentId); + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeJobConfig.java deleted file mode 100644 index 9b4ea76..0000000 --- a/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeJobConfig.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.monew.monew_batch.comments; - -import java.time.LocalDateTime; - -import org.springframework.batch.core.Job; -import org.springframework.batch.core.Step; -import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; -import org.springframework.batch.core.job.builder.JobBuilder; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.batch.repeat.RepeatStatus; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; - -import com.monew.monew_batch.comments.service.CommentPurgeService; - -import lombok.RequiredArgsConstructor; - -@Configuration -@EnableBatchProcessing -@EnableConfigurationProperties(CommentPurgeProperties.class) -@RequiredArgsConstructor -public class CommentPurgeJobConfig { - - private final CommentPurgeService purgeService; - private final CommentPurgeProperties props; - - @Bean - public Job commentPurgeJob(JobRepository jobRepository, Step commentPurgeStep) { - return new JobBuilder("commentPurgeJob", jobRepository) - .start(commentPurgeStep) - .build(); - } - - @Bean - public Step commentPurgeStep(JobRepository jobRepository, - PlatformTransactionManager transactionManager) { - return new StepBuilder("commentPurgeStep", jobRepository) - .tasklet((contribution, chunkContext) -> { - LocalDateTime cutoff = LocalDateTime.now().minusMinutes(props.getRetentionMinutes()); - purgeService.purge(cutoff); - return RepeatStatus.FINISHED; - }, transactionManager) - .build(); - } -} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeProperties.java b/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeProperties.java deleted file mode 100644 index e2442f9..0000000 --- a/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeProperties.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.monew.monew_batch.comments; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "monew.comments.purge") -public class CommentPurgeProperties { - - private int retentionMinutes = 5; - public int getRetentionMinutes() { return retentionMinutes; } - public void setRetentionMinutes(int retentionMinutes) { this.retentionMinutes = retentionMinutes; } -} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeScheduler.java deleted file mode 100644 index 12b0bb8..0000000 --- a/monew-batch/src/main/java/com/monew/monew_batch/comments/CommentPurgeScheduler.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.monew.monew_batch.comments; - -import org.springframework.batch.core.Job; -import org.springframework.batch.core.JobParameters; -import org.springframework.batch.core.JobParametersBuilder; -import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -@RequiredArgsConstructor -@EnableScheduling -public class CommentPurgeScheduler { - - private final JobLauncher jobLauncher; - private final Job commentPurgeJob; - - @Scheduled(cron = "0 * * * * *") // 프로토타입: 1분마다 - public void runPurgeJob() throws Exception { - JobParameters params = new JobParametersBuilder() - .addLong("ts", System.currentTimeMillis()) - .toJobParameters(); - - jobLauncher.run(commentPurgeJob, params); - } - -} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/comments/service/CommentPurgeService.java b/monew-batch/src/main/java/com/monew/monew_batch/comments/service/CommentPurgeService.java deleted file mode 100644 index 0ef9a35..0000000 --- a/monew-batch/src/main/java/com/monew/monew_batch/comments/service/CommentPurgeService.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.monew.monew_batch.comments.service; - -import java.time.LocalDateTime; -import java.util.List; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Service -@RequiredArgsConstructor -public class CommentPurgeService { - - @PersistenceContext - private final EntityManager em; - - private static final int BATCH_SIZE = 500; - - @Transactional - public int purge(LocalDateTime cutoff) { - int totalDeleted = 0; - - while (true) { - List ids = em.createQuery( - "select c.id from Comment c " + - "where c.deleted = true and c.updatedAt < :cutoff", Long.class) - .setParameter("cutoff", cutoff) - .setMaxResults(BATCH_SIZE) - .getResultList(); - - if (ids.isEmpty()) break; - - em.createQuery("delete from CommentLike cl where cl.comment.id in :ids") - .setParameter("ids", ids) - .executeUpdate(); - - int deleted = em.createQuery("delete from Comment c where c.id in :ids") - .setParameter("ids", ids) - .executeUpdate(); - - totalDeleted += deleted; - em.clear(); - } - log.info("[PURGE][COMMENT] cutoff={}, deleted={}", cutoff, totalDeleted); - return totalDeleted; - } - -} From 2db4687842efde55528daa441ca441665f94baea Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Fri, 24 Oct 2025 17:26:02 +0900 Subject: [PATCH 063/178] =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=A4=91=EC=9D=B8?= =?UTF-8?q?=20=EB=82=B4=EC=9A=A9=20=EC=9E=84=EC=8B=9C=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserActivityController.java | 29 ++++++++++++++ .../useractivity/dto/ArticleViewDto.java | 33 ++++++++++++++++ .../useractivity/dto/CommentActivityDto.java | 25 ++++++++++++ .../dto/CommentLikeActivityDto.java | 29 ++++++++++++++ .../useractivity/dto/SubscriptionDto.java | 28 +++++++++++++ .../useractivity/dto/UserActivityDto.java | 39 +++++++++++++++++++ .../UserActivityCustomRepository.java | 16 ++++++++ .../UserActivityCustomRepositoryImpl.java | 4 ++ .../repository/UserActivityRepository.java | 15 +++++++ .../service/Impl/UserActivityServiceImpl.java | 29 ++++++++++++++ .../service/UserActivityService.java | 8 ++++ .../src/main/resources/application-dev.yml | 6 +-- 12 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscriptionDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepositoryImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java new file mode 100644 index 0000000..c341cd2 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java @@ -0,0 +1,29 @@ +package com.monew.monew_api.useractivity.controller; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; +import com.monew.monew_api.useractivity.service.UserActivityService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +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; + +@Slf4j +@RestController +@RequestMapping("/api/user-activities") +@RequiredArgsConstructor +public class UserActivityController { + + private final UserActivityService userActivityService; + + @GetMapping("/{userId}") + public ResponseEntity getUserActivity(@PathVariable String userId) { + log.info("활동내역 조회 요청: userId={}", userId); + + UserActivityDto activity = userActivityService.getUserActivity(userId); + + return ResponseEntity.ok(activity); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewDto.java new file mode 100644 index 0000000..f02a18c --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewDto.java @@ -0,0 +1,33 @@ +package com.monew.monew_api.useractivity.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 기사 조회 정보 DTO (임시) + * TODO: 팀원 작업 완료 후 com.monew.monew_api.article.dto.ArticleViewDto로 교체 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ArticleViewDto { + + private String id; + private String viewedBy; + private LocalDateTime createdAt; + + private String articleId; + private String source; + private String sourceUrl; + private String articleTitle; + private LocalDateTime articlePublishedDate; + private String articleSummary; + private Long articleCommentCount; + private Long articleViewCount; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java new file mode 100644 index 0000000..9bf43d5 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java @@ -0,0 +1,25 @@ +package com.monew.monew_api.useractivity.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CommentActivityDto { + + private String id; + private String articleId; + private String articleTitle; + private String userId; + private String userNickname; + private String content; + private Long likeCount; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java new file mode 100644 index 0000000..95f0972 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java @@ -0,0 +1,29 @@ +package com.monew.monew_api.useractivity.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CommentLikeActivityDto { + + private String id; + private LocalDateTime createdAt; + + private String commentId; + private String articleId; + private String articleTitle; + + private UUID commentUserId; + private String commentUserNickname; + private String commentContent; + private Long commentLikeCount; + private LocalDateTime commentCreatedAt; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscriptionDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscriptionDto.java new file mode 100644 index 0000000..adca038 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscriptionDto.java @@ -0,0 +1,28 @@ +package com.monew.monew_api.useractivity.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +/** + * (임시) + * TODO: 팀원 작업 완료 후 com.monew.monew_api.interest.dto.SubscriptionDto로 교체 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubscriptionDto { + + private String id; + private String interestId; + private String interestName; + private List interestKeywords; + private Long interestSubscriberCount; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java new file mode 100644 index 0000000..da72a19 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java @@ -0,0 +1,39 @@ +package com.monew.monew_api.useractivity.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 사용자 활동내역 응답 DTO + * 사용자의 구독 정보, 최근 작성 댓글, 최근 좋아요, 최근 본 기사를 포함 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserActivityDto { + + private String id; + private String email; + private String nickname; + private LocalDateTime createdAt; + + @Builder.Default + private List subscriptions = new ArrayList<>(); + + @Builder.Default + private List comments = new ArrayList<>(); + + @Builder.Default + private List commentLikes = new ArrayList<>(); + + @Builder.Default + private List articleViews = new ArrayList<>(); +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepository.java new file mode 100644 index 0000000..016f095 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepository.java @@ -0,0 +1,16 @@ +package com.monew.monew_api.useractivity.repository; + +// TODO: Entity 완성 후 주석 해제 및 import 추가 (subscribe, comment, commentLike, articleView) +// import com.monew.monew_api.subscribe.domain.Subscription; +// import com.monew.monew_api.comment.domain.Comment; +// import com.monew.monew_api.comment.domain.CommentLike; +// import com.monew.monew_api.article.domain.ArticleView; +// import java.util.List; + +public interface UserActivityCustomRepository { + + // List findSubscriptionsByUserId(Long userId); + // List findRecentCommentsByUserId(Long userId); + // List findRecentLikesByUserId(Long userId); + // List findRecentViewsByUserId(Long userId); +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepositoryImpl.java new file mode 100644 index 0000000..f42abed --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepositoryImpl.java @@ -0,0 +1,4 @@ +package com.monew.monew_api.useractivity.repository; + +public class UserActivityCustomRepositoryImpl implements UserActivityCustomRepository{ +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java new file mode 100644 index 0000000..c3a3674 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java @@ -0,0 +1,15 @@ +package com.monew.monew_api.useractivity.repository; + +// TODO: Entity 완성 후 주석 해제 및 import 추가 (User) +//import org.springframework.data.jpa.repository.JpaRepository; + +/** + * 사용자 기본 Repository + */ +// public interface UserActivityRepository +// extends JpaRepository, UserActivityCustomRepository { +// } + +public interface UserActivityRepository { + // TODO: Entity 완성 후 위 주석 해제 +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java new file mode 100644 index 0000000..bac59ec --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java @@ -0,0 +1,29 @@ +package com.monew.monew_api.useractivity.service.Impl; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; +import com.monew.monew_api.useractivity.service.UserActivityService; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; + +/* + * (임시) + * TODO: 엔티티 작업이 끝난 이후 Repository와 연동하여 실제 데이터를 반환하도록 수정 + */ +@Service +public class UserActivityServiceImpl implements UserActivityService { + @Override + public UserActivityDto getUserActivity(String userId) { + return UserActivityDto.builder() + .id(userId) + .email("temp@example.com") + .nickname("임시사용자") + .createdAt(LocalDateTime.now()) + .subscriptions(new ArrayList<>()) + .comments(new ArrayList<>()) + .commentLikes(new ArrayList<>()) + .articleViews(new ArrayList<>()) + .build(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java new file mode 100644 index 0000000..f10f557 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java @@ -0,0 +1,8 @@ +package com.monew.monew_api.useractivity.service; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; + + +public interface UserActivityService { + UserActivityDto getUserActivity(String userId); +} \ No newline at end of file diff --git a/monew-api/src/main/resources/application-dev.yml b/monew-api/src/main/resources/application-dev.yml index 14ffbe7..5b56238 100644 --- a/monew-api/src/main/resources/application-dev.yml +++ b/monew-api/src/main/resources/application-dev.yml @@ -3,9 +3,9 @@ server: spring: datasource: - url: ${DB_URL} - username: ${DB_USERNAME} - password: ${DB_PASSWORD} + url: ${DB_URL:jdbc:postgresql://localhost:5432/postgres} + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:postgres} driver-class-name: org.postgresql.Driver sql: From 2389874e9498038ff6f86f54f0b1c3af61098672 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Tue, 28 Oct 2025 17:04:02 +0900 Subject: [PATCH 064/178] =?UTF-8?q?feat:=20add=20=ED=99=9C=EB=8F=99?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EA=B8=B0=EB=B3=B8=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=20-=20=EC=9E=84=EC=8B=9C=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=EB=A5=BC=20=EC=9D=BC=EB=B6=80=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4.=20=20-?= =?UTF-8?q?=20repository=EC=97=90=EC=84=9C=20=EC=BF=BC=EB=A6=AC=EB=AC=B8?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=EC=9D=84=20=20-=20=EB=8B=A4=EC=A4=91=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EA=B3=BC=20=ED=95=98=EB=82=98=EC=9D=98=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=EB=AC=B8=EC=9C=BC=EB=A1=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=ED=95=98=EB=8A=94=20=EB=91=90=20=EA=B0=80=EC=A7=80=20?= =?UTF-8?q?=EB=B0=A9=EB=B2=95=20=EB=AA=A8=EB=91=90=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 또한 컨트롤러에도 둘 다 올라가 있으나 실제 프론트엔드에서 사용할려면 controller 수정이 필요합니다. - 하나의 쿼리문으로 받을 경우 파싱을 자동으로 해주지 못해서 json 문자열로 받고 해당 문자열을 배열로 변환하기 위해서 CommaSeparatedToListDeserializer 클래스를 작성했습니다. - 엔티티의 구조에 따라서 세부 내용은 추후 변경될 수 있습니다. - 특히 엔티티 연관 관계를 객체 참조인지 식별자 참조인지에 따라서 변경될 수 있습니다. --- .../monew_api/article/entity/Interest.java | 7 + .../controller/UserActivityController.java | 16 ++ .../dto/ArticleViewActivityDto.java | 60 +++++ .../useractivity/dto/ArticleViewDto.java | 33 --- .../useractivity/dto/CommentActivityDto.java | 26 +- .../dto/CommentLikeActivityDto.java | 34 ++- .../dto/SubscribesActivityDto.java | 45 ++++ .../useractivity/dto/SubscriptionDto.java | 28 --- .../useractivity/dto/UserActivityDto.java | 32 +-- .../CommaSeparatedToListDeserializer.java | 29 +++ .../mapper/UserActivityMapper.java | 83 +++++++ .../UserActivityCustomRepository.java | 16 -- .../UserActivityCustomRepositoryImpl.java | 4 - .../repository/UserActivityRepository.java | 31 ++- .../UserActivityRepositoryImpl.java | 231 ++++++++++++++++++ .../service/Impl/UserActivityServiceImpl.java | 187 ++++++++++++-- .../service/UserActivityService.java | 1 + .../useractivity/tempEntity/Article.java | 44 ++++ .../useractivity/tempEntity/ArticleView.java | 32 +++ .../useractivity/tempEntity/Comment.java | 50 ++++ .../useractivity/tempEntity/CommentLike.java | 37 +++ .../useractivity/tempEntity/Interest.java | 37 +++ .../tempEntity/InterestsKeywords.java | 40 +++ .../tempEntity/KeywordEntity.java | 28 +++ .../useractivity/tempEntity/Subscription.java | 33 +++ 25 files changed, 1030 insertions(+), 134 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewActivityDto.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscribesActivityDto.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscriptionDto.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/json/CommaSeparatedToListDeserializer.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepository.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepositoryImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Article.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/ArticleView.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Comment.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/CommentLike.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Interest.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/InterestsKeywords.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/KeywordEntity.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Subscription.java diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java index f56dcdf..0e5efa5 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java @@ -1,6 +1,7 @@ package com.monew.monew_api.article.entity; import com.monew.monew_api.common.entity.BaseIdEntity; +import com.monew.monew_api.useractivity.tempEntity.InterestsKeywords; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; @@ -22,4 +23,10 @@ public class Interest extends BaseIdEntity { @OneToMany(mappedBy = "interest", cascade = CascadeType.ALL, orphanRemoval = true) private List interestArticles = new ArrayList<>(); + + @OneToMany(mappedBy = "interest", fetch = FetchType.LAZY) + private List interestsKeywords = new ArrayList<>(); + + @Column(name = "subscriber_count", nullable = false) + private Integer subscriberCount; } diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java index c341cd2..e6042ab 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java @@ -18,6 +18,22 @@ public class UserActivityController { private final UserActivityService userActivityService; + /* + single query 방식으로 사용자 활동내역 조회 + */ + @GetMapping("/{userId}/temp") + public ResponseEntity getUserActivity2(@PathVariable String userId) { + log.info("활동내역 조회 요청: userId={}", userId); + + UserActivityDto activity = userActivityService.getUserActivitySingleQuery(userId); + + return ResponseEntity.ok(activity); + } + + /* + 여러 query 방식으로 사용자 활동내역 조회 + 사용시 엔드포인트와 메서드명 변경 필요 + */ @GetMapping("/{userId}") public ResponseEntity getUserActivity(@PathVariable String userId) { log.info("활동내역 조회 요청: userId={}", userId); diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewActivityDto.java new file mode 100644 index 0000000..93ee743 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewActivityDto.java @@ -0,0 +1,60 @@ +package com.monew.monew_api.useractivity.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ArticleViewActivityDto { + + @JsonProperty("id") + @JsonAlias({"view_id"}) + private String id; + + @JsonProperty("viewedBy") + @JsonAlias({"viewed_by"}) + private String viewedBy; + + @JsonProperty("createdAt") + @JsonAlias({"created_at"}) + private LocalDateTime createdAt; + + @JsonProperty("articleId") + @JsonAlias({"article_id"}) + private String articleId; + + @JsonProperty("source") + private String source; + + @JsonProperty("sourceUrl") + @JsonAlias({"source_url"}) + private String sourceUrl; + + @JsonProperty("articleTitle") + @JsonAlias({"article_title"}) + private String articleTitle; + + @JsonProperty("articlePublishedDate") + @JsonAlias({"publish_date", "article_published_date"}) + private LocalDateTime articlePublishedDate; + + @JsonProperty("articleSummary") + @JsonAlias({"summary", "article_summary"}) + private String articleSummary; + + @JsonProperty("articleCommentCount") + @JsonAlias({"comment_count", "article_comment_count"}) + private Integer articleCommentCount; + + @JsonProperty("articleViewCount") + @JsonAlias({"view_count", "article_view_count"}) + private Integer articleViewCount; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewDto.java deleted file mode 100644 index f02a18c..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewDto.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.monew.monew_api.useractivity.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; -import java.util.UUID; - -/** - * 기사 조회 정보 DTO (임시) - * TODO: 팀원 작업 완료 후 com.monew.monew_api.article.dto.ArticleViewDto로 교체 - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ArticleViewDto { - - private String id; - private String viewedBy; - private LocalDateTime createdAt; - - private String articleId; - private String source; - private String sourceUrl; - private String articleTitle; - private LocalDateTime articlePublishedDate; - private String articleSummary; - private Long articleCommentCount; - private Long articleViewCount; -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java index 9bf43d5..6a2809d 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java @@ -1,5 +1,7 @@ package com.monew.monew_api.useractivity.dto; +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -14,12 +16,34 @@ @AllArgsConstructor public class CommentActivityDto { + @JsonProperty("id") + @JsonAlias({"comment_id"}) private String id; + + @JsonProperty("articleId") + @JsonAlias({"article_id"}) private String articleId; + + @JsonProperty("articleTitle") + @JsonAlias({"article_title"}) private String articleTitle; + + @JsonProperty("userId") + @JsonAlias({"user_id"}) private String userId; + + @JsonProperty("userNickname") + @JsonAlias({"user_nickname"}) private String userNickname; + + @JsonProperty("content") private String content; - private Long likeCount; + + @JsonProperty("likeCount") + @JsonAlias({"like_count"}) + private Integer likeCount; + + @JsonProperty("createdAt") + @JsonAlias({"created_at"}) private LocalDateTime createdAt; } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java index 95f0972..8126f29 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java @@ -1,12 +1,13 @@ package com.monew.monew_api.useractivity.dto; +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import java.time.LocalDateTime; -import java.util.UUID; @Getter @Builder @@ -14,16 +15,43 @@ @AllArgsConstructor public class CommentLikeActivityDto { + @JsonProperty("id") + @JsonAlias({"like_id"}) private String id; + + @JsonProperty("createdAt") + @JsonAlias({"created_at"}) private LocalDateTime createdAt; + @JsonProperty("commentId") + @JsonAlias({"comment_id"}) private String commentId; + + @JsonProperty("articleId") + @JsonAlias({"article_id"}) private String articleId; + + @JsonProperty("articleTitle") + @JsonAlias({"article_title"}) private String articleTitle; - private UUID commentUserId; + @JsonProperty("commentUserId") + @JsonAlias({"comment_user_id"}) + private String commentUserId; + + @JsonProperty("commentUserNickname") + @JsonAlias({"comment_user_nickname"}) private String commentUserNickname; + + @JsonProperty("commentContent") + @JsonAlias({"comment_content"}) private String commentContent; - private Long commentLikeCount; + + @JsonProperty("commentLikeCount") + @JsonAlias({"comment_like_count"}) + private Integer commentLikeCount; + + @JsonProperty("commentCreatedAt") + @JsonAlias({"comment_created_at"}) private LocalDateTime commentCreatedAt; } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscribesActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscribesActivityDto.java new file mode 100644 index 0000000..02e9955 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscribesActivityDto.java @@ -0,0 +1,45 @@ +package com.monew.monew_api.useractivity.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.monew.monew_api.useractivity.json.CommaSeparatedToListDeserializer; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubscribesActivityDto { + + @JsonProperty("id") + @JsonAlias({"subscription_id"}) + private String id; + + @JsonProperty("createdAt") + @JsonAlias({"created_at", "subscription_created_at"}) + private LocalDateTime createdAt; + + @JsonProperty("interestId") + @JsonAlias({"interest_id"}) + private String interestId; + + @JsonProperty("interestName") + @JsonAlias({"interest_name"}) + private String interestName; + + @JsonProperty("interestSubscriberCount") + @JsonAlias({"interest_subscriber_count", "subscriber_count"}) + private Integer interestSubscriberCount; + + @JsonProperty("interestKeywords") + @JsonAlias({"interest_keywords", "keywords"}) + @JsonDeserialize(using = CommaSeparatedToListDeserializer.class) + private List interestKeywords; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscriptionDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscriptionDto.java deleted file mode 100644 index adca038..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscriptionDto.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.monew.monew_api.useractivity.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -/** - * (임시) - * TODO: 팀원 작업 완료 후 com.monew.monew_api.interest.dto.SubscriptionDto로 교체 - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class SubscriptionDto { - - private String id; - private String interestId; - private String interestName; - private List interestKeywords; - private Long interestSubscriberCount; - private LocalDateTime createdAt; -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java index da72a19..a5d3ca7 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java @@ -1,39 +1,25 @@ package com.monew.monew_api.useractivity.dto; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.Setter; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; -import java.util.UUID; -/** - * 사용자 활동내역 응답 DTO - * 사용자의 구독 정보, 최근 작성 댓글, 최근 좋아요, 최근 본 기사를 포함 - */ @Getter @Builder -@NoArgsConstructor -@AllArgsConstructor public class UserActivityDto { - private String id; private String email; private String nickname; private LocalDateTime createdAt; - - @Builder.Default - private List subscriptions = new ArrayList<>(); - - @Builder.Default - private List comments = new ArrayList<>(); - - @Builder.Default - private List commentLikes = new ArrayList<>(); - - @Builder.Default - private List articleViews = new ArrayList<>(); + @Setter + private List subscriptions; + @Setter + private List comments; + @Setter + private List commentLikes; + @Setter + private List articleViews; } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/json/CommaSeparatedToListDeserializer.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/json/CommaSeparatedToListDeserializer.java new file mode 100644 index 0000000..1212774 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/json/CommaSeparatedToListDeserializer.java @@ -0,0 +1,29 @@ +package com.monew.monew_api.useractivity.json; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/* + CommaSeparatedToListDeserializer + - 콤마(,)로 구분된 문자열을 List으로 변환하는 Jackson Deserializer + single 쿼리로 조회한 활동 내역에서, 관심사 키워드(interest_keywords) 필드가 + 콤마로 구분된 문자열로 반환되기 때문에 이를 List으로 변환하기 위해 사용 + */ +public class CommaSeparatedToListDeserializer extends JsonDeserializer> { + @Override + public List deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String text = p.getValueAsString(); + if (text == null || text.isEmpty()) return Collections.emptyList(); + return Arrays.stream(text.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java new file mode 100644 index 0000000..46c3cae --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java @@ -0,0 +1,83 @@ +package com.monew.monew_api.useractivity.mapper; + +import com.monew.monew_api.article.entity.Interest; +import com.monew.monew_api.useractivity.dto.*; +import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.useractivity.tempEntity.Comment; +import com.monew.monew_api.useractivity.tempEntity.CommentLike; +import com.monew.monew_api.useractivity.tempEntity.Subscription; +import org.mapstruct.*; + +import java.util.List; +import java.util.stream.Collectors; + +@Mapper(componentModel = "spring") +public interface UserActivityMapper { + + @Mapping(target = "id", expression = "java(String.valueOf(user.getId()))") + @Mapping(target = "email", source = "email") + @Mapping(target = "nickname", source = "nickname") + @Mapping(target = "createdAt", source = "createdAt") + @Mapping(target = "subscriptions", ignore = true) + @Mapping(target = "comments", ignore = true) + @Mapping(target = "commentLikes", ignore = true) + @Mapping(target = "articleViews", ignore = true) + UserActivityDto toUserActivityDto(User user); + + @Mapping(target = "id", expression = "java(String.valueOf(subscription.getId()))") + @Mapping(target = "interestId", expression = "java(String.valueOf(subscription.getInterest().getId()))") + @Mapping(target = "interestName", source = "interest.name") + @Mapping(target = "interestKeywords", expression = "java(mapKeywords(subscription.getInterest()))") + @Mapping(target = "interestSubscriberCount", source = "interest.subscriberCount") + @Mapping(target = "createdAt", source = "createdAt") + SubscribesActivityDto toSubscriptionDto(Subscription subscription); + + List toSubscriptionDtos(List subscriptions); + + @Mapping(target = "id", expression = "java(String.valueOf(comment.getId()))") + @Mapping(target = "articleId", expression = "java(String.valueOf(comment.getArticle().getId()))") + @Mapping(target = "articleTitle", source = "article.title") + @Mapping(target = "userId", expression = "java(String.valueOf(comment.getUser().getId()))") + @Mapping(target = "userNickname", source = "user.nickname") + @Mapping(target = "content", source = "content") + @Mapping(target = "likeCount", source = "likeCount") + @Mapping(target = "createdAt", source = "createdAt") + CommentActivityDto toCommentDto(Comment comment); + + List toCommentDtos(List comments); + + @Mapping(target = "id", expression = "java(String.valueOf(commentLike.getId()))") + @Mapping(target = "createdAt", source = "createdAt") + @Mapping(target = "commentId", expression = "java(String.valueOf(commentLike.getComment().getId()))") + @Mapping(target = "articleId", expression = "java(String.valueOf(commentLike.getComment().getArticle().getId()))") + @Mapping(target = "articleTitle", source = "comment.article.title") + @Mapping(target = "commentUserId", expression = "java(String.valueOf(commentLike.getComment().getUser().getId()))") + @Mapping(target = "commentUserNickname", source = "comment.user.nickname") + @Mapping(target = "commentContent", source = "comment.content") + @Mapping(target = "commentLikeCount", source = "comment.likeCount") + @Mapping(target = "commentCreatedAt", source = "comment.createdAt") + CommentLikeActivityDto toCommentLikeDto(CommentLike commentLike); + + List toCommentLikeDtos(List commentLikes); + +// @Mapping(target = "id", expression = "java(String.valueOf(articleView.getId()))") +// @Mapping(target = "viewedBy", expression = "java(String.valueOf(articleView.getUserId()))") +// @Mapping(target = "createdAt", source = "createdAt") +// @Mapping(target = "articleId", expression = "java(String.valueOf(articleView.getArticle().getId()))") +// @Mapping(target = "source", source = "article.source") +// @Mapping(target = "sourceUrl", source = "article.sourceUrl") +// @Mapping(target = "articleTitle", source = "article.title") +// @Mapping(target = "articlePublishedDate", source = "article.publishDate") +// @Mapping(target = "articleSummary", source = "article.summary") +// @Mapping(target = "articleCommentCount", source = "article.commentCount") +// @Mapping(target = "articleViewCount", source = "article.viewCount") +// ArticleViewActivityDto toArticleViewDto(ArticleView articleView); +// +// List toArticleViewActivityDtos(List articleViews); + + default List mapKeywords(Interest interest) { + return interest.getInterestsKeywords().stream() + .map(ik -> ik.getKeywordEntity().getKeyword()) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepository.java deleted file mode 100644 index 016f095..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.monew.monew_api.useractivity.repository; - -// TODO: Entity 완성 후 주석 해제 및 import 추가 (subscribe, comment, commentLike, articleView) -// import com.monew.monew_api.subscribe.domain.Subscription; -// import com.monew.monew_api.comment.domain.Comment; -// import com.monew.monew_api.comment.domain.CommentLike; -// import com.monew.monew_api.article.domain.ArticleView; -// import java.util.List; - -public interface UserActivityCustomRepository { - - // List findSubscriptionsByUserId(Long userId); - // List findRecentCommentsByUserId(Long userId); - // List findRecentLikesByUserId(Long userId); - // List findRecentViewsByUserId(Long userId); -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepositoryImpl.java deleted file mode 100644 index f42abed..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCustomRepositoryImpl.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.monew.monew_api.useractivity.repository; - -public class UserActivityCustomRepositoryImpl implements UserActivityCustomRepository{ -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java index c3a3674..ce02ee3 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java @@ -1,15 +1,28 @@ package com.monew.monew_api.useractivity.repository; -// TODO: Entity 완성 후 주석 해제 및 import 추가 (User) -//import org.springframework.data.jpa.repository.JpaRepository; - -/** - * 사용자 기본 Repository +/* +TODO: Entity 클래스 완성 되면 import 수정 */ -// public interface UserActivityRepository -// extends JpaRepository, UserActivityCustomRepository { -// } + +import com.monew.monew_api.article.entity.ArticleView; +import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.tempEntity.Comment; +import com.monew.monew_api.useractivity.tempEntity.CommentLike; +import com.monew.monew_api.useractivity.tempEntity.Subscription; + +import java.util.List; public interface UserActivityRepository { - // TODO: Entity 완성 후 위 주석 해제 + /* + 활동 내역을 4개의 쿼리로 처리 + */ + List findSubscriptionsByUserId(Long userId); + List findRecentCommentsByUserId(Long userId); + List findRecentLikesByUserId(Long userId); + List findRecentViewsByUserId(Long userId); + + /* + 활동 내역을 단일 쿼리로 처리 + */ + Object[] findUserActivitiesByUserId(Long userId); } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java new file mode 100644 index 0000000..931f63e --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java @@ -0,0 +1,231 @@ +package com.monew.monew_api.useractivity.repository; + +import com.monew.monew_api.article.entity.ArticleView; +import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.tempEntity.Comment; +import com.monew.monew_api.useractivity.tempEntity.CommentLike; +import com.monew.monew_api.useractivity.tempEntity.Subscription; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.monew.monew_api.article.entity.QArticle.article; +import static com.monew.monew_api.article.entity.QArticleView.articleView; +import static com.monew.monew_api.useractivity.tempEntity.QComment.comment; +import static com.monew.monew_api.useractivity.tempEntity.QCommentLike.commentLike; +import static com.monew.monew_api.useractivity.tempEntity.QInterestsKeywords.interestsKeywords; +import static com.monew.monew_api.useractivity.tempEntity.QKeywordEntity.keywordEntity; +import static com.monew.monew_api.useractivity.tempEntity.QSubscription.subscription; +import static com.monew.monew_api.article.entity.QInterest.interest; +import static com.monew.monew_api.article.entity.QArticleView.articleView; +import static com.monew.monew_api.domain.user.QUser.user; + +@Repository +@RequiredArgsConstructor +public class UserActivityRepositoryImpl implements UserActivityRepository { + + private final JPAQueryFactory queryFactory; + private final EntityManager entityManager; + + @Override + public List findSubscriptionsByUserId(Long userId) { + return queryFactory + .selectFrom(subscription) + .join(subscription.interest, interest).fetchJoin() + .leftJoin(interest.interestsKeywords, interestsKeywords).fetchJoin() + .leftJoin(interestsKeywords.keywordEntity, keywordEntity).fetchJoin() + .where(subscription.userId.eq(userId)) + .distinct() + .fetch(); + } + + @Override + public List findRecentCommentsByUserId(Long userId) { + return queryFactory + .selectFrom(comment) + .join(comment.article, article).fetchJoin() + .join(comment.user, user).fetchJoin() + .where( + comment.userId.eq(userId), + comment.isDeleted.isFalse(), + article.isDeleted.isFalse(), + user.deletedAt.isNull() + ) + .orderBy(comment.createdAt.desc()) + .limit(10) + .fetch(); + } + + @Override + public List findRecentLikesByUserId(Long userId) { + return queryFactory + .selectFrom(commentLike) + .join(commentLike.comment, comment).fetchJoin() + .join(comment.article, article).fetchJoin() + .join(comment.user, user).fetchJoin() + .where( + commentLike.userId.eq(userId), + comment.isDeleted.eq(false), + article.isDeleted.eq(false), + user.deletedAt.isNull() + ) + .orderBy(commentLike.createdAt.desc()) + .limit(10) + .fetch(); + } + + @Override + public List findRecentViewsByUserId(Long userId) { + return queryFactory + .select(Projections.fields( + ArticleViewActivityDto.class, + articleView.id.stringValue().as("id"), + articleView.userId.stringValue().as("viewedBy"), + articleView.createdAt.as("createdAt"), + articleView.articleId.stringValue().as("articleId"), + article.source.as("source"), + article.sourceUrl.as("sourceUrl"), + article.title.as("articleTitle"), + article.publishDate.as("articlePublishedDate"), + article.summary.as("articleSummary"), + article.commentCount.as("articleCommentCount"), + article.viewCount.as("articleViewCount") + )) + .from(articleView) + .join(article).on(article.id.eq(articleView.articleId)) + .where( + articleView.userId.eq(userId), + article.isDeleted.eq(false) + ) + .orderBy(articleView.createdAt.desc()) + .limit(10) + .fetch(); + } + + @Override + public Object[] findUserActivitiesByUserId(Long userId) { + String sql = """ + WITH recent_subscriptions AS ( + SELECT + s.id AS subscription_id, + s.user_id, + s.created_at AS subscription_created_at, + i.id AS interest_id, + i.name AS interest_name, + i.subscriber_count, + STRING_AGG(k.keyword, ',') AS keywords + FROM subscribes s + JOIN interests i ON s.interest_id = i.id + LEFT JOIN interests_keywords ik ON i.id = ik.interest_id + LEFT JOIN keywords k ON ik.keyword_id = k.id + WHERE s.user_id = :userId + GROUP BY s.id, s.user_id, s.created_at, i.id, i.name, i.subscriber_count + ORDER BY s.created_at DESC + ), + recent_comments AS ( + SELECT + c.id, + c.article_id, + c.user_id, + c.content, + c.like_count, + c.created_at, + a.title AS article_title, + u.nickname AS user_nickname + FROM comments c + JOIN articles a ON c.article_id = a.id + JOIN users u ON c.user_id = u.id + WHERE c.user_id = :userId + AND c.is_deleted = false + AND a.is_deleted = false + AND u.deleted_at IS NULL + ORDER BY c.created_at DESC + LIMIT 10 + ), + recent_likes AS ( + SELECT + cl.id, + cl.user_id, + cl.created_at, + cl.comment_id, + c.content AS comment_content, + c.like_count AS comment_like_count, + c.created_at AS comment_created_at, + c.user_id AS comment_user_id, + u.nickname AS comment_user_nickname, + a.id AS article_id, + a.title AS article_title + FROM comment_likes cl + JOIN comments c ON cl.comment_id = c.id + JOIN articles a ON c.article_id = a.id + JOIN users u ON c.user_id = u.id + WHERE cl.user_id = :userId + AND c.is_deleted = false + AND a.is_deleted = false + AND u.deleted_at IS NULL + ORDER BY cl.created_at DESC + LIMIT 10 + ), + recent_views AS ( + SELECT + av.id, + av.user_id, + av.created_at, + av.article_id, + a.source, + a.source_url, + a.title AS article_title, + a.publish_date, + a.summary, + a.comment_count, + a.view_count + FROM article_views av + JOIN articles a ON av.article_id = a.id + WHERE av.user_id = :userId + AND a.is_deleted = false + ORDER BY av.created_at DESC + LIMIT 10 + ) + SELECT + u.id AS id, + u.email, + u.nickname, + u.created_at AS created_at, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rs) ORDER BY rs.subscription_created_at DESC) + FROM recent_subscriptions rs WHERE rs.user_id = u.id), + '[]'::jsonb + ) AS subscriptions, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rc) ORDER BY rc.created_at DESC) + FROM recent_comments rc WHERE rc.user_id = u.id), + '[]'::jsonb + ) AS comments, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rl) ORDER BY rl.created_at DESC) + FROM recent_likes rl WHERE rl.user_id = u.id), + '[]'::jsonb + ) AS likes, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rv) ORDER BY rv.created_at DESC) + FROM recent_views rv WHERE rv.user_id = u.id), + '[]'::jsonb + ) AS views + FROM users u + WHERE u.id = :userId + """; + + Query query = entityManager.createNativeQuery(sql); + query.setParameter("userId", userId); + + @SuppressWarnings("unchecked") + List results = query.getResultList(); + + return results.isEmpty() ? null : results.get(0); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java index bac59ec..d09263c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java @@ -1,29 +1,182 @@ package com.monew.monew_api.useractivity.service.Impl; -import com.monew.monew_api.useractivity.dto.UserActivityDto; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.monew.monew_api.common.exception.user.UserNotFoundException; +import com.monew.monew_api.useractivity.dto.*; +import com.monew.monew_api.useractivity.mapper.UserActivityMapper; +import com.monew.monew_api.useractivity.repository.UserActivityRepository; import com.monew.monew_api.useractivity.service.UserActivityService; +import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.domain.user.repository.UserRepository; +import com.monew.monew_api.useractivity.tempEntity.Comment; +import com.monew.monew_api.useractivity.tempEntity.CommentLike; +import com.monew.monew_api.useractivity.tempEntity.Subscription; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.sql.Timestamp; import java.time.LocalDateTime; -import java.util.ArrayList; +import java.util.List; -/* - * (임시) - * TODO: 엔티티 작업이 끝난 이후 Repository와 연동하여 실제 데이터를 반환하도록 수정 - */ +@Slf4j @Service +@RequiredArgsConstructor +@Transactional(readOnly = true) public class UserActivityServiceImpl implements UserActivityService { + + private final UserRepository userRepository; + private final UserActivityRepository activityRepository; + private final UserActivityMapper mapper; + private final ObjectMapper objectMapper; + @Override public UserActivityDto getUserActivity(String userId) { - return UserActivityDto.builder() - .id(userId) - .email("temp@example.com") - .nickname("임시사용자") - .createdAt(LocalDateTime.now()) - .subscriptions(new ArrayList<>()) - .comments(new ArrayList<>()) - .commentLikes(new ArrayList<>()) - .articleViews(new ArrayList<>()) - .build(); + log.info("사용자 활동내역 조회 시작: userId={}", userId); + + Long userIdLong = Long.parseLong(userId); + + User user = userRepository.findById(userIdLong) + .orElseThrow(UserNotFoundException::new); + + List subscriptions = activityRepository.findSubscriptionsByUserId(userIdLong); + log.info("구독 정보 조회 완료: {}건", subscriptions.size()); + + List comments = activityRepository.findRecentCommentsByUserId(userIdLong); + log.info("최근 댓글 조회 완료: {}건", comments.size()); + + List likes = activityRepository.findRecentLikesByUserId(userIdLong); + log.info("최근 좋아요 조회 완료: {}건", likes.size()); + + List views = activityRepository.findRecentViewsByUserId(userIdLong); + log.info("최근 조회 기사 조회 완료: {}건", views.size()); + + UserActivityDto result = mapper.toUserActivityDto(user); + result.setSubscriptions(mapper.toSubscriptionDtos(subscriptions)); + result.setComments(mapper.toCommentDtos(comments)); + result.setCommentLikes(mapper.toCommentLikeDtos(likes)); + result.setArticleViews(views); + + log.info("사용자 활동내역 조회 완료: userId={}", userId); + return result; + } + + /** + * 추가: 단일 쿼리 방식 (성능 테스트용) + */ + @Override + public UserActivityDto getUserActivitySingleQuery(String userId) { + log.info("사용자 활동내역 조회 시작 (단일 쿼리): userId={}", userId); + + Long userIdLong = Long.parseLong(userId); + + // User 존재 확인 + userRepository.findById(userIdLong) + .orElseThrow(UserNotFoundException::new); + + // Repository에서 Raw 데이터 가져오기 + Object[] rawData = activityRepository.findUserActivitiesByUserId(userIdLong); + + if (rawData == null) { + log.error("사용자 활동 데이터를 찾을 수 없음: userId={}", userId); + throw new UserNotFoundException(); + } + + // Raw 데이터 파싱 및 DTO 매핑 + UserActivityDto result = parseRawDataToDto(rawData, userIdLong); + + log.info("사용자 활동내역 조회 완료 (단일 쿼리): userId={}, 구독: {}건, 댓글: {}건, 좋아요: {}건, 조회: {}건", + userId, + result.getSubscriptions().size(), + result.getComments().size(), + result.getCommentLikes().size(), + result.getArticleViews().size()); + + return result; + } + + /** + * Object[] Raw 데이터를 UserActivityDto로 변환 + * Object[] 구조: { userId, email, nickname, createdAt, subscriptionsJson, commentsJson, likesJson, viewsJson } + */ + private UserActivityDto parseRawDataToDto(Object[] rawData, Long userId) { + try { + // User 정보 추출 + String email = (String) rawData[1]; + String nickname = (String) rawData[2]; + LocalDateTime userCreatedAt = ((Timestamp) rawData[3]).toLocalDateTime(); + + log.info("===== JSON 데이터 디버깅 시작 ====="); + log.info("rawData[4] 타입: {}", rawData[4].getClass().getName()); + log.info("rawData[4] 값: {}", rawData[4]); + log.info("rawData[5] 타입: {}", rawData[5].getClass().getName()); + log.info("rawData[5] 값: {}", rawData[5]); + log.info("rawData[6] 타입: {}", rawData[6].getClass().getName()); + log.info("rawData[6] 값: {}", rawData[6]); + log.info("rawData[7] 타입: {}", rawData[7].getClass().getName()); + log.info("rawData[7] 값: {}", rawData[7]); + log.info("===== JSON 데이터 디버깅 끝 ====="); + + // JSON 문자열 추출 + String subscriptionsJson = rawData[4].toString(); + String commentsJson = rawData[5].toString(); + String likesJson = rawData[6].toString(); + String viewsJson = rawData[7].toString(); + + log.debug("JSON 파싱 시작: userId={}", userId); + + // JSON → DTO 리스트 변환 + List subscriptions = parseJsonToList( + subscriptionsJson, + new TypeReference>() {} + ); + + List comments = parseJsonToList( + commentsJson, + new TypeReference>() {} + ); + + List likes = parseJsonToList( + likesJson, + new TypeReference>() {} + ); + + List views = parseJsonToList( + viewsJson, + new TypeReference>() {} + ); + + log.debug("JSON 파싱 완료: userId={}", userId); + + // UserActivityDto 조합 + return UserActivityDto.builder() + .id(userId.toString()) + .email(email) + .nickname(nickname) + .createdAt(userCreatedAt) + .subscriptions(subscriptions) + .comments(comments) + .commentLikes(likes) + .articleViews(views) + .build(); + + } catch (Exception e) { + log.error("Raw 데이터 파싱 실패: userId={}", userId, e); + throw new RuntimeException("사용자 활동 데이터 파싱에 실패했습니다.", e); + } + } + + /** + * JSON 문자열을 DTO 리스트로 변환 + */ + private List parseJsonToList(String json, TypeReference> typeReference) { + try { + return objectMapper.readValue(json, typeReference); + } catch (Exception e) { + log.error("JSON 파싱 실패: json={}", json, e); + throw new RuntimeException("JSON 파싱에 실패했습니다.", e); + } } -} +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java index f10f557..698eba3 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java @@ -5,4 +5,5 @@ public interface UserActivityService { UserActivityDto getUserActivity(String userId); + UserActivityDto getUserActivitySingleQuery(String userId); } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Article.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Article.java new file mode 100644 index 0000000..bb2a74e --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Article.java @@ -0,0 +1,44 @@ +//package com.monew.monew_api.useractivity.tempEntity; +// +//import jakarta.persistence.*; +//import lombok.*; +// +//import java.time.LocalDateTime; +// +//@Entity(name = "Article_temp") +//@Table(name = "articles") +//@Getter +//@NoArgsConstructor(access = AccessLevel.PROTECTED) +//@AllArgsConstructor +//@Builder +//public class Article { +// +// @Id +// @GeneratedValue(strategy = GenerationType.IDENTITY) +// private Long id; +// +// @Column(nullable = false, length = 200) +// private String title; +// +// @Column(nullable = false, length = 20) +// private String source; +// +// @Column(name = "source_url", nullable = false, length = 500, unique = true) +// private String sourceUrl; +// +// @Column(nullable = false, length = 200) +// private String summary; +// +// @Column(name = "publish_date", nullable = false) +// private LocalDateTime publishDate; +// +// @Column(name = "comment_count", nullable = false) +// private Integer commentCount; +// +// @Column(name = "view_count", nullable = false) +// private Integer viewCount; +// +// @Column(name = "is_deleted", nullable = false) +// private Boolean isDeleted; +// +//} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/ArticleView.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/ArticleView.java new file mode 100644 index 0000000..3c74b60 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/ArticleView.java @@ -0,0 +1,32 @@ +//package com.monew.monew_api.useractivity.tempEntity; +// +//import jakarta.persistence.*; +//import lombok.*; +// +//import java.time.LocalDateTime; +// +//@Entity(name = "ArticleView_temp") +//@Table(name = "article_views") +//@Getter +//@NoArgsConstructor(access = AccessLevel.PROTECTED) +//@AllArgsConstructor +//@Builder +//public class ArticleView { +// +// @Id +// @GeneratedValue(strategy = GenerationType.IDENTITY) +// private Long id; +// +// @Column(name = "article_id", nullable = false) +// private Long articleId; +// +// @Column(name = "user_id", nullable = false) +// private Long userId; +// +// @Column(name = "created_at", nullable = false) +// private LocalDateTime createdAt; +// +// @ManyToOne(fetch = FetchType.LAZY) +// @JoinColumn(name = "article_id", insertable = false, updatable = false) +// private Article article; +//} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Comment.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Comment.java new file mode 100644 index 0000000..115e0db --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Comment.java @@ -0,0 +1,50 @@ +package com.monew.monew_api.useractivity.tempEntity; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.domain.user.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "comments") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "article_id", nullable = false) + private Long articleId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 500) + private String content; + + @Column(name = "like_count", nullable = false) + private Integer likeCount; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "article_id", insertable = false, updatable = false) + private Article article; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", insertable = false, updatable = false) + private User user; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/CommentLike.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/CommentLike.java new file mode 100644 index 0000000..a5808d1 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/CommentLike.java @@ -0,0 +1,37 @@ +package com.monew.monew_api.useractivity.tempEntity; + +import com.monew.monew_api.domain.user.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "comment_likes") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class CommentLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "comment_id", nullable = false) + private Long commentId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id", insertable = false, updatable = false) + private Comment comment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", insertable = false, updatable = false) + private User user; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Interest.java new file mode 100644 index 0000000..ec46d54 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Interest.java @@ -0,0 +1,37 @@ +//package com.monew.monew_api.useractivity.tempEntity; +// +//import jakarta.persistence.*; +//import lombok.*; +// +//import java.time.LocalDateTime; +//import java.util.ArrayList; +//import java.util.List; +// +//@Entity(name = "Interest_temp") +//@Table(name = "interests") +//@Getter +//@NoArgsConstructor(access = AccessLevel.PROTECTED) +//@AllArgsConstructor +//@Builder +//public class Interest { +// +// @Id +// @GeneratedValue(strategy = GenerationType.IDENTITY) +// private Long id; +// +// @Column(nullable = false, unique = true, length = 100) +// private String name; +// +// @Column(name = "subscriber_count", nullable = false) +// private Integer subscriberCount; +// +// @Column(name = "created_at", nullable = false) +// private LocalDateTime createdAt; +// +// @Column(name = "updated_at", nullable = false) +// private LocalDateTime updatedAt; +// +// @OneToMany(mappedBy = "interest", fetch = FetchType.LAZY) +// @Builder.Default +// private List interestsKeywords = new ArrayList<>(); +//} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/InterestsKeywords.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/InterestsKeywords.java new file mode 100644 index 0000000..516ef43 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/InterestsKeywords.java @@ -0,0 +1,40 @@ +package com.monew.monew_api.useractivity.tempEntity; + +import com.monew.monew_api.article.entity.Interest; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "interests_keywords") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class InterestsKeywords { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "interest_id", nullable = false) + private Long interestId; + + @Column(name = "keyword_id", nullable = false) + private Long keywordId; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "interest_id", insertable = false, updatable = false) + private Interest interest; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "keyword_id", insertable = false, updatable = false) + private KeywordEntity keywordEntity; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/KeywordEntity.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/KeywordEntity.java new file mode 100644 index 0000000..09c6847 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/KeywordEntity.java @@ -0,0 +1,28 @@ +package com.monew.monew_api.useractivity.tempEntity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "keywords") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class KeywordEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 50) + private String keyword; + + @Column(name = "created_at", nullable = false) // ← 추가! + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) // ← 추가! + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Subscription.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Subscription.java new file mode 100644 index 0000000..5c717ad --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Subscription.java @@ -0,0 +1,33 @@ +package com.monew.monew_api.useractivity.tempEntity; + +import jakarta.persistence.*; +import lombok.*; +import com.monew.monew_api.article.entity.Interest; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "subscribes") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Subscription { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "interest_id", nullable = false) + private Long interestId; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "interest_id", insertable = false, updatable = false) + private Interest interest; +} \ No newline at end of file From 9ceafcb14af1500acf3b1edad623a4497315b8c8 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Wed, 29 Oct 2025 16:38:23 +0900 Subject: [PATCH 065/178] =?UTF-8?q?chore=20:=20build.gradle=20mongoDB=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monew-api/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monew-api/build.gradle b/monew-api/build.gradle index 7f76b73..d658d6e 100644 --- a/monew-api/build.gradle +++ b/monew-api/build.gradle @@ -6,7 +6,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' -// implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'com.h2database:h2' implementation 'org.mapstruct:mapstruct:1.6.3' From 536fdad31b8ade56e5dc238fdfb3e0ecba30abec Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Wed, 29 Oct 2025 17:41:44 +0900 Subject: [PATCH 066/178] =?UTF-8?q?chore=20:=20application-dev.yml,=20appl?= =?UTF-8?q?ication-prod.yml=20=20-=20mongoDB=20url=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=20-=20index=20=EC=83=9D=EC=84=B1=EC=9D=84?= =?UTF-8?q?=20=EC=9C=84=ED=95=9C=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=20-=20url=EC=9D=80=20.env.example=EC=97=90=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EC=96=91=EC=8B=9D=EC=9C=BC=EB=A1=9C=20.env=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=ED=95=98=EC=8B=9C=EB=A9=B4=20=EB=90=A9?= =?UTF-8?q?=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat : add Entity 도메인 추가 MongoIndexConfig.java 추가 - 몽고 index TTL 생성 - 생성 완료 및 실패를 체크 임시 엔티티 삭제 --- .env.example | 1 + .../monew_api/article/entity/Interest.java | 32 ------- .../article/entity/InterestArticles.java | 1 + .../monew_api/comments/entity/Comment.java | 89 +++++++++++++++++++ .../comments/entity/CommentLike.java | 57 ++++++++++++ .../monew_api/interest/entity/Interest.java | 47 ++++++++++ .../interest/entity/InterestKeyword.java | 35 ++++++++ .../monew_api/interest/entity/Keyword.java | 22 +++++ .../monew_api/subscribe/entit/Subscribe.java | 31 +++++++ .../useractivity/dto/CommentActivityDto.java | 1 - .../useractivity/tempEntity/Article.java | 44 --------- .../useractivity/tempEntity/ArticleView.java | 32 ------- .../useractivity/tempEntity/Comment.java | 50 ----------- .../useractivity/tempEntity/CommentLike.java | 37 -------- .../useractivity/tempEntity/Interest.java | 37 -------- .../tempEntity/InterestsKeywords.java | 40 --------- .../tempEntity/KeywordEntity.java | 28 ------ .../useractivity/tempEntity/Subscription.java | 33 ------- .../src/main/resources/application-dev.yml | 11 ++- .../src/main/resources/application-prod.yml | 5 ++ 20 files changed, 296 insertions(+), 337 deletions(-) delete mode 100644 monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/entity/Keyword.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/subscribe/entit/Subscribe.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Article.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/ArticleView.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Comment.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/CommentLike.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Interest.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/InterestsKeywords.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/KeywordEntity.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Subscription.java diff --git a/.env.example b/.env.example index 8d7d5ba..00533c5 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ SPRING_PROFILES_ACTIVE= DB_URL= DB_USERNAME= DB_PASSWORD= +MongoDB_URI= # AWS AWS_ACCESS_KEY= diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java deleted file mode 100644 index 0e5efa5..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/Interest.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.monew.monew_api.article.entity; - -import com.monew.monew_api.common.entity.BaseIdEntity; -import com.monew.monew_api.useractivity.tempEntity.InterestsKeywords; -import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.ArrayList; -import java.util.List; - -/** - * 관심사 테이블 - */ -@Getter -@NoArgsConstructor -@Entity -@Table(name = "interests") -public class Interest extends BaseIdEntity { - - @Column(nullable = false, unique = true, length = 50) - private String name; - - @OneToMany(mappedBy = "interest", cascade = CascadeType.ALL, orphanRemoval = true) - private List interestArticles = new ArrayList<>(); - - @OneToMany(mappedBy = "interest", fetch = FetchType.LAZY) - private List interestsKeywords = new ArrayList<>(); - - @Column(name = "subscriber_count", nullable = false) - private Integer subscriberCount; -} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java index 4fd524c..9d58c37 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java @@ -1,6 +1,7 @@ package com.monew.monew_api.article.entity; import com.monew.monew_api.common.entity.BaseIdEntity; +import com.monew.monew_api.interest.entity.Interest; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java new file mode 100644 index 0000000..fe316bb --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java @@ -0,0 +1,89 @@ +package com.monew.monew_api.comments.entity; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.common.entity.BaseTimeEntity; +import com.monew.monew_api.domain.user.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table( + name = "comments", + indexes = { + @Index(name = "ix_comments_user", columnList = "user_id"), + @Index(name = "ix_comments_article", columnList = "article_id") + } +) +@SQLDelete(sql = "UPDATE comments SET is_deleted = true, updated_at = now() WHERE id = ?") +@Where(clause = "is_deleted = false") +public class Comment extends BaseTimeEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "article_id", nullable = false) + private Article article; + + @Size(max = 500) + @NotBlank + @Column(name = "content", nullable = false, length = 500) + private String content; + + @Column(name = "is_deleted", nullable = false) + private boolean deleted = false; + + @Column(name = "like_count", nullable = false) + private int likeCount = 0; + + private Comment(User user, Article article, String content) { + this.user = user; + this.article = article; + this.content = content; + } + + public static Comment of(User user, Article article, String content) { + return new Comment(user, article, content); + } + + public void updateContent(String content) { + this.content = content; + } + + public void increaseLike() { + this.likeCount++; + } + + public void decreaseLike() { + if (this.likeCount > 0) this.likeCount--; + } + + public boolean isOwnedBy(Long userId) { + return this.user != null && this.user.getId().equals(userId); + } + + public Long getUserId() { + return user != null ? user.getId() : null; + } + + public Long getArticleId() { + return article != null ? article.getId() : null; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java new file mode 100644 index 0000000..f0ba96f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java @@ -0,0 +1,57 @@ +package com.monew.monew_api.comments.entity; + +import com.monew.monew_api.common.entity.BaseCreatedEntity; +import com.monew.monew_api.domain.user.User; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table( + name = "comment_likes", + uniqueConstraints = { + @UniqueConstraint(name = "uq_comment_likes", columnNames = {"user_id", "comment_id"}) + }, + indexes = { + @Index(name = "ix_comment_likes_user", columnList = "user_id"), + @Index(name = "ix_comment_likes_comment", columnList = "comment_id") + } + +) +public class CommentLike extends BaseCreatedEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="user_id", nullable=false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="comment_id", nullable=false) + private Comment comment; + + private CommentLike(User user, Comment comment) { + this.user = user; + this.comment = comment; + } + + public static CommentLike of(User user, Comment comment) { + return new CommentLike(user, comment); + } + + public boolean isByUser(Long userId) { + return user != null && user.getId().equals(userId); + } + + public boolean isForComment(Long commentId) { + return comment != null && comment.getId().equals(commentId); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java new file mode 100644 index 0000000..070deca --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java @@ -0,0 +1,47 @@ +package com.monew.monew_api.interest.entity; + +import com.monew.monew_api.common.entity.BaseTimeEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.Table; +import java.util.HashSet; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "interests") +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class Interest extends BaseTimeEntity { + + @Column(length = 100, nullable = false, unique = true) + private String name; + + @Column(nullable = false) + private int subscriberCount = 0; + + @OneToMany(mappedBy = "interest", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("createdAt ASC") + private Set keywords = new HashSet<>(); + + private Interest(String name, int subscriberCount) { + this.name = name; + this.subscriberCount = subscriberCount; + } + + public static Interest create(String interestName) { + return new Interest(interestName, 0); + } + + public InterestKeyword addKeyword(Keyword keyword) { + InterestKeyword interestKeyword = InterestKeyword.create(this, keyword); + this.keywords.add(interestKeyword); + return interestKeyword; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java new file mode 100644 index 0000000..466dd95 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java @@ -0,0 +1,35 @@ +package com.monew.monew_api.interest.entity; + +import com.monew.monew_api.common.entity.BaseTimeEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "interest_keywords") +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class InterestKeyword extends BaseTimeEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "interest_id", nullable = false) + private Interest interest; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "keyword_id", nullable = false) + private Keyword keyword; + + public static InterestKeyword create(Interest interest, Keyword keyword) { + return new InterestKeyword(interest, keyword); + } + +} + + + diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Keyword.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Keyword.java new file mode 100644 index 0000000..2305036 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Keyword.java @@ -0,0 +1,22 @@ +package com.monew.monew_api.interest.entity; + +import com.monew.monew_api.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "keywords") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Keyword extends BaseTimeEntity { + + @Column(name = "keyword", length = 50, nullable = false, unique = true) + private String keyword; + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/entit/Subscribe.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/entit/Subscribe.java new file mode 100644 index 0000000..1a23e84 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/entit/Subscribe.java @@ -0,0 +1,31 @@ +package com.monew.monew_api.subscribe.entit; + +import com.monew.monew_api.common.entity.BaseCreatedEntity; +import com.monew.monew_api.common.entity.BaseTimeEntity; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.domain.user.User; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "subscribes") +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class Subscribe extends BaseCreatedEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "interest_id", nullable = false) + private Interest interest; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java index 6a2809d..f713a95 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java @@ -8,7 +8,6 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; -import java.util.UUID; @Getter @Builder diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Article.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Article.java deleted file mode 100644 index bb2a74e..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Article.java +++ /dev/null @@ -1,44 +0,0 @@ -//package com.monew.monew_api.useractivity.tempEntity; -// -//import jakarta.persistence.*; -//import lombok.*; -// -//import java.time.LocalDateTime; -// -//@Entity(name = "Article_temp") -//@Table(name = "articles") -//@Getter -//@NoArgsConstructor(access = AccessLevel.PROTECTED) -//@AllArgsConstructor -//@Builder -//public class Article { -// -// @Id -// @GeneratedValue(strategy = GenerationType.IDENTITY) -// private Long id; -// -// @Column(nullable = false, length = 200) -// private String title; -// -// @Column(nullable = false, length = 20) -// private String source; -// -// @Column(name = "source_url", nullable = false, length = 500, unique = true) -// private String sourceUrl; -// -// @Column(nullable = false, length = 200) -// private String summary; -// -// @Column(name = "publish_date", nullable = false) -// private LocalDateTime publishDate; -// -// @Column(name = "comment_count", nullable = false) -// private Integer commentCount; -// -// @Column(name = "view_count", nullable = false) -// private Integer viewCount; -// -// @Column(name = "is_deleted", nullable = false) -// private Boolean isDeleted; -// -//} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/ArticleView.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/ArticleView.java deleted file mode 100644 index 3c74b60..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/ArticleView.java +++ /dev/null @@ -1,32 +0,0 @@ -//package com.monew.monew_api.useractivity.tempEntity; -// -//import jakarta.persistence.*; -//import lombok.*; -// -//import java.time.LocalDateTime; -// -//@Entity(name = "ArticleView_temp") -//@Table(name = "article_views") -//@Getter -//@NoArgsConstructor(access = AccessLevel.PROTECTED) -//@AllArgsConstructor -//@Builder -//public class ArticleView { -// -// @Id -// @GeneratedValue(strategy = GenerationType.IDENTITY) -// private Long id; -// -// @Column(name = "article_id", nullable = false) -// private Long articleId; -// -// @Column(name = "user_id", nullable = false) -// private Long userId; -// -// @Column(name = "created_at", nullable = false) -// private LocalDateTime createdAt; -// -// @ManyToOne(fetch = FetchType.LAZY) -// @JoinColumn(name = "article_id", insertable = false, updatable = false) -// private Article article; -//} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Comment.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Comment.java deleted file mode 100644 index 115e0db..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Comment.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.monew.monew_api.useractivity.tempEntity; - -import com.monew.monew_api.article.entity.Article; -import com.monew.monew_api.domain.user.User; -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "comments") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class Comment { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "article_id", nullable = false) - private Long articleId; - - @Column(name = "user_id", nullable = false) - private Long userId; - - @Column(nullable = false, length = 500) - private String content; - - @Column(name = "like_count", nullable = false) - private Integer likeCount; - - @Column(name = "is_deleted", nullable = false) - private Boolean isDeleted; - - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - - @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "article_id", insertable = false, updatable = false) - private Article article; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", insertable = false, updatable = false) - private User user; -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/CommentLike.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/CommentLike.java deleted file mode 100644 index a5808d1..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/CommentLike.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.monew.monew_api.useractivity.tempEntity; - -import com.monew.monew_api.domain.user.User; -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "comment_likes") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class CommentLike { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "comment_id", nullable = false) - private Long commentId; - - @Column(name = "user_id", nullable = false) - private Long userId; - - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "comment_id", insertable = false, updatable = false) - private Comment comment; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", insertable = false, updatable = false) - private User user; -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Interest.java deleted file mode 100644 index ec46d54..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Interest.java +++ /dev/null @@ -1,37 +0,0 @@ -//package com.monew.monew_api.useractivity.tempEntity; -// -//import jakarta.persistence.*; -//import lombok.*; -// -//import java.time.LocalDateTime; -//import java.util.ArrayList; -//import java.util.List; -// -//@Entity(name = "Interest_temp") -//@Table(name = "interests") -//@Getter -//@NoArgsConstructor(access = AccessLevel.PROTECTED) -//@AllArgsConstructor -//@Builder -//public class Interest { -// -// @Id -// @GeneratedValue(strategy = GenerationType.IDENTITY) -// private Long id; -// -// @Column(nullable = false, unique = true, length = 100) -// private String name; -// -// @Column(name = "subscriber_count", nullable = false) -// private Integer subscriberCount; -// -// @Column(name = "created_at", nullable = false) -// private LocalDateTime createdAt; -// -// @Column(name = "updated_at", nullable = false) -// private LocalDateTime updatedAt; -// -// @OneToMany(mappedBy = "interest", fetch = FetchType.LAZY) -// @Builder.Default -// private List interestsKeywords = new ArrayList<>(); -//} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/InterestsKeywords.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/InterestsKeywords.java deleted file mode 100644 index 516ef43..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/InterestsKeywords.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.monew.monew_api.useractivity.tempEntity; - -import com.monew.monew_api.article.entity.Interest; -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "interests_keywords") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class InterestsKeywords { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "interest_id", nullable = false) - private Long interestId; - - @Column(name = "keyword_id", nullable = false) - private Long keywordId; - - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - - @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "interest_id", insertable = false, updatable = false) - private Interest interest; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "keyword_id", insertable = false, updatable = false) - private KeywordEntity keywordEntity; -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/KeywordEntity.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/KeywordEntity.java deleted file mode 100644 index 09c6847..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/KeywordEntity.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.monew.monew_api.useractivity.tempEntity; - -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "keywords") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class KeywordEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true, length = 50) - private String keyword; - - @Column(name = "created_at", nullable = false) // ← 추가! - private LocalDateTime createdAt; - - @Column(name = "updated_at", nullable = false) // ← 추가! - private LocalDateTime updatedAt; -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Subscription.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Subscription.java deleted file mode 100644 index 5c717ad..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/tempEntity/Subscription.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.monew.monew_api.useractivity.tempEntity; - -import jakarta.persistence.*; -import lombok.*; -import com.monew.monew_api.article.entity.Interest; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "subscribes") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class Subscription { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "user_id", nullable = false) - private Long userId; - - @Column(name = "interest_id", nullable = false) - private Long interestId; - - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "interest_id", insertable = false, updatable = false) - private Interest interest; -} \ No newline at end of file diff --git a/monew-api/src/main/resources/application-dev.yml b/monew-api/src/main/resources/application-dev.yml index 5b56238..91df8d4 100644 --- a/monew-api/src/main/resources/application-dev.yml +++ b/monew-api/src/main/resources/application-dev.yml @@ -3,11 +3,16 @@ server: spring: datasource: - url: ${DB_URL:jdbc:postgresql://localhost:5432/postgres} - username: ${DB_USERNAME:postgres} - password: ${DB_PASSWORD:postgres} + url: ${DB_URL:} + username: ${DB_USERNAME:} + password: ${DB_PASSWORD:} driver-class-name: org.postgresql.Driver + data: + mongodb: + uri: ${MongoDB_URI:} + auto-index-creation: true + sql: init: mode: always # 수동으로 제어하려면 never, 자동 실행하려면 always diff --git a/monew-api/src/main/resources/application-prod.yml b/monew-api/src/main/resources/application-prod.yml index b93e46e..d3ac14e 100644 --- a/monew-api/src/main/resources/application-prod.yml +++ b/monew-api/src/main/resources/application-prod.yml @@ -8,6 +8,11 @@ spring: password: ${DB_PASSWORD} driver-class-name: org.postgresql.Driver + data: + mongodb: + uri: ${MongoDB_URI:} + auto-index-creation: true + jpa: hibernate: ddl-auto: validate From 2ed71eebbf2a593294e05c424903d73e3de40f9b Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Wed, 29 Oct 2025 17:53:38 +0900 Subject: [PATCH 067/178] =?UTF-8?q?feat=20:=20postgreDB=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=A7=81=EC=A0=91=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20-=20=EA=B7=B8=EC=A0=84=EC=97=90=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EB=B0=B1=EC=9C=BC=EB=A1=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=EC=BF=BC=EB=A6=AC,=20=EC=97=AC=EB=9F=AC=EB=B2=88?= =?UTF-8?q?=EC=9D=98=20=EC=BF=BC=EB=A6=AC=EB=A1=9C=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=8B=A8=EC=9D=BC=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=A9=B4=20=EC=96=B4?= =?UTF-8?q?=EB=96=BB=EB=83=90=EB=8A=94=20=EC=9D=98=EA=B2=AC=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=88=EC=96=B4=EC=84=9C=20service=EC=97=90=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EB=90=98=EC=96=B4=EC=9E=88=EC=8A=B5=EB=8B=88=EB=8B=A4?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단일쿼리에서 limit 조건을 서브쿼리에 사용할려면 queryDSL으로는 불가능한 것 같아서 네이티브 쿼리로 작성했습니다. 네이티브 쿼리로 작성하면 Object[]로 반환해주게 되는데 이를 자동파싱/맵핑하는 방법을 적용할줄 몰라서 Object[] 내용으로 분해해서 파싱/맵핑했습니다. --- .../controller/UserActivityController.java | 16 +- .../mapper/UserActivityMapper.java | 43 ++- .../repository/UserActivityRepository.java | 12 +- .../UserActivityRepositoryImpl.java | 261 +++++++++--------- .../service/Impl/UserActivityServiceImpl.java | 28 +- .../service/UserActivityService.java | 12 + 6 files changed, 184 insertions(+), 188 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java index e6042ab..d633ba5 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java @@ -18,21 +18,11 @@ public class UserActivityController { private final UserActivityService userActivityService; - /* - single query 방식으로 사용자 활동내역 조회 - */ - @GetMapping("/{userId}/temp") - public ResponseEntity getUserActivity2(@PathVariable String userId) { - log.info("활동내역 조회 요청: userId={}", userId); - - UserActivityDto activity = userActivityService.getUserActivitySingleQuery(userId); - - return ResponseEntity.ok(activity); - } /* - 여러 query 방식으로 사용자 활동내역 조회 - 사용시 엔드포인트와 메서드명 변경 필요 + mongoDB 사용 시 getUserActivityWithCache 메서드 + 단일 쿼리 사용시 getUserActivitySingleQuery 메서드 (네이티브 쿼리) + 여러 쿼리 사용시 getUserActivity 메서드 */ @GetMapping("/{userId}") public ResponseEntity getUserActivity(@PathVariable String userId) { diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java index 46c3cae..d5b538f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java @@ -1,12 +1,17 @@ package com.monew.monew_api.useractivity.mapper; -import com.monew.monew_api.article.entity.Interest; -import com.monew.monew_api.useractivity.dto.*; +import com.monew.monew_api.comments.entity.Comment; +import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.domain.user.User; -import com.monew.monew_api.useractivity.tempEntity.Comment; -import com.monew.monew_api.useractivity.tempEntity.CommentLike; -import com.monew.monew_api.useractivity.tempEntity.Subscription; -import org.mapstruct.*; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.subscribe.entit.Subscribe; +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import com.monew.monew_api.useractivity.dto.CommentActivityDto; +import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto; +import com.monew.monew_api.useractivity.dto.SubscribesActivityDto; +import com.monew.monew_api.useractivity.dto.UserActivityDto; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import java.util.List; import java.util.stream.Collectors; @@ -30,9 +35,9 @@ public interface UserActivityMapper { @Mapping(target = "interestKeywords", expression = "java(mapKeywords(subscription.getInterest()))") @Mapping(target = "interestSubscriberCount", source = "interest.subscriberCount") @Mapping(target = "createdAt", source = "createdAt") - SubscribesActivityDto toSubscriptionDto(Subscription subscription); + SubscribesActivityDto toSubscriptionDto(Subscribe subscription); - List toSubscriptionDtos(List subscriptions); + List toSubscriptionDtos(List subscriptions); @Mapping(target = "id", expression = "java(String.valueOf(comment.getId()))") @Mapping(target = "articleId", expression = "java(String.valueOf(comment.getArticle().getId()))") @@ -60,24 +65,14 @@ public interface UserActivityMapper { List toCommentLikeDtos(List commentLikes); -// @Mapping(target = "id", expression = "java(String.valueOf(articleView.getId()))") -// @Mapping(target = "viewedBy", expression = "java(String.valueOf(articleView.getUserId()))") -// @Mapping(target = "createdAt", source = "createdAt") -// @Mapping(target = "articleId", expression = "java(String.valueOf(articleView.getArticle().getId()))") -// @Mapping(target = "source", source = "article.source") -// @Mapping(target = "sourceUrl", source = "article.sourceUrl") -// @Mapping(target = "articleTitle", source = "article.title") -// @Mapping(target = "articlePublishedDate", source = "article.publishDate") -// @Mapping(target = "articleSummary", source = "article.summary") -// @Mapping(target = "articleCommentCount", source = "article.commentCount") -// @Mapping(target = "articleViewCount", source = "article.viewCount") -// ArticleViewActivityDto toArticleViewDto(ArticleView articleView); -// -// List toArticleViewActivityDtos(List articleViews); + @Mapping(target = "cachedAt", expression = "java(java.time.LocalDateTime.now())") + UserActivityCacheDocument toDocument(UserActivityDto dto); + + UserActivityDto toDto(UserActivityCacheDocument document); default List mapKeywords(Interest interest) { - return interest.getInterestsKeywords().stream() - .map(ik -> ik.getKeywordEntity().getKeyword()) + return interest.getKeywords().stream() + .map(ik -> ik.getKeyword().getKeyword()) .collect(Collectors.toList()); } } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java index ce02ee3..1996284 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java @@ -4,11 +4,10 @@ TODO: Entity 클래스 완성 되면 import 수정 */ -import com.monew.monew_api.article.entity.ArticleView; +import com.monew.monew_api.comments.entity.Comment; +import com.monew.monew_api.comments.entity.CommentLike; +import com.monew.monew_api.subscribe.entit.Subscribe; import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; -import com.monew.monew_api.useractivity.tempEntity.Comment; -import com.monew.monew_api.useractivity.tempEntity.CommentLike; -import com.monew.monew_api.useractivity.tempEntity.Subscription; import java.util.List; @@ -16,9 +15,12 @@ public interface UserActivityRepository { /* 활동 내역을 4개의 쿼리로 처리 */ - List findSubscriptionsByUserId(Long userId); + List findSubscriptionsByUserId(Long userId); + List findRecentCommentsByUserId(Long userId); + List findRecentLikesByUserId(Long userId); + List findRecentViewsByUserId(Long userId); /* diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java index 931f63e..045fd1f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java @@ -1,29 +1,30 @@ package com.monew.monew_api.useractivity.repository; -import com.monew.monew_api.article.entity.ArticleView; +import com.monew.monew_api.comments.entity.Comment; +import com.monew.monew_api.comments.entity.CommentLike; +import com.monew.monew_api.interest.entity.QKeyword; +import com.monew.monew_api.subscribe.entit.Subscribe; import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; -import com.monew.monew_api.useractivity.tempEntity.Comment; -import com.monew.monew_api.useractivity.tempEntity.CommentLike; -import com.monew.monew_api.useractivity.tempEntity.Subscription; import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; +import org.springframework.data.mongodb.core.messaging.Subscription; import org.springframework.stereotype.Repository; import java.util.List; import static com.monew.monew_api.article.entity.QArticle.article; import static com.monew.monew_api.article.entity.QArticleView.articleView; -import static com.monew.monew_api.useractivity.tempEntity.QComment.comment; -import static com.monew.monew_api.useractivity.tempEntity.QCommentLike.commentLike; -import static com.monew.monew_api.useractivity.tempEntity.QInterestsKeywords.interestsKeywords; -import static com.monew.monew_api.useractivity.tempEntity.QKeywordEntity.keywordEntity; -import static com.monew.monew_api.useractivity.tempEntity.QSubscription.subscription; -import static com.monew.monew_api.article.entity.QInterest.interest; -import static com.monew.monew_api.article.entity.QArticleView.articleView; +import static com.monew.monew_api.comments.entity.QComment.comment; +import static com.monew.monew_api.comments.entity.QCommentLike.commentLike; import static com.monew.monew_api.domain.user.QUser.user; +import static com.monew.monew_api.interest.entity.QInterestKeyword.interestKeyword; +import static com.monew.monew_api.subscribe.entit.QSubscribe.subscribe; +import static com.monew.monew_api.interest.entity.QInterest.interest; +import static com.monew.monew_api.interest.entity.QKeyword.keyword1; + @Repository @RequiredArgsConstructor @@ -33,13 +34,13 @@ public class UserActivityRepositoryImpl implements UserActivityRepository { private final EntityManager entityManager; @Override - public List findSubscriptionsByUserId(Long userId) { + public List findSubscriptionsByUserId(Long userId) { return queryFactory - .selectFrom(subscription) - .join(subscription.interest, interest).fetchJoin() - .leftJoin(interest.interestsKeywords, interestsKeywords).fetchJoin() - .leftJoin(interestsKeywords.keywordEntity, keywordEntity).fetchJoin() - .where(subscription.userId.eq(userId)) + .selectFrom(subscribe) + .join(subscribe.interest, interest).fetchJoin() + .leftJoin(interest.keywords, interestKeyword).fetchJoin() + .leftJoin(interestKeyword.keyword, QKeyword.keyword1).fetchJoin() + .where(subscribe.user.id.eq(userId)) .distinct() .fetch(); } @@ -51,8 +52,8 @@ public List findRecentCommentsByUserId(Long userId) { .join(comment.article, article).fetchJoin() .join(comment.user, user).fetchJoin() .where( - comment.userId.eq(userId), - comment.isDeleted.isFalse(), + comment.user.id.eq(userId), + comment.deleted.isFalse(), article.isDeleted.isFalse(), user.deletedAt.isNull() ) @@ -69,8 +70,8 @@ public List findRecentLikesByUserId(Long userId) { .join(comment.article, article).fetchJoin() .join(comment.user, user).fetchJoin() .where( - commentLike.userId.eq(userId), - comment.isDeleted.eq(false), + commentLike.user.id.eq(userId), + comment.deleted.eq(false), article.isDeleted.eq(false), user.deletedAt.isNull() ) @@ -110,120 +111,120 @@ public List findRecentViewsByUserId(Long userId) { @Override public Object[] findUserActivitiesByUserId(Long userId) { String sql = """ - WITH recent_subscriptions AS ( - SELECT - s.id AS subscription_id, - s.user_id, - s.created_at AS subscription_created_at, - i.id AS interest_id, - i.name AS interest_name, - i.subscriber_count, - STRING_AGG(k.keyword, ',') AS keywords - FROM subscribes s - JOIN interests i ON s.interest_id = i.id - LEFT JOIN interests_keywords ik ON i.id = ik.interest_id - LEFT JOIN keywords k ON ik.keyword_id = k.id - WHERE s.user_id = :userId - GROUP BY s.id, s.user_id, s.created_at, i.id, i.name, i.subscriber_count - ORDER BY s.created_at DESC - ), - recent_comments AS ( - SELECT - c.id, - c.article_id, - c.user_id, - c.content, - c.like_count, - c.created_at, - a.title AS article_title, - u.nickname AS user_nickname - FROM comments c - JOIN articles a ON c.article_id = a.id - JOIN users u ON c.user_id = u.id - WHERE c.user_id = :userId - AND c.is_deleted = false - AND a.is_deleted = false - AND u.deleted_at IS NULL - ORDER BY c.created_at DESC - LIMIT 10 - ), - recent_likes AS ( - SELECT - cl.id, - cl.user_id, - cl.created_at, - cl.comment_id, - c.content AS comment_content, - c.like_count AS comment_like_count, - c.created_at AS comment_created_at, - c.user_id AS comment_user_id, - u.nickname AS comment_user_nickname, - a.id AS article_id, - a.title AS article_title - FROM comment_likes cl - JOIN comments c ON cl.comment_id = c.id - JOIN articles a ON c.article_id = a.id - JOIN users u ON c.user_id = u.id - WHERE cl.user_id = :userId - AND c.is_deleted = false - AND a.is_deleted = false - AND u.deleted_at IS NULL - ORDER BY cl.created_at DESC - LIMIT 10 - ), - recent_views AS ( + WITH recent_subscriptions AS ( + SELECT + s.id AS subscription_id, + s.user_id, + s.created_at AS subscription_created_at, + i.id AS interest_id, + i.name AS interest_name, + i.subscriber_count, + STRING_AGG(k.keyword, ',') AS keywords + FROM subscribes s + JOIN interests i ON s.interest_id = i.id + LEFT JOIN interests_keywords ik ON i.id = ik.interest_id + LEFT JOIN keywords k ON ik.keyword_id = k.id + WHERE s.user_id = :userId + GROUP BY s.id, s.user_id, s.created_at, i.id, i.name, i.subscriber_count + ORDER BY s.created_at DESC + ), + recent_comments AS ( + SELECT + c.id, + c.article_id, + c.user_id, + c.content, + c.like_count, + c.created_at, + a.title AS article_title, + u.nickname AS user_nickname + FROM comments c + JOIN articles a ON c.article_id = a.id + JOIN users u ON c.user_id = u.id + WHERE c.user_id = :userId + AND c.is_deleted = false + AND a.is_deleted = false + AND u.deleted_at IS NULL + ORDER BY c.created_at DESC + LIMIT 10 + ), + recent_likes AS ( + SELECT + cl.id, + cl.user_id, + cl.created_at, + cl.comment_id, + c.content AS comment_content, + c.like_count AS comment_like_count, + c.created_at AS comment_created_at, + c.user_id AS comment_user_id, + u.nickname AS comment_user_nickname, + a.id AS article_id, + a.title AS article_title + FROM comment_likes cl + JOIN comments c ON cl.comment_id = c.id + JOIN articles a ON c.article_id = a.id + JOIN users u ON c.user_id = u.id + WHERE cl.user_id = :userId + AND c.is_deleted = false + AND a.is_deleted = false + AND u.deleted_at IS NULL + ORDER BY cl.created_at DESC + LIMIT 10 + ), + recent_views AS ( + SELECT + av.id, + av.user_id, + av.created_at, + av.article_id, + a.source, + a.source_url, + a.title AS article_title, + a.publish_date, + a.summary, + a.comment_count, + a.view_count + FROM article_views av + JOIN articles a ON av.article_id = a.id + WHERE av.user_id = :userId + AND a.is_deleted = false + ORDER BY av.created_at DESC + LIMIT 10 + ) SELECT - av.id, - av.user_id, - av.created_at, - av.article_id, - a.source, - a.source_url, - a.title AS article_title, - a.publish_date, - a.summary, - a.comment_count, - a.view_count - FROM article_views av - JOIN articles a ON av.article_id = a.id - WHERE av.user_id = :userId - AND a.is_deleted = false - ORDER BY av.created_at DESC - LIMIT 10 - ) - SELECT - u.id AS id, - u.email, - u.nickname, - u.created_at AS created_at, - COALESCE( - (SELECT jsonb_agg(to_jsonb(rs) ORDER BY rs.subscription_created_at DESC) - FROM recent_subscriptions rs WHERE rs.user_id = u.id), - '[]'::jsonb - ) AS subscriptions, - COALESCE( - (SELECT jsonb_agg(to_jsonb(rc) ORDER BY rc.created_at DESC) - FROM recent_comments rc WHERE rc.user_id = u.id), - '[]'::jsonb - ) AS comments, - COALESCE( - (SELECT jsonb_agg(to_jsonb(rl) ORDER BY rl.created_at DESC) - FROM recent_likes rl WHERE rl.user_id = u.id), - '[]'::jsonb - ) AS likes, - COALESCE( - (SELECT jsonb_agg(to_jsonb(rv) ORDER BY rv.created_at DESC) - FROM recent_views rv WHERE rv.user_id = u.id), - '[]'::jsonb - ) AS views - FROM users u - WHERE u.id = :userId - """; + u.id AS id, + u.email, + u.nickname, + u.created_at AS created_at, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rs) ORDER BY rs.subscription_created_at DESC) + FROM recent_subscriptions rs WHERE rs.user_id = u.id), + '[]'::jsonb + ) AS subscriptions, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rc) ORDER BY rc.created_at DESC) + FROM recent_comments rc WHERE rc.user_id = u.id), + '[]'::jsonb + ) AS comments, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rl) ORDER BY rl.created_at DESC) + FROM recent_likes rl WHERE rl.user_id = u.id), + '[]'::jsonb + ) AS likes, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rv) ORDER BY rv.created_at DESC) + FROM recent_views rv WHERE rv.user_id = u.id), + '[]'::jsonb + ) AS views + FROM users u + WHERE u.id = :userId + """; Query query = entityManager.createNativeQuery(sql); query.setParameter("userId", userId); - @SuppressWarnings("unchecked") +// @SuppressWarnings("unchecked") List results = query.getResultList(); return results.isEmpty() ? null : results.get(0); diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java index d09263c..7d58c11 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java @@ -2,24 +2,28 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.monew.monew_api.comments.entity.Comment; +import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.common.exception.user.UserNotFoundException; +import com.monew.monew_api.subscribe.entit.Subscribe; +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; import com.monew.monew_api.useractivity.dto.*; import com.monew.monew_api.useractivity.mapper.UserActivityMapper; +import com.monew.monew_api.useractivity.repository.UserActivityCacheRepository; import com.monew.monew_api.useractivity.repository.UserActivityRepository; import com.monew.monew_api.useractivity.service.UserActivityService; import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; -import com.monew.monew_api.useractivity.tempEntity.Comment; -import com.monew.monew_api.useractivity.tempEntity.CommentLike; -import com.monew.monew_api.useractivity.tempEntity.Subscription; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.mongodb.core.messaging.Subscription; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.sql.Timestamp; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; @Slf4j @Service @@ -29,10 +33,12 @@ public class UserActivityServiceImpl implements UserActivityService { private final UserRepository userRepository; private final UserActivityRepository activityRepository; + private final UserActivityCacheRepository cacheRepository; private final UserActivityMapper mapper; private final ObjectMapper objectMapper; @Override + @Transactional(readOnly = true) public UserActivityDto getUserActivity(String userId) { log.info("사용자 활동내역 조회 시작: userId={}", userId); @@ -41,7 +47,7 @@ public UserActivityDto getUserActivity(String userId) { User user = userRepository.findById(userIdLong) .orElseThrow(UserNotFoundException::new); - List subscriptions = activityRepository.findSubscriptionsByUserId(userIdLong); + List subscriptions = activityRepository.findSubscriptionsByUserId(userIdLong); log.info("구독 정보 조회 완료: {}건", subscriptions.size()); List comments = activityRepository.findRecentCommentsByUserId(userIdLong); @@ -64,9 +70,10 @@ public UserActivityDto getUserActivity(String userId) { } /** - * 추가: 단일 쿼리 방식 (성능 테스트용) + * 추가: 단일 쿼리 방식 */ @Override + @Transactional(readOnly = true) public UserActivityDto getUserActivitySingleQuery(String userId) { log.info("사용자 활동내역 조회 시작 (단일 쿼리): userId={}", userId); @@ -108,17 +115,6 @@ private UserActivityDto parseRawDataToDto(Object[] rawData, Long userId) { String nickname = (String) rawData[2]; LocalDateTime userCreatedAt = ((Timestamp) rawData[3]).toLocalDateTime(); - log.info("===== JSON 데이터 디버깅 시작 ====="); - log.info("rawData[4] 타입: {}", rawData[4].getClass().getName()); - log.info("rawData[4] 값: {}", rawData[4]); - log.info("rawData[5] 타입: {}", rawData[5].getClass().getName()); - log.info("rawData[5] 값: {}", rawData[5]); - log.info("rawData[6] 타입: {}", rawData[6].getClass().getName()); - log.info("rawData[6] 값: {}", rawData[6]); - log.info("rawData[7] 타입: {}", rawData[7].getClass().getName()); - log.info("rawData[7] 값: {}", rawData[7]); - log.info("===== JSON 데이터 디버깅 끝 ====="); - // JSON 문자열 추출 String subscriptionsJson = rawData[4].toString(); String commentsJson = rawData[5].toString(); diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java index 698eba3..a51f8b8 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java @@ -4,6 +4,18 @@ public interface UserActivityService { + UserActivityDto getUserActivity(String userId); + + /** + * 사용자 활동내역 조회 (단일 쿼리) + * PostgreSQL에서 직접 조회 + */ UserActivityDto getUserActivitySingleQuery(String userId); + + /** + * 사용자 활동내역 조회 (캐시 적용) ✅ 새로 추가! + * MongoDB 캐시 확인 → 없으면 PostgreSQL 조회 → 캐시 저장 + */ +// UserActivityDto getUserActivityWithCache(String userId); } \ No newline at end of file From 391728dc7d8915cffc80db24816f201009f3f89d Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Wed, 29 Oct 2025 17:56:31 +0900 Subject: [PATCH 068/178] =?UTF-8?q?feat=20:=20add=20mongoDB=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=EB=9E=B5=20Look=20Aside=EC=9D=84=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.=20Servi?= =?UTF-8?q?ce=20=EC=BD=94=EB=93=9C=EC=97=90=EC=84=9C=20cache=EA=B0=80=20?= =?UTF-8?q?=EC=A0=81=EC=A4=91=ED=95=98=EB=A9=B4=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20dto=EB=A1=9C=20=EB=B0=98=ED=99=98=20=EC=95=84?= =?UTF-8?q?=EB=8B=88=EB=A9=B4=20=EA=B8=B0=EC=A1=B4=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이때 단일쿼리/여러쿼리중 무엇을 할 지 아직 정해지지않아서 해당부분은 정해지면 수정될 예정입니다. UserActivityDto result = `getUserActivity(userId)`; --- .../document/UserActivityCacheDocument.java | 39 +++++++++++++++++++ .../UserActivityCacheRepository.java | 9 +++++ .../service/Impl/UserActivityServiceImpl.java | 31 +++++++++++++++ .../service/UserActivityService.java | 2 +- 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java new file mode 100644 index 0000000..51aa1bf --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java @@ -0,0 +1,39 @@ +package com.monew.monew_api.useractivity.document; + +import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.dto.CommentActivityDto; +import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto; +import com.monew.monew_api.useractivity.dto.SubscribesActivityDto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.util.List; + +@Document(collection = "user_activity_cache") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserActivityCacheDocument { + + @Id + private String id; + + private String email; + private String nickname; + private LocalDateTime createdAt; + + private List subscriptions; + private List comments; + private List commentLikes; + private List articleViews; + + @Indexed(expireAfter = "1h") + private LocalDateTime cachedAt; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java new file mode 100644 index 0000000..eb59229 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java @@ -0,0 +1,9 @@ +package com.monew.monew_api.useractivity.repository; + +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserActivityCacheRepository extends MongoRepository { +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java index 7d58c11..f2aac30 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java @@ -104,6 +104,37 @@ public UserActivityDto getUserActivitySingleQuery(String userId) { return result; } + @Override + public UserActivityDto getUserActivityWithCache(String userId) { + log.info("사용자 활동내역 조회 시작 (캐시): userId={}", userId); + + Optional cached = cacheRepository.findById(userId); + + if (cached.isPresent()) { + log.info("Cache HIT: userId={}", userId); + return mapper.toDto(cached.get()); + } + + log.info("Cache MISS: userId={}", userId); + + UserActivityDto result = getUserActivity(userId); + + saveToCache(result); + + log.info("사용자 활동내역 조회 완료 (캐시): userId={}", userId); + return result; + } + + private void saveToCache(UserActivityDto dto) { + try { + UserActivityCacheDocument document = mapper.toDocument(dto); + cacheRepository.save(document); + log.info("MongoDB 캐시 저장 완료: userId={}", dto.getId()); + } catch (Exception e) { + log.error("MongoDB 캐시 저장 실패: userId={}", dto.getId(), e); + } + } + /** * Object[] Raw 데이터를 UserActivityDto로 변환 * Object[] 구조: { userId, email, nickname, createdAt, subscriptionsJson, commentsJson, likesJson, viewsJson } diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java index a51f8b8..cc1cb55 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java @@ -17,5 +17,5 @@ public interface UserActivityService { * 사용자 활동내역 조회 (캐시 적용) ✅ 새로 추가! * MongoDB 캐시 확인 → 없으면 PostgreSQL 조회 → 캐시 저장 */ -// UserActivityDto getUserActivityWithCache(String userId); + UserActivityDto getUserActivityWithCache(String userId); } \ No newline at end of file From 8176260776936875d86f978311d985ff1b0eba4e Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Wed, 29 Oct 2025 18:25:10 +0900 Subject: [PATCH 069/178] =?UTF-8?q?feat=20:=20=EB=88=84=EB=9D=BD=EB=90=9C?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20throw=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useractivity/service/Impl/UserActivityServiceImpl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java index f2aac30..bab2454 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java @@ -132,6 +132,7 @@ private void saveToCache(UserActivityDto dto) { log.info("MongoDB 캐시 저장 완료: userId={}", dto.getId()); } catch (Exception e) { log.error("MongoDB 캐시 저장 실패: userId={}", dto.getId(), e); + throw new RuntimeException("캐시 저장에 실패했습니다.", e); } } From c0a099136de8ab2b6ae3e483417d360947feff08 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 30 Oct 2025 10:00:40 +0900 Subject: [PATCH 070/178] =?UTF-8?q?feat=20:=20=EB=8B=A8=EC=9D=BC=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20object[]=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20=ED=95=98=EB=8D=98=EA=B1=B8=20tuple?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=98=ED=99=98=ED=95=98=EA=B3=A0=20=ED=95=B4?= =?UTF-8?q?=EB=8B=B9=20tuple=EC=9D=84=20record=ED=83=80=EC=9E=85=EC=9D=98?= =?UTF-8?q?=20UserActivityRaw=EB=A5=BC=20=ED=95=98=EB=82=98=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EC=96=B4=EC=84=9C=20projection=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 object[] 받아와서 index기반으로 바꾸면 실제 쿼리가 바뀌었을때 반환되는 컬럼의 순서가 매우 중요해서 실수할까봐 alias 기준으로 변경할수있게 구조를 변경했습니다. 데이터 타입 변환 흐름은 tuple[] -> record -> DTO 입니다 --- .../controller/UserActivityController.java | 2 +- .../useractivity/dto/UserActivityDto.java | 11 +- .../mapper/UserActivityRawMapper.java | 73 +++++++++ .../repository/UserActivityRepository.java | 5 + .../UserActivityRepositoryImpl.java | 145 +++++++++++++++++- .../projection/UserActivityRaw.java | 19 +++ .../service/Impl/UserActivityServiceImpl.java | 93 ++--------- 7 files changed, 254 insertions(+), 94 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/projection/UserActivityRaw.java diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java index d633ba5..189d200 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java @@ -28,7 +28,7 @@ public class UserActivityController { public ResponseEntity getUserActivity(@PathVariable String userId) { log.info("활동내역 조회 요청: userId={}", userId); - UserActivityDto activity = userActivityService.getUserActivity(userId); + UserActivityDto activity = userActivityService.getUserActivitySingleQuery(userId); return ResponseEntity.ok(activity); } diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java index a5d3ca7..572d4f7 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java @@ -1,25 +1,22 @@ package com.monew.monew_api.useractivity.dto; -import lombok.Builder; -import lombok.Getter; -import lombok.Setter; +import lombok.*; import java.time.LocalDateTime; import java.util.List; @Getter +@Setter @Builder +@NoArgsConstructor +@AllArgsConstructor public class UserActivityDto { private String id; private String email; private String nickname; private LocalDateTime createdAt; - @Setter private List subscriptions; - @Setter private List comments; - @Setter private List commentLikes; - @Setter private List articleViews; } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java new file mode 100644 index 0000000..cf816c8 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java @@ -0,0 +1,73 @@ +package com.monew.monew_api.useractivity.mapper; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.monew.monew_api.useractivity.dto.*; +import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserActivityRawMapper { + + private final ObjectMapper objectMapper; + + /** + * UserActivityRaw (Record) → UserActivityDto 변환 + */ + public UserActivityDto toDto(UserActivityRaw record) { + if (record == null) { + return null; + } + + UserActivityDto dto = new UserActivityDto(); + dto.setId(String.valueOf(record.id())); + dto.setEmail(record.email()); + dto.setNickname(record.nickname()); + dto.setCreatedAt(record.createdAt()); + + // JSON String → List 변환 + dto.setSubscriptions(parseJsonList( + record.subscriptions(), + new TypeReference>() {} + )); + dto.setComments(parseJsonList( + record.comments(), + new TypeReference>() {} + )); + dto.setCommentLikes(parseJsonList( + record.likes(), + new TypeReference>() {} + )); + dto.setArticleViews(parseJsonList( + record.views(), + new TypeReference>() {} + )); + + return dto; + } + + /** + * JSON String → List 파싱 + */ + private List parseJsonList(String json, TypeReference> typeRef) { + if (json == null || json.isBlank() || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + + try { + List result = objectMapper.readValue(json, typeRef); + return result != null ? result : Collections.emptyList(); + } catch (JsonProcessingException e) { + log.error("JSON 파싱 실패: {}", json, e); + return Collections.emptyList(); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java index 1996284..514e8a9 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java @@ -8,6 +8,7 @@ import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.subscribe.entit.Subscribe; import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; import java.util.List; @@ -27,4 +28,8 @@ public interface UserActivityRepository { 활동 내역을 단일 쿼리로 처리 */ Object[] findUserActivitiesByUserId(Long userId); + /* + record 사용한 단일 쿼리 + */ + UserActivityRaw findUserActivityRaw(Long userId); } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java index 045fd1f..ad93fdc 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java @@ -5,14 +5,17 @@ import com.monew.monew_api.interest.entity.QKeyword; import com.monew.monew_api.subscribe.entit.Subscribe; import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; + import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; +import jakarta.persistence.Tuple; import lombok.RequiredArgsConstructor; -import org.springframework.data.mongodb.core.messaging.Subscription; import org.springframework.stereotype.Repository; +import java.sql.Timestamp; import java.util.List; import static com.monew.monew_api.article.entity.QArticle.article; @@ -20,10 +23,9 @@ import static com.monew.monew_api.comments.entity.QComment.comment; import static com.monew.monew_api.comments.entity.QCommentLike.commentLike; import static com.monew.monew_api.domain.user.QUser.user; +import static com.monew.monew_api.interest.entity.QInterest.interest; import static com.monew.monew_api.interest.entity.QInterestKeyword.interestKeyword; import static com.monew.monew_api.subscribe.entit.QSubscribe.subscribe; -import static com.monew.monew_api.interest.entity.QInterest.interest; -import static com.monew.monew_api.interest.entity.QKeyword.keyword1; @Repository @@ -229,4 +231,141 @@ recent_views AS ( return results.isEmpty() ? null : results.get(0); } + + @Override + public UserActivityRaw findUserActivityRaw(Long userId) { + String sql = """ + WITH recent_subscriptions AS ( + SELECT + s.id AS subscription_id, + s.user_id, + s.created_at AS subscription_created_at, + i.id AS interest_id, + i.name AS interest_name, + i.subscriber_count, + STRING_AGG(k.keyword, ',') AS keywords + FROM subscribes s + JOIN interests i ON s.interest_id = i.id + LEFT JOIN interests_keywords ik ON i.id = ik.interest_id + LEFT JOIN keywords k ON ik.keyword_id = k.id + WHERE s.user_id = :userId + GROUP BY s.id, s.user_id, s.created_at, i.id, i.name, i.subscriber_count + ORDER BY s.created_at DESC + ), + recent_comments AS ( + SELECT + c.id, + c.article_id, + c.user_id, + c.content, + c.like_count, + c.created_at, + a.title AS article_title, + u.nickname AS user_nickname + FROM comments c + JOIN articles a ON c.article_id = a.id + JOIN users u ON c.user_id = u.id + WHERE c.user_id = :userId + AND c.is_deleted = false + AND a.is_deleted = false + AND u.deleted_at IS NULL + ORDER BY c.created_at DESC + LIMIT 10 + ), + recent_likes AS ( + SELECT + cl.id, + cl.user_id, + cl.created_at, + cl.comment_id, + c.content AS comment_content, + c.like_count AS comment_like_count, + c.created_at AS comment_created_at, + c.user_id AS comment_user_id, + u.nickname AS comment_user_nickname, + a.id AS article_id, + a.title AS article_title + FROM comment_likes cl + JOIN comments c ON cl.comment_id = c.id + JOIN articles a ON c.article_id = a.id + JOIN users u ON c.user_id = u.id + WHERE cl.user_id = :userId + AND c.is_deleted = false + AND a.is_deleted = false + AND u.deleted_at IS NULL + ORDER BY cl.created_at DESC + LIMIT 10 + ), + recent_views AS ( + SELECT + av.id, + av.user_id, + av.created_at, + av.article_id, + a.source, + a.source_url, + a.title AS article_title, + a.publish_date, + a.summary, + a.comment_count, + a.view_count + FROM article_views av + JOIN articles a ON av.article_id = a.id + WHERE av.user_id = :userId + AND a.is_deleted = false + ORDER BY av.created_at DESC + LIMIT 10 + ) + SELECT + u.id as id, + u.email as email, + u.nickname as nickname, + u.created_at as createdAt, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rs) ORDER BY rs.subscription_created_at DESC) + FROM recent_subscriptions rs WHERE rs.user_id = u.id), + '[]'::jsonb + )::text as subscriptions, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rc) ORDER BY rc.created_at DESC) + FROM recent_comments rc WHERE rc.user_id = u.id), + '[]'::jsonb + )::text as comments, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rl) ORDER BY rl.created_at DESC) + FROM recent_likes rl WHERE rl.user_id = u.id), + '[]'::jsonb + )::text as likes, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rv) ORDER BY rv.created_at DESC) + FROM recent_views rv WHERE rv.user_id = u.id), + '[]'::jsonb + )::text as views + FROM users u + WHERE u.id = :userId + """; + + Query query = entityManager.createNativeQuery(sql, Tuple.class); + query.setParameter("userId", userId); + + @SuppressWarnings("unchecked") + List results = query.getResultList(); + + if (results.isEmpty()) { + return null; + } + + Tuple tuple = results.get(0); + + return new UserActivityRaw( + tuple.get("id", Long.class), + tuple.get("email", String.class), + tuple.get("nickname", String.class), + tuple.get("createdat", Timestamp.class).toLocalDateTime(), + tuple.get("subscriptions", String.class), + tuple.get("comments", String.class), + tuple.get("likes", String.class), + tuple.get("views", String.class) + ); + } } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/projection/UserActivityRaw.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/projection/UserActivityRaw.java new file mode 100644 index 0000000..da058db --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/projection/UserActivityRaw.java @@ -0,0 +1,19 @@ +package com.monew.monew_api.useractivity.repository.projection; + +import java.time.LocalDateTime; + +/** + * 사용자 활동 네이티브 쿼리 결과를 담는 불변 데이터 컨테이너 + * alias와 일치하는 필드명을 사용 + * jsonb 필드는 String으로 들어오기 때문에 Mapper에서 List 변환 필요 + */ +public record UserActivityRaw( + Long id, + String email, + String nickname, + LocalDateTime createdAt, + String subscriptions, + String comments, + String likes, + String views +) {} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java index bab2454..c308807 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java @@ -9,8 +9,10 @@ import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; import com.monew.monew_api.useractivity.dto.*; import com.monew.monew_api.useractivity.mapper.UserActivityMapper; +import com.monew.monew_api.useractivity.mapper.UserActivityRawMapper; import com.monew.monew_api.useractivity.repository.UserActivityCacheRepository; import com.monew.monew_api.useractivity.repository.UserActivityRepository; +import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; import com.monew.monew_api.useractivity.service.UserActivityService; import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; @@ -35,6 +37,7 @@ public class UserActivityServiceImpl implements UserActivityService { private final UserActivityRepository activityRepository; private final UserActivityCacheRepository cacheRepository; private final UserActivityMapper mapper; + private final UserActivityRawMapper rawMapper; private final ObjectMapper objectMapper; @Override @@ -75,24 +78,19 @@ public UserActivityDto getUserActivity(String userId) { @Override @Transactional(readOnly = true) public UserActivityDto getUserActivitySingleQuery(String userId) { - log.info("사용자 활동내역 조회 시작 (단일 쿼리): userId={}", userId); + log.info("사용자 활동내역 조회 시작 (단일 쿼리 - Record): userId={}", userId); Long userIdLong = Long.parseLong(userId); - // User 존재 확인 - userRepository.findById(userIdLong) - .orElseThrow(UserNotFoundException::new); - - // Repository에서 Raw 데이터 가져오기 - Object[] rawData = activityRepository.findUserActivitiesByUserId(userIdLong); + UserActivityRaw raw = activityRepository.findUserActivityRaw(userIdLong); - if (rawData == null) { + if (raw == null) { log.error("사용자 활동 데이터를 찾을 수 없음: userId={}", userId); throw new UserNotFoundException(); } - // Raw 데이터 파싱 및 DTO 매핑 - UserActivityDto result = parseRawDataToDto(rawData, userIdLong); + // 2. Record → DTO 변환 + UserActivityDto result = rawMapper.toDto(raw); log.info("사용자 활동내역 조회 완료 (단일 쿼리): userId={}, 구독: {}건, 댓글: {}건, 좋아요: {}건, 조회: {}건", userId, @@ -112,12 +110,13 @@ public UserActivityDto getUserActivityWithCache(String userId) { if (cached.isPresent()) { log.info("Cache HIT: userId={}", userId); + log.info("사용자 활동내역 조회 완료 (캐시)"); return mapper.toDto(cached.get()); } log.info("Cache MISS: userId={}", userId); - UserActivityDto result = getUserActivity(userId); + UserActivityDto result = getUserActivitySingleQuery(userId); saveToCache(result); @@ -135,76 +134,4 @@ private void saveToCache(UserActivityDto dto) { throw new RuntimeException("캐시 저장에 실패했습니다.", e); } } - - /** - * Object[] Raw 데이터를 UserActivityDto로 변환 - * Object[] 구조: { userId, email, nickname, createdAt, subscriptionsJson, commentsJson, likesJson, viewsJson } - */ - private UserActivityDto parseRawDataToDto(Object[] rawData, Long userId) { - try { - // User 정보 추출 - String email = (String) rawData[1]; - String nickname = (String) rawData[2]; - LocalDateTime userCreatedAt = ((Timestamp) rawData[3]).toLocalDateTime(); - - // JSON 문자열 추출 - String subscriptionsJson = rawData[4].toString(); - String commentsJson = rawData[5].toString(); - String likesJson = rawData[6].toString(); - String viewsJson = rawData[7].toString(); - - log.debug("JSON 파싱 시작: userId={}", userId); - - // JSON → DTO 리스트 변환 - List subscriptions = parseJsonToList( - subscriptionsJson, - new TypeReference>() {} - ); - - List comments = parseJsonToList( - commentsJson, - new TypeReference>() {} - ); - - List likes = parseJsonToList( - likesJson, - new TypeReference>() {} - ); - - List views = parseJsonToList( - viewsJson, - new TypeReference>() {} - ); - - log.debug("JSON 파싱 완료: userId={}", userId); - - // UserActivityDto 조합 - return UserActivityDto.builder() - .id(userId.toString()) - .email(email) - .nickname(nickname) - .createdAt(userCreatedAt) - .subscriptions(subscriptions) - .comments(comments) - .commentLikes(likes) - .articleViews(views) - .build(); - - } catch (Exception e) { - log.error("Raw 데이터 파싱 실패: userId={}", userId, e); - throw new RuntimeException("사용자 활동 데이터 파싱에 실패했습니다.", e); - } - } - - /** - * JSON 문자열을 DTO 리스트로 변환 - */ - private List parseJsonToList(String json, TypeReference> typeReference) { - try { - return objectMapper.readValue(json, typeReference); - } catch (Exception e) { - log.error("JSON 파싱 실패: json={}", json, e); - throw new RuntimeException("JSON 파싱에 실패했습니다.", e); - } - } } \ No newline at end of file From ea5e6e48a14966b4dbe1e0e2d4f8194b24168699 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 30 Oct 2025 10:48:15 +0900 Subject: [PATCH 071/178] =?UTF-8?q?feat=20:=20modify=20UserActivityDto=20@?= =?UTF-8?q?Setter=20=EC=A0=9C=EA=B1=B0,=20=EB=8B=A4=EB=A5=B8=20=EA=B3=B3?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=8A=94=20builder=20=ED=8C=A8=ED=84=B4?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useractivity/dto/UserActivityDto.java | 1 - .../mapper/UserActivityMapper.java | 32 ++++++++----- .../mapper/UserActivityRawMapper.java | 47 +++++++++---------- .../service/Impl/UserActivityServiceImpl.java | 6 +-- 4 files changed, 42 insertions(+), 44 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java index 572d4f7..da3b95c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java @@ -6,7 +6,6 @@ import java.util.List; @Getter -@Setter @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java index d5b538f..a32c7c6 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java @@ -6,10 +6,7 @@ import com.monew.monew_api.interest.entity.Interest; import com.monew.monew_api.subscribe.entit.Subscribe; import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; -import com.monew.monew_api.useractivity.dto.CommentActivityDto; -import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto; -import com.monew.monew_api.useractivity.dto.SubscribesActivityDto; -import com.monew.monew_api.useractivity.dto.UserActivityDto; +import com.monew.monew_api.useractivity.dto.*; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @@ -19,15 +16,24 @@ @Mapper(componentModel = "spring") public interface UserActivityMapper { - @Mapping(target = "id", expression = "java(String.valueOf(user.getId()))") - @Mapping(target = "email", source = "email") - @Mapping(target = "nickname", source = "nickname") - @Mapping(target = "createdAt", source = "createdAt") - @Mapping(target = "subscriptions", ignore = true) - @Mapping(target = "comments", ignore = true) - @Mapping(target = "commentLikes", ignore = true) - @Mapping(target = "articleViews", ignore = true) - UserActivityDto toUserActivityDto(User user); + default UserActivityDto toUserActivityDto( + User user, + List subscriptions, + List comments, + List likes, + List views + ) { + return UserActivityDto.builder() + .id(String.valueOf(user.getId())) + .email(user.getEmail()) + .nickname(user.getNickname()) + .createdAt(user.getCreatedAt()) + .subscriptions(toSubscriptionDtos(subscriptions)) + .comments(toCommentDtos(comments)) + .commentLikes(toCommentLikeDtos(likes)) + .articleViews(views) + .build(); + } @Mapping(target = "id", expression = "java(String.valueOf(subscription.getId()))") @Mapping(target = "interestId", expression = "java(String.valueOf(subscription.getInterest().getId()))") diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java index cf816c8..25626d8 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java @@ -27,31 +27,28 @@ public UserActivityDto toDto(UserActivityRaw record) { return null; } - UserActivityDto dto = new UserActivityDto(); - dto.setId(String.valueOf(record.id())); - dto.setEmail(record.email()); - dto.setNickname(record.nickname()); - dto.setCreatedAt(record.createdAt()); - - // JSON String → List 변환 - dto.setSubscriptions(parseJsonList( - record.subscriptions(), - new TypeReference>() {} - )); - dto.setComments(parseJsonList( - record.comments(), - new TypeReference>() {} - )); - dto.setCommentLikes(parseJsonList( - record.likes(), - new TypeReference>() {} - )); - dto.setArticleViews(parseJsonList( - record.views(), - new TypeReference>() {} - )); - - return dto; + return UserActivityDto.builder() + .id(String.valueOf(record.id())) + .email(record.email()) + .nickname(record.nickname()) + .createdAt(record.createdAt()) + .subscriptions(parseJsonList( + record.subscriptions(), + new TypeReference>() {} + )) + .comments(parseJsonList( + record.comments(), + new TypeReference>() {} + )) + .commentLikes(parseJsonList( + record.likes(), + new TypeReference>() {} + )) + .articleViews(parseJsonList( + record.views(), + new TypeReference>() {} + )) + .build(); } /** diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java index c308807..f0562b4 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java @@ -62,11 +62,7 @@ public UserActivityDto getUserActivity(String userId) { List views = activityRepository.findRecentViewsByUserId(userIdLong); log.info("최근 조회 기사 조회 완료: {}건", views.size()); - UserActivityDto result = mapper.toUserActivityDto(user); - result.setSubscriptions(mapper.toSubscriptionDtos(subscriptions)); - result.setComments(mapper.toCommentDtos(comments)); - result.setCommentLikes(mapper.toCommentLikeDtos(likes)); - result.setArticleViews(views); + UserActivityDto result = mapper.toUserActivityDto(user, subscriptions, comments, likes, views); log.info("사용자 활동내역 조회 완료: userId={}", userId); return result; From 59f29ab1805f634f1b96e853febc2f5d94257359 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 30 Oct 2025 11:16:20 +0900 Subject: [PATCH 072/178] =?UTF-8?q?refactor=20:=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=EB=AA=85=20entit=20->=20entity=20=EC=98=A4=ED=83=88=EC=9E=90?= =?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 --- .../monew/monew_api/subscribe/{entit => entity}/Subscribe.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename monew-api/src/main/java/com/monew/monew_api/subscribe/{entit => entity}/Subscribe.java (88%) diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/entit/Subscribe.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java similarity index 88% rename from monew-api/src/main/java/com/monew/monew_api/subscribe/entit/Subscribe.java rename to monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java index 1a23e84..99bdc45 100644 --- a/monew-api/src/main/java/com/monew/monew_api/subscribe/entit/Subscribe.java +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java @@ -1,7 +1,6 @@ -package com.monew.monew_api.subscribe.entit; +package com.monew.monew_api.subscribe.entity; import com.monew.monew_api.common.entity.BaseCreatedEntity; -import com.monew.monew_api.common.entity.BaseTimeEntity; import com.monew.monew_api.interest.entity.Interest; import com.monew.monew_api.domain.user.User; import jakarta.persistence.Entity; From 7114f1457875f547824128a81c2d4ce71c52c2cb Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 30 Oct 2025 13:57:37 +0900 Subject: [PATCH 073/178] =?UTF-8?q?refactor=20:=20subscribe.entity=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=EB=AA=85=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20import=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useractivity/mapper/UserActivityMapper.java | 2 +- .../repository/UserActivityRepository.java | 2 +- .../repository/UserActivityRepositoryImpl.java | 4 ++-- .../service/Impl/UserActivityServiceImpl.java | 13 +++++-------- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java index a32c7c6..f87e608 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java @@ -4,7 +4,7 @@ import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.domain.user.User; import com.monew.monew_api.interest.entity.Interest; -import com.monew.monew_api.subscribe.entit.Subscribe; +import com.monew.monew_api.subscribe.entity.Subscribe; import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; import com.monew.monew_api.useractivity.dto.*; import org.mapstruct.Mapper; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java index 514e8a9..f661b7f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java @@ -6,7 +6,7 @@ import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; -import com.monew.monew_api.subscribe.entit.Subscribe; +import com.monew.monew_api.subscribe.entity.Subscribe; import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java index ad93fdc..5e5eca7 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java @@ -3,7 +3,7 @@ import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.interest.entity.QKeyword; -import com.monew.monew_api.subscribe.entit.Subscribe; +import com.monew.monew_api.subscribe.entity.Subscribe; import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; @@ -25,7 +25,7 @@ import static com.monew.monew_api.domain.user.QUser.user; import static com.monew.monew_api.interest.entity.QInterest.interest; import static com.monew.monew_api.interest.entity.QInterestKeyword.interestKeyword; -import static com.monew.monew_api.subscribe.entit.QSubscribe.subscribe; +import static com.monew.monew_api.subscribe.entity.QSubscribe.subscribe; @Repository diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java index f0562b4..32bff8b 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java @@ -1,29 +1,26 @@ package com.monew.monew_api.useractivity.service.Impl; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.common.exception.user.UserNotFoundException; -import com.monew.monew_api.subscribe.entit.Subscribe; +import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.domain.user.repository.UserRepository; +import com.monew.monew_api.subscribe.entity.Subscribe; import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; -import com.monew.monew_api.useractivity.dto.*; +import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.dto.UserActivityDto; import com.monew.monew_api.useractivity.mapper.UserActivityMapper; import com.monew.monew_api.useractivity.mapper.UserActivityRawMapper; import com.monew.monew_api.useractivity.repository.UserActivityCacheRepository; import com.monew.monew_api.useractivity.repository.UserActivityRepository; import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; import com.monew.monew_api.useractivity.service.UserActivityService; -import com.monew.monew_api.domain.user.User; -import com.monew.monew_api.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.mongodb.core.messaging.Subscription; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.sql.Timestamp; -import java.time.LocalDateTime; import java.util.List; import java.util.Optional; From 9d19cbc2ea8153d18505171b2fa250fdacd2382c Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Thu, 30 Oct 2025 16:27:53 +0900 Subject: [PATCH 074/178] =?UTF-8?q?refactor=20:=20=EA=B4=80=EC=8B=AC?= =?UTF-8?q?=EC=82=AC=20=EC=9D=B4=EB=A6=84=EC=88=9C=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=9E=AC=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InterestRepositoryCustomImpl.java | 184 ++++++++++-------- 1 file changed, 102 insertions(+), 82 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java index a1fe0c2..4533a7a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java @@ -3,15 +3,19 @@ import com.monew.monew_api.interest.entity.QInterest; import com.monew.monew_api.interest.dto.InterestOrderBy; import com.monew.monew_api.interest.entity.Interest; -import com.querydsl.core.BooleanBuilder; -import com.querydsl.core.types.Order; +import com.monew.monew_api.interest.entity.QInterestKeyword; +import com.monew.monew_api.interest.entity.QKeyword; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Expression; import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; import org.springframework.data.domain.Sort.Direction; @@ -22,113 +26,129 @@ public class InterestRepositoryCustomImpl implements InterestRepositoryCustom { private final JPAQueryFactory queryFactory; - private static final QInterest i = QInterest.interest; + private static final QInterest i = QInterest.interest; + private static final QKeyword k = QKeyword.keyword1; + private static final QInterestKeyword ik = QInterestKeyword.interestKeyword; + + private Expression sortExpression(InterestOrderBy sortBy) { + return switch (sortBy) { + case name -> i.name; + case subscriberCount -> i.subscriberCount; + }; + } + + // 키워드 검색으로 얻은 관심사를 통해 그 관심사에 포함된 모든 키워드 반환 + // ID로 필터링 후 fetch join으로 로딩 @Override - public Slice findAll( - String searchKeyword, InterestOrderBy sortBy, Direction direction, - String cursor, LocalDateTime after, int limit - ) { + public Slice findAll(String searchKeyword, InterestOrderBy sortBy, Direction direction, + String cursor, LocalDateTime after, int limit) { + Expression sortExpr = sortExpression(sortBy); + + // id, 정렬칼럼 받기 + List rows = queryFactory + .selectDistinct(i.id, sortExpr) + .from(i) + .leftJoin(i.keywords, ik) + .leftJoin(ik.keyword, k) + .where( + containsInterestOrKeyword(searchKeyword), + createdAfter(after), + createCursorPredicate(sortBy, direction, cursor) + ) + .orderBy( + sortBy(sortBy, direction), + secondSortBy(direction) + ) + .limit(limit + 1) + .fetch(); - BooleanBuilder builder = new BooleanBuilder(); - addSearchConditions(builder, searchKeyword); // 관심사, 키워드 부분일치 조건 + boolean hasNext = rows.size() > limit; + if (hasNext) rows = rows.subList(0, limit); - if (cursor != null && !cursor.isBlank()) { - builder.and(createCursorCondition(sortBy, direction, cursor)); // 커서 조건 + if (rows.isEmpty()) { + return new SliceImpl<>(Collections.emptyList(), PageRequest.of(0, limit), false); } - OrderSpecifier orderSpecifier = createOrderSpecifier(sortBy, direction); - OrderSpecifier idOrderSpecifier = direction == Direction.ASC ? i.id.asc() : i.id.desc(); + // id만 추출 + List interestIds = rows.stream() + .map(t -> t.get(i.id)) + .toList(); + // 관심사 id 포함 전체 로딩 List results = queryFactory .selectFrom(i) - .where(builder) - .orderBy(orderSpecifier, idOrderSpecifier) - .limit(limit + 1) + .distinct() + .leftJoin(i.keywords, ik).fetchJoin() + .leftJoin(ik.keyword, k).fetchJoin() + .where(i.id.in(interestIds)) + .orderBy( + sortBy(sortBy, direction), + secondSortBy(direction) + ) .fetch(); - boolean hasNext = results.size() > limit; - if (hasNext) { - results.remove(limit); - } - - Pageable pageable = PageRequest.of(0, limit); - return new SliceImpl<>(results, pageable, hasNext); + return new SliceImpl<>(results, PageRequest.of(0, limit), hasNext); } - - private BooleanBuilder createCursorCondition(InterestOrderBy sortBy, Direction direction, - String cursor) { - BooleanBuilder builder = new BooleanBuilder(); - - if (sortBy == InterestOrderBy.subscriberCount) { - Long cursorSubscriberCount = Long.parseLong(cursor); - handleSubscriberCountCursor(builder, cursorSubscriberCount, direction); - } else { - handleNameCursor(builder, cursor, direction); - } - return builder; + // 관심사명 OR 키워드명 부분일치 + private BooleanExpression containsInterestOrKeyword(String keyword) { + if (keyword == null || keyword.isBlank()) return null; + return i.name.containsIgnoreCase(keyword) + .or(k.keyword.containsIgnoreCase(keyword)); } - - private void handleSubscriberCountCursor(BooleanBuilder builder, Long cursorSubscriberCount, - Direction direction) { - if (direction == Direction.DESC) { - builder.and(i.subscriberCount.lt(cursorSubscriberCount)); - } else { - builder.and(i.subscriberCount.gt(cursorSubscriberCount)); - } + // after + private BooleanExpression createdAfter(LocalDateTime after) { + if (after == null) return null; + return i.createdAt.goe(after); } - - private void handleNameCursor(BooleanBuilder builder, String cursor, Direction direction) { - if (direction == Direction.DESC) { - builder.and(i.name.lt(cursor)); - } else { - builder.and(i.name.eq(cursor)); - } + // 커서 조건: 정렬 기준별 비교 + private BooleanExpression createCursorPredicate(InterestOrderBy sortBy, Direction dir, String cursor) { + if (cursor == null || cursor.isBlank()) return null; + + return switch (sortBy) { + case name -> (dir == Direction.ASC) ? i.name.gt(cursor) : i.name.lt(cursor); + case subscriberCount -> { + int v = Integer.parseInt(cursor); + yield (dir == Direction.ASC) ? i.subscriberCount.gt(v) : i.subscriberCount.lt(v); + } + }; } - - private OrderSpecifier createOrderSpecifier(InterestOrderBy sortBy, Direction direction) { - Order order = (direction == Direction.DESC) ? Order.DESC : Order.ASC; - - switch (sortBy) { - case subscriberCount: - return new OrderSpecifier<>(order, i.subscriberCount); - case name: - return new OrderSpecifier<>(order, i.name); - default: - throw new IllegalStateException("Unhandled sort by: " + sortBy); - } + // 정렬 지정 + private OrderSpecifier sortBy(InterestOrderBy sortBy, Direction dir) { + boolean asc = (dir == Direction.ASC); + return switch (sortBy) { + case name -> asc ? i.name.asc() : i.name.desc(); + case subscriberCount -> asc ? i.subscriberCount.asc() : i.subscriberCount.desc(); + }; } - - private void addSearchConditions(BooleanBuilder builder, String searchKeyword) { - if (searchKeyword != null && !searchKeyword.isEmpty()) { - builder.and( - i.name.containsIgnoreCase(searchKeyword) - .or(i.keywords.any().keyword.keyword.containsIgnoreCase(searchKeyword)) - ); - } + // 보조정렬: 동일값일 때는 id로 정렬하기!! + private OrderSpecifier secondSortBy(Direction dir) { + return (dir == Direction.ASC) ? i.id.asc() : i.id.desc(); } - @Override - public long countFilteredTotalElements(String keyword, InterestOrderBy sortBy, + public long countFilteredTotalElements(String keyword, InterestOrderBy orderBy, Direction direction) { - QInterest i = QInterest.interest; - BooleanBuilder builder = new BooleanBuilder(); - addSearchConditions(builder, keyword); + JPAQuery query = queryFactory + .select(i.countDistinct()) + .from(i); - Long count = queryFactory - .select(i.count()) - .from(i) - .where(builder) - .fetchOne(); + // keyword가 있을 때만 조인 + if (keyword != null && !keyword.isBlank()) { + query + .leftJoin(i.keywords, ik) + .leftJoin(ik.keyword, k); + } - return count != null ? count : 0; + query.where(containsInterestOrKeyword(keyword)); + return query.fetchOne(); } } + From 8037e48c749bd00f72ad9896aa78528cb85a11ed Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:34:38 +0900 Subject: [PATCH 075/178] =?UTF-8?q?refactor:=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9E=AC=EA=B5=AC=EC=84=B1=20(=EA=B2=80=EC=82=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=84=9C=EB=B9=84=EC=8A=A4=EC=97=90=20=ED=95=A0?= =?UTF-8?q?=EB=8B=B9,=20Dto=EB=8A=94=20=EC=B5=9C=EC=86=8C=ED=95=9C?= =?UTF-8?q?=EC=9D=98=20=EA=B8=B0=EB=B3=B8=EA=B0=92=EB=A7=8C=20=EB=B3=B4?= =?UTF-8?q?=EC=9E=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/controller/ArticleController.java | 48 +++++-------- .../article/dto/ArticleSearchRequest.java | 8 ++- .../article/service/ArticleService.java | 67 ++++++++++++------- 3 files changed, 63 insertions(+), 60 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java index bcc0c92..65ad091 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java @@ -12,7 +12,6 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; -import java.time.LocalDateTime; import java.util.List; @Slf4j @@ -21,7 +20,6 @@ @RequestMapping("/api/articles") public class ArticleController { - private static final String DEFAULT_ARTICLE_SOURCE = "Naver"; private final ArticleService articleService; /** @@ -46,37 +44,9 @@ public ResponseEntity> getArticles( @Validated @ModelAttribute ArticleSearchRequest request, @RequestHeader("Monew-Request-User-ID") Long userId ) { - log.info("[API 요청] GET /api/articles - 기사 목록 조회 요청, 사용자 ID: {}, 키워드: {}, 관심사 ID: {}", - userId, request.getKeyword(), request.getInterestId()); - - if (request.getSourceIn() == null || request.getSourceIn().isEmpty()) { - request.setSourceIn(List.of(DEFAULT_ARTICLE_SOURCE)); - } - - LocalDateTime now = LocalDateTime.now(); - if (request.getPublishDateFrom() == null) { - request.setPublishDateFrom(now.minusDays(7)); - } - if (request.getPublishDateTo() == null) { - request.setPublishDateTo(now); - } - - log.debug("[조회 파라미터] {}", request); - - CursorPageResponseArticleDto dto = articleService.getArticles( - request.getKeyword(), - request.getInterestId(), - request.getSourceIn(), - request.getPublishDateFrom(), - request.getPublishDateTo(), - request.getOrderBy(), - request.getDirection(), - request.getCursor(), - request.getAfter(), - request.getLimit(), - userId - ); - + log.info("[API 요청] GET /api/articles - 기사 목록 조회 요청, 사용자 ID: {}, 키워드: {}, 관심사 ID: {}, 커서: {}, After: {}", + userId, request.getKeyword(), request.getInterestId(), request.getCursor(), request.getAfter()); + CursorPageResponseArticleDto dto = articleService.getArticles(request, userId); log.info("[API 응답] GET /api/articles - 조회 성공, 반환된 기사 수: {}", dto.getContent().size()); return ResponseEntity.ok(dto); } @@ -91,6 +61,11 @@ public ResponseEntity getArticleById( ) { log.info("[API 요청] GET /api/articles/{} - 기사 상세 조회 요청, 사용자 ID: {}", articleId, userId); ArticleDto dto = articleService.findArticle(articleId, userId); + + if (!dto.isViewedByMe()) { + articleService.recordArticleView(articleId, userId); + } + log.info("[API 응답] GET /api/articles/{} - 기사 상세 조회 성공", articleId); return ResponseEntity.status(HttpStatus.OK).body(dto); } @@ -127,4 +102,11 @@ public ResponseEntity hardDeleteArticle(@PathVariable Long articleId) { log.info("[API 응답] DELETE /api/articles/{}/hard - 기사 영구 삭제 성공", articleId); return ResponseEntity.noContent().build(); } + + // RSS 문제 + // 포폴 + + // S3 + // S3 + // 로직 (A이베 } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleSearchRequest.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleSearchRequest.java index d7c6808..525c1c1 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleSearchRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleSearchRequest.java @@ -21,14 +21,16 @@ public class ArticleSearchRequest { @Size(max = 50, message = "검색어(keyword)는 최대 50자까지 입력할 수 있습니다.") private String keyword; + private Long interestId; - private List sourceIn; + + private List sourceIn = List.of("Naver"); @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - private LocalDateTime publishDateFrom; + private LocalDateTime publishDateFrom = LocalDateTime.now().minusDays(7); @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - private LocalDateTime publishDateTo; + private LocalDateTime publishDateTo = LocalDateTime.now(); @Pattern(regexp = "^(publishDate|viewCount|commentCount)$", message = "정렬 기준(orderBy)은 publishDate, viewCount, commentCount 중 하나여야 합니다.") diff --git a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java index 46b60a1..95c1c03 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java @@ -1,13 +1,13 @@ package com.monew.monew_api.article.service; import com.monew.monew_api.article.dto.ArticleDto; +import com.monew.monew_api.article.dto.ArticleSearchRequest; import com.monew.monew_api.article.dto.ArticleViewDto; import com.monew.monew_api.article.dto.CursorPageResponseArticleDto; import com.monew.monew_api.article.entity.Article; import com.monew.monew_api.article.entity.ArticleView; import com.monew.monew_api.article.repository.ArticleRepository; import com.monew.monew_api.article.repository.ArticleViewRepository; -import com.monew.monew_api.common.exception.article.ArticleAlreadyViewedException; import com.monew.monew_api.common.exception.article.ArticleNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -33,22 +33,36 @@ public class ArticleService { public ArticleViewDto recordArticleView(Long articleId, Long userId) { log.info("[기사 조회 기록 시도] 기사 ID: {}, 사용자 ID: {}", articleId, userId); - // 이미 조회한 기사라면 예외 발생 if (articleViewRepository.existsByUserIdAndArticleId(userId, articleId)) { log.warn("[조회 기록 실패] 이미 조회한 기사입니다. 사용자 ID: {}, 기사 ID: {}", userId, articleId); - throw new ArticleAlreadyViewedException(); + + Article article = articleRepository.findByIdAndIsDeletedFalse(articleId) + .orElseThrow(ArticleNotFoundException::new); + + return ArticleViewDto.builder() + .id(null) + .viewedBy(userId) + .createdAt(LocalDateTime.now()) + .articleId(articleId) + .source(article.getSource()) + .sourceUrl(article.getSourceUrl()) + .articleTitle(article.getTitle()) + .articlePublishedDate(article.getPublishDate()) + .articleSummary(article.getSummary()) + .articleCommentCount(article.getCommentCount()) + .articleViewCount(article.getViewCount()) + .build(); } - // 기사 존재 여부 확인 Article article = articleRepository.findByIdAndIsDeletedFalse(articleId) .orElseThrow(() -> { log.warn("[조회 기록 실패] 존재하지 않는 기사: {}", articleId); return new ArticleNotFoundException(); }); - // 조회 기록 생성 ArticleView articleView = new ArticleView(userId, articleId); ArticleView saved = articleViewRepository.save(articleView); + article.increaseViewCount(); log.info("[조회 기록 성공] 기사 ID: {}, 사용자 ID: {}", articleId, userId); return ArticleViewDto.builder() @@ -69,29 +83,27 @@ public ArticleViewDto recordArticleView(Long articleId, Long userId) { /** * 기사 목록 조회 (검색/필터/페이징 포함) */ - public CursorPageResponseArticleDto getArticles( - String keyword, Long interestId, List sourceIn, - LocalDateTime publishDateFrom, LocalDateTime publishDateTo, - String orderBy, String direction, - String cursor, LocalDateTime after, int limit, Long userId - ) { + public CursorPageResponseArticleDto getArticles(ArticleSearchRequest request, Long userId) { + String keyword = request.getKeyword(); + Long interestId = request.getInterestId(); + + if ((keyword == null || keyword.isBlank()) && interestId == null) { + interestId = 1L; + } else if (keyword != null && !keyword.isBlank() && interestId != null) { + keyword = null; + } + log.info("[기사 목록 조회] 사용자 ID: {}, 키워드: {}, 관심사 ID: {}", userId, keyword, interestId); CursorPageResponseArticleDto result = articleRepository.searchArticles( - keyword, - interestId, - sourceIn, - publishDateFrom, - publishDateTo, - orderBy, - direction, - cursor, - after, - limit, - userId + keyword, interestId, request.getSourceIn(), + request.getPublishDateFrom(), request.getPublishDateTo(), + request.getOrderBy(), request.getDirection(), + request.getCursor(), request.getAfter(), request.getLimit(), userId ); - log.info("[기사 목록 조회 완료] 조회된 기사 수: {}", result.getContent().size()); + log.info("[기사 목록 조회 완료] 조회된 기사 수: {}, 커서: {}, After: {}", + result.getContent().size(), result.getNextCursor(), result.getNextAfter()); return result; } @@ -128,7 +140,14 @@ public ArticleDto findArticle(Long articleId, Long userId) { public List getAllSources() { log.info("[뉴스 출처 목록 조회]"); List sources = articleRepository.findDistinctSources(); - log.debug("[뉴스 출처 조회 완료] 출처 개수: {}", sources.size()); + + sources.sort((a, b) -> { + if (a.equalsIgnoreCase("Naver")) return -1; + if (b.equalsIgnoreCase("Naver")) return 1; + return a.compareToIgnoreCase(b); + }); + + log.debug("[뉴스 출처 조회 완료] 출처 개수: {}, 정렬 결과: {}", sources.size(), sources); return sources; } From 142a11ee3e28ca4bd00c090c70e223733bee383a Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:35:14 +0900 Subject: [PATCH 076/178] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9E=90=20=EC=A0=91=EA=B7=BC=20=EB=A0=88=EB=B2=A8=20?= =?UTF-8?q?=EB=B6=80=EC=97=AC=20=EB=B0=8F=20=EC=83=9D=EC=84=B1=EC=9E=90?= =?UTF-8?q?=EC=99=80=20=EC=97=94=ED=8B=B0=ED=8B=B0=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 --- .../monew_api/article/entity/Article.java | 19 ++++++++++++++----- .../monew_api/article/entity/ArticleView.java | 3 ++- .../article/entity/InterestArticles.java | 9 ++++++++- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java index 04535a7..0607c03 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java @@ -3,18 +3,17 @@ import com.monew.monew_api.common.entity.BaseIdEntity; import com.monew.monew_api.common.exception.article.ArticleNotFoundException; import jakarta.persistence.*; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; /** * 뉴스 기사 테이블 */ @Getter -@NoArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @Table(name = "articles") public class Article extends BaseIdEntity { @@ -43,8 +42,14 @@ public class Article extends BaseIdEntity { @Column(name = "is_deleted", nullable = false) private boolean isDeleted = false; - @OneToMany(mappedBy = "article", cascade = CascadeType.ALL, orphanRemoval = true) - private List interestArticles = new ArrayList<>(); + + public Article(String source, String sourceUrl, String title, LocalDateTime publishDate, String summary) { + this.source = source; + this.sourceUrl = sourceUrl; + this.title = title; + this.publishDate = publishDate; + this.summary = summary; + } public void softDelete() { if (this.isDeleted) { @@ -52,4 +57,8 @@ public void softDelete() { } this.isDeleted = true; } + + public void increaseViewCount() { + this.viewCount++; + } } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java index 60685ca..c17d2e2 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java @@ -2,6 +2,7 @@ import com.monew.monew_api.common.entity.BaseCreatedEntity; import jakarta.persistence.*; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -10,7 +11,7 @@ * 뉴스 기사 조회 테이블 */ @Getter -@NoArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Entity @Table( diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java index 4fd524c..8da7326 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java @@ -1,7 +1,9 @@ package com.monew.monew_api.article.entity; import com.monew.monew_api.common.entity.BaseIdEntity; +import com.monew.monew_api.interest.entity.Interest; import jakarta.persistence.*; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -9,7 +11,7 @@ * 기사 - 관심사 연결 테이블 */ @Getter -@NoArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @Table( name = "interest_articles", @@ -24,4 +26,9 @@ public class InterestArticles extends BaseIdEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "interest_id", nullable = false) private Interest interest; + + public InterestArticles(Article article, Interest interest) { + this.article = article; + this.interest = interest; + } } From f576a0dae5f02cc416e2e334d937a384f55af1a1 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:36:00 +0900 Subject: [PATCH 077/178] =?UTF-8?q?feat:=20=EC=BB=A4=EC=84=9C=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArticleQueryRepositoryImpl.java | 120 +++++++++++++----- 1 file changed, 88 insertions(+), 32 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java index a60a772..3602c2d 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java @@ -3,19 +3,22 @@ import com.monew.monew_api.article.dto.ArticleDto; import com.monew.monew_api.article.dto.CursorPageResponseArticleDto; import com.monew.monew_api.article.dto.QArticleDto; +import com.monew.monew_api.article.entity.Article; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import java.time.LocalDateTime; import java.util.List; -import static com.monew.monew_api.article.entity.QArticle.*; +import static com.monew.monew_api.article.entity.QArticle.article; +import static com.monew.monew_api.article.entity.QArticleView.articleView; import static com.monew.monew_api.article.entity.QInterestArticles.interestArticles; -import static com.monew.monew_api.article.entity.QArticleView.*; +@Slf4j @RequiredArgsConstructor public class ArticleQueryRepositoryImpl implements ArticleQueryRepository { @@ -28,7 +31,7 @@ public CursorPageResponseArticleDto searchArticles( String orderBy, String direction, String cursor, LocalDateTime after, int limit, Long userId ) { - List articleDtos = queryFactory + List articles = queryFactory .select(new QArticleDto( article.id, article.source, @@ -53,66 +56,119 @@ public CursorPageResponseArticleDto searchArticles( keywordContains(keyword), interestEq(interestId), sourceIn(sourceIn), - publishDateBetween(publishDateFrom, publishDateTo) + publishDateBetween(publishDateFrom, publishDateTo), + cursorCondition(cursor, orderBy, direction) ) .orderBy(order(orderBy, direction)) .limit(limit + 1) .fetch(); - boolean hasNext = articleDtos.size() > limit; - if (hasNext) { - articleDtos.remove(limit); - } + boolean hasNext = articles.size() > limit; + if (hasNext) articles.remove(limit); - String nextCursor = hasNext ? String.valueOf(articleDtos.get(articleDtos.size() - 1).getId()) : null; - LocalDateTime nextAfter = hasNext ? articleDtos.get(articleDtos.size() - 1).getPublishDate() : null; + ArticleDto last = hasNext ? articles.get(articles.size() - 1) : null; return CursorPageResponseArticleDto.builder() - .content(articleDtos) - .nextCursor(nextCursor) - .nextAfter(nextAfter) + .content(articles) + .nextCursor(last != null ? String.valueOf(last.getId()) : null) + .nextAfter(null) .size(limit) .hasNext(hasNext) .build(); } private BooleanExpression keywordContains(String keyword) { - return (keyword == null || keyword.isBlank()) - ? null - : article.title.containsIgnoreCase(keyword) + if (keyword == null || keyword.isBlank()) return null; + return article.title.containsIgnoreCase(keyword) .or(article.summary.containsIgnoreCase(keyword)); } + // 추후 읽기 성능이 중요해진다 생각되면 join으로 변경 가능 private BooleanExpression interestEq(Long interestId) { if (interestId == null) return null; - return article.id.in( - JPAExpressions - .select(interestArticles.article.id) + JPAExpressions.select(interestArticles.article.id) .from(interestArticles) .where(interestArticles.interest.id.eq(interestId)) ); } private BooleanExpression sourceIn(List sourceIn) { + if (sourceIn == null || sourceIn.isEmpty()) return null; return article.source.in(sourceIn); } private BooleanExpression publishDateBetween(LocalDateTime from, LocalDateTime to) { + if (from == null || to == null) return null; return article.publishDate.between(from, to); } - private OrderSpecifier order(String orderBy, String direction) { - OrderSpecifier order; - switch (orderBy) { - case "commentCount" -> order = direction.equalsIgnoreCase("ASC") - ? article.commentCount.asc() : article.commentCount.desc(); - case "viewCount" -> order = direction.equalsIgnoreCase("ASC") - ? article.viewCount.asc() : article.viewCount.desc(); - default -> order = direction.equalsIgnoreCase("ASC") - ? article.publishDate.asc() : article.publishDate.desc(); - } - - return order; + // Java 14 이상에서 도입된 Switch Expression 문법 도입 + private OrderSpecifier[] order(String orderBy, String direction) { + boolean asc = "ASC".equalsIgnoreCase(direction); + + return switch (orderBy) { + case "commentCount" -> asc + ? new OrderSpecifier[]{article.commentCount.asc(), article.id.asc()} + : new OrderSpecifier[]{article.commentCount.desc(), article.id.desc()}; + case "viewCount" -> asc + ? new OrderSpecifier[]{article.viewCount.asc(), article.id.asc()} + : new OrderSpecifier[]{article.viewCount.desc(), article.id.desc()}; + default -> asc + ? new OrderSpecifier[]{article.publishDate.asc(), article.id.asc()} + : new OrderSpecifier[]{article.publishDate.desc(), article.id.desc()}; + }; + } + + // after는 사실상 무쓸모, 즉 정렬 기준을 사용해야함. + private BooleanExpression cursorCondition( + String cursor, String orderBy, String direction) { + + if (cursor == null) return null; + + boolean desc = "DESC".equalsIgnoreCase(direction); + Long cursorId = Long.valueOf(cursor); + + Article cursorArticle = queryFactory + .selectFrom(article) + .where(article.id.eq(cursorId)) + .fetchOne(); + + if (cursorArticle == null) return null; + + return switch (orderBy) { + case "commentCount" -> { + int afterComment = cursorArticle.getCommentCount(); + yield desc + ? article.commentCount.lt(afterComment) + .or(article.commentCount.eq(afterComment) + .and(article.id.lt(cursorId))) + : article.commentCount.gt(afterComment) + .or(article.commentCount.eq(afterComment) + .and(article.id.gt(cursorId))); + } + + case "viewCount" -> { + int afterView = cursorArticle.getViewCount(); + yield desc + ? article.viewCount.lt(afterView) + .or(article.viewCount.eq(afterView) + .and(article.id.lt(cursorId))) + : article.viewCount.gt(afterView) + .or(article.viewCount.eq(afterView) + .and(article.id.gt(cursorId))); + } + + default -> { + LocalDateTime afterDate = cursorArticle.getPublishDate(); + yield desc + ? article.publishDate.lt(afterDate) + .or(article.publishDate.eq(afterDate) + .and(article.id.lt(cursorId))) + : article.publishDate.gt(afterDate) + .or(article.publishDate.eq(afterDate) + .and(article.id.gt(cursorId))); + } + }; } -} +} \ No newline at end of file From 8c14f3ba72ac715eac5942a9c6fd54ecf20b44bf Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Thu, 30 Oct 2025 16:51:42 +0900 Subject: [PATCH 078/178] =?UTF-8?q?feat=20:=20delete=20UserActivityReposit?= =?UTF-8?q?oryImpl.java=20->=20=EB=8B=A8=EC=9D=BC=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=95=A0=20=EB=95=8C=20record=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EB=B0=9B=EB=8A=94=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=A1=9C=20=EC=82=AC=EC=9A=A9=ED=95=98=EA=B8=B0?= =?UTF-8?q?=EB=A1=9C=20=ED=96=88=EB=8A=94=EB=8D=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전 레거시 코드가 살아있어서 제거 --- .../repository/UserActivityRepository.java | 4 - .../UserActivityRepositoryImpl.java | 122 ------------------ 2 files changed, 126 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java index f661b7f..27409fd 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java @@ -24,10 +24,6 @@ public interface UserActivityRepository { List findRecentViewsByUserId(Long userId); - /* - 활동 내역을 단일 쿼리로 처리 - */ - Object[] findUserActivitiesByUserId(Long userId); /* record 사용한 단일 쿼리 */ diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java index 5e5eca7..93c2332 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java @@ -110,128 +110,6 @@ public List findRecentViewsByUserId(Long userId) { .fetch(); } - @Override - public Object[] findUserActivitiesByUserId(Long userId) { - String sql = """ - WITH recent_subscriptions AS ( - SELECT - s.id AS subscription_id, - s.user_id, - s.created_at AS subscription_created_at, - i.id AS interest_id, - i.name AS interest_name, - i.subscriber_count, - STRING_AGG(k.keyword, ',') AS keywords - FROM subscribes s - JOIN interests i ON s.interest_id = i.id - LEFT JOIN interests_keywords ik ON i.id = ik.interest_id - LEFT JOIN keywords k ON ik.keyword_id = k.id - WHERE s.user_id = :userId - GROUP BY s.id, s.user_id, s.created_at, i.id, i.name, i.subscriber_count - ORDER BY s.created_at DESC - ), - recent_comments AS ( - SELECT - c.id, - c.article_id, - c.user_id, - c.content, - c.like_count, - c.created_at, - a.title AS article_title, - u.nickname AS user_nickname - FROM comments c - JOIN articles a ON c.article_id = a.id - JOIN users u ON c.user_id = u.id - WHERE c.user_id = :userId - AND c.is_deleted = false - AND a.is_deleted = false - AND u.deleted_at IS NULL - ORDER BY c.created_at DESC - LIMIT 10 - ), - recent_likes AS ( - SELECT - cl.id, - cl.user_id, - cl.created_at, - cl.comment_id, - c.content AS comment_content, - c.like_count AS comment_like_count, - c.created_at AS comment_created_at, - c.user_id AS comment_user_id, - u.nickname AS comment_user_nickname, - a.id AS article_id, - a.title AS article_title - FROM comment_likes cl - JOIN comments c ON cl.comment_id = c.id - JOIN articles a ON c.article_id = a.id - JOIN users u ON c.user_id = u.id - WHERE cl.user_id = :userId - AND c.is_deleted = false - AND a.is_deleted = false - AND u.deleted_at IS NULL - ORDER BY cl.created_at DESC - LIMIT 10 - ), - recent_views AS ( - SELECT - av.id, - av.user_id, - av.created_at, - av.article_id, - a.source, - a.source_url, - a.title AS article_title, - a.publish_date, - a.summary, - a.comment_count, - a.view_count - FROM article_views av - JOIN articles a ON av.article_id = a.id - WHERE av.user_id = :userId - AND a.is_deleted = false - ORDER BY av.created_at DESC - LIMIT 10 - ) - SELECT - u.id AS id, - u.email, - u.nickname, - u.created_at AS created_at, - COALESCE( - (SELECT jsonb_agg(to_jsonb(rs) ORDER BY rs.subscription_created_at DESC) - FROM recent_subscriptions rs WHERE rs.user_id = u.id), - '[]'::jsonb - ) AS subscriptions, - COALESCE( - (SELECT jsonb_agg(to_jsonb(rc) ORDER BY rc.created_at DESC) - FROM recent_comments rc WHERE rc.user_id = u.id), - '[]'::jsonb - ) AS comments, - COALESCE( - (SELECT jsonb_agg(to_jsonb(rl) ORDER BY rl.created_at DESC) - FROM recent_likes rl WHERE rl.user_id = u.id), - '[]'::jsonb - ) AS likes, - COALESCE( - (SELECT jsonb_agg(to_jsonb(rv) ORDER BY rv.created_at DESC) - FROM recent_views rv WHERE rv.user_id = u.id), - '[]'::jsonb - ) AS views - FROM users u - WHERE u.id = :userId - """; - - Query query = entityManager.createNativeQuery(sql); - query.setParameter("userId", userId); - -// @SuppressWarnings("unchecked") - List results = query.getResultList(); - - return results.isEmpty() ? null : results.get(0); - } - @Override public UserActivityRaw findUserActivityRaw(Long userId) { String sql = """ From 1651ecc97500e9814090c96eb4e790b79f5c4879 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:57:33 +0900 Subject: [PATCH 079/178] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EC=97=90=EC=84=9C=20=EC=A4=91=EB=B3=B5=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EC=B6=9C=EC=B2=98=EC=9D=98=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EA=B8=B0=EC=82=AC=EC=9D=98=20=EC=A0=80=EC=9E=A5=EC=9D=84=20?= =?UTF-8?q?=EB=A7=89=EA=B8=B0=20=EC=9C=84=ED=95=9C=20findBySourceUrl=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 --- .../monew/monew_api/article/repository/ArticleRepository.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java index 779dc6e..5698b4f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java @@ -13,4 +13,6 @@ public interface ArticleRepository extends JpaRepository, Article @Query("SELECT DISTINCT a.source FROM Article a WHERE a.isDeleted = false") List findDistinctSources(); + + Optional
findBySourceUrl(String sourceUrl); } From b339c60febd2d1e884fc064ddaaf72286f7a78e2 Mon Sep 17 00:00:00 2001 From: truuuely Date: Fri, 31 Oct 2025 09:19:52 +0900 Subject: [PATCH 080/178] =?UTF-8?q?chore:=20monew-api=EC=99=80=20monew-bat?= =?UTF-8?q?ch=20=EB=AA=A8=EB=93=88=EC=9D=98=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EC=A1=B4=EC=9D=84=20UTC=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=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 --- .../main/java/com/monew/monew_api/MonewApiApplication.java | 3 +++ monew-api/src/main/resources/application-dev.yml | 5 +++++ monew-api/src/main/resources/application-prod.yml | 5 +++++ .../java/com/monew/monew_batch/MonewBatchApplication.java | 3 +++ monew-batch/src/main/resources/application-dev.yml | 2 ++ monew-batch/src/main/resources/application-prod.yml | 4 ++++ 6 files changed, 22 insertions(+) diff --git a/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java b/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java index 13728a4..7bfcc43 100644 --- a/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java +++ b/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java @@ -4,11 +4,14 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import java.util.TimeZone; + @SpringBootApplication @EnableJpaAuditing public class MonewApiApplication { public static void main(String[] args) { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); SpringApplication.run(MonewApiApplication.class, args); } diff --git a/monew-api/src/main/resources/application-dev.yml b/monew-api/src/main/resources/application-dev.yml index 91df8d4..e05d0c6 100644 --- a/monew-api/src/main/resources/application-dev.yml +++ b/monew-api/src/main/resources/application-dev.yml @@ -34,6 +34,11 @@ spring: properties: hibernate: format_sql: true + jdbc: + time_zone: UTC + + jackson: + time-zone: UTC servlet: multipart: diff --git a/monew-api/src/main/resources/application-prod.yml b/monew-api/src/main/resources/application-prod.yml index d3ac14e..cf55f25 100644 --- a/monew-api/src/main/resources/application-prod.yml +++ b/monew-api/src/main/resources/application-prod.yml @@ -20,6 +20,11 @@ spring: properties: hibernate: format_sql: true + jdbc: + time_zone: UTC + + jackson: + time-zone: UTC servlet: multipart: diff --git a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java index d2fcd58..54af2ef 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java @@ -4,11 +4,14 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; +import java.util.TimeZone; + @SpringBootApplication @EnableScheduling public class MonewBatchApplication { public static void main(String[] args) { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); SpringApplication.run(MonewBatchApplication.class, args); } diff --git a/monew-batch/src/main/resources/application-dev.yml b/monew-batch/src/main/resources/application-dev.yml index 085d655..4e2defd 100644 --- a/monew-batch/src/main/resources/application-dev.yml +++ b/monew-batch/src/main/resources/application-dev.yml @@ -15,6 +15,8 @@ spring: properties: hibernate: format_sql: true + jdbc: + time_zone: UTC batch: jdbc: diff --git a/monew-batch/src/main/resources/application-prod.yml b/monew-batch/src/main/resources/application-prod.yml index fb773e1..ccb1d51 100644 --- a/monew-batch/src/main/resources/application-prod.yml +++ b/monew-batch/src/main/resources/application-prod.yml @@ -12,6 +12,10 @@ spring: hibernate: ddl-auto: none show-sql: false + properties: + hibernate: + jdbc: + time_zone: UTC batch: jdbc: From cfb422f3757f2893615b4e4fc6993392abf375d5 Mon Sep 17 00:00:00 2001 From: truuuely Date: Fri, 31 Oct 2025 10:12:45 +0900 Subject: [PATCH 081/178] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20=EC=95=8C=EB=A6=BC=20=EC=83=9D=EC=84=B1=EC=9E=90=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 --- .../monew/monew_api/notification/entity/Notification.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java b/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java index a893bf7..5ae3670 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java @@ -31,6 +31,13 @@ public class Notification extends BaseTimeEntity { @Column(nullable = false) private boolean confirmed; + public Notification(User user, String content, ResourceType resourceType, Long resourceId) { + this.user = user; + this.content = content; + this.resourceType = resourceType; + this.resourceId = resourceId; + } + public void confirm() { this.confirmed = true; } From 2f8509e434a2228dac6b683b2724c2934deb2521 Mon Sep 17 00:00:00 2001 From: truuuely Date: Fri, 31 Oct 2025 10:16:04 +0900 Subject: [PATCH 082/178] =?UTF-8?q?feat:=20=ED=99=95=EC=9D=B8=EB=90=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=A4=91=20=EC=9D=BC=EC=A3=BC=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=BC=EB=90=9C=20=EC=95=8C=EB=A6=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/repository/NotificationRepository.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java index df0b49e..b030959 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java @@ -5,6 +5,8 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import java.time.LocalDateTime; + public interface NotificationRepository extends JpaRepository, NotificationRepositoryCustom { @Modifying(clearAutomatically = true) @@ -13,4 +15,8 @@ public interface NotificationRepository extends JpaRepository Date: Fri, 31 Oct 2025 10:19:26 +0900 Subject: [PATCH 083/178] =?UTF-8?q?feat:=20=ED=99=95=EC=9D=B8=EB=90=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=A4=91=20=EC=9D=BC=EC=A3=BC=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=BC=EB=90=9C=20=EC=95=8C=EB=A6=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EB=B0=B0=EC=B9=98=20=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/monew/monew_batch/BatchScheduler.java | 46 ++++++++++++++ .../monew_batch/NotificationJobConfig.java | 63 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/BatchScheduler.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/NotificationJobConfig.java diff --git a/monew-batch/src/main/java/com/monew/monew_batch/BatchScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/BatchScheduler.java new file mode 100644 index 0000000..935228a --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/BatchScheduler.java @@ -0,0 +1,46 @@ +package com.monew.monew_batch; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BatchScheduler { + + private final JobLauncher jobLauncher; + private final Job deleteOldNotificationJob; + + // 한국 기준 오전 4시 + @Scheduled(cron = "0 0 19 * * *", zone = "UTC") + public void runDeleteOldNotificationJob() { + try { + JobParameters parameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + log.info("[스케줄러] deleteOldNotificationJob 실행"); + + JobExecution exec = jobLauncher.run(deleteOldNotificationJob, parameters); + + // 실행 결과 로그 + log.info("==== Job Finished ===="); + log.info("Status : {}", exec.getStatus()); + log.info("Exit Status : {}", exec.getExitStatus()); + log.info("Job Instance ID : {}", exec.getJobId()); + log.info("Job getCreateTime : {}", exec.getCreateTime()); + log.info("Job getEndTime : {}", exec.getEndTime()); + log.info("Last Updated : {}", exec.getLastUpdated()); + log.info("Failure Exceptions: {}", exec.getFailureExceptions()); + } catch (Exception e) { + log.error("[스케줄러] deleteOldNotificationJob 실행 중 오류 발생", e); + } + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/NotificationJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/NotificationJobConfig.java new file mode 100644 index 0000000..938681b --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/NotificationJobConfig.java @@ -0,0 +1,63 @@ +package com.monew.monew_batch; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDateTime; + +@Slf4j +@Configuration +@EnableBatchProcessing +@RequiredArgsConstructor +public class NotificationJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JdbcTemplate jdbcTemplate; + + @Bean + public Job deleteOldNotificationJob() { + + return new JobBuilder("deleteOldNotificationJob", jobRepository) + .start(deleteOldNotificationStep()) + .build(); + } + + @Bean + public Step deleteOldNotificationStep() { + return new StepBuilder("deleteOldNotificationStep", jobRepository) + .tasklet(deleteOldNotificationTasklet(), transactionManager) + .build(); + } + + /** + * 확인한지 일주일 경과한 알림 삭제 + */ + @Bean + public Tasklet deleteOldNotificationTasklet() { + return ((contribution, chunkContext) -> { + log.info("[배치 시작] 확인한지 일주일이 경과한 알림 삭제 작업 시작"); + + LocalDateTime oneWeekAgo = LocalDateTime.now().minusWeeks(1); + + String sql = "DELETE FROM notifications WHERE confirmed = true AND updated_at < ?"; + int deletedRows = jdbcTemplate.update(sql, oneWeekAgo); + + log.info("[배치 성공] 오랜된 확인 알림 삭제 작업 완료. 삭제된 개수: {}", deletedRows); + + return RepeatStatus.FINISHED; + }); + } +} From 48ee699829a747c45e0a4f61256b8867f7d3ae1c Mon Sep 17 00:00:00 2001 From: truuuely Date: Fri, 31 Oct 2025 10:31:41 +0900 Subject: [PATCH 084/178] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?JVM=EC=9D=98=20=EA=B8=B0=EB=B3=B8=20=EC=8B=9C=EA=B0=84=EB=8C=80?= =?UTF-8?q?=EB=A5=BC=20UTC=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=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 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index 7d24310..95ffd95 100644 --- a/build.gradle +++ b/build.gradle @@ -37,5 +37,8 @@ subprojects { tasks.named('test') { useJUnitPlatform() + + // 테스트시 모든 시간대를 UTC 기준으로 변경 + systemProperty 'user.timezone', 'UTC' } } \ No newline at end of file From e14406398f54a9c4b112d1b65c084f3580174075 Mon Sep 17 00:00:00 2001 From: truuuely Date: Fri, 31 Oct 2025 10:33:15 +0900 Subject: [PATCH 085/178] =?UTF-8?q?test:=20=EC=95=8C=EB=A6=BC=20=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20-=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=EB=90=9C=EC=A7=80=20=EC=9D=BC=EC=A3=BC=EC=9D=BC=20=EC=A7=80?= =?UTF-8?q?=EB=82=9C=20=EC=95=8C=EB=A6=BC=EB=A7=8C=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EB=90=98=EB=8A=94=EC=A7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationRepositoryTest.java | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java diff --git a/monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java b/monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java new file mode 100644 index 0000000..7718fcd --- /dev/null +++ b/monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java @@ -0,0 +1,100 @@ +package com.monew.monew_api.notification.repository; + +import com.monew.monew_api.common.config.QuerydslConfig; +import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.notification.entity.Notification; +import com.monew.monew_api.notification.enums.ResourceType; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@Import(QuerydslConfig.class) +class NotificationRepositoryTest { + + @Autowired + NotificationRepository notificationRepository; + + @Autowired + EntityManager entityManager; + + private User user1; + private User user2; + + @BeforeEach + void setup() { + user1 = new User("user1@example.com", "테스트 유저", "1234"); + entityManager.persist(user1); + entityManager.flush(); + + user2 = new User("user2@example.com", "테스트 유저 2", "1234"); + entityManager.persist(user2); + entityManager.flush(); + } + + @Test + @DisplayName("확인한지 일주일 경과된 알림은 삭제된다.") + void deleteAllOldConfirmedTest() { + // Given + LocalDateTime now = LocalDateTime.now(); + System.out.println("지금!!!!" + now); + LocalDateTime oneWeekAgo = now.minusWeeks(1); + LocalDateTime eightDaysAgo = now.minusDays(8); + LocalDateTime sixDaysAgo = now.minusDays(6); + + Notification toDelete = createAndPersistNotification(user1, "삭제 대상 알림", true, eightDaysAgo.minusDays(1), eightDaysAgo); + Notification keepConfirmed = createAndPersistNotification(user1, "유지 대상 (확인 됨)", true, oneWeekAgo, sixDaysAgo); + Notification keepUnconfirmed = createAndPersistNotification(user1, "유지 대상 (미확인)", false, eightDaysAgo, eightDaysAgo); + Notification toDelete2 = createAndPersistNotification(user2, "삭제 대상 (다른 사용자 알림)", true, eightDaysAgo, eightDaysAgo); + + entityManager.flush(); + entityManager.clear(); + + // When + int deletedCount = notificationRepository.deleteAllOldConfirmed(oneWeekAgo); + + // Then + assertThat(deletedCount).isEqualTo(2); + + assertThat(notificationRepository.findById(toDelete.getId()).isEmpty()); + assertThat(notificationRepository.findById(toDelete2.getId()).isEmpty()); + + assertThat(notificationRepository.findById(keepConfirmed.getId()).isPresent()); + assertThat(notificationRepository.findById(keepUnconfirmed.getId()).isPresent()); + + assertThat(notificationRepository.count()).isEqualTo(2); + } + + private Notification createAndPersistNotification(User user, String content, boolean confirmed, LocalDateTime createdAt, LocalDateTime updatedAt) { + + Notification notification = new Notification(user, content, ResourceType.interest, 1L); + + if (confirmed) { + notification.confirm(); + } + + notificationRepository.save(notification); + entityManager.flush(); + + int updatedRows = entityManager.createQuery("UPDATE Notification n SET n.createdAt = :createdAt, n.updatedAt = :updatedAt WHERE n.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("updatedAt", updatedAt) + .setParameter("id", notification.getId()) + .executeUpdate(); + + assertThat(updatedRows).isEqualTo(1); + + return notification; + } + +} \ No newline at end of file From e8d93f843a6ee46296e26c1a7c8eeeb7f52d3f7a Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Fri, 31 Oct 2025 11:01:20 +0900 Subject: [PATCH 086/178] =?UTF-8?q?fix=20:=20interests=5Fkeywords=20->=20i?= =?UTF-8?q?nterest=5Fkeywords=20=ED=85=8C=EC=9D=B4=EB=B8=94=EB=AA=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=98=EC=98=81=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useractivity/repository/UserActivityRepositoryImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java index 93c2332..5ccc5db 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java @@ -124,7 +124,7 @@ WITH recent_subscriptions AS ( STRING_AGG(k.keyword, ',') AS keywords FROM subscribes s JOIN interests i ON s.interest_id = i.id - LEFT JOIN interests_keywords ik ON i.id = ik.interest_id + LEFT JOIN interest_keywords ik ON i.id = ik.interest_id LEFT JOIN keywords k ON ik.keyword_id = k.id WHERE s.user_id = :userId GROUP BY s.id, s.user_id, s.created_at, i.id, i.name, i.subscriber_count From fb41f3b2358c846770df056d07cefdc82c14431c Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 31 Oct 2025 11:03:34 +0900 Subject: [PATCH 087/178] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20Naver=20API=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20=EC=B6=94=EA=B0=80=20-?= =?UTF-8?q?=20batch=20=EB=AA=A8=EB=93=88=20build.gradle=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20(monew-api,=20web,=20?= =?UTF-8?q?querydsl)=20-=20application.yml=EC=97=90=20Naver=20API=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20(env=20=EA=B8=B0=EB=B0=98)=20=EB=B0=8F=20P?= =?UTF-8?q?roperties=20=ED=8C=8C=EC=9D=BC=20=E3=85=9C=EA=B0=80=20-=20Entit?= =?UTF-8?q?yScan,=20EnableJpaRepositories=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20QuerydslConfig,=20RestTemplateConfig=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20ArticleSource=20Enum=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monew-batch/build.gradle | 7 +++++++ .../monew_batch/MonewBatchApplication.java | 4 ++++ .../article/enums/ArticleSource.java | 13 +++++++++++++ .../properties/NaverApiProperties.java | 16 ++++++++++++++++ .../common/config/QuerydslConfig.java | 19 +++++++++++++++++++ .../common/config/RestTemplateConfig.java | 15 +++++++++++++++ .../src/main/resources/application.yml | 8 +++++++- 7 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/enums/ArticleSource.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverApiProperties.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/common/config/QuerydslConfig.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/common/config/RestTemplateConfig.java diff --git a/monew-batch/build.gradle b/monew-batch/build.gradle index 23ba004..1eae30a 100644 --- a/monew-batch/build.gradle +++ b/monew-batch/build.gradle @@ -6,6 +6,13 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-batch' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + implementation project(':monew-api') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'com.h2database:h2' implementation 'org.mapstruct:mapstruct:1.6.3' diff --git a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java index d2fcd58..7d6c7d9 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java @@ -2,9 +2,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EntityScan(basePackages = "com.monew.monew_api") +@EnableJpaRepositories(basePackages = "com.monew.monew_api") @EnableScheduling public class MonewBatchApplication { diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/enums/ArticleSource.java b/monew-batch/src/main/java/com/monew/monew_batch/article/enums/ArticleSource.java new file mode 100644 index 0000000..9c1767f --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/enums/ArticleSource.java @@ -0,0 +1,13 @@ +package com.monew.monew_batch.article.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ArticleSource { + NAVER, + HANKYUNG, + CHOSUN, + YEONHAP +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverApiProperties.java b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverApiProperties.java new file mode 100644 index 0000000..4f836f3 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverApiProperties.java @@ -0,0 +1,16 @@ +package com.monew.monew_batch.article.properties; + +import com.monew.monew_batch.article.enums.ArticleSource; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "naver.api") +public class NaverApiProperties { + private final String baseUrl; + private final String clientId; + private final String clientSecret; + private final ArticleSource articleSource = ArticleSource.NAVER; +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/common/config/QuerydslConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/common/config/QuerydslConfig.java new file mode 100644 index 0000000..da0e8e2 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/common/config/QuerydslConfig.java @@ -0,0 +1,19 @@ +package com.monew.monew_batch.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext + private EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/common/config/RestTemplateConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/common/config/RestTemplateConfig.java new file mode 100644 index 0000000..dcb7f11 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/common/config/RestTemplateConfig.java @@ -0,0 +1,15 @@ +package com.monew.monew_batch.common.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder.build(); + } +} diff --git a/monew-batch/src/main/resources/application.yml b/monew-batch/src/main/resources/application.yml index 00ccff3..4ad3b40 100644 --- a/monew-batch/src/main/resources/application.yml +++ b/monew-batch/src/main/resources/application.yml @@ -23,4 +23,10 @@ management: logging: level: - root: INFO \ No newline at end of file + root: INFO + +naver: + api: + base-url: https://openapi.naver.com/v1/search/news.json + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} \ No newline at end of file From c5f4b1b33527a33be1cab081ba704b9ae3698b98 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 31 Oct 2025 11:04:38 +0900 Subject: [PATCH 088/178] =?UTF-8?q?feat:=20Naver=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20Job=20=EA=B5=AC=EC=84=B1=20-=20ItemReader,?= =?UTF-8?q?=20Processor,=20Writer=20=EA=B5=AC=ED=98=84=20-=20NaverNewsJobC?= =?UTF-8?q?onfig=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A9=80=ED=8B=B0=20?= =?UTF-8?q?=EC=8A=A4=EB=A0=88=EB=93=9C=20=EC=84=A4=EC=A0=95=20-=20ArticleJ?= =?UTF-8?q?dbcRepository=20=EC=B6=94=EA=B0=80=20(insertIgnore=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98)=20-=20NaverNewsScheduler=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?ArticleInterestPair=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/dto/ArticleInterestPair.java | 9 ++ .../article/job/NaverNewsItemProcessor.java | 133 ++++++++++++++++++ .../article/job/NaverNewsItemReader.java | 38 +++++ .../article/job/NaverNewsItemWriter.java | 117 +++++++++++++++ .../repository/ArticleJdbcRepository.java | 33 +++++ .../article/scheduler/NaverNewsScheduler.java | 33 +++++ .../common/config/NaverNewsJobConfig.java | 64 +++++++++ 7 files changed, 427 insertions(+) create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/dto/ArticleInterestPair.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemProcessor.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemReader.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleJdbcRepository.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/common/config/NaverNewsJobConfig.java diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/dto/ArticleInterestPair.java b/monew-batch/src/main/java/com/monew/monew_batch/article/dto/ArticleInterestPair.java new file mode 100644 index 0000000..0ea2f13 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/dto/ArticleInterestPair.java @@ -0,0 +1,9 @@ +package com.monew.monew_batch.article.dto; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.interest.entity.Interest; + +public record ArticleInterestPair( + Article article, + Interest interest +) {} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemProcessor.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemProcessor.java new file mode 100644 index 0000000..55ec7ee --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemProcessor.java @@ -0,0 +1,133 @@ +package com.monew.monew_batch.article.job; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.InterestKeyword; +import com.monew.monew_batch.article.dto.ArticleInterestPair; +import com.monew.monew_batch.article.properties.NaverApiProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NaverNewsItemProcessor implements ItemProcessor> { + + private static final int DISPLAY_COUNT = 10; + private static final String SORT_TYPE = "sim"; + private static final int REQUEST_DELAY_MS = 400; + + private final RestTemplate restTemplate; + private final NaverApiProperties properties; + + @Override + public List process(Interest interest) { + List collectedArticles = new ArrayList<>(); + int totalFetched = 0; + + for (InterestKeyword ik : interest.getKeywords()) { + String keyword = ik.getKeyword().getKeyword(); + log.info("🧩 [{}] '{}' 뉴스 수집 시작", interest.getName(), keyword); + + try { + List> items = fetchNewsItems(keyword); + if (items.isEmpty()) { + log.warn("⚠️ [{} - {}] 뉴스 없음", interest.getName(), keyword); + continue; + } + + totalFetched += items.size(); + collectedArticles.addAll(convertToPairs(items, interest)); + + log.info("✅ [{} - {}] 뉴스 {}건 수집 완료 (누적 {})", + interest.getName(), keyword, items.size(), totalFetched); + + Thread.sleep(REQUEST_DELAY_MS); + + } catch (Exception e) { + log.error("❌ [{} - {}] 뉴스 수집 실패: {}", interest.getName(), keyword, e.getMessage(), e); + } + } + + log.info("📊 [{}] 총 {}건 기사 수집 완료", interest.getName(), totalFetched); + return collectedArticles; + } + + /** + * 네이버 뉴스 API 호출 + */ + private List> fetchNewsItems(String keyword) { + String uri = UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl()) + .queryParam("query", keyword) + .queryParam("display", DISPLAY_COUNT) + .queryParam("sort", SORT_TYPE) + .build() + .toUriString(); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Naver-Client-Id", properties.getClientId()); + headers.set("X-Naver-Client-Secret", properties.getClientSecret()); + + HttpEntity entity = new HttpEntity<>(headers); + + Map response = restTemplate.exchange(uri, HttpMethod.GET, entity, Map.class).getBody(); + if (response == null) return Collections.emptyList(); + + return (List>) response.getOrDefault("items", Collections.emptyList()); + } + + /** + * API 응답 데이터를 Article-Interest 쌍으로 변환 + */ + private List convertToPairs(List> items, Interest interest) { + List pairs = new ArrayList<>(); + + for (Map item : items) { + String title = cleanText((String) item.get("title")); + String link = Optional.ofNullable((String) item.get("link")).orElse(""); + String desc = cleanText((String) item.get("description")); + String pubDateStr = (String) item.get("pubDate"); + + LocalDateTime publishDate = parsePublishDate(pubDateStr); + + Article article = new Article("Naver", link, title, publishDate, desc); + pairs.add(new ArticleInterestPair(article, interest)); + } + + return pairs; + } + + /** + * HTML 태그 제거 유틸 + */ + private String cleanText(String text) { + return Optional.ofNullable(text) + .map(t -> t.replaceAll("<[^>]*>", "")) + .orElse(""); + } + + /** + * pubDate 문자열을 LocalDateTime으로 변환 + */ + private LocalDateTime parsePublishDate(String pubDateStr) { + try { + return ZonedDateTime.parse(pubDateStr, DateTimeFormatter.RFC_1123_DATE_TIME) + .toLocalDateTime(); + } catch (Exception e) { + return LocalDateTime.now(); + } + } + +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemReader.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemReader.java new file mode 100644 index 0000000..dce6ce8 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemReader.java @@ -0,0 +1,38 @@ +package com.monew.monew_batch.article.job; + +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.repository.InterestRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemReader; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@StepScope +@RequiredArgsConstructor +@Slf4j +public class NaverNewsItemReader implements ItemReader { + + private final InterestRepository interestRepository; + private List items; + private int nextIndex = 0; + + @Override + public Interest read() { + if (items == null) { + items = interestRepository.findAllWithKeywords(); + log.info("📰 관심사 {}개 로드 완료", items.size()); + } + + if (nextIndex < items.size()) { + return items.get(nextIndex++); + } else { + log.info("✅ 모든 관심사 처리 완료"); + return null; + } + } + +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java new file mode 100644 index 0000000..1938682 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java @@ -0,0 +1,117 @@ +package com.monew.monew_batch.article.job; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.article.entity.InterestArticles; +import com.monew.monew_api.article.repository.ArticleKeywordLogRepository; +import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_api.article.repository.InterestArticlesRepository; +import com.monew.monew_api.common.exception.article.ArticleNotFoundException; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.InterestKeyword; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleInterestPair; +import com.monew.monew_batch.article.repository.ArticleJdbcRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NaverNewsItemWriter implements ItemWriter> { + + private final ArticleJdbcRepository articleJdbcRepository; + private final ArticleRepository articleRepository; + private final InterestArticlesRepository interestArticlesRepository; + private final ArticleKeywordLogRepository articleKeywordLogRepository; + + @Override + public void write(Chunk> chunk) { + int total = 0, newCount = 0, linkedCount = 0, skippedCount = 0; + + for (List batch : chunk) { + for (ArticleInterestPair pair : batch) { + total++; + Article article = pair.article(); + Interest interest = pair.interest(); + + // 1. 기사 저장 및 복구 처리 + boolean isNew = handleInsertIgnore(article); + if (isNew) newCount++; + + Article savedArticle = handleRestoreAndFind(article); + + // 2. 관심사-기사 및 키워드 로그 처리 + ProcessResult result = handleInterestAndLogs(savedArticle, interest); + + linkedCount += result.linkedCount(); + skippedCount += result.skippedCount(); + } + } + + logSummary(total, newCount, linkedCount, skippedCount); + } + + /** + * JdbcTemplate 기반 insertIgnore 실행 + */ + private boolean handleInsertIgnore(Article article) { + boolean isNew = articleJdbcRepository.insertIgnore(article); + + if (isNew) { + log.info("🆕 신규 기사 저장: {}", article.getTitle()); + } + + return isNew; + } + + /** + * 삭제된 기사 복구 + DB 조회 + */ + private Article handleRestoreAndFind(Article article) { + if (articleRepository.restoreIfDeleted(article.getSourceUrl()) > 0) { + log.info("♻️ 복구된 기사: {}", article.getTitle()); + } + + return articleRepository.findBySourceUrl(article.getSourceUrl()) + .orElseThrow(ArticleNotFoundException::new); + } + + /** + * 관심사-기사 관계 및 키워드 로그 처리 + */ + private ProcessResult handleInterestAndLogs(Article article, Interest interest) { + int linkedCount = 0; + int skippedCount = 0; + + for (InterestKeyword ik : interest.getKeywords()) { + Keyword keyword = ik.getKeyword(); + + // 키워드 로그 중복 무시 (interest 포함) + articleKeywordLogRepository.insertIgnore(article.getId(), keyword.getId(), interest.getId()); + + // 관심사-기사 연결 (현재 연결 상태용) + if (!interestArticlesRepository.existsByArticleAndInterest(article, interest)) { + interestArticlesRepository.save(new InterestArticles(article, interest)); + linkedCount++; + log.info("🔗 [{}] 관심사-기사 연결 완료: {}", interest.getName(), article.getTitle()); + } + } + + return new ProcessResult(linkedCount, skippedCount); + } + + /** + * 결과 요약 로그 + */ + private void logSummary(int total, int newCount, int linkedCount, int skippedCount) { + log.info("💾 Writer 결과 | 총: {} | 신규 기사: {} | 연결: {} | 스킵(로그 중복): {}", + total, newCount, linkedCount, skippedCount); + } + + private record ProcessResult(int linkedCount, int skippedCount) {} +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleJdbcRepository.java b/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleJdbcRepository.java new file mode 100644 index 0000000..22163fc --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleJdbcRepository.java @@ -0,0 +1,33 @@ +package com.monew.monew_batch.article.repository; + +import com.monew.monew_api.article.entity.Article; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ArticleJdbcRepository { + + private final JdbcTemplate jdbcTemplate; + + public boolean insertIgnore(Article article) { + String sql = """ + INSERT INTO articles (source, source_url, title, summary, publish_date, comment_count, view_count, is_deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (source_url) DO NOTHING + """; + + int rows = jdbcTemplate.update(sql, + article.getSource(), + article.getSourceUrl(), + article.getTitle(), + article.getSummary(), + article.getPublishDate(), + article.getCommentCount(), + article.getViewCount(), + article.isDeleted()); + + return rows > 0; + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java new file mode 100644 index 0000000..a90f9b7 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java @@ -0,0 +1,33 @@ +package com.monew.monew_batch.article.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@EnableScheduling +@RequiredArgsConstructor +public class NaverNewsScheduler { + + private final JobLauncher jobLauncher; + private final Job naverNewsJob; + + @Scheduled(cron = "0 0 * * * *") + public void runJob() throws Exception { + log.info("🕒 [Batch Scheduler] 네이버 뉴스 수집 Job 실행"); + + JobParameters params = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(naverNewsJob, params); + } + +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/common/config/NaverNewsJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/common/config/NaverNewsJobConfig.java new file mode 100644 index 0000000..a2c9cdf --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/common/config/NaverNewsJobConfig.java @@ -0,0 +1,64 @@ +package com.monew.monew_batch.common.config; + +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_batch.article.dto.ArticleInterestPair; +import com.monew.monew_batch.article.job.NaverNewsItemProcessor; +import com.monew.monew_batch.article.job.NaverNewsItemReader; +import com.monew.monew_batch.article.job.NaverNewsItemWriter; +import com.monew.monew_batch.article.properties.NaverApiProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; + +@Configuration +@EnableBatchProcessing +@RequiredArgsConstructor +@EnableConfigurationProperties(NaverApiProperties.class) +public class NaverNewsJobConfig { + + private final NaverNewsItemReader reader; + private final NaverNewsItemProcessor processor; + private final NaverNewsItemWriter writer; + + @Bean + public Job naverNewsJob(JobRepository jobRepository, Step naverNewsStep) { + return new JobBuilder("naverNewsJob", jobRepository) + .start(naverNewsStep) + .build(); + } + + @Bean + public Step naverNewsStep(JobRepository jobRepository, + PlatformTransactionManager transactionManager) { + return new StepBuilder("naverNewsStep", jobRepository) + .>chunk(1, transactionManager) + .reader(reader) + .processor(processor) + .writer(writer) + .taskExecutor(taskExecutor()) + .build(); + } + + /** + * 스레드 동시성은 TaskExecutor에서 직접 설정 + */ + @Bean + public TaskExecutor taskExecutor() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("naver-news-thread-"); + executor.setConcurrencyLimit(5); + return executor; + } + +} From 76a3a40ca74e859630533af8f95c8992b1041b7b Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 31 Oct 2025 11:05:11 +0900 Subject: [PATCH 089/178] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=EC=9A=A9=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=20=ED=99=95=EC=9E=A5=20-=20ArticleK?= =?UTF-8?q?eywordLog=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20Repositor?= =?UTF-8?q?y=20=EC=B6=94=EA=B0=80=20-=20InterestArticlesRepository=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(existsByArticleAndInterest)=20-=20Interes?= =?UTF-8?q?tRepository=EC=97=90=20findAllWithKeywords=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20-=20ArticleRepository=EC=97=90=20restoreIfDeleted,=20findAll?= =?UTF-8?q?ByIsDeletedTrue=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/entity/ArticleKeywordLog.java | 51 +++++++++++++++++++ .../ArticleKeywordLogRepository.java | 22 ++++++++ .../article/repository/ArticleRepository.java | 23 +++++++++ .../InterestArticlesRepository.java | 11 ++++ .../repository/InterestRepository.java | 9 +++- 5 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleKeywordLog.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleKeywordLogRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleKeywordLog.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleKeywordLog.java new file mode 100644 index 0000000..2500565 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleKeywordLog.java @@ -0,0 +1,51 @@ +package com.monew.monew_api.article.entity; + +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table( + name = "article_keyword_logs", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_article_keyword_logs_article_keyword_interest", + columnNames = {"article_id", "keyword_id", "interest_id"} + ) + } +) +public class ArticleKeywordLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "article_id", nullable = false) + private Article article; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "keyword_id", nullable = false) + private Keyword keyword; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "interest_id", nullable = false) + private Interest interest; + + @Column(nullable = false) + private LocalDateTime collectedAt = LocalDateTime.now(); + + public ArticleKeywordLog(Article article, Keyword keyword, Interest interest) { + this.article = article; + this.keyword = keyword; + this.interest = interest; + this.collectedAt = LocalDateTime.now(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleKeywordLogRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleKeywordLogRepository.java new file mode 100644 index 0000000..d5999be --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleKeywordLogRepository.java @@ -0,0 +1,22 @@ +package com.monew.monew_api.article.repository; + +import com.monew.monew_api.article.entity.ArticleKeywordLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ArticleKeywordLogRepository extends JpaRepository { + + @Modifying + @Query(value = """ + INSERT INTO article_keyword_logs (article_id, keyword_id, interest_id, collected_at) + VALUES (:articleId, :keywordId, :interestId, now()) + ON CONFLICT (article_id, keyword_id, interest_id) DO NOTHING + """, nativeQuery = true) + void insertIgnore( + @Param("articleId") Long articleId, + @Param("keywordId") Long keywordId, + @Param("interestId") Long interestId + ); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java index 5698b4f..dcd3657 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java @@ -2,7 +2,9 @@ import com.monew.monew_api.article.entity.Article; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -15,4 +17,25 @@ public interface ArticleRepository extends JpaRepository, Article List findDistinctSources(); Optional
findBySourceUrl(String sourceUrl); + + @Modifying + @Query(""" + UPDATE Article a + SET a.isDeleted = false + WHERE a.sourceUrl = :sourceUrl AND a.isDeleted = true + """) + int restoreIfDeleted(@Param("sourceUrl") String sourceUrl); + + List
findAllByIsDeletedTrue(); + +/* + @Modifying + @Query(value = """ + INSERT INTO articles (source, source_url, title, summary, publish_date, comment_count, view_count, is_deleted) + VALUES (:#{#a.source}, :#{#a.sourceUrl}, :#{#a.title}, :#{#a.summary}, + :#{#a.publishDate}, :#{#a.commentCount}, :#{#a.viewCount}, :#{#a.isDeleted}) + ON CONFLICT (source_url) DO NOTHING + """, nativeQuery = true) + void insertIgnore(@Param("a") Article article); +*/ } diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java new file mode 100644 index 0000000..9f147f2 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java @@ -0,0 +1,11 @@ +package com.monew.monew_api.article.repository; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.article.entity.InterestArticles; +import com.monew.monew_api.interest.entity.Interest; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InterestArticlesRepository extends JpaRepository { + + boolean existsByArticleAndInterest(Article article, Interest interest); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java index 924acbc..02e6436 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java @@ -2,9 +2,14 @@ import com.monew.monew_api.interest.entity.Interest; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; -public interface InterestRepository extends JpaRepository, - InterestRepositoryCustom { +import java.util.List; + +public interface InterestRepository extends JpaRepository, InterestRepositoryCustom { boolean existsByName(String name); + + @Query("SELECT i FROM Interest i JOIN FETCH i.keywords k JOIN FETCH k.keyword") + List findAllWithKeywords(); } From 647fded4460e3fe86f49a54411a184096dd44ddc Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 31 Oct 2025 11:05:43 +0900 Subject: [PATCH 090/178] =?UTF-8?q?docs:=20schema.sql=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=B0=8F=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20-=20ArticleKeywordLog=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monew-api/src/main/resources/db/schema.sql | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/monew-api/src/main/resources/db/schema.sql b/monew-api/src/main/resources/db/schema.sql index e83ce52..237ab99 100644 --- a/monew-api/src/main/resources/db/schema.sql +++ b/monew-api/src/main/resources/db/schema.sql @@ -59,6 +59,33 @@ CREATE TABLE article_views CREATE INDEX ix_article_views_user ON article_views (user_id); CREATE INDEX ix_article_views_article ON article_views (article_id); +-- ====================================================== +-- Article Keyword Logs (뉴스가 어떤 관심사·키워드로 수집됐는지 추적) +-- ====================================================== +CREATE TABLE article_keyword_logs +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + article_id BIGINT NOT NULL, + keyword_id BIGINT NOT NULL, + interest_id BIGINT NOT NULL, + collected_at TIMESTAMP NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_article_keyword_logs UNIQUE (article_id, keyword_id, interest_id), + + CONSTRAINT fk_article_keyword_logs_article + FOREIGN KEY (article_id) REFERENCES articles (id) ON DELETE CASCADE, + + CONSTRAINT fk_article_keyword_logs_keyword + FOREIGN KEY (keyword_id) REFERENCES keywords (id) ON DELETE CASCADE, + + CONSTRAINT fk_article_keyword_logs_interest + FOREIGN KEY (interest_id) REFERENCES interests (id) ON DELETE CASCADE +); + +CREATE INDEX ix_article_keyword_logs_article ON article_keyword_logs (article_id); +CREATE INDEX ix_article_keyword_logs_keyword ON article_keyword_logs (keyword_id); +CREATE INDEX ix_article_keyword_logs_interest ON article_keyword_logs (interest_id); + -- ====================================================== -- Interests -- ====================================================== From 07966e24563afc13211f13aeaa418e1876eb160f Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 31 Oct 2025 11:05:54 +0900 Subject: [PATCH 091/178] =?UTF-8?q?feat:=20=EC=98=A4=EB=9E=98=EB=90=9C=20?= =?UTF-8?q?=EA=B8=B0=EC=82=AC=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20ArticleCleanupScheduler=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(=EC=A3=BC=EA=B8=B0=EC=A0=81=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B0=B1=EC=97=85=20=EB=8C=80=EC=83=81=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/ArticleCleanupScheduler.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java new file mode 100644 index 0000000..d01cc6c --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java @@ -0,0 +1,47 @@ +package com.monew.monew_batch.article.scheduler; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.article.repository.ArticleKeywordLogRepository; +import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_api.article.repository.InterestArticlesRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ArticleCleanupScheduler { + + private final ArticleRepository articleRepository; + private final ArticleKeywordLogRepository articleKeywordLogRepository; + private final InterestArticlesRepository interestArticlesRepository; + + /** + * 매일 새벽 4시에 is_deleted = true인 뉴스들을 물리 삭제 + */ + @Transactional + @Scheduled(cron = "0 10 4 * * *") + public void deleteSoftDeletedArticles() { + log.info("🧹 [ArticleCleanupScheduler] 논리 삭제된 뉴스 정리 시작"); + + // 1️⃣ 삭제 대상 조회 + List
deletedArticles = articleRepository.findAllByIsDeletedTrue(); + + if (deletedArticles.isEmpty()) { + log.info("✅ 삭제할 뉴스 없음"); + return; + } + + int total = deletedArticles.size(); + + articleRepository.deleteAll(deletedArticles); + + log.info("🧾 뉴스 삭제 완료 | 총: {}건", total); + } + +} From 7c6d4f9fdb0a579e9bcc205ba9e12f7ceee94149 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 31 Oct 2025 11:06:02 +0900 Subject: [PATCH 092/178] =?UTF-8?q?remove:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20JobConfig=20=EB=B0=8F=20Scheduler=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew/monew_batch/ExampleJobConfig01.java | 50 ------------------- .../com/monew/monew_batch/JobScheduler.java | 43 ---------------- 2 files changed, 93 deletions(-) delete mode 100644 monew-batch/src/main/java/com/monew/monew_batch/ExampleJobConfig01.java delete mode 100644 monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java diff --git a/monew-batch/src/main/java/com/monew/monew_batch/ExampleJobConfig01.java b/monew-batch/src/main/java/com/monew/monew_batch/ExampleJobConfig01.java deleted file mode 100644 index 5a5913f..0000000 --- a/monew-batch/src/main/java/com/monew/monew_batch/ExampleJobConfig01.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.monew.monew_batch; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.Step; -import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; -import org.springframework.batch.core.job.builder.JobBuilder; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.batch.repeat.RepeatStatus; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; - -/** - * 테스트용 - */ -@Slf4j -@Configuration -@EnableBatchProcessing -@RequiredArgsConstructor -public class ExampleJobConfig01 { - - private final JobRepository jobRepository; - private final PlatformTransactionManager transactionManager; - - @Bean - public Job exampleJob01() { - - return new JobBuilder("exampleJob01", jobRepository) - .start(step01()) - .build(); - } - - @Bean - public Step step01() { - return new StepBuilder("step01", jobRepository) - .tasklet((contribution, chunkContext) -> { - log.info("In step 01"); - System.out.println("In step 01"); -// return RepeatStatus.CONTINUABLE; - return RepeatStatus.FINISHED; - } - , transactionManager) - .build(); - - } - -} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java deleted file mode 100644 index 8ebd7b1..0000000 --- a/monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.monew.monew_batch; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.JobExecution; -import org.springframework.batch.core.JobParameters; -import org.springframework.batch.core.JobParametersBuilder; -import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -/** - * 테스트용 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class JobScheduler { - - // job을 실행시켜줄 객체 - private final JobLauncher jobLauncher; - private final Job exampleJob01; // 사용자가 만든 job - - @Scheduled(initialDelay = 1000, fixedRate = 5000) - public void runJob() throws Exception { - JobParameters parameters = new JobParametersBuilder() - .addLong("ts", System.currentTimeMillis()) - .toJobParameters(); - - JobExecution exec = jobLauncher.run(exampleJob01, parameters); - - // 실행 결과 로그 - log.info("==== Job Finished ===="); - log.info("Status : {}", exec.getStatus()); - log.info("Exit Status : {}", exec.getExitStatus()); - log.info("Job Instance ID : {}", exec.getJobId()); - log.info("Job getCreateTime : {}", exec.getCreateTime()); - log.info("Job getEndTime : {}", exec.getEndTime()); - log.info("Last Updated : {}", exec.getLastUpdated()); - log.info("Failure Exceptions: {}", exec.getFailureExceptions()); - } -} From 1ae92b40c1326866e734977b213cba55b2d38d16 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Fri, 31 Oct 2025 11:20:47 +0900 Subject: [PATCH 093/178] =?UTF-8?q?refactor:=20=ED=83=80=EC=9E=84=EC=A1=B4?= =?UTF-8?q?=EC=9D=84=20UTC=EB=A1=9C=20=ED=86=B5=EC=9D=BC=ED=95=98=EA=B8=B0?= =?UTF-8?q?=20=EC=9C=84=ED=95=B4=20ZoneId.systemDefault()=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 --- .../comments/repository/impl/CommentRepositoryImpl.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java index 67e0887..61c23dc 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java @@ -5,7 +5,6 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; import java.util.List; import org.springframework.stereotype.Repository; @@ -29,7 +28,6 @@ @RequiredArgsConstructor public class CommentRepositoryImpl implements CommentRepositoryCustom { - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private final JPAQueryFactory jpaQueryFactory; @Override @@ -89,7 +87,7 @@ public CursorPageResponseCommentDto searchComments( } // after는 입력받은 값을 그대로 유지 (시간 필터 고정) - nextAfter = after != null ? after.atZone(KST) : null; + nextAfter = after != null ? after.atZone(ZoneId.systemDefault()) : null; } From a2164ac41a87d7a7d28dce419edc400a42d37031 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:24:02 +0900 Subject: [PATCH 094/178] =?UTF-8?q?refactor:=20=ED=83=80=EC=9E=84=EC=A1=B4?= =?UTF-8?q?=20UTC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_batch/article/scheduler/ArticleCleanupScheduler.java | 2 +- .../monew/monew_batch/article/scheduler/NaverNewsScheduler.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java index d01cc6c..42639fd 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java @@ -25,7 +25,7 @@ public class ArticleCleanupScheduler { * 매일 새벽 4시에 is_deleted = true인 뉴스들을 물리 삭제 */ @Transactional - @Scheduled(cron = "0 10 4 * * *") + @Scheduled(cron = "0 10 4 * * *", zone = "UTC") public void deleteSoftDeletedArticles() { log.info("🧹 [ArticleCleanupScheduler] 논리 삭제된 뉴스 정리 시작"); diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java index a90f9b7..69262e7 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java @@ -19,7 +19,7 @@ public class NaverNewsScheduler { private final JobLauncher jobLauncher; private final Job naverNewsJob; - @Scheduled(cron = "0 0 * * * *") + @Scheduled(cron = "0 0 * * * *", zone = "UTC") public void runJob() throws Exception { log.info("🕒 [Batch Scheduler] 네이버 뉴스 수집 Job 실행"); From 5b522f77f12e0126eb79575966441751051c17b7 Mon Sep 17 00:00:00 2001 From: truuuely Date: Fri, 31 Oct 2025 13:30:39 +0900 Subject: [PATCH 095/178] =?UTF-8?q?refactor:=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{BatchScheduler.java => NotificationCleanupScheduler.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename monew-batch/src/main/java/com/monew/monew_batch/{BatchScheduler.java => NotificationCleanupScheduler.java} (97%) diff --git a/monew-batch/src/main/java/com/monew/monew_batch/BatchScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/NotificationCleanupScheduler.java similarity index 97% rename from monew-batch/src/main/java/com/monew/monew_batch/BatchScheduler.java rename to monew-batch/src/main/java/com/monew/monew_batch/NotificationCleanupScheduler.java index 935228a..2cb682c 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/BatchScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/NotificationCleanupScheduler.java @@ -13,7 +13,7 @@ @Slf4j @Component @RequiredArgsConstructor -public class BatchScheduler { +public class NotificationCleanupScheduler { private final JobLauncher jobLauncher; private final Job deleteOldNotificationJob; From d73b6d36270d2f02fefb85abf4e891a2aa1abf5f Mon Sep 17 00:00:00 2001 From: truuuely Date: Fri, 31 Oct 2025 13:35:11 +0900 Subject: [PATCH 096/178] =?UTF-8?q?refactor:=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=82=AD=EC=A0=9C=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC,=20JobConfig=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{ => notification/config}/NotificationJobConfig.java | 2 +- .../scheduler}/NotificationCleanupScheduler.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename monew-batch/src/main/java/com/monew/monew_batch/{ => notification/config}/NotificationJobConfig.java (97%) rename monew-batch/src/main/java/com/monew/monew_batch/{ => notification/scheduler}/NotificationCleanupScheduler.java (97%) diff --git a/monew-batch/src/main/java/com/monew/monew_batch/NotificationJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/notification/config/NotificationJobConfig.java similarity index 97% rename from monew-batch/src/main/java/com/monew/monew_batch/NotificationJobConfig.java rename to monew-batch/src/main/java/com/monew/monew_batch/notification/config/NotificationJobConfig.java index 938681b..5034d8f 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/NotificationJobConfig.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/notification/config/NotificationJobConfig.java @@ -1,4 +1,4 @@ -package com.monew.monew_batch; +package com.monew.monew_batch.notification.config; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/monew-batch/src/main/java/com/monew/monew_batch/NotificationCleanupScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/notification/scheduler/NotificationCleanupScheduler.java similarity index 97% rename from monew-batch/src/main/java/com/monew/monew_batch/NotificationCleanupScheduler.java rename to monew-batch/src/main/java/com/monew/monew_batch/notification/scheduler/NotificationCleanupScheduler.java index 2cb682c..87df301 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/NotificationCleanupScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/notification/scheduler/NotificationCleanupScheduler.java @@ -1,4 +1,4 @@ -package com.monew.monew_batch; +package com.monew.monew_batch.notification.scheduler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; From 7ed977b3294a9608e4db4f4ed5e0c2681312125b Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Fri, 31 Oct 2025 16:17:20 +0900 Subject: [PATCH 097/178] =?UTF-8?q?feat=20:=20=EA=B5=AC=EB=8F=85=20dto=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 --- .../monew_api/subscribe/dto/SubscribeDto.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/subscribe/dto/SubscribeDto.java diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/dto/SubscribeDto.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/dto/SubscribeDto.java new file mode 100644 index 0000000..5f113c1 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/dto/SubscribeDto.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.subscribe.dto; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; + +@Builder +public record SubscribeDto( + Long id, + Long interestId, + String interestName, + List interestKeywords, + int interestSubscriberCount, + LocalDateTime createdAt +) { + +} From 1a9917547738c1caba2dcfcc2d833e3648828114 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Fri, 31 Oct 2025 11:05:24 +0900 Subject: [PATCH 098/178] =?UTF-8?q?feat=20:=20Subscribe=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=B0=92=20dto=20=EC=84=A4=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/monew/monew_api/subscribe/entity/Subscribe.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java index 99bdc45..5763252 100644 --- a/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java @@ -1,8 +1,8 @@ package com.monew.monew_api.subscribe.entity; import com.monew.monew_api.common.entity.BaseCreatedEntity; -import com.monew.monew_api.interest.entity.Interest; import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.interest.entity.Interest; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; @@ -27,4 +27,8 @@ public class Subscribe extends BaseCreatedEntity { @JoinColumn(name = "user_id", nullable = false) private User user; + public static Subscribe create(Interest interest, User user) { + return new Subscribe(interest, user); + } } + From cbb7af05e1c6f790a4ed8bc4cc20b727397bc489 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Fri, 31 Oct 2025 11:05:59 +0900 Subject: [PATCH 099/178] =?UTF-8?q?feat=20:=20Subscribe=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_api/common/exception/ErrorCode.java | 4 ++++ .../subscribe/SubscribeDuplicateException.java | 16 ++++++++++++++++ .../subscribe/SubscribeNotFoundException.java | 17 +++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeDuplicateException.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeNotFoundException.java diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java index ce97b6f..26f3d82 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java @@ -16,6 +16,10 @@ public enum ErrorCode { INTEREST_DUPLICATED(HttpStatus.CONFLICT.value(), "유사한 관심사가 이미 존재합니다."), INTEREST_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "관심사 정보를 찾을 수 없습니다."), + //관심사 구독 - SUBSCRIBE + SUBSCRIBE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "구독 정보를 찾을 수 없습니다."), + SUBSCRIBE_DUPLICATE(HttpStatus.CONFLICT.value(), "이미 구독 중입니다."), + // 뉴스 기사 - ARTICLE ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "뉴스 기사 정보를 찾을 수 없습니다."), ARTICLE_ALREADY_VIEWED(HttpStatus.CONFLICT.value(), "이미 조회한 기사입니다."), diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeDuplicateException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeDuplicateException.java new file mode 100644 index 0000000..6d50633 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeDuplicateException.java @@ -0,0 +1,16 @@ +package com.monew.monew_api.common.exception.subscribe; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; +import java.util.Map; + +public class SubscribeDuplicateException extends BaseException { + + public SubscribeDuplicateException() { + super(ErrorCode.SUBSCRIBE_DUPLICATE); + } + + public SubscribeDuplicateException(Map details) { + super(ErrorCode.SUBSCRIBE_DUPLICATE, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeNotFoundException.java new file mode 100644 index 0000000..47882d8 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeNotFoundException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.subscribe; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; +import java.util.Map; + +public class SubscribeNotFoundException extends BaseException { + + public SubscribeNotFoundException() { + super(ErrorCode.SUBSCRIBE_NOT_FOUND); + } + + public SubscribeNotFoundException(Map details) { + super(ErrorCode.SUBSCRIBE_NOT_FOUND, details); + } + +} From fb09863e9258d8d705fa6bb340191a3aaa041084 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Fri, 31 Oct 2025 11:38:20 +0900 Subject: [PATCH 100/178] =?UTF-8?q?refactor=20:=20Interest=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=EC=97=90=20@Setter=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/monew/monew_api/interest/entity/Interest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java index 070deca..0d0b2d1 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java @@ -12,10 +12,11 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; @Entity @Table(name = "interests") -@Getter +@Getter @Setter @AllArgsConstructor @NoArgsConstructor public class Interest extends BaseTimeEntity { @@ -35,8 +36,8 @@ private Interest(String name, int subscriberCount) { this.subscriberCount = subscriberCount; } - public static Interest create(String interestName) { - return new Interest(interestName, 0); + public static Interest create(String name) { + return new Interest(name, 0); } public InterestKeyword addKeyword(Keyword keyword) { From 007fbeba06214e3fca87c0d7d95b4e4244e0b98d Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Fri, 31 Oct 2025 11:42:40 +0900 Subject: [PATCH 101/178] =?UTF-8?q?refactor=20:=20subscriberCount=20int?= =?UTF-8?q?=EB=A1=9C=20=ED=83=80=EC=9E=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/monew/monew_api/interest/dto/response/InterestDto.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java index 7edeec3..9e7fbda 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java @@ -6,7 +6,7 @@ public record InterestDto( Long id, String name, List keywords, - Long subscriberCount, + int subscriberCount, boolean subscribedByMe ) { From 1d38dfc0e0bcb84da3af937083f163634729c50f Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Fri, 31 Oct 2025 11:51:29 +0900 Subject: [PATCH 102/178] =?UTF-8?q?feat=20:=20=EA=B5=AC=EB=8F=85=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B5=AC=EB=8F=85=20=EC=B7=A8=EC=86=8C=20=EC=84=9C?= =?UTF-8?q?=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 --- .../subscribe/service/SubscribeService.java | 11 +++ .../service/SubscribeServiceImpl.java | 70 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeService.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeService.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeService.java new file mode 100644 index 0000000..b6b42f0 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeService.java @@ -0,0 +1,11 @@ +package com.monew.monew_api.subscribe.service; + +import com.monew.monew_api.subscribe.dto.SubscribeDto; + +public interface SubscribeService { + + SubscribeDto createSubscribe(Long interestId, Long userId); + + void deleteSubscribe(Long interestId, Long userId); + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java new file mode 100644 index 0000000..5cba7c5 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java @@ -0,0 +1,70 @@ +package com.monew.monew_api.subscribe.service; + +import com.monew.monew_api.common.exception.ErrorCode; +import com.monew.monew_api.common.exception.subscribe.SubscribeDuplicateException; +import com.monew.monew_api.common.exception.subscribe.SubscribeNotFoundException; +import com.monew.monew_api.common.exception.user.UserNotFoundException; +import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.domain.user.repository.UserRepository; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.repository.InterestRepository; +import com.monew.monew_api.subscribe.dto.SubscribeDto; +import com.monew.monew_api.subscribe.entity.Subscribe; +import com.monew.monew_api.subscribe.mapper.SubscribeMapper; +import com.monew.monew_api.subscribe.repository.SubscribeRepository; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SubscribeServiceImpl implements SubscribeService { + + private final InterestRepository interestRepository; + private final UserRepository userRepository; + private final SubscribeRepository subscribeRepository; + + private final SubscribeMapper subscribeMapper; + + @Override + @Transactional + public SubscribeDto createSubscribe(Long interestId, Long userId) { + + Interest interest = interestRepository.findById(interestId) + .orElseThrow(UserNotFoundException::new); + User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + + if(subscribeRepository.existsByInterestAndUser(interest, user)){ + throw new SubscribeDuplicateException(); + } + log.info("현재 관심사 구독자 수 : {}", interest.getSubscriberCount()); + interest.setSubscriberCount(interest.getSubscriberCount() + 1); + log.info("관심사 구독 후 구독자 수: {}", interest.getSubscriberCount()); + + Subscribe subscribe = Subscribe.create(interest, user); + Subscribe saved = subscribeRepository.save(subscribe); + + return subscribeMapper.toSubscribeDto(saved); + } + + @Override + @Transactional + public void deleteSubscribe(Long interestId, Long userId) { + + Interest interest = interestRepository.findById(interestId) + .orElseThrow(UserNotFoundException::new); + User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + + Subscribe subscribe = subscribeRepository.findByInterestAndUser(interest,user) + .orElseThrow(SubscribeNotFoundException::new); + + subscribeRepository.delete(subscribe); + log.info("현재 관심사 구독자 수 : {}", interest.getSubscriberCount()); + interest.setSubscriberCount(Math.max(0,interest.getSubscriberCount() - 1)); + log.info("관심사 구독 취소 후 구독자 수: {}", interest.getSubscriberCount()); + } +} From 93b53fad1398b165f251a5317ab6f8347ff8db70 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Fri, 31 Oct 2025 15:45:39 +0900 Subject: [PATCH 103/178] =?UTF-8?q?Refactor=20:=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=EC=97=90=20@Valid=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_api/interest/controller/InterestController.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java b/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java index 88c9e78..3d9b836 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java @@ -45,7 +45,7 @@ public ResponseEntity createInterest( @GetMapping public ResponseEntity getInterests( @RequestHeader("Monew-Request-User-Id") Long userId, - @ParameterObject @ModelAttribute CursorPageRequestInterestDto request + @ParameterObject @ModelAttribute @Valid CursorPageRequestInterestDto request ) { log.info("[API 요청] GET/api/interests/ - 관심사 조회 요청 : {}", request); CursorPageResponseInterestDto response = interestService.getInterests(userId, request); @@ -67,12 +67,13 @@ public ResponseEntity deleteInterest( @PatchMapping("/{interestId}") public ResponseEntity updateInterestKeywords( + @RequestHeader(name = "Monew-Request-User-Id") Long userId, @PathVariable Long interestId, - @RequestBody InterestUpdateRequest request + @RequestBody @Valid InterestUpdateRequest request ) { log.info("[API 요청] PATCH/api/interests/{} - 관심사 키워드 수정 요청 : {}", interestId, request); InterestDto response = interestService - .updateInterestKeywords(request, interestId); + .updateInterestKeywords(request, interestId, userId); log.info("[API 응답] PATCH/api/interests/{} - 관심사 키워드 수정 응답 : {}", interestId, response); return ResponseEntity.status(HttpStatus.OK).body(response); } From fd30116dc455811da552ab2d41198a8a249b79cd Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Fri, 31 Oct 2025 15:47:41 +0900 Subject: [PATCH 104/178] =?UTF-8?q?feat=20:=20=EA=B5=AC=EB=8F=85=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interest/mapper/InterestMapper.java | 5 ++ .../InterestRepositoryCustomImpl.java | 37 ++++++++---- .../interest/service/InterestService.java | 7 +-- .../interest/service/InterestServiceImpl.java | 60 +++++++++++-------- .../controller/SubscribeController.java | 44 ++++++++++++++ .../subscribe/mapper/SubscribeMapper.java | 31 ++++++++++ .../repository/SubscribeRepository.java | 36 +++++++++++ .../service/SubscribeServiceImpl.java | 3 - 8 files changed, 178 insertions(+), 45 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/subscribe/controller/SubscribeController.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/subscribe/mapper/SubscribeMapper.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java b/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java index efe91f8..5949f49 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Set; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; @Mapper(componentModel = "spring") @@ -14,6 +15,10 @@ public interface InterestMapper { InterestDto toInterestDto(Interest interest, List keywords, Boolean subscribedByMe); + @Mapping(target = "subscriberCount", source = "subscriberCount") + InterestDto toInterestDto(Interest interest, List keywords, Boolean subscribedByMe, + int subscriberCount); + // 커스텀 매핑 메서드 (Set -> List) default List map(Set interestKeywords) { if (interestKeywords == null) { diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java index 4533a7a..423657a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java @@ -1,10 +1,7 @@ package com.monew.monew_api.interest.repository; -import com.monew.monew_api.interest.entity.QInterest; -import com.monew.monew_api.interest.dto.InterestOrderBy; import com.monew.monew_api.interest.entity.Interest; -import com.monew.monew_api.interest.entity.QInterestKeyword; -import com.monew.monew_api.interest.entity.QKeyword; +import com.monew.monew_api.interest.dto.InterestOrderBy; import com.querydsl.core.Tuple; import com.querydsl.core.types.Expression; import com.querydsl.core.types.OrderSpecifier; @@ -21,13 +18,17 @@ import org.springframework.data.domain.Sort.Direction; import org.springframework.stereotype.Repository; +import com.monew.monew_api.interest.entity.QInterestKeyword; +import com.monew.monew_api.interest.entity.QKeyword; +import com.monew.monew_api.interest.entity.QInterest; + @Repository @RequiredArgsConstructor public class InterestRepositoryCustomImpl implements InterestRepositoryCustom { private final JPAQueryFactory queryFactory; - private static final QInterest i = QInterest.interest; + private static final QInterest i = QInterest.interest; private static final QKeyword k = QKeyword.keyword1; private static final QInterestKeyword ik = QInterestKeyword.interestKeyword; @@ -64,7 +65,9 @@ public Slice findAll(String searchKeyword, InterestOrderBy sortBy, Dir .fetch(); boolean hasNext = rows.size() > limit; - if (hasNext) rows = rows.subList(0, limit); + if (hasNext) { + rows = rows.subList(0, limit); + } if (rows.isEmpty()) { return new SliceImpl<>(Collections.emptyList(), PageRequest.of(0, limit), false); @@ -93,26 +96,38 @@ public Slice findAll(String searchKeyword, InterestOrderBy sortBy, Dir // 관심사명 OR 키워드명 부분일치 private BooleanExpression containsInterestOrKeyword(String keyword) { - if (keyword == null || keyword.isBlank()) return null; + if (keyword == null || keyword.isBlank()) { + return null; + } return i.name.containsIgnoreCase(keyword) .or(k.keyword.containsIgnoreCase(keyword)); } // after private BooleanExpression createdAfter(LocalDateTime after) { - if (after == null) return null; + if (after == null) { + return null; + } return i.createdAt.goe(after); } // 커서 조건: 정렬 기준별 비교 - private BooleanExpression createCursorPredicate(InterestOrderBy sortBy, Direction dir, String cursor) { - if (cursor == null || cursor.isBlank()) return null; + private BooleanExpression createCursorPredicate(InterestOrderBy sortBy, Direction dir, + String cursor) { + if (cursor == null || cursor.isBlank()) { + return null; + } return switch (sortBy) { case name -> (dir == Direction.ASC) ? i.name.gt(cursor) : i.name.lt(cursor); case subscriberCount -> { int v = Integer.parseInt(cursor); - yield (dir == Direction.ASC) ? i.subscriberCount.gt(v) : i.subscriberCount.lt(v); + BooleanExpression condition = (dir == Direction.ASC) + ? i.subscriberCount.gt(v) + .or(i.subscriberCount.eq(v).and(i.id.gt(Long.parseLong(cursor)))) + : i.subscriberCount.lt(v) + .or(i.subscriberCount.eq(v).and(i.id.lt(Long.parseLong(cursor)))); + yield condition; } }; } diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java index fc2341a..339f700 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java @@ -13,11 +13,8 @@ public interface InterestService { CursorPageResponseInterestDto getInterests(Long userId, CursorPageRequestInterestDto cursorRequest); - InterestDto updateInterestKeywords(InterestUpdateRequest request, Long interestId); + InterestDto updateInterestKeywords(InterestUpdateRequest request, Long interestId, Long userId); void deleteInterest(Long interestId); - // 구독 관련 메서드 추후에 구현 예정!!! - // 구독 - //- 사용자는 관심사를 구독할 수 있습니다. - //- 구독한 관심사와 관련된 뉴스 기사가 등록되면 알림을 받을 수 있습니다. + } diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index 0a8a95b..db2aa4a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -3,6 +3,7 @@ import com.monew.monew_api.common.exception.interest.InterestDuplicatedException; import com.monew.monew_api.common.exception.interest.InterestNotFoundException; import com.monew.monew_api.common.exception.user.UserNotFoundException; +import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; import com.monew.monew_api.interest.dto.InterestOrderBy; import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; @@ -16,6 +17,8 @@ import com.monew.monew_api.interest.mapper.InterestMapper; import com.monew.monew_api.interest.repository.InterestRepository; import com.monew.monew_api.interest.repository.KeywordRepository; +import com.monew.monew_api.subscribe.repository.SubscribeRepository; +import com.monew.monew_api.subscribe.repository.SubscribeRepository.InterestCountProjection; import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import java.util.ArrayList; @@ -28,6 +31,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.text.similarity.LevenshteinDistance; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort.Direction; import org.springframework.stereotype.Service; @@ -41,13 +45,14 @@ public class InterestServiceImpl implements InterestService { private final InterestRepository interestRepository; private final UserRepository userRepository; private final KeywordRepository keywordRepository; + private final SubscribeRepository subscribeRepository; + private final InterestMapper interestMapper; @Override @Transactional public InterestDto createInterest(InterestRegisterRequest request) { - log.info("새로운 관심사 등록 요청: {}", request); String interestName = request.name(); // 유사도 검사 @@ -75,10 +80,7 @@ public InterestDto createInterest(InterestRegisterRequest request) { .map(ik -> ik.getKeyword().getKeyword()) .collect(Collectors.toList()); - InterestDto response = interestMapper.toInterestDto(savedInterest, keywords, false); - log.info("관심사 등록 완료: {}", response); - - return response; + return interestMapper.toInterestDto(savedInterest, keywords, false); } @Override @@ -86,9 +88,6 @@ public InterestDto createInterest(InterestRegisterRequest request) { public CursorPageResponseInterestDto getInterests(Long userId, CursorPageRequestInterestDto request) { - log.info("관심사 조회 요청 : {}", request); - userRepository.findById(userId).orElseThrow(UserNotFoundException::new); - final String keyword = (request.keyword() == null) ? null : request.keyword(); final InterestOrderBy orderBy = (request.orderBy() == null) ? InterestOrderBy.name : request.orderBy(); @@ -102,20 +101,34 @@ public CursorPageResponseInterestDto getInterests(Long userId, List interests = slices.getContent(); + // 관심사 Id 수집 + Set interestIds = interests.stream().map(Interest::getId).collect(Collectors.toSet()); + // 내가 구독중인 관심사 ID + Set subscribedIds = subscribeRepository.findSubscribedByInterestIds(userId, + interestIds); + // 관심사별 구독자 수 벌크 집계 + Map countMap = subscribeRepository.countByInterestIds(interestIds).stream() + .collect(Collectors.toMap( + InterestCountProjection::getInterestId, + InterestCountProjection::getCount + )); + + // dto 채우기 List interestDtos = new ArrayList<>(interests.size()); for (Interest interest : interests) { - List keywords = new ArrayList<>(); - for (InterestKeyword ik : interest.getKeywords()) { - String name = ik.getKeyword().getKeyword(); - keywords.add(name); - } - // ⭐️⭐️ 구독 여부 확인 조회 코드 필요!!!! - interestDtos.add(interestMapper.toInterestDto(interest, keywords, false)); + List keywords = interest.getKeywords().stream() + .map(ik -> ik.getKeyword().getKeyword()) + .toList(); + + boolean subscribedByMe = subscribedIds.contains(interest.getId()); + + Long countLong = countMap.getOrDefault(interest.getId(), 0L); + int subscriberCount = Math.toIntExact(countLong); + interestDtos.add( + interestMapper.toInterestDto(interest, keywords, subscribedByMe, subscriberCount)); } -// Set interestIds = interests.stream().map(Interest::getId).collect(Collectors.toSet()); boolean hasNext = slices.hasNext(); - String nextCursor = calculateNextCursor(interests, orderBy, hasNext); LocalDateTime nextAfter = calculateNextAfter(interests); long totalElements = interestRepository.countFilteredTotalElements(keyword, orderBy, direction); @@ -127,12 +140,12 @@ public CursorPageResponseInterestDto getInterests(Long userId, @Override @Transactional public InterestDto updateInterestKeywords( - InterestUpdateRequest request, Long interestId) { - log.info("interestId = {}, 관심사 키워드 수정 요청 : {}", interestId, request); + InterestUpdateRequest request, Long interestId, Long userId) { -// userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); Interest interest = interestRepository.findById(interestId) .orElseThrow(InterestNotFoundException::new); + boolean subscribedByMe = subscribeRepository.existsByInterestAndUser(interest, user); updateKeywords(interest, request.keywords()); @@ -140,21 +153,16 @@ public InterestDto updateInterestKeywords( .map(ik -> ik.getKeyword().getKeyword()) .collect(Collectors.toList()); - // ⭐️⭐️구독 여부 가져오는 코드 추가 필요!! - - log.info("interestId = {}, 관심사 키워드 수정 완료 : {}", interestId, keywords); - return interestMapper.toInterestDto(interest, keywords, false); + return interestMapper.toInterestDto(interest, keywords, subscribedByMe); } @Override @Transactional public void deleteInterest(Long interestId) { - log.info("관심사 삭제 요청 : interestId = {}", interestId); Interest interest = interestRepository.findById(interestId) .orElseThrow(InterestNotFoundException::new); interestRepository.delete(interest); - log.info("관심사 삭제 완료 : interestId = {}", interestId); } diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/controller/SubscribeController.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/controller/SubscribeController.java new file mode 100644 index 0000000..c1c4170 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/controller/SubscribeController.java @@ -0,0 +1,44 @@ +package com.monew.monew_api.subscribe.controller; + +import com.monew.monew_api.subscribe.dto.SubscribeDto; +import com.monew.monew_api.subscribe.service.SubscribeService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/interests") +@RequiredArgsConstructor +@Slf4j +public class SubscribeController { + + private final SubscribeService subscribeService; + + @PostMapping("/{interestId}/subscriptions") + public ResponseEntity createSubscribe( + @PathVariable Long interestId, + @RequestHeader("Monew-Request-User-ID") Long userId){ + log.info("[API 요청] POST/api/interests/{}/subscriptions - 관심사 구독 요청", interestId); + SubscribeDto subscribeDto = subscribeService.createSubscribe(interestId, userId); + log.info("[API 요청] POST/api/interests/{}/subscriptions - 관심사 구독 응답", interestId); + return ResponseEntity.status(HttpStatus.CREATED).body(subscribeDto); + } + + @DeleteMapping("/{interestId}/subscriptions") + public ResponseEntity deleteSubscribe( + @PathVariable Long interestId, + @RequestHeader("Monew-Request-User-ID") Long userId){ + log.info("[API 요청] DELETE/api/interests/{}/subscriptions - 구독 취소 요청", interestId); + subscribeService.deleteSubscribe(interestId, userId); + log.info("[API 요청] DELETE/api/interests/{}/subscriptions - 구독 취소 응답", interestId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/mapper/SubscribeMapper.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/mapper/SubscribeMapper.java new file mode 100644 index 0000000..0ce3774 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/mapper/SubscribeMapper.java @@ -0,0 +1,31 @@ +package com.monew.monew_api.subscribe.mapper; + +import com.monew.monew_api.interest.entity.InterestKeyword; +import com.monew.monew_api.subscribe.dto.SubscribeDto; +import com.monew.monew_api.subscribe.entity.Subscribe; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; + +@Mapper(componentModel = "spring") +public interface SubscribeMapper { + + @Mappings({ + @Mapping(source = "interest.id", target = "interestId"), + @Mapping(source = "interest.name", target = "interestName"), + @Mapping(source = "interest.keywords", target = "interestKeywords"), + @Mapping(source = "interest.subscriberCount", target = "interestSubscriberCount") + }) + SubscribeDto toSubscribeDto(Subscribe subscribe); + + default List map(Set keywords) { + if (keywords == null) return null; + return keywords.stream() + .map(ik -> ik.getKeyword().getKeyword()) + .collect(Collectors.toList()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java new file mode 100644 index 0000000..c9864d7 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java @@ -0,0 +1,36 @@ +package com.monew.monew_api.subscribe.repository; + +import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.subscribe.entity.Subscribe; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface SubscribeRepository extends JpaRepository { + + boolean existsByInterestAndUser(Interest interest, User user); + + @Query("SELECT s.interest.id FROM Subscribe s " + + "WHERE s.user.id = :userId AND s.interest.id IN :interestIds") + Set findSubscribedByInterestIds(@Param("userId") Long userId, + @Param("interestIds") Set interestIds); + + Optional findByInterestAndUser(Interest interest, User user); + + // 관심사별로 구독자 수 벌크 집계 + @Query("SELECT s.interest.id AS interestId, COUNT(s.id) AS count " + + "FROM Subscribe s WHERE s.interest.id IN :interestIds GROUP BY s.interest.id") + List countByInterestIds(@Param("interestIds") Set interestIds); + + interface InterestCountProjection { + + Long getInterestId(); + Long getCount(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java index 5cba7c5..504f1cd 100644 --- a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java @@ -1,6 +1,5 @@ package com.monew.monew_api.subscribe.service; -import com.monew.monew_api.common.exception.ErrorCode; import com.monew.monew_api.common.exception.subscribe.SubscribeDuplicateException; import com.monew.monew_api.common.exception.subscribe.SubscribeNotFoundException; import com.monew.monew_api.common.exception.user.UserNotFoundException; @@ -12,8 +11,6 @@ import com.monew.monew_api.subscribe.entity.Subscribe; import com.monew.monew_api.subscribe.mapper.SubscribeMapper; import com.monew.monew_api.subscribe.repository.SubscribeRepository; -import java.util.List; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; From 2d8a1842305b42fdc6d5f9cadb0836ebaffbe668 Mon Sep 17 00:00:00 2001 From: truuuely Date: Fri, 31 Oct 2025 17:28:52 +0900 Subject: [PATCH 105/178] =?UTF-8?q?test:=20=EC=95=8C=EB=A6=BC=20=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=82=B4=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=B6=9C=EB=A0=A5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/repository/NotificationRepositoryTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java b/monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java index 7718fcd..6a8c3f0 100644 --- a/monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java +++ b/monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java @@ -47,7 +47,6 @@ void setup() { void deleteAllOldConfirmedTest() { // Given LocalDateTime now = LocalDateTime.now(); - System.out.println("지금!!!!" + now); LocalDateTime oneWeekAgo = now.minusWeeks(1); LocalDateTime eightDaysAgo = now.minusDays(8); LocalDateTime sixDaysAgo = now.minusDays(6); From 07fb21477cf1d3658a4d21e32276fe97fa5d5a7e Mon Sep 17 00:00:00 2001 From: truuuely Date: Sat, 1 Nov 2025 14:33:39 +0900 Subject: [PATCH 106/178] =?UTF-8?q?chore:=20api=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=A1=B4=20KST=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 --- monew-api/src/main/resources/application-dev.yml | 4 ++-- monew-api/src/main/resources/application-prod.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/monew-api/src/main/resources/application-dev.yml b/monew-api/src/main/resources/application-dev.yml index e05d0c6..5bcd69b 100644 --- a/monew-api/src/main/resources/application-dev.yml +++ b/monew-api/src/main/resources/application-dev.yml @@ -35,10 +35,10 @@ spring: hibernate: format_sql: true jdbc: - time_zone: UTC + time_zone: Asia/Seoul jackson: - time-zone: UTC + time-zone: Asia/Seoul servlet: multipart: diff --git a/monew-api/src/main/resources/application-prod.yml b/monew-api/src/main/resources/application-prod.yml index cf55f25..2b99fbb 100644 --- a/monew-api/src/main/resources/application-prod.yml +++ b/monew-api/src/main/resources/application-prod.yml @@ -21,10 +21,10 @@ spring: hibernate: format_sql: true jdbc: - time_zone: UTC + time_zone: Asia/Seoul jackson: - time-zone: UTC + time-zone: Asia/Seoul servlet: multipart: From 94796ebc05d571d6f1765d2eebb0f6ff38f74216 Mon Sep 17 00:00:00 2001 From: truuuely Date: Sat, 1 Nov 2025 14:34:04 +0900 Subject: [PATCH 107/178] =?UTF-8?q?chore:=20batch=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=A1=B4=20KST=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 --- monew-batch/src/main/resources/application-dev.yml | 2 +- monew-batch/src/main/resources/application-prod.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/monew-batch/src/main/resources/application-dev.yml b/monew-batch/src/main/resources/application-dev.yml index 4e2defd..b462f4a 100644 --- a/monew-batch/src/main/resources/application-dev.yml +++ b/monew-batch/src/main/resources/application-dev.yml @@ -16,7 +16,7 @@ spring: hibernate: format_sql: true jdbc: - time_zone: UTC + time_zone: Asia/Seoul batch: jdbc: diff --git a/monew-batch/src/main/resources/application-prod.yml b/monew-batch/src/main/resources/application-prod.yml index ccb1d51..be0d5be 100644 --- a/monew-batch/src/main/resources/application-prod.yml +++ b/monew-batch/src/main/resources/application-prod.yml @@ -15,7 +15,7 @@ spring: properties: hibernate: jdbc: - time_zone: UTC + time_zone: Asia/Seoul batch: jdbc: From 6bd248e4220eb824a31175cff42183551c8609a5 Mon Sep 17 00:00:00 2001 From: truuuely Date: Sat, 1 Nov 2025 14:34:51 +0900 Subject: [PATCH 108/178] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=9C=20=ED=83=80=EC=9E=84=EC=A1=B4=20KST=EB=A1=9C=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 | 3 --- 1 file changed, 3 deletions(-) diff --git a/build.gradle b/build.gradle index 95ffd95..7d24310 100644 --- a/build.gradle +++ b/build.gradle @@ -37,8 +37,5 @@ subprojects { tasks.named('test') { useJUnitPlatform() - - // 테스트시 모든 시간대를 UTC 기준으로 변경 - systemProperty 'user.timezone', 'UTC' } } \ No newline at end of file From 869a41b78b51039b9644b20c887fb8f6c6c5cb9d Mon Sep 17 00:00:00 2001 From: truuuely Date: Sat, 1 Nov 2025 14:36:06 +0900 Subject: [PATCH 109/178] =?UTF-8?q?chore:=20api=20=EB=B0=8F=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20JVM=20=ED=83=80=EC=9E=84=EC=A1=B4=20KST=EB=A1=9C=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 --- .../src/main/java/com/monew/monew_api/MonewApiApplication.java | 2 +- .../main/java/com/monew/monew_batch/MonewBatchApplication.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java b/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java index 7bfcc43..127b9f8 100644 --- a/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java +++ b/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java @@ -11,7 +11,7 @@ public class MonewApiApplication { public static void main(String[] args) { - TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); SpringApplication.run(MonewApiApplication.class, args); } diff --git a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java index 1a46a07..1ec8c1b 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java @@ -15,7 +15,7 @@ public class MonewBatchApplication { public static void main(String[] args) { - TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); SpringApplication.run(MonewBatchApplication.class, args); } From adbefcc2185890dc28f9c6b49f4e8643343907b4 Mon Sep 17 00:00:00 2001 From: truuuely Date: Sat, 1 Nov 2025 17:40:02 +0900 Subject: [PATCH 110/178] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=B0=9C=EC=83=9D=20=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20-=20=EB=B9=84=EB=8F=99=EA=B8=B0=EC=A0=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=AC=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comments/event/CommentLikedEvent.java | 5 +--- .../comments/service/CommentService.java | 28 +++++-------------- .../NotificationEventListener.java | 27 ++++++++++++++++++ .../service/NotificationService.java | 19 +++++++++++++ 4 files changed, 54 insertions(+), 25 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/notification/eventlistener/NotificationEventListener.java diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java index 67e7889..7d9f4ad 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java @@ -1,9 +1,6 @@ package com.monew.monew_api.comments.event; -import java.time.LocalDateTime; - public record CommentLikedEvent(Long commentId, Long commentAuthorId, - Long likedByUserId, - LocalDateTime createdAt) { + String likerNickname) { } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index fda57b9..6c6ff52 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -1,36 +1,22 @@ package com.monew.monew_api.comments.service; -import java.time.LocalDateTime; -import java.time.ZoneId; - -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import com.monew.monew_api.article.entity.Article; import com.monew.monew_api.article.repository.ArticleRepository; -import com.monew.monew_api.comments.dto.CommentDto; -import com.monew.monew_api.comments.dto.CommentLikeDto; -import com.monew.monew_api.comments.dto.CommentRegisterRequest; -import com.monew.monew_api.comments.dto.CommentSearchRequest; -import com.monew.monew_api.comments.dto.CommentUpdateRequest; -import com.monew.monew_api.comments.dto.CursorPageResponseCommentDto; +import com.monew.monew_api.comments.dto.*; import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.comments.event.CommentCreatedEvent; import com.monew.monew_api.comments.event.CommentLikedEvent; import com.monew.monew_api.comments.repository.CommentLikeRepository; import com.monew.monew_api.comments.repository.CommentRepository; -import com.monew.monew_api.common.exception.comment.CommentArticleNotFoundException; -import com.monew.monew_api.common.exception.comment.CommentForbiddenException; -import com.monew.monew_api.common.exception.comment.CommentNotFoundException; -import com.monew.monew_api.common.exception.comment.CommentNotLikedException; -import com.monew.monew_api.common.exception.comment.CommentUserNotFoundException; +import com.monew.monew_api.common.exception.comment.*; import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @@ -88,8 +74,8 @@ public CommentLikeDto like(Long userId, Long commentId) { comment.increaseLike(); eventPublisher.publishEvent( - new CommentLikedEvent(comment.getId(), comment.getUserId(), userId, LocalDateTime.now()) - ); + new CommentLikedEvent(comment.getId(), comment.getUserId(), user.getNickname())); + log.info("[COMMENT][LIKE] userId={}, commentId={}", userId, commentId); return CommentLikeDto.from(saved); } diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/eventlistener/NotificationEventListener.java b/monew-api/src/main/java/com/monew/monew_api/notification/eventlistener/NotificationEventListener.java new file mode 100644 index 0000000..410c652 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/eventlistener/NotificationEventListener.java @@ -0,0 +1,27 @@ +package com.monew.monew_api.notification.eventlistener; + +import com.monew.monew_api.comments.event.CommentLikedEvent; +import com.monew.monew_api.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationEventListener { + + private final NotificationService notificationService; + + @Async + @EventListener(CommentLikedEvent.class) + public void handleCommentLiked(CommentLikedEvent event) { + try { + notificationService.createCommentLikeNotification(event); + } catch (Exception e) { + log.error("알림 생성 실패: {}", event.commentId(), e); + } + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java index b96ea25..86590f7 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java @@ -1,12 +1,16 @@ package com.monew.monew_api.notification.service; +import com.monew.monew_api.comments.event.CommentLikedEvent; import com.monew.monew_api.common.dto.CursorPageResponse; import com.monew.monew_api.common.exception.notification.NotificationAccessDeniedException; import com.monew.monew_api.common.exception.notification.NotificationAlreadyConfirmedException; import com.monew.monew_api.common.exception.notification.NotificationNotFoundException; +import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.domain.user.repository.UserRepository; import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest; import com.monew.monew_api.notification.dto.response.NotificationDto; import com.monew.monew_api.notification.entity.Notification; +import com.monew.monew_api.notification.enums.ResourceType; import com.monew.monew_api.notification.repository.NotificationRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -19,6 +23,21 @@ @Transactional(readOnly = true) public class NotificationService { private final NotificationRepository notificationRepository; + private final UserRepository userRepository; + + @Transactional + public void createCommentLikeNotification(CommentLikedEvent event) { + User commentAuthorIdOnly = userRepository.getReferenceById(event.commentAuthorId()); + + String content = String.format("%s님이 나의 댓글을 좋아합니다.", event.likerNickname()); + + notificationRepository.save( + new Notification( + commentAuthorIdOnly, + content, + ResourceType.comment, + event.commentId())); + } public CursorPageResponse getNonConfirmedNotifications(Long userId, NotificationCursorPageRequest cursorPageRequest) { return notificationRepository.findAllNonConfirmedNotifications(userId, cursorPageRequest); From a1777e61a516f3630468707ff0355fc14923361e Mon Sep 17 00:00:00 2001 From: truuuely Date: Sat, 1 Nov 2025 17:41:24 +0900 Subject: [PATCH 111/178] =?UTF-8?q?chore:=20=EC=8A=A4=ED=82=A4=EB=A7=88?= =?UTF-8?q?=EC=97=90=20article=5Fkeyword=5Flogs=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monew-api/src/main/resources/db/schema.sql | 55 +++++++++++----------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/monew-api/src/main/resources/db/schema.sql b/monew-api/src/main/resources/db/schema.sql index 237ab99..d567067 100644 --- a/monew-api/src/main/resources/db/schema.sql +++ b/monew-api/src/main/resources/db/schema.sql @@ -1,4 +1,5 @@ -- ===== Clean drop (drop children first) ===== +DROP TABLE IF EXISTS article_keyword_logs CASCADE; DROP TABLE IF EXISTS comment_likes CASCADE; DROP TABLE IF EXISTS article_views CASCADE; DROP TABLE IF EXISTS comments CASCADE; @@ -59,33 +60,6 @@ CREATE TABLE article_views CREATE INDEX ix_article_views_user ON article_views (user_id); CREATE INDEX ix_article_views_article ON article_views (article_id); --- ====================================================== --- Article Keyword Logs (뉴스가 어떤 관심사·키워드로 수집됐는지 추적) --- ====================================================== -CREATE TABLE article_keyword_logs -( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - article_id BIGINT NOT NULL, - keyword_id BIGINT NOT NULL, - interest_id BIGINT NOT NULL, - collected_at TIMESTAMP NOT NULL DEFAULT NOW(), - - CONSTRAINT uq_article_keyword_logs UNIQUE (article_id, keyword_id, interest_id), - - CONSTRAINT fk_article_keyword_logs_article - FOREIGN KEY (article_id) REFERENCES articles (id) ON DELETE CASCADE, - - CONSTRAINT fk_article_keyword_logs_keyword - FOREIGN KEY (keyword_id) REFERENCES keywords (id) ON DELETE CASCADE, - - CONSTRAINT fk_article_keyword_logs_interest - FOREIGN KEY (interest_id) REFERENCES interests (id) ON DELETE CASCADE -); - -CREATE INDEX ix_article_keyword_logs_article ON article_keyword_logs (article_id); -CREATE INDEX ix_article_keyword_logs_keyword ON article_keyword_logs (keyword_id); -CREATE INDEX ix_article_keyword_logs_interest ON article_keyword_logs (interest_id); - -- ====================================================== -- Interests -- ====================================================== @@ -220,3 +194,30 @@ CREATE TABLE notifications CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); + +-- ====================================================== +-- Article Keyword Logs (뉴스가 어떤 관심사·키워드로 수집됐는지 추적) +-- ====================================================== +CREATE TABLE article_keyword_logs +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + article_id BIGINT NOT NULL, + keyword_id BIGINT NOT NULL, + interest_id BIGINT NOT NULL, + collected_at TIMESTAMP NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_article_keyword_logs UNIQUE (article_id, keyword_id, interest_id), + + CONSTRAINT fk_article_keyword_logs_article + FOREIGN KEY (article_id) REFERENCES articles (id) ON DELETE CASCADE, + + CONSTRAINT fk_article_keyword_logs_keyword + FOREIGN KEY (keyword_id) REFERENCES keywords (id) ON DELETE CASCADE, + + CONSTRAINT fk_article_keyword_logs_interest + FOREIGN KEY (interest_id) REFERENCES interests (id) ON DELETE CASCADE +); + +CREATE INDEX ix_article_keyword_logs_article ON article_keyword_logs (article_id); +CREATE INDEX ix_article_keyword_logs_keyword ON article_keyword_logs (keyword_id); +CREATE INDEX ix_article_keyword_logs_interest ON article_keyword_logs (interest_id); From 690bdc56f6f349de6586bbf05f30052373c3d462 Mon Sep 17 00:00:00 2001 From: truuuely Date: Sat, 1 Nov 2025 17:51:59 +0900 Subject: [PATCH 112/178] =?UTF-8?q?fix:=20=EB=88=84=EB=9D=BD=EB=90=9C=20@E?= =?UTF-8?q?nableAsync=20=EC=B6=94=EA=B0=80=20-=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/monew/monew_api/MonewApiApplication.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java b/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java index 127b9f8..9c1f9f7 100644 --- a/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java +++ b/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java @@ -3,11 +3,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; import java.util.TimeZone; @SpringBootApplication @EnableJpaAuditing +@EnableAsync public class MonewApiApplication { public static void main(String[] args) { From b8266598ffe0ed282ae9ac6d17d14b35fc8463cf Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Sun, 2 Nov 2025 01:15:13 +0900 Subject: [PATCH 113/178] =?UTF-8?q?=20refactor:=20=EB=89=B4=EC=8A=A4-?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=97=B0=EA=B2=B0=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ArticleKeywordLog 엔티티 및 레포지토리 제거 - InterestArticleKeyword 엔티티 추가 (InterestArticles-Keywords 다대다 관계용) - schema.sql 수정: - ArticleKeywordLogs 테이블 삭제 - Interest_articles_keywords 테이블 추가 --- .../article/entity/ArticleKeywordLog.java | 51 ------------------- .../entity/InterestArticleKeyword.java | 29 +++++++++++ .../ArticleKeywordLogRepository.java | 22 -------- monew-api/src/main/resources/db/schema.sql | 36 +++++-------- 4 files changed, 41 insertions(+), 97 deletions(-) delete mode 100644 monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleKeywordLog.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticleKeyword.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleKeywordLogRepository.java diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleKeywordLog.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleKeywordLog.java deleted file mode 100644 index 2500565..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleKeywordLog.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.monew.monew_api.article.entity; - -import com.monew.monew_api.interest.entity.Interest; -import com.monew.monew_api.interest.entity.Keyword; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Entity -@Table( - name = "article_keyword_logs", - uniqueConstraints = { - @UniqueConstraint( - name = "uq_article_keyword_logs_article_keyword_interest", - columnNames = {"article_id", "keyword_id", "interest_id"} - ) - } -) -public class ArticleKeywordLog { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "article_id", nullable = false) - private Article article; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "keyword_id", nullable = false) - private Keyword keyword; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "interest_id", nullable = false) - private Interest interest; - - @Column(nullable = false) - private LocalDateTime collectedAt = LocalDateTime.now(); - - public ArticleKeywordLog(Article article, Keyword keyword, Interest interest) { - this.article = article; - this.keyword = keyword; - this.interest = interest; - this.collectedAt = LocalDateTime.now(); - } -} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticleKeyword.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticleKeyword.java new file mode 100644 index 0000000..73d4b60 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticleKeyword.java @@ -0,0 +1,29 @@ +package com.monew.monew_api.article.entity; + +import com.monew.monew_api.common.entity.BaseIdEntity; +import com.monew.monew_api.interest.entity.Keyword; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table( + name = "interest_articles_keywords", + uniqueConstraints = @UniqueConstraint( + name = "uq_interest_articles_keywords", + columnNames = {"interest_article_id", "keyword_id"} + ) +) +public class InterestArticleKeyword extends BaseIdEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "interest_article_id", nullable = false) + private InterestArticles interestArticle; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "keyword_id", nullable = false) + private Keyword keyword; +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleKeywordLogRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleKeywordLogRepository.java deleted file mode 100644 index d5999be..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleKeywordLogRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.monew.monew_api.article.repository; - -import com.monew.monew_api.article.entity.ArticleKeywordLog; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface ArticleKeywordLogRepository extends JpaRepository { - - @Modifying - @Query(value = """ - INSERT INTO article_keyword_logs (article_id, keyword_id, interest_id, collected_at) - VALUES (:articleId, :keywordId, :interestId, now()) - ON CONFLICT (article_id, keyword_id, interest_id) DO NOTHING - """, nativeQuery = true) - void insertIgnore( - @Param("articleId") Long articleId, - @Param("keywordId") Long keywordId, - @Param("interestId") Long interestId - ); -} diff --git a/monew-api/src/main/resources/db/schema.sql b/monew-api/src/main/resources/db/schema.sql index d567067..a62ab49 100644 --- a/monew-api/src/main/resources/db/schema.sql +++ b/monew-api/src/main/resources/db/schema.sql @@ -1,11 +1,11 @@ -- ===== Clean drop (drop children first) ===== -DROP TABLE IF EXISTS article_keyword_logs CASCADE; DROP TABLE IF EXISTS comment_likes CASCADE; DROP TABLE IF EXISTS article_views CASCADE; DROP TABLE IF EXISTS comments CASCADE; DROP TABLE IF EXISTS notifications CASCADE; DROP TABLE IF EXISTS subscribes CASCADE; DROP TABLE IF EXISTS interest_keywords CASCADE; +DROP TABLE IF EXISTS interest_articles_keywords CASCADE; DROP TABLE IF EXISTS interest_articles CASCADE; DROP TABLE IF EXISTS keywords CASCADE; DROP TABLE IF EXISTS articles CASCADE; @@ -196,28 +196,16 @@ CREATE TABLE notifications ); -- ====================================================== --- Article Keyword Logs (뉴스가 어떤 관심사·키워드로 수집됐는지 추적) +-- Interest - Article - Keyword 관계 (현재 상태) -- ====================================================== -CREATE TABLE article_keyword_logs -( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - article_id BIGINT NOT NULL, - keyword_id BIGINT NOT NULL, - interest_id BIGINT NOT NULL, - collected_at TIMESTAMP NOT NULL DEFAULT NOW(), - - CONSTRAINT uq_article_keyword_logs UNIQUE (article_id, keyword_id, interest_id), - - CONSTRAINT fk_article_keyword_logs_article - FOREIGN KEY (article_id) REFERENCES articles (id) ON DELETE CASCADE, - - CONSTRAINT fk_article_keyword_logs_keyword - FOREIGN KEY (keyword_id) REFERENCES keywords (id) ON DELETE CASCADE, - - CONSTRAINT fk_article_keyword_logs_interest - FOREIGN KEY (interest_id) REFERENCES interests (id) ON DELETE CASCADE +CREATE TABLE interest_articles_keywords ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + interest_article_id BIGINT NOT NULL, + keyword_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_interest_articles_keywords UNIQUE (interest_article_id, keyword_id), + CONSTRAINT fk_iak_interest_article FOREIGN KEY (interest_article_id) REFERENCES interest_articles (id) ON DELETE CASCADE, + CONSTRAINT fk_iak_keyword FOREIGN KEY (keyword_id) REFERENCES keywords (id) ON DELETE CASCADE ); - -CREATE INDEX ix_article_keyword_logs_article ON article_keyword_logs (article_id); -CREATE INDEX ix_article_keyword_logs_keyword ON article_keyword_logs (keyword_id); -CREATE INDEX ix_article_keyword_logs_interest ON article_keyword_logs (interest_id); +CREATE INDEX ix_interest_articles_keywords_interest_article ON interest_articles_keywords (interest_article_id); +CREATE INDEX ix_interest_articles_keywords_keyword ON interest_articles_keywords (keyword_id); \ No newline at end of file From 00f2dd6ce892a768b515a4bf4bbe4d32086b17a6 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Sun, 2 Nov 2025 01:17:41 +0900 Subject: [PATCH 114/178] =?UTF-8?q?refactor:=20=EA=B4=80=EC=8B=AC=EC=82=AC?= =?UTF-8?q?=20=EB=B0=8F=20=EA=B8=B0=EC=82=AC=20=EC=97=B0=EA=B4=80=20Reposi?= =?UTF-8?q?tory=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ArticleRepository: - markAsDeleted() 논리 삭제 메서드 추가 - InterestArticlesRepository: - existsByArticleAndInterest → findByArticleAndInterest 대체 - findArticleIdsByInterestId / findArticleIdsUsedByOtherInterests 추가 - InterestArticleKeywordRepository: - findArticleIdsByKeywordIds / findArticlesUsedElsewhere 추가 - insertIgnore() 메서드 이동 (기존 ArticleKeywordLogRepository 기능 통합) - InterestRepository: - findAllWithKeywords 쿼리 구조 가독성 향상 (줄바꿈 처리) --- .../article/repository/ArticleRepository.java | 25 ++++++---- .../InterestArticleKeywordRepository.java | 50 +++++++++++++++++++ .../InterestArticlesRepository.java | 32 +++++++++++- .../repository/InterestRepository.java | 8 ++- 4 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticleKeywordRepository.java diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java index dcd3657..c58c82e 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java @@ -11,13 +11,17 @@ public interface ArticleRepository extends JpaRepository, ArticleQueryRepository { + /** 논리 삭제되지 않은 기사 단건 조회 */ Optional
findByIdAndIsDeletedFalse(Long id); + /** 기사 출처(source) 중복 없이 조회 */ @Query("SELECT DISTINCT a.source FROM Article a WHERE a.isDeleted = false") List findDistinctSources(); + /** 기사 URL로 중복 여부 확인 (뉴스 중복 방지용) */ Optional
findBySourceUrl(String sourceUrl); + /** 논리 삭제된 기사 복구 (isDeleted = false) */ @Modifying @Query(""" UPDATE Article a @@ -26,16 +30,15 @@ public interface ArticleRepository extends JpaRepository, Article """) int restoreIfDeleted(@Param("sourceUrl") String sourceUrl); - List
findAllByIsDeletedTrue(); + /** 여러 기사 논리 삭제 (isDeleted = true) */ + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE Article a + SET a.isDeleted = true + WHERE a.id IN :articleIds + """) + void markAsDeleted(@Param("articleIds") List articleIds); -/* - @Modifying - @Query(value = """ - INSERT INTO articles (source, source_url, title, summary, publish_date, comment_count, view_count, is_deleted) - VALUES (:#{#a.source}, :#{#a.sourceUrl}, :#{#a.title}, :#{#a.summary}, - :#{#a.publishDate}, :#{#a.commentCount}, :#{#a.viewCount}, :#{#a.isDeleted}) - ON CONFLICT (source_url) DO NOTHING - """, nativeQuery = true) - void insertIgnore(@Param("a") Article article); -*/ + /** 논리 삭제된 기사 전체 조회 (스케줄러 등에서 사용) */ + List
findAllByIsDeletedTrue(); } diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticleKeywordRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticleKeywordRepository.java new file mode 100644 index 0000000..d32ef07 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticleKeywordRepository.java @@ -0,0 +1,50 @@ +package com.monew.monew_api.article.repository; + +import com.monew.monew_api.article.entity.InterestArticleKeyword; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface InterestArticleKeywordRepository extends JpaRepository { + + /** + * 특정 키워드들이 연결된 기사 ID 목록 조회 + */ + @Query(""" + SELECT DISTINCT iak.interestArticle.article.id + FROM InterestArticleKeyword iak + WHERE iak.keyword.id IN :keywordIds + """) + List findArticleIdsByKeywordIds(@Param("keywordIds") List keywordIds); + + /** + * 주어진 키워드가 아닌 다른 키워드나 관심사에서도 + * 동일한 기사가 사용 중인지 확인 + */ + @Query(""" + SELECT DISTINCT iak.interestArticle.article.id + FROM InterestArticleKeyword iak + WHERE iak.interestArticle.article.id IN :articleIds + AND (iak.interestArticle.interest.id <> :interestId + OR iak.keyword.id NOT IN :keywordIds) + """) + List findArticlesUsedElsewhere( + @Param("articleIds") List articleIds, + @Param("keywordIds") List keywordIds, + @Param("interestId") Long interestId + ); + + @Modifying + @Query(value = """ + INSERT INTO interest_articles_keywords (interest_article_id, keyword_id) + VALUES (:interestArticleId, :keywordId) + ON CONFLICT (interest_article_id, keyword_id) DO NOTHING + """, nativeQuery = true) + int insertIgnore( + @Param("interestArticleId") Long interestArticleId, + @Param("keywordId") Long keywordId + ); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java index 9f147f2..386c707 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java @@ -4,8 +4,38 @@ import com.monew.monew_api.article.entity.InterestArticles; import com.monew.monew_api.interest.entity.Interest; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; public interface InterestArticlesRepository extends JpaRepository { - boolean existsByArticleAndInterest(Article article, Interest interest); + /** 특정 관심사(interestId)에 연결된 모든 기사 ID 조회 */ + @Query(""" + SELECT ia.article.id + FROM InterestArticles ia + WHERE ia.interest.id = :interestId + """) + List findArticleIdsByInterestId(@Param("interestId") Long interestId); + + /** + * 주어진 기사들(articleIds)이 + * 현재 관심사(interestId)를 제외한 다른 관심사에서도 사용 중인지 확인. + * (즉, “공유된 기사”를 식별하기 위한 쿼리) + */ + @Query(""" + SELECT DISTINCT ia.article.id + FROM InterestArticles ia + WHERE ia.article.id IN :articleIds + AND ia.interest.id <> :interestId + """) + List findArticleIdsUsedByOtherInterests( + @Param("articleIds") List articleIds, + @Param("interestId") Long interestId + ); + + /** 특정 기사와 관심사 간의 연결이 이미 존재하는지 확인 */ + Optional findByArticleAndInterest(Article article, Interest interest); } diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java index 02e6436..c94fb86 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java @@ -10,6 +10,12 @@ public interface InterestRepository extends JpaRepository, Inter boolean existsByName(String name); - @Query("SELECT i FROM Interest i JOIN FETCH i.keywords k JOIN FETCH k.keyword") + /** 모든 관심사와 해당 관심사에 연결된 키워드들을 함께 조회 */ + @Query(""" + SELECT i + FROM Interest i + JOIN FETCH i.keywords ik + JOIN FETCH ik.keyword + """) List findAllWithKeywords(); } From 63c041f26f60c0e286ef3a402f83e6e41445dc5d Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Sun, 2 Nov 2025 01:18:06 +0900 Subject: [PATCH 115/178] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=20=ED=83=80=EC=9E=84=EC=A1=B4=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 - NaverNewsItemReader: 스레드 안전성 확보를 위한 synchronized 적용 - NaverNewsItemWriter: - ArticleKeywordLogRepository → InterestArticleKeywordRepository 교체 - NaverNewsScheduler, ArticleCleanupScheduler: - cron 타임존을 UTC → Asia/Seoul 로 변경 --- .../article/job/NaverNewsItemReader.java | 2 +- .../article/job/NaverNewsItemWriter.java | 41 +++++++++++-------- .../scheduler/ArticleCleanupScheduler.java | 14 ++----- .../article/scheduler/NaverNewsScheduler.java | 3 +- 4 files changed, 31 insertions(+), 29 deletions(-) diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemReader.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemReader.java index dce6ce8..495ecfd 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemReader.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemReader.java @@ -21,7 +21,7 @@ public class NaverNewsItemReader implements ItemReader { private int nextIndex = 0; @Override - public Interest read() { + public synchronized Interest read() { if (items == null) { items = interestRepository.findAllWithKeywords(); log.info("📰 관심사 {}개 로드 완료", items.size()); diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java index 1938682..6a6b51e 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java @@ -2,8 +2,8 @@ import com.monew.monew_api.article.entity.Article; import com.monew.monew_api.article.entity.InterestArticles; -import com.monew.monew_api.article.repository.ArticleKeywordLogRepository; import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_api.article.repository.InterestArticleKeywordRepository; import com.monew.monew_api.article.repository.InterestArticlesRepository; import com.monew.monew_api.common.exception.article.ArticleNotFoundException; import com.monew.monew_api.interest.entity.Interest; @@ -27,7 +27,7 @@ public class NaverNewsItemWriter implements ItemWriter private final ArticleJdbcRepository articleJdbcRepository; private final ArticleRepository articleRepository; private final InterestArticlesRepository interestArticlesRepository; - private final ArticleKeywordLogRepository articleKeywordLogRepository; + private final InterestArticleKeywordRepository interestArticleKeywordRepository; @Override public void write(Chunk> chunk) { @@ -45,8 +45,8 @@ public void write(Chunk> chunk) { Article savedArticle = handleRestoreAndFind(article); - // 2. 관심사-기사 및 키워드 로그 처리 - ProcessResult result = handleInterestAndLogs(savedArticle, interest); + // 2. 관심사·기사·키워드 관계 처리 + ProcessResult result = handleInterestAndKeywords(savedArticle, interest); linkedCount += result.linkedCount(); skippedCount += result.skippedCount(); @@ -61,11 +61,9 @@ public void write(Chunk> chunk) { */ private boolean handleInsertIgnore(Article article) { boolean isNew = articleJdbcRepository.insertIgnore(article); - if (isNew) { log.info("🆕 신규 기사 저장: {}", article.getTitle()); } - return isNew; } @@ -82,23 +80,34 @@ private Article handleRestoreAndFind(Article article) { } /** - * 관심사-기사 관계 및 키워드 로그 처리 + * 관심사-기사 관계 및 키워드 연결 처리 */ - private ProcessResult handleInterestAndLogs(Article article, Interest interest) { + private ProcessResult handleInterestAndKeywords(Article article, Interest interest) { int linkedCount = 0; int skippedCount = 0; + // 1. 관심사-기사 연결 (InterestArticles) + InterestArticles interestArticle = + interestArticlesRepository.findByArticleAndInterest(article, interest) + .orElseGet(() -> { + InterestArticles newLink = new InterestArticles(article, interest); + interestArticlesRepository.save(newLink); + log.info("🔗 [{}] 관심사-기사 연결 완료: {}", interest.getName(), article.getTitle()); + return newLink; + }); + + // 2. 관심사-키워드 연결 (InterestArticlesKeywords) for (InterestKeyword ik : interest.getKeywords()) { Keyword keyword = ik.getKeyword(); + int inserted = interestArticleKeywordRepository.insertIgnore( + interestArticle.getId(), keyword.getId() + ); - // 키워드 로그 중복 무시 (interest 포함) - articleKeywordLogRepository.insertIgnore(article.getId(), keyword.getId(), interest.getId()); - - // 관심사-기사 연결 (현재 연결 상태용) - if (!interestArticlesRepository.existsByArticleAndInterest(article, interest)) { - interestArticlesRepository.save(new InterestArticles(article, interest)); + if (inserted > 0) { linkedCount++; - log.info("🔗 [{}] 관심사-기사 연결 완료: {}", interest.getName(), article.getTitle()); + log.info("📎 [{}-{}] 연결 완료: {}", interest.getName(), keyword.getKeyword(), article.getTitle()); + } else { + skippedCount++; } } @@ -109,7 +118,7 @@ private ProcessResult handleInterestAndLogs(Article article, Interest interest) * 결과 요약 로그 */ private void logSummary(int total, int newCount, int linkedCount, int skippedCount) { - log.info("💾 Writer 결과 | 총: {} | 신규 기사: {} | 연결: {} | 스킵(로그 중복): {}", + log.info("💾 Writer 결과 | 총: {} | 신규 기사: {} | 연결: {} | 스킵(중복): {}", total, newCount, linkedCount, skippedCount); } diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java index 42639fd..d0caafc 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java @@ -1,9 +1,7 @@ package com.monew.monew_batch.article.scheduler; import com.monew.monew_api.article.entity.Article; -import com.monew.monew_api.article.repository.ArticleKeywordLogRepository; import com.monew.monew_api.article.repository.ArticleRepository; -import com.monew.monew_api.article.repository.InterestArticlesRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -18,30 +16,24 @@ public class ArticleCleanupScheduler { private final ArticleRepository articleRepository; - private final ArticleKeywordLogRepository articleKeywordLogRepository; - private final InterestArticlesRepository interestArticlesRepository; /** * 매일 새벽 4시에 is_deleted = true인 뉴스들을 물리 삭제 */ @Transactional - @Scheduled(cron = "0 10 4 * * *", zone = "UTC") + @Scheduled(cron = "0 10 4 * * *", zone = "Asia/Seoul") +// @Scheduled(fixedRate = 60000) // 테스트용 public void deleteSoftDeletedArticles() { log.info("🧹 [ArticleCleanupScheduler] 논리 삭제된 뉴스 정리 시작"); - - // 1️⃣ 삭제 대상 조회 List
deletedArticles = articleRepository.findAllByIsDeletedTrue(); - if (deletedArticles.isEmpty()) { log.info("✅ 삭제할 뉴스 없음"); return; } int total = deletedArticles.size(); - articleRepository.deleteAll(deletedArticles); - - log.info("🧾 뉴스 삭제 완료 | 총: {}건", total); + log.info("🗑 물리 삭제 완료 | 총 {}건 (FK CASCADE 포함)", total); } } diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java index 69262e7..2a58d8c 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java @@ -19,7 +19,8 @@ public class NaverNewsScheduler { private final JobLauncher jobLauncher; private final Job naverNewsJob; - @Scheduled(cron = "0 0 * * * *", zone = "UTC") + @Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul") +// @Scheduled(fixedRate = 60000) // 테스트용 public void runJob() throws Exception { log.info("🕒 [Batch Scheduler] 네이버 뉴스 수집 Job 실행"); From ee0994e1ed73eb64ebc939288932b1325a34e003 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Sun, 2 Nov 2025 01:31:53 +0900 Subject: [PATCH 116/178] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=B9=B4?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew/monew_api/article/entity/Article.java | 10 ++++++++++ .../comments/service/CommentService.java | 16 +++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java index 0607c03..b9323c2 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java @@ -58,6 +58,16 @@ public void softDelete() { this.isDeleted = true; } + public void increaseCommentCount() { + this.commentCount++; + } + + public void decreaseCommentCount() { + if (this.commentCount > 0) { + this.commentCount--; + } + } + public void increaseViewCount() { this.viewCount++; } diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index 6c6ff52..49a053f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -12,8 +12,10 @@ import com.monew.monew_api.common.exception.comment.*; import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; + import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -37,6 +39,7 @@ public CommentDto register(CommentRegisterRequest request) { User user = getUserById(request.userId()); Article article = getArticleById(request.articleId()); + log.info("[COMMENT_COUNT] 댓글 작성 전 카운트: {}", article.getCommentCount()); Comment saved = commentRepository.save(Comment.of(user, article, request.content())); log.info("[COMMENT][CREATE] userId={}, articleId={}, commentId={}", user.getId(), article.getId(), saved.getId()); @@ -45,6 +48,10 @@ public CommentDto register(CommentRegisterRequest request) { new CommentCreatedEvent(saved.getId(), user.getId(), article.getId(), saved.getCreatedAt()) ); + article.increaseCommentCount(); + articleRepository.save(article); + + log.info("[COMMENT_COUNT] 댓글 작성 후 카운트: {}", article.getCommentCount()); return CommentDto.from(saved, false); } @@ -74,7 +81,7 @@ public CommentLikeDto like(Long userId, Long commentId) { comment.increaseLike(); eventPublisher.publishEvent( - new CommentLikedEvent(comment.getId(), comment.getUserId(), user.getNickname())); + new CommentLikedEvent(comment.getId(), comment.getUserId(), user.getNickname())); log.info("[COMMENT][LIKE] userId={}, commentId={}", userId, commentId); return CommentLikeDto.from(saved); @@ -100,7 +107,14 @@ public void delete(Long commentId) { log.info("[COMMENT][DELETE][START] commentId={}", commentId); Comment comment = getCommentById(commentId); + Article article = comment.getArticle(); + log.info("[COMMENT_COUNT] 댓글 삭제 전 카운트: {}", article.getCommentCount()); + commentRepository.delete(comment); + + article.decreaseCommentCount(); + articleRepository.save(article); + log.info("[COMMENT_COUNT] 댓글 삭제 후 카운트: {}", article.getCommentCount()); log.info("[COMMENT][DELETE] commentId={}", commentId); } From 1b1f8e4175e58685c62813ecc6dbc473734badc6 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Sun, 2 Nov 2025 01:56:27 +0900 Subject: [PATCH 117/178] =?UTF-8?q?refactor=20:=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew/monew_api/interest/controller/InterestController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java b/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java index 3d9b836..fccdbed 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java @@ -45,7 +45,7 @@ public ResponseEntity createInterest( @GetMapping public ResponseEntity getInterests( @RequestHeader("Monew-Request-User-Id") Long userId, - @ParameterObject @ModelAttribute @Valid CursorPageRequestInterestDto request + @ParameterObject @ModelAttribute CursorPageRequestInterestDto request ) { log.info("[API 요청] GET/api/interests/ - 관심사 조회 요청 : {}", request); CursorPageResponseInterestDto response = interestService.getInterests(userId, request); From edf6434af7c9df6dc6a41db390fe06d6ba58790d Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Sun, 2 Nov 2025 02:06:40 +0900 Subject: [PATCH 118/178] =?UTF-8?q?refactor=20:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interest/dto/response/InterestDto.java | 2 +- .../monew/monew_api/interest/entity/Interest.java | 14 ++++++++++++-- .../subscribe/service/SubscribeServiceImpl.java | 9 +++++---- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java index 9e7fbda..7edeec3 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java @@ -6,7 +6,7 @@ public record InterestDto( Long id, String name, List keywords, - int subscriberCount, + Long subscriberCount, boolean subscribedByMe ) { diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java index 0d0b2d1..0b562a3 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java @@ -1,6 +1,8 @@ package com.monew.monew_api.interest.entity; import com.monew.monew_api.common.entity.BaseTimeEntity; +import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.subscribe.entity.Subscribe; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -12,11 +14,10 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; @Entity @Table(name = "interests") -@Getter @Setter +@Getter @AllArgsConstructor @NoArgsConstructor public class Interest extends BaseTimeEntity { @@ -45,4 +46,13 @@ public InterestKeyword addKeyword(Keyword keyword) { this.keywords.add(interestKeyword); return interestKeyword; } + + public void addSubscriberCount(){ + this.subscriberCount++; + + } + + public void cancelSubscriberCount(){ + this.subscriberCount--; + } } diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java index 504f1cd..7937956 100644 --- a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java @@ -1,5 +1,6 @@ package com.monew.monew_api.subscribe.service; +import com.monew.monew_api.common.exception.interest.InterestNotFoundException; import com.monew.monew_api.common.exception.subscribe.SubscribeDuplicateException; import com.monew.monew_api.common.exception.subscribe.SubscribeNotFoundException; import com.monew.monew_api.common.exception.user.UserNotFoundException; @@ -32,14 +33,14 @@ public class SubscribeServiceImpl implements SubscribeService { public SubscribeDto createSubscribe(Long interestId, Long userId) { Interest interest = interestRepository.findById(interestId) - .orElseThrow(UserNotFoundException::new); + .orElseThrow(InterestNotFoundException::new); User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); if(subscribeRepository.existsByInterestAndUser(interest, user)){ throw new SubscribeDuplicateException(); } log.info("현재 관심사 구독자 수 : {}", interest.getSubscriberCount()); - interest.setSubscriberCount(interest.getSubscriberCount() + 1); + interest.addSubscriberCount(); log.info("관심사 구독 후 구독자 수: {}", interest.getSubscriberCount()); Subscribe subscribe = Subscribe.create(interest, user); @@ -53,7 +54,7 @@ public SubscribeDto createSubscribe(Long interestId, Long userId) { public void deleteSubscribe(Long interestId, Long userId) { Interest interest = interestRepository.findById(interestId) - .orElseThrow(UserNotFoundException::new); + .orElseThrow(InterestNotFoundException::new); User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); Subscribe subscribe = subscribeRepository.findByInterestAndUser(interest,user) @@ -61,7 +62,7 @@ public void deleteSubscribe(Long interestId, Long userId) { subscribeRepository.delete(subscribe); log.info("현재 관심사 구독자 수 : {}", interest.getSubscriberCount()); - interest.setSubscriberCount(Math.max(0,interest.getSubscriberCount() - 1)); + interest.cancelSubscriberCount(); log.info("관심사 구독 취소 후 구독자 수: {}", interest.getSubscriberCount()); } } From 4524f43b167975d3d93e8e156ce97edb0b0fc3b0 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Sun, 2 Nov 2025 02:16:40 +0900 Subject: [PATCH 119/178] =?UTF-8?q?refactor=20:=20=EA=B4=80=EC=8B=AC?= =?UTF-8?q?=EC=82=AC=20=EC=A1=B0=ED=9A=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InterestRepositoryCustomImpl.java | 194 +++++++++--------- .../interest/service/InterestServiceImpl.java | 3 +- 2 files changed, 103 insertions(+), 94 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java index 423657a..bc8a87a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java @@ -2,6 +2,7 @@ import com.monew.monew_api.interest.entity.Interest; import com.monew.monew_api.interest.dto.InterestOrderBy; +import com.querydsl.core.BooleanBuilder; import com.querydsl.core.Tuple; import com.querydsl.core.types.Expression; import com.querydsl.core.types.OrderSpecifier; @@ -9,7 +10,6 @@ import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.LocalDateTime; -import java.util.Collections; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; @@ -18,9 +18,10 @@ import org.springframework.data.domain.Sort.Direction; import org.springframework.stereotype.Repository; -import com.monew.monew_api.interest.entity.QInterestKeyword; -import com.monew.monew_api.interest.entity.QKeyword; -import com.monew.monew_api.interest.entity.QInterest; +import static com.monew.monew_api.interest.entity.QInterestKeyword.interestKeyword; +import static com.monew.monew_api.interest.entity.QKeyword.keyword1; +import static com.monew.monew_api.interest.entity.QInterest.interest; +import static com.monew.monew_api.subscribe.entity.QSubscribe.subscribe; @Repository @RequiredArgsConstructor @@ -28,141 +29,148 @@ public class InterestRepositoryCustomImpl implements InterestRepositoryCustom { private final JPAQueryFactory queryFactory; - private static final QInterest i = QInterest.interest; - private static final QKeyword k = QKeyword.keyword1; - private static final QInterestKeyword ik = QInterestKeyword.interestKeyword; - - private Expression sortExpression(InterestOrderBy sortBy) { - return switch (sortBy) { - case name -> i.name; - case subscriberCount -> i.subscriberCount; - }; - } + @Override + public Slice findAll( + String searchKeyword, + InterestOrderBy sortBy, + Direction direction, + String cursor, + LocalDateTime after, + int limit + ) { + + BooleanBuilder builder = new BooleanBuilder() + .and(containsInterestOrKeyword(searchKeyword)); + if (after != null) { + builder.and(interest.createdAt.goe(after)); + } + builder.and(cursorCondition(cursor, sortBy, direction)); - // 키워드 검색으로 얻은 관심사를 통해 그 관심사에 포함된 모든 키워드 반환 - // ID로 필터링 후 fetch join으로 로딩 - @Override - public Slice findAll(String searchKeyword, InterestOrderBy sortBy, Direction direction, - String cursor, LocalDateTime after, int limit) { + // id + 정렬값 Expression sortExpr = sortExpression(sortBy); - // id, 정렬칼럼 받기 List rows = queryFactory - .selectDistinct(i.id, sortExpr) - .from(i) - .leftJoin(i.keywords, ik) - .leftJoin(ik.keyword, k) - .where( - containsInterestOrKeyword(searchKeyword), - createdAfter(after), - createCursorPredicate(sortBy, direction, cursor) - ) - .orderBy( - sortBy(sortBy, direction), - secondSortBy(direction) - ) + .selectDistinct(interest.id, sortExpr) + .from(interest) + .leftJoin(interest.keywords, + interestKeyword) + .leftJoin(interestKeyword.keyword, keyword1) + .where(builder) + .orderBy(sortBy(sortBy, direction)) .limit(limit + 1) .fetch(); boolean hasNext = rows.size() > limit; - if (hasNext) { + if (hasNext) rows = rows.subList(0, limit); - } - - if (rows.isEmpty()) { - return new SliceImpl<>(Collections.emptyList(), PageRequest.of(0, limit), false); - } - // id만 추출 - List interestIds = rows.stream() - .map(t -> t.get(i.id)) + // 추출된 id들만 조회 + List ids = rows.stream() + .map(t -> t.get(interest.id)) .toList(); - // 관심사 id 포함 전체 로딩 - List results = queryFactory - .selectFrom(i) + List interests = queryFactory + .selectFrom(interest) .distinct() - .leftJoin(i.keywords, ik).fetchJoin() - .leftJoin(ik.keyword, k).fetchJoin() - .where(i.id.in(interestIds)) - .orderBy( - sortBy(sortBy, direction), - secondSortBy(direction) - ) + .leftJoin(interest.keywords, interestKeyword).fetchJoin() + .leftJoin(interestKeyword.keyword, keyword1).fetchJoin() + .where(interest.id.in(ids)) + .orderBy(sortBy(sortBy, direction + )) .fetch(); - return new SliceImpl<>(results, PageRequest.of(0, limit), hasNext); + return new SliceImpl<>(interests, PageRequest.of(0, limit), hasNext); } - // 관심사명 OR 키워드명 부분일치 - private BooleanExpression containsInterestOrKeyword(String keyword) { - if (keyword == null || keyword.isBlank()) { + + private BooleanExpression containsInterestOrKeyword(String searchKeyword) { + if (searchKeyword == null || searchKeyword.isBlank()) return null; - } - return i.name.containsIgnoreCase(keyword) - .or(k.keyword.containsIgnoreCase(keyword)); + return interest.name.containsIgnoreCase(searchKeyword) + .or(keyword1.keyword.containsIgnoreCase(searchKeyword)); } - // after - private BooleanExpression createdAfter(LocalDateTime after) { - if (after == null) { - return null; - } - return i.createdAt.goe(after); + private Expression sortExpression(InterestOrderBy sortBy) { + return switch (sortBy) { + case name -> interest.name; + case subscriberCount -> interest.subscriberCount; + }; } - // 커서 조건: 정렬 기준별 비교 - private BooleanExpression createCursorPredicate(InterestOrderBy sortBy, Direction dir, - String cursor) { - if (cursor == null || cursor.isBlank()) { + // 커서 조건: cursor는 id로 가정 -> 해당 id 레코드를 읽어 1차 정렬값 + id로 커팅 + private BooleanExpression cursorCondition( + String cursor, InterestOrderBy sortBy, Direction direction) { + if (cursor == null || cursor.isBlank()) + return null; + + boolean desc = (direction == Direction.DESC); + Long cursorId = Long.valueOf(cursor); + + Interest cursorInterest = queryFactory + .selectFrom(interest) + .where(interest.id.eq(cursorId)) + .fetchOne(); + + if (cursorInterest == null) return null; - } return switch (sortBy) { - case name -> (dir == Direction.ASC) ? i.name.gt(cursor) : i.name.lt(cursor); + case name -> { + String afterName = cursorInterest.getName(); + yield desc + ? interest.name.lt(afterName) + : interest.name.gt(afterName); + } case subscriberCount -> { - int v = Integer.parseInt(cursor); - BooleanExpression condition = (dir == Direction.ASC) - ? i.subscriberCount.gt(v) - .or(i.subscriberCount.eq(v).and(i.id.gt(Long.parseLong(cursor)))) - : i.subscriberCount.lt(v) - .or(i.subscriberCount.eq(v).and(i.id.lt(Long.parseLong(cursor)))); - yield condition; + int afterCnt = cursorInterest.getSubscriberCount(); + yield desc + ? interest.subscriberCount.lt(afterCnt) + .or(interest.subscriberCount.eq(afterCnt) + .and(interest.id.lt(cursorId))) + : interest.subscriberCount.gt(afterCnt) + .or(interest.subscriberCount.eq(afterCnt) + .and(interest.id.gt(cursorId))); } }; } - // 정렬 지정 - private OrderSpecifier sortBy(InterestOrderBy sortBy, Direction dir) { - boolean asc = (dir == Direction.ASC); + private OrderSpecifier[] sortBy(InterestOrderBy sortBy, Direction direction) { + boolean asc = (direction == Direction.ASC); return switch (sortBy) { - case name -> asc ? i.name.asc() : i.name.desc(); - case subscriberCount -> asc ? i.subscriberCount.asc() : i.subscriberCount.desc(); + case name -> asc + ? new OrderSpecifier[]{interest.name.asc(), interest.id.asc()} + : new OrderSpecifier[]{interest.name.desc(), interest.id.desc()}; + case subscriberCount -> asc + ? new OrderSpecifier[]{interest.subscriberCount.asc(), interest.id.asc()} + : new OrderSpecifier[]{interest.subscriberCount.desc(), interest.id.desc()}; }; } - // 보조정렬: 동일값일 때는 id로 정렬하기!! - private OrderSpecifier secondSortBy(Direction dir) { - return (dir == Direction.ASC) ? i.id.asc() : i.id.desc(); - } + @Override public long countFilteredTotalElements(String keyword, InterestOrderBy orderBy, Direction direction) { JPAQuery query = queryFactory - .select(i.countDistinct()) - .from(i); + .select(interest.countDistinct()) + .from(interest); + + BooleanBuilder builder = new BooleanBuilder(); // keyword가 있을 때만 조인 if (keyword != null && !keyword.isBlank()) { query - .leftJoin(i.keywords, ik) - .leftJoin(ik.keyword, k); + .leftJoin(interest.keywords, interestKeyword) + .leftJoin(interestKeyword.keyword, keyword1); + builder.and( + interest.name.containsIgnoreCase(keyword) + .or(keyword1.keyword.containsIgnoreCase(keyword)) + ); } + Long count = query.where(builder).fetchOne(); + return (count != null) ? count : 0L; - query.where(containsInterestOrKeyword(keyword)); - return query.fetchOne(); } } diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index db2aa4a..18174d3 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -205,7 +205,8 @@ private String calculateNextCursor(List interests, InterestOrderBy ord default: throw new IllegalArgumentException("invalid order"); } - return cursorValue; + return String.valueOf(last.getId()); +// return cursorValue; } From 04e278c558c06433c3a4b6f8fefa7291f3828dda Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Sun, 2 Nov 2025 23:11:01 +0900 Subject: [PATCH 120/178] =?UTF-8?q?feat=20:=20add=20event=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80,=20=EA=B5=AC=ED=98=84=EC=B2=B4?= =?UTF-8?q?=EB=8A=94=20Impl=ED=8F=B4=EB=8D=94=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/event/ArticleViewedEvent.java | 61 +++++++++++++++++++ .../event/CommentContentEditedEvent.java | 13 ++++ .../comments/event/CommentCreatedEvent.java | 59 ++++++++++++++++-- .../comments/event/CommentDeletedEvent.java | 18 ++++++ .../comments/event/CommentLikedEvent.java | 61 +++++++++++++++++-- .../comments/event/CommentUnlikedEvent.java | 20 ++++++ .../interest/event/InterestDeletedEvent.java | 17 ++++++ .../interest/event/InterestUpdatedEvent.java | 21 +++++++ .../event/SubscriptionAddedEvent.java | 36 +++++++++++ .../event/SubscriptionRemovedEvent.java | 20 ++++++ .../controller/UserActivityController.java | 15 ++++- .../useractivity/dto/UserActivityDto.java | 5 +- .../useractivity/event/CacheSaveEvent.java | 16 +++++ .../mapper/UserActivityDocumentMapper.java | 12 ++++ .../mapper/UserActivityMapper.java | 4 -- .../UserActivityRepositoryImpl.java | 3 +- .../service/Impl/UserActivityServiceImpl.java | 59 +++--------------- .../service/UserActivityService.java | 6 -- 18 files changed, 372 insertions(+), 74 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java rename monew-api/src/main/java/com/monew/monew_api/useractivity/repository/{ => Impl}/UserActivityRepositoryImpl.java (98%) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java b/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java new file mode 100644 index 0000000..f18bcdc --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java @@ -0,0 +1,61 @@ +package com.monew.monew_api.article.event; + +import java.time.LocalDateTime; + +/** + * 기사 조회 이벤트 + * 사용자가 기사를 조회했을 때 발행 + * Update 전략 사용 + * @param viewId + * @param userId + * @param createdAt + * @param articleId + * @param source + * @param sourceUrl + * @param articleTitle + * @param articlePublishedDate + * @param articleSummary + * @param articleCommentCount + * @param articleViewCount + * @param occurredAt + */ +public record ArticleViewedEvent( + Long viewId, + Long userId, + LocalDateTime createdAt, + Long articleId, + String source, + String sourceUrl, + String articleTitle, + LocalDateTime articlePublishedDate, + String articleSummary, + Integer articleCommentCount, + Integer articleViewCount, + LocalDateTime occurredAt +) { + public static ArticleViewedEvent of( + Long id, + Long userId, + LocalDateTime createdAt, + Long articleId, + String source, + String sourceUrl, + String articleTitle, + LocalDateTime articlePublishedDate, + String articleSummary, + Integer articleCommentCount, + Integer articleViewCount + ) { + return new ArticleViewedEvent( + id, userId, createdAt, articleId, + source, sourceUrl, articleTitle, + articlePublishedDate, articleSummary, + articleCommentCount, articleViewCount, + LocalDateTime.now() + ); + } + + public Integer getDelta() { + return +1; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java new file mode 100644 index 0000000..bfe89bc --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java @@ -0,0 +1,13 @@ +package com.monew.monew_api.comments.event; + +import java.time.LocalDateTime; + +public record CommentContentEditedEvent( + Long commentId, + String newContent, + LocalDateTime occurredAt +) { + public static CommentContentEditedEvent of(Long commentId, String newContent) { + return new CommentContentEditedEvent(commentId, newContent, LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java index c673b30..4705aa3 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java @@ -2,8 +2,57 @@ import java.time.LocalDateTime; -public record CommentCreatedEvent(Long commentId, - Long userId, - Long articleId, - LocalDateTime createdAt) { -} +/** + * 댓글 작성 이벤트 + * 사용자가 기사에 댓글을 작성했을 때 발행 + * 기사의 commentCount +1 + * Update 전략 사용 + * @param commentId + * @param articleId + * @param articleTitle + * @param userId + * @param userNickname + * @param content + * @param likeCount + * @param createdAt + * @param occurredAt + */ +public record CommentCreatedEvent( + Long commentId, + Long articleId, + String articleTitle, + Long userId, + String userNickname, + String content, + Integer likeCount, + LocalDateTime createdAt, + LocalDateTime occurredAt +) { + + public static CommentCreatedEvent of( + Long commentId, + Long articleId, + String articleTitle, + Long userId, + String userNickname, + String content, + Integer likeCount, + LocalDateTime createdAt + ) { + return new CommentCreatedEvent( + commentId, + articleId, + articleTitle, + userId, + userNickname, + content, + likeCount, + createdAt, + LocalDateTime.now() + ); + } + + public Integer getDelta() { + return 1; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java new file mode 100644 index 0000000..260a045 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java @@ -0,0 +1,18 @@ +package com.monew.monew_api.comments.event; + +import java.time.LocalDateTime; + +/** + * 댓글 삭제 이벤트 + * 댓글이 삭제되었을 때 발행 + * @param commentId + * @param occurredAt + */ +public record CommentDeletedEvent( + Long commentId, + LocalDateTime occurredAt +) { + public static CommentDeletedEvent of(Long commentId) { + return new CommentDeletedEvent(commentId, LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java index 7d9f4ad..1fd8f3a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java @@ -1,6 +1,59 @@ package com.monew.monew_api.comments.event; -public record CommentLikedEvent(Long commentId, - Long commentAuthorId, - String likerNickname) { -} +import java.time.LocalDateTime; + +/** + * 댓글 좋아요/취소 이벤트 + * 사용자가 댓글에 좋아요를 누르거나 취소했을 때 발행 + * Update 전략 사용 + * @param likeId + * @param likeCreatedAt + * @param commentId + * @param articleId + * @param articleTitle + * @param commentAuthorId + * @param commentUserNickname + * @param commentContent + * @param commentLikeCount + * @param commentCreatedAt + * @param likedByUserId + * @param likerNickname + * @param occurredAt + */ +public record CommentLikedEvent( + Long likeId, + LocalDateTime likeCreatedAt, + Long commentId, + Long articleId, + String articleTitle, + Long commentAuthorId, + String commentUserNickname, + String commentContent, + Integer commentLikeCount, + LocalDateTime commentCreatedAt, + Long likedByUserId, + String likerNickname, + LocalDateTime occurredAt +) { + public static CommentLikedEvent of( + Long likeId, + LocalDateTime likeCreatedAt, + Long commentId, + Long articleId, + String articleTitle, + Long commentAuthorId, + String commentUserNickname, + String commentContent, + Integer commentLikeCount, + LocalDateTime commentCreatedAt, + Long likedByUserId, + String likerNickname + ) { + return new CommentLikedEvent( + likeId, likeCreatedAt, commentId, articleId, articleTitle, + commentAuthorId, commentUserNickname, commentContent, + commentLikeCount, commentCreatedAt, likedByUserId, likerNickname, + LocalDateTime.now() + ); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java new file mode 100644 index 0000000..9d0a731 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java @@ -0,0 +1,20 @@ +package com.monew.monew_api.comments.event; + +import java.time.LocalDateTime; + +/** + * 댓글 좋아요 취소 이벤트 + * 사용자가 댓글 좋아요를 취소했을 때 발행 + * @param commentId + * @param likedByUserId + * @param occurredAt + */ +public record CommentUnlikedEvent( + Long commentId, + Long likedByUserId, + LocalDateTime occurredAt +) { + public static CommentUnlikedEvent of(Long commentId, Long likedByUserId) { + return new CommentUnlikedEvent(commentId, likedByUserId, LocalDateTime.now()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java new file mode 100644 index 0000000..ccc2b06 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.interest.event; + +import java.time.LocalDateTime; + +/** + * Interest 삭제 이벤트 + * @param interestId + * @param occurredAt + */ +public record InterestDeletedEvent( + Long interestId, + LocalDateTime occurredAt +) { + public static InterestDeletedEvent of(Long interestId) { + return new InterestDeletedEvent(interestId, LocalDateTime.now()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java new file mode 100644 index 0000000..02a84e8 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java @@ -0,0 +1,21 @@ +package com.monew.monew_api.interest.event; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Interest 정보 변경 이벤트 + * Interest 정보를 수정했을 때 발행 + * @param interestId + * @param newKeywords + * @param occurredAt + */ +public record InterestUpdatedEvent( + Long interestId, + List newKeywords, + LocalDateTime occurredAt +) { + public static InterestUpdatedEvent of(Long interestId, List newKeywords) { + return new InterestUpdatedEvent(interestId, newKeywords, LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java new file mode 100644 index 0000000..b616832 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java @@ -0,0 +1,36 @@ +package com.monew.monew_api.subscribe.event; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 구독 추가 이벤트 + * @param userId + * @param subscriptionId + * @param interestId + * @param interestName + * @param interestKeywords + * @param interestSubscriberCount + * @param createdAt + * @param occurredAt + */ +public record SubscriptionAddedEvent( + Long userId, + Long subscriptionId, + Long interestId, + String interestName, + List interestKeywords, + Integer interestSubscriberCount, + LocalDateTime createdAt, + LocalDateTime occurredAt +) { + public static SubscriptionAddedEvent of( + Long userId, Long subscriptionId, Long interestId, String interestName, + List interestKeywords, Integer interestSubscriberCount, LocalDateTime createdAt + ) { + return new SubscriptionAddedEvent( + userId, subscriptionId, interestId, interestName, interestKeywords, + interestSubscriberCount, createdAt, LocalDateTime.now() + ); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java new file mode 100644 index 0000000..9978c85 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java @@ -0,0 +1,20 @@ +package com.monew.monew_api.subscribe.event; + +import java.time.LocalDateTime; + +/** + * 구독 제거 이벤트 + * @param userId + * @param subscriptionId + * @param occurredAt + */ +public record SubscriptionRemovedEvent( + Long userId, + Long subscriptionId, + Long interestId, + LocalDateTime occurredAt +) { + public static SubscriptionRemovedEvent of(Long userId, Long subscriptionId, Long interestId) { + return new SubscriptionRemovedEvent(userId, subscriptionId, interestId, LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java index 189d200..818a688 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java @@ -1,6 +1,7 @@ package com.monew.monew_api.useractivity.controller; import com.monew.monew_api.useractivity.dto.UserActivityDto; +import com.monew.monew_api.useractivity.service.UserActivityCacheService; import com.monew.monew_api.useractivity.service.UserActivityService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,18 +18,26 @@ public class UserActivityController { private final UserActivityService userActivityService; - + private final UserActivityCacheService userActivityCacheService; /* + userActivityCacheService. mongoDB 사용 시 getUserActivityWithCache 메서드 + + userActivityService. 단일 쿼리 사용시 getUserActivitySingleQuery 메서드 (네이티브 쿼리) 여러 쿼리 사용시 getUserActivity 메서드 */ @GetMapping("/{userId}") public ResponseEntity getUserActivity(@PathVariable String userId) { - log.info("활동내역 조회 요청: userId={}", userId); + log.info("[활동내역 API 요청]: userId={}", userId); + +// UserActivityDto activity = userActivityService.getUserActivitySingleQuery(userId); + UserActivityDto activity = userActivityCacheService.getUserActivityWithCache(userId); - UserActivityDto activity = userActivityService.getUserActivitySingleQuery(userId); + log.info("[활동내역 API 응답]: userId={}, Subscriptions_size={}, Comments_size={}, CommentLikes_size={}, ArticleViews_size={}", + activity.getId(), activity.getSubscriptions().size(), activity.getComments().size(), + activity.getCommentLikes().size(), activity.getArticleViews().size()); return ResponseEntity.ok(activity); } diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java index da3b95c..d46b2fd 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java @@ -1,6 +1,9 @@ package com.monew.monew_api.useractivity.dto; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.List; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java new file mode 100644 index 0000000..8760828 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java @@ -0,0 +1,16 @@ +package com.monew.monew_api.useractivity.event; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; + +/** + * 캐시 저장 이벤트 + * PostgreSQL 조회 후 MongoDB에 비동기 캐시 저장 + * useractivity 내부, create 전략 + * @param userId + * @param data + */ +public record CacheSaveEvent( + String userId, + UserActivityDto data +) { +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java new file mode 100644 index 0000000..ac3bc77 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java @@ -0,0 +1,12 @@ +package com.monew.monew_api.useractivity.mapper; + +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import com.monew.monew_api.useractivity.dto.UserActivityDto; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface UserActivityDocumentMapper { + @Mapping(target = "cachedAt", expression = "java(java.time.LocalDateTime.now())") + UserActivityCacheDocument toDocument(UserActivityDto dto); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java index f87e608..c738350 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java @@ -62,7 +62,6 @@ default UserActivityDto toUserActivityDto( @Mapping(target = "commentId", expression = "java(String.valueOf(commentLike.getComment().getId()))") @Mapping(target = "articleId", expression = "java(String.valueOf(commentLike.getComment().getArticle().getId()))") @Mapping(target = "articleTitle", source = "comment.article.title") - @Mapping(target = "commentUserId", expression = "java(String.valueOf(commentLike.getComment().getUser().getId()))") @Mapping(target = "commentUserNickname", source = "comment.user.nickname") @Mapping(target = "commentContent", source = "comment.content") @Mapping(target = "commentLikeCount", source = "comment.likeCount") @@ -71,9 +70,6 @@ default UserActivityDto toUserActivityDto( List toCommentLikeDtos(List commentLikes); - @Mapping(target = "cachedAt", expression = "java(java.time.LocalDateTime.now())") - UserActivityCacheDocument toDocument(UserActivityDto dto); - UserActivityDto toDto(UserActivityCacheDocument document); default List mapKeywords(Interest interest) { diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java similarity index 98% rename from monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java rename to monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java index 5ccc5db..1adf5b4 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java @@ -1,10 +1,11 @@ -package com.monew.monew_api.useractivity.repository; +package com.monew.monew_api.useractivity.repository.Impl; import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.interest.entity.QKeyword; import com.monew.monew_api.subscribe.entity.Subscribe; import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.repository.UserActivityRepository; import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; import com.querydsl.core.types.Projections; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java index 32bff8b..e527b79 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java @@ -1,18 +1,15 @@ package com.monew.monew_api.useractivity.service.Impl; -import com.fasterxml.jackson.databind.ObjectMapper; import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.common.exception.user.UserNotFoundException; import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; import com.monew.monew_api.subscribe.entity.Subscribe; -import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; import com.monew.monew_api.useractivity.dto.UserActivityDto; import com.monew.monew_api.useractivity.mapper.UserActivityMapper; import com.monew.monew_api.useractivity.mapper.UserActivityRawMapper; -import com.monew.monew_api.useractivity.repository.UserActivityCacheRepository; import com.monew.monew_api.useractivity.repository.UserActivityRepository; import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; import com.monew.monew_api.useractivity.service.UserActivityService; @@ -22,25 +19,21 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.Optional; @Slf4j @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class UserActivityServiceImpl implements UserActivityService { private final UserRepository userRepository; private final UserActivityRepository activityRepository; - private final UserActivityCacheRepository cacheRepository; private final UserActivityMapper mapper; private final UserActivityRawMapper rawMapper; - private final ObjectMapper objectMapper; @Override @Transactional(readOnly = true) public UserActivityDto getUserActivity(String userId) { - log.info("사용자 활동내역 조회 시작: userId={}", userId); + log.info("[UserActivity] 사용자 활동내역 조회 시작: userId={}", userId); Long userIdLong = Long.parseLong(userId); @@ -48,20 +41,20 @@ public UserActivityDto getUserActivity(String userId) { .orElseThrow(UserNotFoundException::new); List subscriptions = activityRepository.findSubscriptionsByUserId(userIdLong); - log.info("구독 정보 조회 완료: {}건", subscriptions.size()); + log.info("[UserActivity] 구독 정보 조회 완료: {}건", subscriptions.size()); List comments = activityRepository.findRecentCommentsByUserId(userIdLong); - log.info("최근 댓글 조회 완료: {}건", comments.size()); + log.info("[UserActivity] 최근 댓글 조회 완료: {}건", comments.size()); List likes = activityRepository.findRecentLikesByUserId(userIdLong); - log.info("최근 좋아요 조회 완료: {}건", likes.size()); + log.info("[UserActivity] 최근 좋아요 조회 완료: {}건", likes.size()); List views = activityRepository.findRecentViewsByUserId(userIdLong); - log.info("최근 조회 기사 조회 완료: {}건", views.size()); + log.info("[UserActivity] 최근 조회 기사 조회 완료: {}건", views.size()); UserActivityDto result = mapper.toUserActivityDto(user, subscriptions, comments, likes, views); - log.info("사용자 활동내역 조회 완료: userId={}", userId); + log.info("[UserActivity] 사용자 활동내역 조회 완료: userId={}", userId); return result; } @@ -71,21 +64,20 @@ public UserActivityDto getUserActivity(String userId) { @Override @Transactional(readOnly = true) public UserActivityDto getUserActivitySingleQuery(String userId) { - log.info("사용자 활동내역 조회 시작 (단일 쿼리 - Record): userId={}", userId); + log.info("[UserActivity] 사용자 활동내역 조회 시작 (단일 쿼리 - Record): userId={}", userId); Long userIdLong = Long.parseLong(userId); UserActivityRaw raw = activityRepository.findUserActivityRaw(userIdLong); if (raw == null) { - log.error("사용자 활동 데이터를 찾을 수 없음: userId={}", userId); + log.error("[UserActivity] 사용자 활동 데이터를 찾을 수 없음: userId={}", userId); throw new UserNotFoundException(); } - // 2. Record → DTO 변환 UserActivityDto result = rawMapper.toDto(raw); - log.info("사용자 활동내역 조회 완료 (단일 쿼리): userId={}, 구독: {}건, 댓글: {}건, 좋아요: {}건, 조회: {}건", + log.info("[UserActivity] 사용자 활동내역 조회 완료 (단일 쿼리): userId={}, 구독: {}건, 댓글: {}건, 좋아요: {}건, 조회: {}건", userId, result.getSubscriptions().size(), result.getComments().size(), @@ -94,37 +86,4 @@ public UserActivityDto getUserActivitySingleQuery(String userId) { return result; } - - @Override - public UserActivityDto getUserActivityWithCache(String userId) { - log.info("사용자 활동내역 조회 시작 (캐시): userId={}", userId); - - Optional cached = cacheRepository.findById(userId); - - if (cached.isPresent()) { - log.info("Cache HIT: userId={}", userId); - log.info("사용자 활동내역 조회 완료 (캐시)"); - return mapper.toDto(cached.get()); - } - - log.info("Cache MISS: userId={}", userId); - - UserActivityDto result = getUserActivitySingleQuery(userId); - - saveToCache(result); - - log.info("사용자 활동내역 조회 완료 (캐시): userId={}", userId); - return result; - } - - private void saveToCache(UserActivityDto dto) { - try { - UserActivityCacheDocument document = mapper.toDocument(dto); - cacheRepository.save(document); - log.info("MongoDB 캐시 저장 완료: userId={}", dto.getId()); - } catch (Exception e) { - log.error("MongoDB 캐시 저장 실패: userId={}", dto.getId(), e); - throw new RuntimeException("캐시 저장에 실패했습니다.", e); - } - } } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java index cc1cb55..418cc34 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java @@ -12,10 +12,4 @@ public interface UserActivityService { * PostgreSQL에서 직접 조회 */ UserActivityDto getUserActivitySingleQuery(String userId); - - /** - * 사용자 활동내역 조회 (캐시 적용) ✅ 새로 추가! - * MongoDB 캐시 확인 → 없으면 PostgreSQL 조회 → 캐시 저장 - */ - UserActivityDto getUserActivityWithCache(String userId); } \ No newline at end of file From 5842e9d93f947d4c2235a8bea604a109a5f92006 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Mon, 3 Nov 2025 00:14:28 +0900 Subject: [PATCH 121/178] =?UTF-8?q?feat=20:=20add=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A6=AC=EC=8A=A4=EB=84=88,=20mongoDB=20cache,=20r?= =?UTF-8?q?everse-index=20Document,=20cache=EC=99=80=20revers-index=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EC=99=80=20repository=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 --- .../document/ReverseIndexDocument.java | 78 +++++ .../document/UserActivityCacheDocument.java | 4 +- .../dto/CommentLikeActivityDto.java | 2 +- .../listener/ArticleViewEventListener.java | 56 ++++ .../listener/CacheSaveEventListener.java | 37 +++ .../CommentContentEditedEventListener.java | 25 ++ .../listener/CommentCreateEventListener.java | 53 +++ .../listener/CommentDeletedEventListener.java | 33 ++ .../listener/CommentLikedEventListener.java | 43 +++ .../listener/CommentUnlikedEventListener.java | 31 ++ .../InterestDeletedEventListener.java | 25 ++ .../listener/InterestUpdateEventListener.java | 37 +++ .../SubscriptionAddedEventListener.java | 34 ++ .../SubscriptionRemovedEventListener.java | 26 ++ .../mapper/UserActivityDocumentMapper.java | 2 +- .../Impl/ReverseIndexRepositoryImpl.java | 50 +++ .../Impl/UserActivityCacheRepositoryImpl.java | 197 +++++++++++ .../ReverseIndexCustomRepository.java | 11 + .../repository/ReverseIndexRepository.java | 7 + .../UserActivityCacheCustomRepository.java | 38 +++ .../UserActivityCacheRepository.java | 2 +- .../service/CacheUpdateService.java | 125 +++++++ .../service/Impl/CacheUpdateServiceImpl.java | 314 ++++++++++++++++++ .../service/Impl/ReverseIndexServiceImpl.java | 69 ++++ .../Impl/UserActivityCacheServiceImpl.java | 61 ++++ .../service/ReverseIndexService.java | 11 + .../service/UserActivityCacheService.java | 11 + 27 files changed, 1377 insertions(+), 5 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java new file mode 100644 index 0000000..8061093 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java @@ -0,0 +1,78 @@ +package com.monew.monew_api.useractivity.document; + +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +/* + 역인덱스 문서 + - key : id 패턴 "도메인_{id}_행동" + - 댓글 작성자: "comment_{id}_author" -> {userIds} + - 댓글 좋아요: "comment_{id}_likes" -> {userIds} + - 기사 조회: "article_{id}_views" -> {userIds} + */ +@Document(collection = "reverse_indexes") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReverseIndexDocument { + + @Id + private String id; + + @Builder.Default + private Set userIds = new HashSet<>(); + + private LocalDateTime createdAt; + + @Indexed(name = "index_ttl", expireAfter = "1h") + private LocalDateTime updatedAt; + + /** + * 댓글 작성자 역인덱스 키 생성 + * + * @param commentId 댓글 ID + * @return "comment_{commentId}_author" + */ + public static String makeCommentAuthorKey(Long commentId) { + return "comment_" + commentId + "_author"; + } + + /** + * 댓글 좋아요 역인덱스 키 생성 + * + * @param commentId 댓글 ID + * @return "comment_{commentId}_likes" + */ + public static String makeCommentLikesKey(Long commentId) { + return "comment_" + commentId + "_likes"; + } + + /** + * 기사 조회 역인덱스 키 생성 + * + * @param articleId 기사 ID + * @return "article_{articleId}_views" + */ + public static String makeArticleViewsKey(Long articleId) { + return "article_" + articleId + "_views"; + } + + /** + * Interest 구독자 역인덱스 키 생성 + * @param interestId + * @return + */ + public static String makeInterestSubscribersKey(Long interestId) { + return "interest_" + interestId + "_subs"; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java index 51aa1bf..ed35f32 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java @@ -34,6 +34,6 @@ public class UserActivityCacheDocument { private List commentLikes; private List articleViews; - @Indexed(expireAfter = "1h") - private LocalDateTime cachedAt; + @Indexed(name = "cache_ttl", expireAfter = "1h") + private LocalDateTime updatedAt; } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java index 8126f29..068cf1c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java @@ -35,7 +35,7 @@ public class CommentLikeActivityDto { @JsonAlias({"article_title"}) private String articleTitle; - @JsonProperty("commentUserId") + @JsonProperty("commentAuthorId") @JsonAlias({"comment_user_id"}) private String commentUserId; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java new file mode 100644 index 0000000..94dbf59 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java @@ -0,0 +1,56 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.article.event.ArticleViewedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 기사 조회 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ArticleViewEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ArticleViewedEvent event) { + log.info("[Listener] 기사 조회 이벤트 수신: articleId={}, userId={}", + event.articleId(), event.userId()); + + try { + // 1. 조회수 증가 (기존 조회한 사람들) + cacheUpdateService.incrementArticleViewCount( + event.articleId(), + event.getDelta() + ); + + // 2. 조회한 사람 캐시에 추가 + 역인덱스 생성 + cacheUpdateService.addArticleView( + event.viewId(), + event.userId(), + event.createdAt(), + event.articleId(), + event.source(), + event.sourceUrl(), + event.articleTitle(), + event.articlePublishedDate(), + event.articleSummary(), + event.articleCommentCount(), + event.articleViewCount() + ); + + log.info("[Listener] 기사 조회 캐시 업데이트 완료: articleId={}", event.articleId()); + + } catch (Exception e) { + log.error("[Listener] 기사 조회 캐시 업데이트 실패: articleId={}", event.articleId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java new file mode 100644 index 0000000..b0ff91e --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java @@ -0,0 +1,37 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.useractivity.event.CacheSaveEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 캐시 저장 이벤트 리스너 + * PostgreSQL 조회 후 MongoDB에 비동기 저장 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CacheSaveEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CacheSaveEvent event) { + log.info("[Listener] 캐시 저장 이벤트 수신: userId={}", event.userId()); + + try { + cacheUpdateService.saveCache( + event.userId(), + event.data() + ); + } catch (Exception e) { + log.error("[Listener] 캐시 저장 실패: userId={}", event.userId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java new file mode 100644 index 0000000..63a0cd0 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java @@ -0,0 +1,25 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentContentEditedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentContentEditedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentContentEditedEvent e) { + cacheUpdateService.updateCommentContent(e.commentId(), e.newContent()); + log.info("[Event] CommentContentEdited handled: commentId={}", e.commentId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java new file mode 100644 index 0000000..86d3ef6 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java @@ -0,0 +1,53 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentCreatedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 댓글 작성 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentCreateEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentCreatedEvent event) { + log.info("[Listener] 댓글 작성 이벤트 수신: commentId={}, userId={}, articleId={}", + event.commentId(), event.userId(), event.articleId()); + + try { + // 1. 기사 댓글수 증가 (기존 조회한 사람들) + cacheUpdateService.incrementArticleCommentCount( + event.articleId(), + event.getDelta() + ); + + // 2. 작성자 캐시에 댓글 추가 + 역인덱스 생성 + cacheUpdateService.addComment( + event.commentId(), + event.userId(), + event.userNickname(), + event.articleId(), + event.articleTitle(), + event.content(), + event.likeCount(), + event.createdAt() + ); + + log.info("[Listener] 댓글 작성 캐시 업데이트 완료: commentId={}", event.commentId()); + + } catch (Exception e) { + log.error("[Listener] 댓글 작성 캐시 업데이트 실패: commentId={}", event.commentId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java new file mode 100644 index 0000000..51a9c15 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java @@ -0,0 +1,33 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentDeletedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 댓글 삭제 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentDeletedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentDeletedEvent event) { + log.info("[Listener] 댓글 삭제 이벤트 수신: commentId={}", event.commentId()); + + try { + cacheUpdateService.removeComment(event.commentId()); + } catch (Exception e) { + log.error("[Listener] 댓글 삭제 캐시 처리 실패: commentId={}", event.commentId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java new file mode 100644 index 0000000..399fa09 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java @@ -0,0 +1,43 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentLikedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 댓글 좋아요 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentLikedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentLikedEvent e) { + cacheUpdateService.addCommentLike( + e.likeId(), + e.likedByUserId(), + e.likeCreatedAt(), + e.commentId(), + e.articleId(), + e.articleTitle(), + e.commentAuthorId(), + e.commentUserNickname(), + e.commentContent(), + e.commentLikeCount(), + e.commentCreatedAt() + ); + cacheUpdateService.updateCommentLikeCount(e.commentId(), +1); + + log.info("[Event] CommentLikedEvent handled: commentId={}, likedBy={}", + e.commentId(), e.likedByUserId()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java new file mode 100644 index 0000000..7133b65 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java @@ -0,0 +1,31 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentUnlikedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 댓글 좋아요 취소 이벤트 리스너 + * 사용자가 댓글 좋아요를 취소했을 때 캐시 업데이트 수행 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentUnlikedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentUnlikedEvent e) { + cacheUpdateService.updateCommentLikeCount(e.commentId(), -1); + cacheUpdateService.removeCommentLike(e.likedByUserId(), e.commentId()); + log.info("[Event] CommentUnlikedEvent handled: commentId={}, likedBy={}", + e.commentId(), e.likedByUserId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java new file mode 100644 index 0000000..67aa41f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java @@ -0,0 +1,25 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.interest.event.InterestDeletedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class InterestDeletedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(InterestDeletedEvent e) { + cacheUpdateService.removeInterest(e.interestId()); + log.info("[Event] InterestDeleted handled: interestId={}", e.interestId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java new file mode 100644 index 0000000..003c0fb --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java @@ -0,0 +1,37 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.interest.event.InterestUpdatedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Interest 정보 변경 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class InterestUpdateEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(InterestUpdatedEvent event) { + log.info("[Listener] Interest 정보 변경 이벤트 수신: interestId={}", + event.interestId()); + + try { + cacheUpdateService.updateInterestKeyword( + event.interestId(), + event.newKeywords() + ); + } catch (Exception e) { + log.error("[Listener] Interest 정보 캐시 업데이트 실패: interestId={}", event.interestId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java new file mode 100644 index 0000000..4369b9c --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java @@ -0,0 +1,34 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import com.monew.monew_api.subscribe.event.SubscriptionAddedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SubscriptionAddedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(SubscriptionAddedEvent e) { + cacheUpdateService.addSubscription( + e.userId(), + e.subscriptionId(), + e.interestId(), + e.interestName(), + e.interestKeywords(), + e.interestSubscriberCount(), + e.createdAt() + ); + log.info("[Event] SubscriptionAdded handled: userId={}, subId={}, interestId={}", + e.userId(), e.subscriptionId(), e.interestId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java new file mode 100644 index 0000000..b0b41f2 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java @@ -0,0 +1,26 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import com.monew.monew_api.subscribe.event.SubscriptionRemovedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SubscriptionRemovedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(SubscriptionRemovedEvent e) { + cacheUpdateService.removeSubscription(e.userId(), e.subscriptionId(), e.interestId()); + log.info("[Event] SubscriptionRemoved handled: userId={}, subId={}", + e.userId(), e.subscriptionId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java index ac3bc77..251a9ea 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java @@ -7,6 +7,6 @@ @Mapper(componentModel = "spring") public interface UserActivityDocumentMapper { - @Mapping(target = "cachedAt", expression = "java(java.time.LocalDateTime.now())") + @Mapping(target = "updatedAt", expression = "java(java.time.LocalDateTime.now())") UserActivityCacheDocument toDocument(UserActivityDto dto); } diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java new file mode 100644 index 0000000..9a92a9f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java @@ -0,0 +1,50 @@ +package com.monew.monew_api.useractivity.repository.Impl; + +import com.monew.monew_api.useractivity.document.ReverseIndexDocument; +import com.monew.monew_api.useractivity.repository.ReverseIndexCustomRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +@Repository +@RequiredArgsConstructor +public class ReverseIndexRepositoryImpl implements ReverseIndexCustomRepository { + + private final MongoTemplate mongoTemplate; + + @Override + public void addUser(String indexKey, String userId) { + Query q = Query.query(Criteria.where("_id").is(indexKey)); + Update u = new Update() + .addToSet("userIds", userId) + .set("updatedAt", LocalDateTime.now()); + mongoTemplate.upsert(q, u, ReverseIndexDocument.class); + } + + @Override + public void removeUser(String indexKey, String userId) { + Query q = Query.query(Criteria.where("_id").is(indexKey)); + Update u = new Update() + .pull("userIds", userId) + .set("updatedAt", LocalDateTime.now()); + mongoTemplate.updateFirst(q, u, ReverseIndexDocument.class); + } + + @Override + public Set findUserIdsByKeys(Set indexKeys) { + if (indexKeys.isEmpty()) return Collections.emptySet(); + Query q = Query.query(Criteria.where("_id").in(indexKeys)); + return mongoTemplate.find(q, ReverseIndexDocument.class) + .stream() + .flatMap(doc -> doc.getUserIds().stream()) + .collect(Collectors.toSet()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java new file mode 100644 index 0000000..ae69fbc --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java @@ -0,0 +1,197 @@ +package com.monew.monew_api.useractivity.repository.Impl; + +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.dto.CommentActivityDto; +import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto; +import com.monew.monew_api.useractivity.dto.SubscribesActivityDto; +import com.monew.monew_api.useractivity.repository.UserActivityCacheCustomRepository; +import com.mongodb.BasicDBObject; +import com.mongodb.client.result.UpdateResult; +import lombok.RequiredArgsConstructor; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Repository +@RequiredArgsConstructor +public class UserActivityCacheRepositoryImpl implements UserActivityCacheCustomRepository { + + private final MongoTemplate mongo; + + @Override + public long incCommentLikeCount(Set userIds, String commentId, int delta) { + if (userIds.isEmpty()) return 0; + + var q1 = Query.query(where("_id").in(userIds).and("comments.id").is(commentId)); + var u1 = new Update() + .inc("comments.$.likeCount", delta) + .set("updatedAt", LocalDateTime.now()); + UpdateResult r1 = mongo.updateMulti(q1, u1, UserActivityCacheDocument.class); + + var q2 = Query.query(where("_id").in(userIds).and("commentLikes.commentId").is(commentId)); + var u2 = new Update().inc("commentLikes.$.commentLikeCount", delta) + .set("updatedAt", LocalDateTime.now()); + UpdateResult r2 = mongo.updateMulti(q2, u2, UserActivityCacheDocument.class); + + return r1.getModifiedCount() + r2.getModifiedCount(); + } + + @Override + public long incArticleViewCount(Set userIds, String articleId, int delta) { + if (userIds.isEmpty()) return 0; + var q = Query.query(where("_id").in(userIds).and("articleViews.articleId").is(articleId)); + var u = new Update() + .inc("articleViews.$.articleViewCount", delta) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long incArticleCommentCount(Set userIds, String articleId, int delta) { + if (userIds.isEmpty()) return 0; + var q = Query.query(where("_id").in(userIds).and("articleViews.articleId").is(articleId)); + var u = new Update() + .inc("articleViews.$.articleCommentCount", delta) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long pushCommentLike(String userId, CommentLikeActivityDto dto, int keepLatest) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .push("commentLikes") + .atPosition(0) + .slice(keepLatest) + .each(dto) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long pullCommentLike(String userId, String commentId) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .pull("commentLikes", new BasicDBObject("commentId", commentId)) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long pushComment(String userId, CommentActivityDto dto, int keepLatest) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .push("comments") + .atPosition(0) + .slice(keepLatest) + .each(dto) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long updateCommentContentForUsers(Set userIds, String commentId, String newContent) { + if (userIds.isEmpty()) return 0; + + var q1 = Query.query(where("_id").in(userIds).and("comments.id").is(commentId)); + var u1 = new Update() + .set("comments.$.content", newContent) + .set("updatedAt", LocalDateTime.now()); + var r1 = mongo.updateMulti(q1, u1, UserActivityCacheDocument.class); + + var q2 = Query.query(where("_id").in(userIds).and("commentLikes.commentId").is(commentId)); + var u2 = new Update() + .set("commentLikes.$[l].commentContent", newContent) + .set("updatedAt", LocalDateTime.now()); + u2.filterArray(where("l.commentId").is(commentId)); + var r2 = mongo.updateMulti(q2, u2, UserActivityCacheDocument.class); + + return r1.getModifiedCount() + r2.getModifiedCount(); + } + + + @Override + public long removeCommentEverywhere(Set userIds, String commentId) { + if (userIds.isEmpty()) return 0; + var q = Query.query(where("_id").in(userIds)); + var u = new Update() + .pull("comments", new BasicDBObject("id", commentId)) + .pull("commentLikes", new BasicDBObject("commentId", commentId)) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long pushArticleView(String userId, ArticleViewActivityDto dto, int keepLatest) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .push("articleViews") + .atPosition(0) + .slice(keepLatest) + .each(dto) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long updateInterestKeywords(String interestId, List newKeywords) { + var q = Query.query(where("subscriptions.interestId").is(interestId)); + var u = new Update() + .set("subscriptions.$[it].interestKeywords", newKeywords) + .set("updatedAt", LocalDateTime.now()); + u.filterArray(where("it.interestId").is(interestId)); + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long removeInterestEverywhere(Set userIds, String interestId) { + if (userIds.isEmpty()) return 0; + + Query q = Query.query(Criteria.where("_id").in(userIds)); + Update u = new Update() + .pull("subscriptions", new BasicDBObject("interestId", interestId)) + .set("updatedAt", LocalDateTime.now()); + + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long addSubscription(String userId, SubscribesActivityDto dto) { + var q = Query.query(where("_id").is(userId)); + + if (dto.getId() != null) { + var pullExisting = new Update() + .pull("subscriptions", Query.query(where("id").is(dto.getId())).getQueryObject()) + .set("updatedAt", LocalDateTime.now()); + mongo.updateFirst(q, pullExisting, UserActivityCacheDocument.class); + } + + var push = new Update() + .push("subscriptions") + .atPosition(0) + .slice(10) + .each(dto) + .set("updatedAt", LocalDateTime.now()); + + var result = mongo.updateFirst(q, push, UserActivityCacheDocument.class); + return result.getModifiedCount(); + } + + @Override + public long removeSubscription(String userId, String subscriptionId) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .pull("subscriptions", Query.query(where("id").is(subscriptionId)).getQueryObject()) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java new file mode 100644 index 0000000..4583392 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java @@ -0,0 +1,11 @@ +package com.monew.monew_api.useractivity.repository; + +import java.util.Set; + +public interface ReverseIndexCustomRepository { + void addUser(String indexKey, String userId); + + void removeUser(String indexKey, String userId); + + Set findUserIdsByKeys(Set indexKeys); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java new file mode 100644 index 0000000..8080a8d --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java @@ -0,0 +1,7 @@ +package com.monew.monew_api.useractivity.repository; + +import com.monew.monew_api.useractivity.document.ReverseIndexDocument; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface ReverseIndexRepository extends MongoRepository, ReverseIndexCustomRepository { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java new file mode 100644 index 0000000..f506111 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java @@ -0,0 +1,38 @@ +package com.monew.monew_api.useractivity.repository; + +import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.dto.CommentActivityDto; +import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto; +import com.monew.monew_api.useractivity.dto.SubscribesActivityDto; + +import java.util.List; +import java.util.Set; + +public interface UserActivityCacheCustomRepository { + + long incCommentLikeCount(Set userIds, String commentId, int delta); + + long incArticleViewCount(Set userIds, String articleId, int delta); + + long incArticleCommentCount(Set userIds, String articleId, int delta); + + long pushCommentLike(String userId, CommentLikeActivityDto dto, int keepLatest); + + long pullCommentLike(String userId, String commentId); + + long pushComment(String userId, CommentActivityDto dto, int keepLatest); + + long updateCommentContentForUsers(Set userIds, String commentId, String newContent); + + long removeCommentEverywhere(Set userIds, String commentId); + + long pushArticleView(String userId, ArticleViewActivityDto dto, int keepLatest); + + long updateInterestKeywords(String interestId, List newKeywords); + + long removeInterestEverywhere(Set userIds, String interestId); + + long addSubscription(String userId, SubscribesActivityDto dto); + + long removeSubscription(String userId, String subscriptionId); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java index eb59229..e6e2e1d 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java @@ -5,5 +5,5 @@ import org.springframework.stereotype.Repository; @Repository -public interface UserActivityCacheRepository extends MongoRepository { +public interface UserActivityCacheRepository extends MongoRepository, UserActivityCacheCustomRepository { } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java new file mode 100644 index 0000000..3362d94 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java @@ -0,0 +1,125 @@ +package com.monew.monew_api.useractivity.service; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 캐시 업데이트 서비스 인터페이스 + */ +public interface CacheUpdateService { + + /** + * 댓글 좋아요수 증가/감소 + */ + void updateCommentLikeCount(Long commentId, Integer delta); + + /** + * 기사 조회수 증가 + */ + void incrementArticleViewCount(Long articleId, Integer delta); + + /** + * 기사 댓글수 증가 + */ + void incrementArticleCommentCount(Long articleId, Integer delta); + + /** + * Interest 정보 업데이트 + */ + void updateInterestKeyword(Long interestId, List newKeywords); + + /** + * Interest 삭제 처리 + * @param interestId + */ + void removeInterest(Long interestId); + + /** + * 구독 추가 + */ + void addSubscription(Long userId, Long subscriptionId, Long interestId, String interestName, + List interestKeywords, Integer interestSubscriberCount, LocalDateTime createdAt); + + /** + * 구독 취소 + */ + void removeSubscription(Long userId, Long subscriptionId, Long interestId); + + /** + * 댓글 생성 시 캐시 데이터 + 역인덱스 업데이트 + * @param id + * @param userId + * @param userNickname + * @param articleId + * @param articleTitle + * @param content + * @param likeCount + * @param createdAt + */ + void addComment(Long id, Long userId, String userNickname, Long articleId, String articleTitle, + String content, Integer likeCount, LocalDateTime createdAt); + + /** + * 좋아요 생성 시 캐시 데이터 + 역인덱스 업데이트 + * @param id + * @param userId + * @param createdAt + * @param commentId + * @param articleId + * @param articleTitle + * @param commentUserId + * @param commentUserNickname + * @param commentContent + * @param commentLikeCount + * @param commentCreatedAt + */ + void addCommentLike(Long id, Long userId, LocalDateTime createdAt, Long commentId, Long articleId, String articleTitle, + Long commentUserId, String commentUserNickname, String commentContent, Integer commentLikeCount, + LocalDateTime commentCreatedAt); + + /** + * 댓글 내용 수정 시 캐시 데이터 + 역인덱스 업데이트 + * @param commentId + * @param newContent + */ + void updateCommentContent(Long commentId, String newContent); + + /** + * 기사 조회 생성 시 캐시 데이터 + 역인덱스 업데이트 + * @param id + * @param userId + * @param createdAt + * @param articleId + * @param source + * @param sourceUrl + * @param articleTitle + * @param articlePublishedDate + * @param articleSummary + * @param articleCommentCount + * @param articleViewCount + */ + void addArticleView(Long id, + Long userId, LocalDateTime createdAt, + Long articleId, String source, String sourceUrl, + String articleTitle, LocalDateTime articlePublishedDate, + String articleSummary, Integer articleCommentCount, + Integer articleViewCount); + + /** + * 좋아요 삭제 처리 + * @param userId + * @param commentId + */ + void removeCommentLike(Long userId, Long commentId); + /** + * 댓글 삭제 처리 + */ + void removeComment(Long commentId); + + /** + * 캐시 저장 (PostgreSQL 조회 후 비동기 저장) + */ + void saveCache(String userId, UserActivityDto data); +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java new file mode 100644 index 0000000..a4bfa5d --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java @@ -0,0 +1,314 @@ +package com.monew.monew_api.useractivity.service.Impl; + +import com.monew.monew_api.useractivity.document.ReverseIndexDocument; +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import com.monew.monew_api.useractivity.dto.*; +import com.monew.monew_api.useractivity.mapper.UserActivityDocumentMapper; +import com.monew.monew_api.useractivity.repository.UserActivityCacheRepository; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import com.monew.monew_api.useractivity.service.ReverseIndexService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +/** + * 캐시 업데이트 서비스 구현체 + * MongoDB 캐시를 부분 업데이트 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CacheUpdateServiceImpl implements CacheUpdateService { + + private final ReverseIndexService reverseIndexService; + private final UserActivityCacheRepository cacheRepository; + private final UserActivityDocumentMapper documentMapper; + + @Override + public void updateCommentLikeCount(Long commentId, Integer delta) { + Set userIds = reverseIndexService.getUserIds(Set.of( + ReverseIndexDocument.makeCommentAuthorKey(commentId), + ReverseIndexDocument.makeCommentLikesKey(commentId) + )); + if (userIds.isEmpty()) { + log.debug("[CacheUpdate] 영향 사용자 없음: commentId={}", commentId); + return; + } + long modified = cacheRepository.incCommentLikeCount(userIds, commentId.toString(), delta); + log.info("[CacheUpdate] 댓글 좋아요수 업데이트: commentId={}, delta={}, users={}, modified={}", + commentId, delta, userIds.size(), modified); + } + + @Override + public void incrementArticleViewCount(Long articleId, Integer delta) { + Set viewers = reverseIndexService.getUserIds( + ReverseIndexDocument.makeArticleViewsKey(articleId) + ); + if (viewers.isEmpty()) { + log.debug("[CacheUpdate] 영향 사용자 없음: articleId={}", articleId); + return; + } + long modified = cacheRepository.incArticleViewCount(viewers, articleId.toString(), delta); + log.info("[CacheUpdate] 기사 조회수 업데이트: articleId={}, delta={}, users={}, modified={}", + articleId, delta, viewers.size(), modified); + } + + @Override + public void incrementArticleCommentCount(Long articleId, Integer delta) { + Set viewers = reverseIndexService.getUserIds( + ReverseIndexDocument.makeArticleViewsKey(articleId) + ); + if (viewers.isEmpty()) { + log.debug("[CacheUpdate] 영향 사용자 없음: articleId={}", articleId); + return; + } + long modified = cacheRepository.incArticleCommentCount(viewers, articleId.toString(), delta); + log.info("[CacheUpdate] 기사 댓글수 업데이트: articleId={}, delta={}, users={}, modified={}", + articleId, delta, viewers.size(), modified); + } + + + @Override + public void addComment(Long id, Long userId, String userNickname, + Long articleId, String articleTitle, String content, + Integer likeCount, LocalDateTime createdAt) { + + String uid = userId.toString(); + CommentActivityDto dto = CommentActivityDto.builder() + .id(id.toString()) + .userId(uid) + .userNickname(userNickname) + .articleId(articleId.toString()) + .articleTitle(articleTitle) + .content(content) + .likeCount(likeCount) + .createdAt(createdAt) + .build(); + + + long modified = cacheRepository.pushComment(uid, dto, 10); + if (modified == 0) { + log.warn("[CacheUpdate] 캐시 없음(만료?): userId={}, commentId={}", uid, id); + } + reverseIndexService.addUser(ReverseIndexDocument.makeCommentAuthorKey(id), uid); + log.info("[CacheUpdate] 댓글 추가: commentId={}, userId={}, modified={}", id, uid, modified); + } + + @Override + public void addCommentLike(Long id, Long userId, LocalDateTime createdAt, + Long commentId, Long articleId, String articleTitle, + Long commentUserId, String commentUserNickname, + String commentContent, Integer commentLikeCount, + LocalDateTime commentCreatedAt) { + String uid = userId.toString(); + CommentLikeActivityDto dto = CommentLikeActivityDto.builder() + .id(id.toString()) + .createdAt(createdAt) + .commentId(commentId.toString()) + .articleId(articleId.toString()) + .articleTitle(articleTitle) + .commentUserId(commentUserId.toString()) + .commentUserNickname(commentUserNickname) + .commentContent(commentContent) + .commentLikeCount(commentLikeCount) + .commentCreatedAt(commentCreatedAt) + .build(); + + long modified = cacheRepository.pushCommentLike(uid, dto, 10); + if (modified == 0) { + log.warn("[CacheUpdate] 캐시 없음(만료?): userId={}, likeId={}", uid, id); + } + reverseIndexService.addUser(ReverseIndexDocument.makeCommentLikesKey(commentId), uid); + log.info("[CacheUpdate] 댓글 좋아요 추가: commentId={}, userId={}, modified={}", commentId, uid, modified); + } + + @Override + public void updateCommentContent(Long commentId, String newContent) { + Set userIds = reverseIndexService.getUserIds(Set.of( + ReverseIndexDocument.makeCommentAuthorKey(commentId), + ReverseIndexDocument.makeCommentLikesKey(commentId) + )); + if (userIds.isEmpty()) { + log.debug("[CacheUpdate] 댓글 내용 수정 영향 사용자 없음: commentId={}", commentId); + return; + } + long modified = cacheRepository.updateCommentContentForUsers(userIds, commentId.toString(), newContent); + log.info("[CacheUpdate] 댓글 내용 수정 반영: commentId={}, users={}, modified={}", + commentId, userIds.size(), modified); + } + + @Override + public void removeCommentLike(Long userId, Long commentId) { + String uid = userId.toString(); + long modified = cacheRepository.pullCommentLike(uid, commentId.toString()); + reverseIndexService.removeUser(ReverseIndexDocument.makeCommentLikesKey(commentId), uid); + log.info("[CacheUpdate] 댓글 좋아요 제거: commentId={}, userId={}, modified={}", commentId, uid, modified); + } + + @Override + public void addArticleView(Long id, Long userId, LocalDateTime createdAt, + Long articleId, String source, String sourceUrl, + String articleTitle, LocalDateTime articlePublishedDate, + String articleSummary, Integer articleCommentCount, + Integer articleViewCount) { + String uid = userId.toString(); + ArticleViewActivityDto dto = ArticleViewActivityDto.builder() + .id(id.toString()) + .viewedBy(uid) + .createdAt(createdAt) + .articleId(articleId.toString()) + .source(source) + .sourceUrl(sourceUrl) + .articleTitle(articleTitle) + .articlePublishedDate(articlePublishedDate) + .articleSummary(articleSummary) + .articleCommentCount(articleCommentCount) + .articleViewCount(articleViewCount) + .build(); + + long modified = cacheRepository.pushArticleView(uid, dto, 10); + if (modified == 0) { + log.warn("[CacheUpdate] 캐시 없음(만료?): userId={}, viewId={}", uid, id); + } + reverseIndexService.addUser(ReverseIndexDocument.makeArticleViewsKey(articleId), uid); + log.info("[CacheUpdate] 기사 조회 추가: articleId={}, userId={}, modified={}", articleId, uid, modified); + } + + /* + * version 이전인 경우에만 관심사 키워드 업데이트 + */ + @Override + public void updateInterestKeyword(Long interestId, List newKeywords) { + String iid = String.valueOf(interestId); + long modified = cacheRepository.updateInterestKeywords(iid, newKeywords); + log.info("[CacheUpdate] Interest 키워드 갱신(set): interestId={}, modified={}", iid, modified); + } + + @Override + public void removeInterest(Long interestId) { + String id = String.valueOf(interestId); + + Set userIds = reverseIndexService.getUserIds( + ReverseIndexDocument.makeInterestSubscribersKey(interestId) + ); + + long modified = 0; + if (!userIds.isEmpty()) { + modified = cacheRepository.removeInterestEverywhere(userIds, id); + } + + reverseIndexService.deleteIndexes(Set.of(ReverseIndexDocument.makeInterestSubscribersKey(interestId))); + + log.info("[CacheUpdate] 관심사 삭제 반영: interestId={}, users={}, modified={}", id, userIds.size(), modified); + } + + @Override + public void addSubscription(Long userId, + Long subscriptionId, + Long interestId, + String interestName, + List interestKeywords, + Integer interestSubscriberCount, + LocalDateTime createdAt) { + + String uid = String.valueOf(userId); + SubscribesActivityDto dto = SubscribesActivityDto.builder() + .id(String.valueOf(subscriptionId)) + .interestId(String.valueOf(interestId)) + .interestName(interestName) + .interestKeywords(interestKeywords) + .interestSubscriberCount(interestSubscriberCount) + .createdAt(createdAt) + .build(); + + long modified = cacheRepository.addSubscription(uid, dto); + reverseIndexService.addUser(ReverseIndexDocument.makeInterestSubscribersKey(interestId), uid); + log.info("[CacheUpdate] 구독 추가: userId={}, subId={}, interestId={}, modified={}", + uid, subscriptionId, interestId, modified); + } + + @Override + public void removeSubscription(Long userId, Long subscriptionId, Long interestId) { + String uid = String.valueOf(userId); + long modified = cacheRepository.removeSubscription(uid, subscriptionId.toString()); + reverseIndexService.removeUser(ReverseIndexDocument.makeInterestSubscribersKey(interestId), uid); + log.info("[CacheUpdate] 구독 제거: userId={}, subId={}, modified={}", uid, subscriptionId, modified); + } + + @Override + public void removeComment(Long commentId) { + Set userIds = reverseIndexService.getUserIds(Set.of( + ReverseIndexDocument.makeCommentAuthorKey(commentId), + ReverseIndexDocument.makeCommentLikesKey(commentId) + )); + if (userIds.isEmpty()) { + log.debug("[CacheUpdate] 영향 사용자 없음: commentId={}", commentId); + return; + } + long modified = cacheRepository.removeCommentEverywhere(userIds, commentId.toString()); + log.info("[CacheUpdate] 댓글 삭제 캐시 반영: commentId={}, users={}, modified={}", + commentId, userIds.size(), modified); + + reverseIndexService.deleteIndexes(Set.of( + ReverseIndexDocument.makeCommentAuthorKey(commentId), + ReverseIndexDocument.makeCommentLikesKey(commentId) + )); + } + + @Override + public void saveCache(String userId, UserActivityDto data) { + log.info("[CacheUpdate] 캐시 저장 시작: userId={}", userId); + UserActivityCacheDocument doc = documentMapper.toDocument(data); + cacheRepository.save(doc); + log.debug("[CacheUpdate] 캐시 저장 완료: userId={}", userId); + + buildReverseIndexes(userId, data); + log.info("[CacheUpdate] 캐시 및 역인덱스 저장 완료: userId={}", userId); + } + + /** + * 역인덱스 초기 생성 + */ + private void buildReverseIndexes(String userId, UserActivityDto data) { + data.getComments().forEach(comment -> { + reverseIndexService.addUser( + ReverseIndexDocument.makeCommentAuthorKey(Long.parseLong(comment.getId())), + userId + ); + }); + + data.getCommentLikes().forEach(like -> { + reverseIndexService.addUser( + ReverseIndexDocument.makeCommentLikesKey(Long.parseLong(like.getCommentId())), + userId + ); + }); + + data.getArticleViews().forEach(view -> { + reverseIndexService.addUser( + ReverseIndexDocument.makeArticleViewsKey(Long.parseLong(view.getArticleId())), + userId + ); + }); + + data.getSubscriptions().forEach(sub -> { + reverseIndexService.addUser( + ReverseIndexDocument.makeInterestSubscribersKey(Long.parseLong(sub.getInterestId())), + userId + ); + }); + + log.info("[CacheUpdate] 역인덱스 생성 완료: userId={}, 댓글작성={}개, 좋아요={}개, 기사조회={}개, 구독={}개", + userId, + data.getComments().size(), + data.getCommentLikes().size(), + data.getArticleViews().size(), + data.getSubscriptions().size()); + + + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java new file mode 100644 index 0000000..f5a80ee --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java @@ -0,0 +1,69 @@ +package com.monew.monew_api.useractivity.service.Impl; + +import com.monew.monew_api.useractivity.document.ReverseIndexDocument; +import com.monew.monew_api.useractivity.repository.ReverseIndexRepository; +import com.monew.monew_api.useractivity.service.ReverseIndexService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.Set; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReverseIndexServiceImpl implements ReverseIndexService { + + private final ReverseIndexRepository reverseIndexRepository; + + /** + * 역인덱스에 사용자 추가 + * document key 형태 그대로 + * indexKey (예: "comment_123_likes") + */ + @Override + @Transactional + public void addUser(String indexKey, String userId) { + reverseIndexRepository.addUser(indexKey, userId); + log.debug("[ReverseIndex] add: key={}, user={}", indexKey, userId); + } + + /** + * 역인덱스에서 사용자 제거 + */ + public void removeUser(String indexKey, String userId) { + reverseIndexRepository.removeUser(indexKey, userId); + log.debug("[ReverseIndex] remove: key={}, user={}", indexKey, userId); + } + + /** + * 역인덱스에서 영향받는 사용자 ID 조회 + */ + @Override + public Set getUserIds(String indexKey) { + return reverseIndexRepository.findById(indexKey) + .map(ReverseIndexDocument::getUserIds) + .orElse(Collections.emptySet()); + } + + /** + * 여러 인덱스 키에서 사용자 ID 조회 + */ + @Override + public Set getUserIds(Set indexKeys) { + return reverseIndexRepository.findUserIdsByKeys(indexKeys); + } + + /** + * 역인덱스 일괄 삭제 + */ + @Override + @Transactional + public void deleteIndexes(Set indexKeys) { + reverseIndexRepository.deleteAllById(indexKeys); + log.debug("[ReverseIndex] deleteIndexes: {}개", indexKeys.size()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java new file mode 100644 index 0000000..ea7cfa1 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java @@ -0,0 +1,61 @@ +package com.monew.monew_api.useractivity.service.Impl; + +import com.monew.monew_api.useractivity.event.CacheSaveEvent; +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import com.monew.monew_api.useractivity.dto.UserActivityDto; +import com.monew.monew_api.useractivity.mapper.UserActivityMapper; +import com.monew.monew_api.useractivity.repository.UserActivityCacheRepository; +import com.monew.monew_api.useractivity.service.UserActivityCacheService; +import com.monew.monew_api.useractivity.service.UserActivityService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * 캐시 기반 사용자 활동 조회 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserActivityCacheServiceImpl implements UserActivityCacheService { + + private final UserActivityCacheRepository cacheRepository; + private final UserActivityService userActivityService; + private final UserActivityMapper mapper; + private final ApplicationEventPublisher eventPublisher; + + /** + * 캐시 기반 사용자 활동 조회 + * 1. MongoDB 캐시 조회 + * 2. Cache Hit → 반환 + * 3. Cache Miss → PostgreSQL 조회 → 비동기 캐시 저장 + */ + @Transactional(readOnly = true) + public UserActivityDto getUserActivityWithCache(String userId) { + log.info("[UserActivityCache] 사용자 활동 조회 시작 (캐시): userId={}", userId); + + Optional cached = cacheRepository.findById(userId); + + if (cached.isPresent()) { + log.info("캐시 히트: userId={}", userId); + return mapper.toDto(cached.get()); + } + + log.info("[UserActivityCache] 캐시 미스: userId={} - PostgreSQL 조회", userId); + UserActivityDto result = userActivityService.getUserActivity(userId); + + try { + eventPublisher.publishEvent(new CacheSaveEvent(userId, result)); + log.info("[UserActivityCache] 캐시 저장 이벤트 발행: userId={}", userId); + } catch (Exception e) { + log.error("[UserActivityCache] 캐시 저장 이벤트 발행 실패: userId={}", userId, e); + } + + log.info("[UserActivityCache] 사용자 활동 조회 완료 (캐시): userId={}", userId); + return result; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java new file mode 100644 index 0000000..fec6f92 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java @@ -0,0 +1,11 @@ +package com.monew.monew_api.useractivity.service; + +import java.util.Set; + +public interface ReverseIndexService { + void addUser(String indexKey, String userId); + void removeUser(String indexKey, String userId); + Set getUserIds(String indexKey); + Set getUserIds(Set indexKeys); + void deleteIndexes(Set indexKeys); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java new file mode 100644 index 0000000..ed091f5 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java @@ -0,0 +1,11 @@ +package com.monew.monew_api.useractivity.service; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; + +public interface UserActivityCacheService { + /** + * 사용자 활동내역 조회 (캐시 적용) + * MongoDB 캐시 확인 → 없으면 PostgreSQL 조회 → 캐시 저장 + */ + UserActivityDto getUserActivityWithCache(String userId); +} From 3deb46123fe5a80dfb88e3a50ea24255dfd8e3e6 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Mon, 3 Nov 2025 00:15:14 +0900 Subject: [PATCH 122/178] =?UTF-8?q?feat=20:=20add=20=EA=B0=81=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EC=97=90=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TODO: 구독 도메인 코드 추가시 이벤트 발행추가해야함 # Conflicts: # monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java # monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java --- .../article/service/ArticleService.java | 16 ++++++++ .../comments/service/CommentService.java | 39 +++++++++++-------- .../interest/service/InterestServiceImpl.java | 19 +++++++++ 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java index 95c1c03..61223c6 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java @@ -9,8 +9,10 @@ import com.monew.monew_api.article.repository.ArticleRepository; import com.monew.monew_api.article.repository.ArticleViewRepository; import com.monew.monew_api.common.exception.article.ArticleNotFoundException; +import com.monew.monew_api.article.event.ArticleViewedEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,6 +27,7 @@ public class ArticleService { private final ArticleRepository articleRepository; private final ArticleViewRepository articleViewRepository; + private final ApplicationEventPublisher eventPublisher; /** * 기사 조회 기록 등록 @@ -63,6 +66,19 @@ public ArticleViewDto recordArticleView(Long articleId, Long userId) { ArticleView articleView = new ArticleView(userId, articleId); ArticleView saved = articleViewRepository.save(articleView); article.increaseViewCount(); + eventPublisher.publishEvent( + ArticleViewedEvent.of( + saved.getId(), + saved.getUserId(), + saved.getCreatedAt(), + saved.getArticleId(), + article.getSource(), + article.getSourceUrl(), + article.getTitle(), + article.getPublishDate(), + article.getSummary(), + article.getCommentCount(), + article.getViewCount())); log.info("[조회 기록 성공] 기사 ID: {}, 사용자 ID: {}", articleId, userId); return ArticleViewDto.builder() diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index 49a053f..460941c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -12,10 +12,8 @@ import com.monew.monew_api.common.exception.comment.*; import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; - import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,19 +37,22 @@ public CommentDto register(CommentRegisterRequest request) { User user = getUserById(request.userId()); Article article = getArticleById(request.articleId()); - log.info("[COMMENT_COUNT] 댓글 작성 전 카운트: {}", article.getCommentCount()); Comment saved = commentRepository.save(Comment.of(user, article, request.content())); log.info("[COMMENT][CREATE] userId={}, articleId={}, commentId={}", user.getId(), article.getId(), saved.getId()); eventPublisher.publishEvent( - new CommentCreatedEvent(saved.getId(), user.getId(), article.getId(), saved.getCreatedAt()) + CommentCreatedEvent.of( + saved.getId(), + saved.getArticleId(), + article.getTitle(), + saved.getUserId(), + user.getNickname(), + saved.getContent(), + saved.getLikeCount(), + saved.getCreatedAt()) ); - article.increaseCommentCount(); - articleRepository.save(article); - - log.info("[COMMENT_COUNT] 댓글 작성 후 카운트: {}", article.getCommentCount()); return CommentDto.from(saved, false); } @@ -81,8 +82,19 @@ public CommentLikeDto like(Long userId, Long commentId) { comment.increaseLike(); eventPublisher.publishEvent( - new CommentLikedEvent(comment.getId(), comment.getUserId(), user.getNickname())); - + CommentLikedEvent.of( + saved.getId(), + saved.getCreatedAt(), + commentId, + comment.getArticle().getId(), + comment.getArticle().getTitle(), + comment.getUserId(), + comment.getUser().getNickname(), + comment.getContent(), + comment.getLikeCount(), + comment.getCreatedAt(), + user.getId(), + user.getNickname())); log.info("[COMMENT][LIKE] userId={}, commentId={}", userId, commentId); return CommentLikeDto.from(saved); } @@ -107,14 +119,7 @@ public void delete(Long commentId) { log.info("[COMMENT][DELETE][START] commentId={}", commentId); Comment comment = getCommentById(commentId); - Article article = comment.getArticle(); - log.info("[COMMENT_COUNT] 댓글 삭제 전 카운트: {}", article.getCommentCount()); - commentRepository.delete(comment); - - article.decreaseCommentCount(); - articleRepository.save(article); - log.info("[COMMENT_COUNT] 댓글 삭제 후 카운트: {}", article.getCommentCount()); log.info("[COMMENT][DELETE] commentId={}", commentId); } diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index 18174d3..6d83166 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -5,6 +5,8 @@ import com.monew.monew_api.common.exception.user.UserNotFoundException; import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; +import com.monew.monew_api.interest.event.InterestDeletedEvent; +import com.monew.monew_api.interest.event.InterestUpdatedEvent; import com.monew.monew_api.interest.dto.InterestOrderBy; import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; import com.monew.monew_api.interest.dto.request.InterestRegisterRequest; @@ -48,6 +50,7 @@ public class InterestServiceImpl implements InterestService { private final SubscribeRepository subscribeRepository; private final InterestMapper interestMapper; + private final ApplicationEventPublisher eventPublisher; @Override @Transactional @@ -153,6 +156,18 @@ public InterestDto updateInterestKeywords( .map(ik -> ik.getKeyword().getKeyword()) .collect(Collectors.toList()); + // ⭐️⭐️구독 여부 가져오는 코드 추가 필요!! + + // 키워드 수정 이벤트 발행 + eventPublisher.publishEvent( + InterestUpdatedEvent.of( + interest.getId(), + keywords + )); + + + log.info("interestId = {}, 관심사 키워드 수정 완료 : {}", interestId, keywords); + return interestMapper.toInterestDto(interest, keywords, subscribedByMe); } @@ -163,6 +178,10 @@ public void deleteInterest(Long interestId) { .orElseThrow(InterestNotFoundException::new); interestRepository.delete(interest); + + eventPublisher.publishEvent(InterestDeletedEvent.of(interest.getId())); + + log.info("관심사 삭제 완료 : interestId = {}", interestId); } From d9dc34fc75bafc9ec6ac6e65f84469bfb4e3e6ae Mon Sep 17 00:00:00 2001 From: truuuely Date: Mon, 3 Nov 2025 00:56:18 +0900 Subject: [PATCH 123/178] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=20=EC=8B=9C=EA=B0=84=20=EC=88=98=EC=A0=95=20-=20KST?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=20=EC=98=A4=EC=A0=84=2004:30?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/scheduler/NotificationCleanupScheduler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monew-batch/src/main/java/com/monew/monew_batch/notification/scheduler/NotificationCleanupScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/notification/scheduler/NotificationCleanupScheduler.java index 87df301..88007cf 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/notification/scheduler/NotificationCleanupScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/notification/scheduler/NotificationCleanupScheduler.java @@ -19,7 +19,7 @@ public class NotificationCleanupScheduler { private final Job deleteOldNotificationJob; // 한국 기준 오전 4시 - @Scheduled(cron = "0 0 19 * * *", zone = "UTC") + @Scheduled(cron = "0 30 4 * * *", zone = "Asia/Seoul") public void runDeleteOldNotificationJob() { try { JobParameters parameters = new JobParametersBuilder() From 9ecaf6edaa7b5934d1f96c6dfe03dd7a6c835b6d Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Mon, 3 Nov 2025 01:17:04 +0900 Subject: [PATCH 124/178] =?UTF-8?q?feat=20:=20add=20comment=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EC=97=90=EC=84=9C=20=EB=B9=A0=EC=A7=84=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EA=B5=AC=EB=8F=85=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=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 구독 서비스에 이벤트 발행추가 - 이벤트에서 발행된 걸 토대로 바로 캐시데이터를 갱신하기 때문에 구독에 관련된 관심사의 키워드들이 필요해서 조회했습니다. --- .../comments/service/CommentService.java | 10 ++++++-- .../repository/InterestRepository.java | 8 +++++++ .../service/SubscribeServiceImpl.java | 24 ++++++++++++++++++- .../service/CacheUpdateService.java | 1 + 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index 460941c..8d4b4a3 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -5,8 +5,7 @@ import com.monew.monew_api.comments.dto.*; import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; -import com.monew.monew_api.comments.event.CommentCreatedEvent; -import com.monew.monew_api.comments.event.CommentLikedEvent; +import com.monew.monew_api.comments.event.*; import com.monew.monew_api.comments.repository.CommentLikeRepository; import com.monew.monew_api.comments.repository.CommentRepository; import com.monew.monew_api.common.exception.comment.*; @@ -68,6 +67,9 @@ public CommentDto update(Long userId, Long commentId, CommentUpdateRequest reque userId, commentId, request.content().length()); boolean likedByMe = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, userId); + + eventPublisher.publishEvent(CommentContentEditedEvent.of(commentId, request.content())); + return CommentDto.from(comment, likedByMe); } @@ -110,6 +112,8 @@ public void dislike(Long userId, Long commentId) { commentLikeRepository.deleteByComment_IdAndUser_Id(commentId, userId); commentRepository.decLikeCount(commentId); + eventPublisher.publishEvent(CommentUnlikedEvent.of(commentId, userId)); + log.info("[COMMENT][DISLIKE] userId={}, commentId={}", userId, commentId); } @@ -119,6 +123,8 @@ public void delete(Long commentId) { log.info("[COMMENT][DELETE][START] commentId={}", commentId); Comment comment = getCommentById(commentId); + eventPublisher.publishEvent(CommentDeletedEvent.of(commentId)); + commentRepository.delete(comment); log.info("[COMMENT][DELETE] commentId={}", commentId); } diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java index c94fb86..cc160d7 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java @@ -1,10 +1,12 @@ package com.monew.monew_api.interest.repository; import com.monew.monew_api.interest.entity.Interest; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import java.util.List; +import java.util.Optional; public interface InterestRepository extends JpaRepository, InterestRepositoryCustom { @@ -18,4 +20,10 @@ public interface InterestRepository extends JpaRepository, Inter JOIN FETCH ik.keyword """) List findAllWithKeywords(); + + /** 특정 관심사와 해당 관심사에 연결된 키워드들을 함께 조회 + * (이벤트에서 필요해서 추가했어요) + */ + @EntityGraph(attributePaths = {"keywords", "keywords.keyword"}) + Optional findById(Long id); } diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java index 7937956..b77e630 100644 --- a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java @@ -10,13 +10,18 @@ import com.monew.monew_api.interest.repository.InterestRepository; import com.monew.monew_api.subscribe.dto.SubscribeDto; import com.monew.monew_api.subscribe.entity.Subscribe; +import com.monew.monew_api.subscribe.event.SubscriptionAddedEvent; +import com.monew.monew_api.subscribe.event.SubscriptionRemovedEvent; import com.monew.monew_api.subscribe.mapper.SubscribeMapper; import com.monew.monew_api.subscribe.repository.SubscribeRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Slf4j @Service @RequiredArgsConstructor @@ -25,8 +30,8 @@ public class SubscribeServiceImpl implements SubscribeService { private final InterestRepository interestRepository; private final UserRepository userRepository; private final SubscribeRepository subscribeRepository; - private final SubscribeMapper subscribeMapper; + private final ApplicationEventPublisher eventPublisher; @Override @Transactional @@ -46,6 +51,20 @@ public SubscribeDto createSubscribe(Long interestId, Long userId) { Subscribe subscribe = Subscribe.create(interest, user); Subscribe saved = subscribeRepository.save(subscribe); + /* 이벤트 발행 keyword의 내용 그대로 캐시에 저장되어서 조회했습니다! */ + List keywordNames = interest.getKeywords().stream() + .map(ik -> ik.getKeyword().getKeyword()) + .toList(); + eventPublisher.publishEvent(SubscriptionAddedEvent.of( + user.getId(), + saved.getId(), + interest.getId(), + interest.getName(), + keywordNames, + interest.getSubscriberCount(), + saved.getCreatedAt() + )); + return subscribeMapper.toSubscribeDto(saved); } @@ -61,6 +80,9 @@ public void deleteSubscribe(Long interestId, Long userId) { .orElseThrow(SubscribeNotFoundException::new); subscribeRepository.delete(subscribe); + + eventPublisher.publishEvent(SubscriptionRemovedEvent.of(user.getId(), subscribe.getId(), interest.getId())); + log.info("현재 관심사 구독자 수 : {}", interest.getSubscriberCount()); interest.cancelSubscriberCount(); log.info("관심사 구독 취소 후 구독자 수: {}", interest.getSubscriberCount()); diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java index 3362d94..a74bed0 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java @@ -1,6 +1,7 @@ package com.monew.monew_api.useractivity.service; import com.monew.monew_api.useractivity.dto.UserActivityDto; +import org.hibernate.mapping.Set; import java.time.LocalDateTime; import java.util.List; From f76a8a7564cae559cfa432c6e59c4dfcb23e6244 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Mon, 3 Nov 2025 01:47:40 +0900 Subject: [PATCH 125/178] =?UTF-8?q?refactor=20:=20log=20[event]=20?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=98=EB=AA=BB=20=ED=91=9C=EA=B8=B0=EB=90=9C?= =?UTF-8?q?=EA=B2=83=20[Listener]=20=EC=9D=B4=EC=A0=84=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=99=80=20=EB=8F=99=EC=9D=BC=ED=95=98=EA=B2=8C=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 --- .../listener/CommentContentEditedEventListener.java | 2 +- .../useractivity/listener/CommentLikedEventListener.java | 2 +- .../useractivity/listener/CommentUnlikedEventListener.java | 2 +- .../useractivity/listener/InterestDeletedEventListener.java | 2 +- .../useractivity/listener/SubscriptionAddedEventListener.java | 2 +- .../useractivity/listener/SubscriptionRemovedEventListener.java | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java index 63a0cd0..73bbcec 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java @@ -20,6 +20,6 @@ public class CommentContentEditedEventListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(CommentContentEditedEvent e) { cacheUpdateService.updateCommentContent(e.commentId(), e.newContent()); - log.info("[Event] CommentContentEdited handled: commentId={}", e.commentId()); + log.info("[Listener] CommentContentEdited handled: commentId={}", e.commentId()); } } diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java index 399fa09..cc88a3d 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java @@ -37,7 +37,7 @@ public void handle(CommentLikedEvent e) { ); cacheUpdateService.updateCommentLikeCount(e.commentId(), +1); - log.info("[Event] CommentLikedEvent handled: commentId={}, likedBy={}", + log.info("[Listener] CommentLikedEvent handled: commentId={}, likedBy={}", e.commentId(), e.likedByUserId()); } } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java index 7133b65..2cd67d6 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java @@ -25,7 +25,7 @@ public class CommentUnlikedEventListener { public void handle(CommentUnlikedEvent e) { cacheUpdateService.updateCommentLikeCount(e.commentId(), -1); cacheUpdateService.removeCommentLike(e.likedByUserId(), e.commentId()); - log.info("[Event] CommentUnlikedEvent handled: commentId={}, likedBy={}", + log.info("[Listener] CommentUnlikedEvent handled: commentId={}, likedBy={}", e.commentId(), e.likedByUserId()); } } diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java index 67aa41f..50c662b 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java @@ -20,6 +20,6 @@ public class InterestDeletedEventListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(InterestDeletedEvent e) { cacheUpdateService.removeInterest(e.interestId()); - log.info("[Event] InterestDeleted handled: interestId={}", e.interestId()); + log.info("[Listener] InterestDeleted handled: interestId={}", e.interestId()); } } diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java index 4369b9c..62e4f80 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java @@ -28,7 +28,7 @@ public void handle(SubscriptionAddedEvent e) { e.interestSubscriberCount(), e.createdAt() ); - log.info("[Event] SubscriptionAdded handled: userId={}, subId={}, interestId={}", + log.info("[Listener] SubscriptionAdded handled: userId={}, subId={}, interestId={}", e.userId(), e.subscriptionId(), e.interestId()); } } diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java index b0b41f2..a9e13ff 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java @@ -20,7 +20,7 @@ public class SubscriptionRemovedEventListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(SubscriptionRemovedEvent e) { cacheUpdateService.removeSubscription(e.userId(), e.subscriptionId(), e.interestId()); - log.info("[Event] SubscriptionRemoved handled: userId={}, subId={}", + log.info("[Listener] SubscriptionRemoved handled: userId={}, subId={}", e.userId(), e.subscriptionId()); } } From 1a4ff3f65c4b02df73fbd63028993f301b143fcb Mon Sep 17 00:00:00 2001 From: truuuely Date: Mon, 3 Nov 2025 07:07:29 +0900 Subject: [PATCH 126/178] =?UTF-8?q?feat:=20=EA=B4=80=EC=8B=AC=EC=82=AC=20i?= =?UTF-8?q?d=EB=A1=9C=20=ED=99=9C=EC=84=B1=20=EA=B5=AC=EB=8F=85=EC=9E=90?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= =?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 - 알림 기능에서 새 기사 관련 알림을 받을 대상을 조회하기 위해 findAllByInterestIds 메서드 추가 - JOIN FETCH로 User, Interests를 함께 조회해 N+1 방지 - 탈퇴한 유저 제외 --- .../repository/SubscribeRepository.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java index c9864d7..2b9a8e0 100644 --- a/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java @@ -3,14 +3,15 @@ import com.monew.monew_api.domain.user.User; import com.monew.monew_api.interest.entity.Interest; import com.monew.monew_api.subscribe.entity.Subscribe; -import java.util.List; -import java.util.Optional; -import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; +import java.util.Set; + @Repository public interface SubscribeRepository extends JpaRepository { @@ -28,6 +29,15 @@ Set findSubscribedByInterestIds(@Param("userId") Long userId, "FROM Subscribe s WHERE s.interest.id IN :interestIds GROUP BY s.interest.id") List countByInterestIds(@Param("interestIds") Set interestIds); + @Query(""" + SELECT s FROM Subscribe s + JOIN FETCH s.user + JOIN FETCH s.interest + WHERE s.interest.id IN :interestIds + AND s.user.deletedAt IS NULL + """) + List findAllByInterestIds(Set interestIds); + interface InterestCountProjection { Long getInterestId(); From 35f9d7c1dffc0ce507caf53d238bca6cb09aa777 Mon Sep 17 00:00:00 2001 From: truuuely Date: Mon, 3 Nov 2025 07:11:23 +0900 Subject: [PATCH 127/178] =?UTF-8?q?feat:=20=EA=B4=80=EC=8B=AC=EC=82=AC?= =?UTF-8?q?=EB=B3=84=20=EC=83=88=20=EA=B8=B0=EC=82=AC=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(NotificationService)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createInterestRegisteredNotification 메서드 추가 --- .../service/NotificationService.java | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java index 86590f7..f2c026a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java @@ -7,16 +7,25 @@ import com.monew.monew_api.common.exception.notification.NotificationNotFoundException; import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; +import com.monew.monew_api.interest.entity.Interest; import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest; import com.monew.monew_api.notification.dto.response.NotificationDto; import com.monew.monew_api.notification.entity.Notification; import com.monew.monew_api.notification.enums.ResourceType; import com.monew.monew_api.notification.repository.NotificationRepository; +import com.monew.monew_api.subscribe.entity.Subscribe; +import com.monew.monew_api.subscribe.repository.SubscribeRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + @Slf4j @RequiredArgsConstructor @Service @@ -24,6 +33,7 @@ public class NotificationService { private final NotificationRepository notificationRepository; private final UserRepository userRepository; + private final SubscribeRepository subscribeRepository; @Transactional public void createCommentLikeNotification(CommentLikedEvent event) { @@ -39,6 +49,58 @@ public void createCommentLikeNotification(CommentLikedEvent event) { event.commentId())); } + @Transactional + public void createInterestRegisteredNotification(Map countsByInterestId) { +// Map countsByInterestId = event.newLinkCountsByInterestId(); // 기존 event 기반 처리시 사용 + Set interestIds = countsByInterestId.keySet(); + + // 관심사를 구독하고 있는 구독자 조회 + List subscriptions = subscribeRepository.findAllByInterestIds(interestIds); + if (subscriptions.isEmpty()) { + return; + } + + // 관심사별 구독자 리스트 그룹핑 + Map> usersByInterestId = subscriptions.stream() + .collect(Collectors.groupingBy( + subscribe -> subscribe.getInterest().getId(), + Collectors.mapping(Subscribe::getUser, Collectors.toList()) + )); + + // 관심사 ID - 관심사 명 + Map interestNameMap = subscriptions.stream() + .map(Subscribe::getInterest) + .distinct() + .collect(Collectors.toMap(Interest::getId, Interest::getName)); + + // 알림 생성 + List newNotifications = new ArrayList<>(); + for (Long interestId : interestIds) { + List usersToNotify = usersByInterestId.get(interestId); + int count = countsByInterestId.get(interestId); + String interestName = interestNameMap.get(interestId); + + if (usersToNotify == null || usersToNotify.isEmpty() || count == 0) { + continue; + } + + String content = String.format("%s와 관련된 기사가 %d건 등록되었습니다.", interestName, count); + for (User user : usersToNotify) { + newNotifications.add(new Notification( + user, + content, + ResourceType.interest, + interestId + )); + } + } + + if (!newNotifications.isEmpty()) { + notificationRepository.saveAll(newNotifications); + log.info("[관심사 알림 생성 성공] {}개의 알림 생성", newNotifications.size()); + } + } + public CursorPageResponse getNonConfirmedNotifications(Long userId, NotificationCursorPageRequest cursorPageRequest) { return notificationRepository.findAllNonConfirmedNotifications(userId, cursorPageRequest); } From 4bfbede3ce8651def507feade38ed76282e7331e Mon Sep 17 00:00:00 2001 From: truuuely Date: Mon, 3 Nov 2025 07:14:35 +0900 Subject: [PATCH 128/178] =?UTF-8?q?feat:=20=EA=B4=80=EC=8B=AC=EC=82=AC=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(=EC=9E=84=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationInternalController.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationInternalController.java diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationInternalController.java b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationInternalController.java new file mode 100644 index 0000000..8272693 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationInternalController.java @@ -0,0 +1,26 @@ +package com.monew.monew_api.notification.controller; + +import com.monew.monew_api.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequiredArgsConstructor +public class NotificationInternalController { + + private final NotificationService notificationService; + + /** + * 배치 서버가 관심사 관련 기사 등록 알림 생성을 요청하는 엔드포인트 + * 추후 MQ로 전환 예정 + * @param newLinkCountsByInterestId 관심사별 등록된 기사 갯수 + */ + @PostMapping("/api/internal/notifications/articles-registered") + public void createInterestNotification(@RequestBody Map newLinkCountsByInterestId) { + notificationService.createInterestRegisteredNotification(newLinkCountsByInterestId); + } +} From 493136e81819aba6ca1b214e3f39557792543e24 Mon Sep 17 00:00:00 2001 From: truuuely Date: Mon, 3 Nov 2025 07:20:39 +0900 Subject: [PATCH 129/178] =?UTF-8?q?feat:=20=EB=89=B4=EC=8A=A4=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EC=B2=98=EB=A6=AC=20=EC=8B=9C=20=EA=B4=80=EC=8B=AC?= =?UTF-8?q?=EC=82=AC=EB=B3=84=20=EA=B8=B0=EC=82=AC=20=EA=B0=9C=EC=88=98=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20=EB=B0=8F=20=EC=95=8C=EB=A6=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20api=20=ED=98=B8=EC=B6=9C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(=EC=9E=84=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - batch와 api 모듈이 분리되어있어 이벤트 기반 처리 불가능 - 코드 간 결합도를 낮추기 위해 임시로 api 호출로 처리했으나, 추후 메시지 큐를 사용해 수정할 예정 --- .../article/job/NaverNewsItemWriter.java | 31 +++++++++++++++++-- .../src/main/resources/application-dev.yml | 8 +++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java index 6a6b51e..cd8232e 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java @@ -15,9 +15,13 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.batch.item.Chunk; import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Slf4j @Component @@ -28,11 +32,18 @@ public class NaverNewsItemWriter implements ItemWriter private final ArticleRepository articleRepository; private final InterestArticlesRepository interestArticlesRepository; private final InterestArticleKeywordRepository interestArticleKeywordRepository; + private final RestTemplate restTemplate; + + @Value("${monew.api.url}") + private String monewApiUrl; @Override public void write(Chunk> chunk) { int total = 0, newCount = 0, linkedCount = 0, skippedCount = 0; + // <관심사ID, 새롭게 연결된 기사 수> 집계용 맵 + Map newLinkCountsByInterestId = new HashMap<>(); + for (List batch : chunk) { for (ArticleInterestPair pair : batch) { total++; @@ -46,13 +57,23 @@ public void write(Chunk> chunk) { Article savedArticle = handleRestoreAndFind(article); // 2. 관심사·기사·키워드 관계 처리 - ProcessResult result = handleInterestAndKeywords(savedArticle, interest); + ProcessResult result = handleInterestAndKeywords(savedArticle, interest, newLinkCountsByInterestId); linkedCount += result.linkedCount(); skippedCount += result.skippedCount(); } } + if (!newLinkCountsByInterestId.isEmpty()) { + try { + String apiUrl = monewApiUrl + "/api/internal/notifications/articles-registered"; + restTemplate.postForEntity(apiUrl, newLinkCountsByInterestId, Void.class); + log.info("API 서버에 알림 생성 요청 완료: {}개 관심사", newLinkCountsByInterestId.size()); + } catch (Exception e) { + log.error("API 서버 알림 생성 요청 실패"); + } + } + logSummary(total, newCount, linkedCount, skippedCount); } @@ -82,7 +103,8 @@ private Article handleRestoreAndFind(Article article) { /** * 관심사-기사 관계 및 키워드 연결 처리 */ - private ProcessResult handleInterestAndKeywords(Article article, Interest interest) { + private ProcessResult handleInterestAndKeywords(Article article, Interest interest, + Map newLinkCountsByInterestId) { int linkedCount = 0; int skippedCount = 0; @@ -93,6 +115,11 @@ private ProcessResult handleInterestAndKeywords(Article article, Interest intere InterestArticles newLink = new InterestArticles(article, interest); interestArticlesRepository.save(newLink); log.info("🔗 [{}] 관심사-기사 연결 완료: {}", interest.getName(), article.getTitle()); + + // 알림 이벤트 생성용 <관심사, 추가된 기사 개수> 처리 + newLinkCountsByInterestId.put(interest.getId(), + newLinkCountsByInterestId.getOrDefault(interest.getId(), 0) + 1); + return newLink; }); diff --git a/monew-batch/src/main/resources/application-dev.yml b/monew-batch/src/main/resources/application-dev.yml index b462f4a..2d62703 100644 --- a/monew-batch/src/main/resources/application-dev.yml +++ b/monew-batch/src/main/resources/application-dev.yml @@ -11,7 +11,7 @@ spring: jpa: hibernate: ddl-auto: update - show-sql: true + show-sql: false properties: hibernate: format_sql: true @@ -52,4 +52,8 @@ logging: level: root: INFO org.springframework.batch: DEBUG - org.hibernate.SQL: DEBUG \ No newline at end of file + org.hibernate.SQL: DEBUG + +monew: + api: + url: http://localhost:8080 \ No newline at end of file From c6d76f9c48ebc99786cc8000ae85a0e3a6d993ac Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:54:39 +0900 Subject: [PATCH 130/178] =?UTF-8?q?feat:=20S3=20=EC=84=A4=EC=A0=95=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 --- .../monew_api/common/config/AWSConfig.java | 36 ++++++++++ monew-api/src/main/resources/application.yml | 8 ++- .../monew_batch/common/config/AWSConfig.java | 36 ++++++++++ .../com/monew/monew_batch/s3/AWSS3Test.java | 70 +++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/common/config/AWSConfig.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/common/config/AWSConfig.java create mode 100755 monew-batch/src/test/java/com/monew/monew_batch/s3/AWSS3Test.java diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/AWSConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/AWSConfig.java new file mode 100644 index 0000000..7dbdc05 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/config/AWSConfig.java @@ -0,0 +1,36 @@ +package com.monew.monew_api.common.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Getter +@Configuration +public class AWSConfig { + @Value("${aws.accessKeyId}") + private String accessKey; + + @Value("${aws.secretKey}") + private String secretKey; + + @Value("${aws.region}") + private String region; + + @Value("${aws.bucket}") + private String bucket; + + @Bean + public S3Client s3Client() { + AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey); + + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsCredentials)) + .build(); + } +} \ No newline at end of file diff --git a/monew-api/src/main/resources/application.yml b/monew-api/src/main/resources/application.yml index b324e98..43f3418 100644 --- a/monew-api/src/main/resources/application.yml +++ b/monew-api/src/main/resources/application.yml @@ -4,4 +4,10 @@ spring: profiles: active: dev config: - import: optional:file:../.env[.properties],optional:file:.env[.properties] \ No newline at end of file + import: optional:file:../.env[.properties],optional:file:.env[.properties] + +aws: + accessKeyId: ${AWS_S3_ACCESS_KEY} + secretKey: ${AWS_S3_SECRET_KEY} + region: ${AWS_S3_REGION} + bucket: ${AWS_S3_BUCKET} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/common/config/AWSConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/common/config/AWSConfig.java new file mode 100644 index 0000000..2b39793 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/common/config/AWSConfig.java @@ -0,0 +1,36 @@ +package com.monew.monew_batch.common.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Getter +@Configuration +public class AWSConfig { + @Value("${aws.accessKeyId}") + private String accessKey; + + @Value("${aws.secretKey}") + private String secretKey; + + @Value("${aws.region}") + private String region; + + @Value("${aws.bucket}") + private String bucket; + + @Bean + public S3Client s3Client() { + AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey); + + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsCredentials)) + .build(); + } +} \ No newline at end of file diff --git a/monew-batch/src/test/java/com/monew/monew_batch/s3/AWSS3Test.java b/monew-batch/src/test/java/com/monew/monew_batch/s3/AWSS3Test.java new file mode 100755 index 0000000..af20833 --- /dev/null +++ b/monew-batch/src/test/java/com/monew/monew_batch/s3/AWSS3Test.java @@ -0,0 +1,70 @@ +package com.monew.monew_batch.s3; + +import com.monew.monew_batch.common.config.AWSConfig; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; + +import java.util.UUID; + +@Slf4j +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AWSS3Test { + + private String testKey; + + @Autowired + private S3Client s3Client; + + @Autowired + private AWSConfig config; + + @BeforeAll + void setUp() { + testKey = "test/" + UUID.randomUUID() + ".txt"; + } + + @Test + @Order(1) + void uploadFile() { + s3Client.putObject( + PutObjectRequest.builder() + .bucket(config.getBucket()) + .key(testKey) + .build(), + RequestBody.fromString("hello world") + ); + log.info("업로드 성공: {}", testKey); + } + + @Test + @Order(2) + void downloadFile() { + String content = s3Client.getObjectAsBytes( + GetObjectRequest.builder() + .bucket(config.getBucket()) + .key(testKey) + .build() + ).asUtf8String(); + + Assertions.assertEquals("hello world", content); + log.info("다운로드 성공: {}", content); + } + + @AfterAll + void cleanUp() { + s3Client.deleteObject( + DeleteObjectRequest.builder() + .bucket(config.getBucket()) + .key(testKey) + .build() + ); + log.info("테스트 파일 삭제 완료: {}", testKey); + } +} \ No newline at end of file From b250863ca98be0aeec36c7a4d084f9d15e785133 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:54:57 +0900 Subject: [PATCH 131/178] =?UTF-8?q?refactor:=20Naver=20API=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=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 --- .../article/properties/NaverApiProperties.java | 4 ++-- monew-batch/src/main/resources/application.yml | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverApiProperties.java b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverApiProperties.java index 4f836f3..817cf7e 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverApiProperties.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverApiProperties.java @@ -7,9 +7,9 @@ @Getter @RequiredArgsConstructor -@ConfigurationProperties(prefix = "naver.api") +@ConfigurationProperties(prefix = "naver") public class NaverApiProperties { - private final String baseUrl; + private final String url; private final String clientId; private final String clientSecret; private final ArticleSource articleSource = ArticleSource.NAVER; diff --git a/monew-batch/src/main/resources/application.yml b/monew-batch/src/main/resources/application.yml index 4ad3b40..4fc141b 100644 --- a/monew-batch/src/main/resources/application.yml +++ b/monew-batch/src/main/resources/application.yml @@ -25,8 +25,13 @@ logging: level: root: INFO +aws: + accessKeyId: ${AWS_S3_ACCESS_KEY} + secretKey: ${AWS_S3_SECRET_KEY} + region: ${AWS_S3_REGION} + bucket: ${AWS_S3_BUCKET} + naver: - api: - base-url: https://openapi.naver.com/v1/search/news.json + url: https://openapi.naver.com/v1/search/news.json client-id: ${NAVER_CLIENT_ID} client-secret: ${NAVER_CLIENT_SECRET} \ No newline at end of file From 358e58f537dd3b3431c4c64099e5e4c5c8db8ea9 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:55:27 +0900 Subject: [PATCH 132/178] =?UTF-8?q?feat:=20=EB=89=B4=EC=8A=A4=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20(Keyword=20=EB=8B=A8=EC=9C=84=20=EA=B5=AC=EC=A1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...erestPair.java => ArticleKeywordPair.java} | 6 +- .../article/job/NaverNewsItemProcessor.java | 94 ++++++---------- .../article/job/NaverNewsItemReader.java | 15 +-- .../article/job/NaverNewsItemWriter.java | 104 ++++-------------- .../common/config/NaverNewsJobConfig.java | 11 +- 5 files changed, 68 insertions(+), 162 deletions(-) rename monew-batch/src/main/java/com/monew/monew_batch/article/dto/{ArticleInterestPair.java => ArticleKeywordPair.java} (52%) diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/dto/ArticleInterestPair.java b/monew-batch/src/main/java/com/monew/monew_batch/article/dto/ArticleKeywordPair.java similarity index 52% rename from monew-batch/src/main/java/com/monew/monew_batch/article/dto/ArticleInterestPair.java rename to monew-batch/src/main/java/com/monew/monew_batch/article/dto/ArticleKeywordPair.java index 0ea2f13..05093fd 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/dto/ArticleInterestPair.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/dto/ArticleKeywordPair.java @@ -1,9 +1,9 @@ package com.monew.monew_batch.article.dto; import com.monew.monew_api.article.entity.Article; -import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; -public record ArticleInterestPair( +public record ArticleKeywordPair( Article article, - Interest interest + Keyword keyword ) {} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemProcessor.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemProcessor.java index 55ec7ee..3e91efb 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemProcessor.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemProcessor.java @@ -1,9 +1,8 @@ package com.monew.monew_batch.article.job; import com.monew.monew_api.article.entity.Article; -import com.monew.monew_api.interest.entity.Interest; -import com.monew.monew_api.interest.entity.InterestKeyword; -import com.monew.monew_batch.article.dto.ArticleInterestPair; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; import com.monew.monew_batch.article.properties.NaverApiProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,53 +22,52 @@ @Slf4j @Component @RequiredArgsConstructor -public class NaverNewsItemProcessor implements ItemProcessor> { +public class NaverNewsItemProcessor implements ItemProcessor> { private static final int DISPLAY_COUNT = 10; private static final String SORT_TYPE = "sim"; - private static final int REQUEST_DELAY_MS = 400; + private static final int REQUEST_DELAY_MS = 200; private final RestTemplate restTemplate; private final NaverApiProperties properties; @Override - public List process(Interest interest) { - List collectedArticles = new ArrayList<>(); - int totalFetched = 0; - - for (InterestKeyword ik : interest.getKeywords()) { - String keyword = ik.getKeyword().getKeyword(); - log.info("🧩 [{}] '{}' 뉴스 수집 시작", interest.getName(), keyword); - - try { - List> items = fetchNewsItems(keyword); - if (items.isEmpty()) { - log.warn("⚠️ [{} - {}] 뉴스 없음", interest.getName(), keyword); - continue; - } + public List process(Keyword keyword) { + String keywordText = keyword.getKeyword(); + log.info("'{}' 뉴스 수집 시작", keywordText); + + List> items = fetchNewsItems(keywordText); + if (items.isEmpty()) { + log.warn("[{}] 뉴스 없음", keywordText); + return Collections.emptyList(); + } - totalFetched += items.size(); - collectedArticles.addAll(convertToPairs(items, interest)); + List pairs = new ArrayList<>(); + for (Map item : items) { + String title = cleanText((String) item.get("title")); + String desc = cleanText((String) item.get("description")); + String link = Optional.ofNullable((String) item.get("link")).orElse(""); + String pubDateStr = (String) item.get("pubDate"); + LocalDateTime publishDate = parsePublishDate(pubDateStr); - log.info("✅ [{} - {}] 뉴스 {}건 수집 완료 (누적 {})", - interest.getName(), keyword, items.size(), totalFetched); + Article article = new Article("Naver", link, title, publishDate, desc); + pairs.add(new ArticleKeywordPair(article, keyword)); + } - Thread.sleep(REQUEST_DELAY_MS); + log.info("'{}' 뉴스 {}건 수집 완료", keywordText, pairs.size()); - } catch (Exception e) { - log.error("❌ [{} - {}] 뉴스 수집 실패: {}", interest.getName(), keyword, e.getMessage(), e); - } + try { + Thread.sleep(REQUEST_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("뉴스 수집 중 인터럽트 발생", e); } - log.info("📊 [{}] 총 {}건 기사 수집 완료", interest.getName(), totalFetched); - return collectedArticles; + return pairs; } - /** - * 네이버 뉴스 API 호출 - */ private List> fetchNewsItems(String keyword) { - String uri = UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl()) + String uri = UriComponentsBuilder.fromHttpUrl(properties.getUrl()) .queryParam("query", keyword) .queryParam("display", DISPLAY_COUNT) .queryParam("sort", SORT_TYPE) @@ -81,46 +79,16 @@ private List> fetchNewsItems(String keyword) { headers.set("X-Naver-Client-Secret", properties.getClientSecret()); HttpEntity entity = new HttpEntity<>(headers); - Map response = restTemplate.exchange(uri, HttpMethod.GET, entity, Map.class).getBody(); - if (response == null) return Collections.emptyList(); - return (List>) response.getOrDefault("items", Collections.emptyList()); } - /** - * API 응답 데이터를 Article-Interest 쌍으로 변환 - */ - private List convertToPairs(List> items, Interest interest) { - List pairs = new ArrayList<>(); - - for (Map item : items) { - String title = cleanText((String) item.get("title")); - String link = Optional.ofNullable((String) item.get("link")).orElse(""); - String desc = cleanText((String) item.get("description")); - String pubDateStr = (String) item.get("pubDate"); - - LocalDateTime publishDate = parsePublishDate(pubDateStr); - - Article article = new Article("Naver", link, title, publishDate, desc); - pairs.add(new ArticleInterestPair(article, interest)); - } - - return pairs; - } - - /** - * HTML 태그 제거 유틸 - */ private String cleanText(String text) { return Optional.ofNullable(text) .map(t -> t.replaceAll("<[^>]*>", "")) .orElse(""); } - /** - * pubDate 문자열을 LocalDateTime으로 변환 - */ private LocalDateTime parsePublishDate(String pubDateStr) { try { return ZonedDateTime.parse(pubDateStr, DateTimeFormatter.RFC_1123_DATE_TIME) diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemReader.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemReader.java index 495ecfd..3d194f4 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemReader.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemReader.java @@ -1,7 +1,9 @@ package com.monew.monew_batch.article.job; import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; import com.monew.monew_api.interest.repository.InterestRepository; +import com.monew.monew_api.interest.repository.KeywordRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.configuration.annotation.StepScope; @@ -14,23 +16,22 @@ @StepScope @RequiredArgsConstructor @Slf4j -public class NaverNewsItemReader implements ItemReader { +public class NaverNewsItemReader implements ItemReader { - private final InterestRepository interestRepository; - private List items; + private final KeywordRepository keywordRepository; + private List items; private int nextIndex = 0; @Override - public synchronized Interest read() { + public synchronized Keyword read() { if (items == null) { - items = interestRepository.findAllWithKeywords(); - log.info("📰 관심사 {}개 로드 완료", items.size()); + items = keywordRepository.findAll(); + log.info("키워드 {}개 로드 완료", items.size()); } if (nextIndex < items.size()) { return items.get(nextIndex++); } else { - log.info("✅ 모든 관심사 처리 완료"); return null; } } diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java index 6a6b51e..f2e67b4 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java @@ -2,6 +2,7 @@ import com.monew.monew_api.article.entity.Article; import com.monew.monew_api.article.entity.InterestArticles; +import com.monew.monew_api.article.repository.ArticleJdbcRepository; import com.monew.monew_api.article.repository.ArticleRepository; import com.monew.monew_api.article.repository.InterestArticleKeywordRepository; import com.monew.monew_api.article.repository.InterestArticlesRepository; @@ -9,8 +10,8 @@ import com.monew.monew_api.interest.entity.Interest; import com.monew.monew_api.interest.entity.InterestKeyword; import com.monew.monew_api.interest.entity.Keyword; -import com.monew.monew_batch.article.dto.ArticleInterestPair; -import com.monew.monew_batch.article.repository.ArticleJdbcRepository; +import com.monew.monew_api.interest.repository.InterestRepository; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.item.Chunk; @@ -22,105 +23,44 @@ @Slf4j @Component @RequiredArgsConstructor -public class NaverNewsItemWriter implements ItemWriter> { +public class NaverNewsItemWriter implements ItemWriter> { private final ArticleJdbcRepository articleJdbcRepository; private final ArticleRepository articleRepository; + private final InterestRepository interestRepository; private final InterestArticlesRepository interestArticlesRepository; private final InterestArticleKeywordRepository interestArticleKeywordRepository; @Override - public void write(Chunk> chunk) { - int total = 0, newCount = 0, linkedCount = 0, skippedCount = 0; + public void write(Chunk> chunk) { + int total = 0, newCount = 0, linkedCount = 0; - for (List batch : chunk) { - for (ArticleInterestPair pair : batch) { + for (List batch : chunk) { + for (ArticleKeywordPair pair : batch) { total++; Article article = pair.article(); - Interest interest = pair.interest(); + Keyword keyword = pair.keyword(); - // 1. 기사 저장 및 복구 처리 - boolean isNew = handleInsertIgnore(article); + boolean isNew = articleJdbcRepository.insertIgnore(article); if (isNew) newCount++; - Article savedArticle = handleRestoreAndFind(article); + Article savedArticle = articleRepository.findBySourceUrl(article.getSourceUrl()) + .orElseThrow(); - // 2. 관심사·기사·키워드 관계 처리 - ProcessResult result = handleInterestAndKeywords(savedArticle, interest); + List relatedInterests = interestRepository.findAllByKeyword(keyword); + for (Interest interest : relatedInterests) { + interestArticlesRepository.insertIgnore(interest.getId(), savedArticle.getId()); - linkedCount += result.linkedCount(); - skippedCount += result.skippedCount(); - } - } - - logSummary(total, newCount, linkedCount, skippedCount); - } - - /** - * JdbcTemplate 기반 insertIgnore 실행 - */ - private boolean handleInsertIgnore(Article article) { - boolean isNew = articleJdbcRepository.insertIgnore(article); - if (isNew) { - log.info("🆕 신규 기사 저장: {}", article.getTitle()); - } - return isNew; - } - - /** - * 삭제된 기사 복구 + DB 조회 - */ - private Article handleRestoreAndFind(Article article) { - if (articleRepository.restoreIfDeleted(article.getSourceUrl()) > 0) { - log.info("♻️ 복구된 기사: {}", article.getTitle()); - } - - return articleRepository.findBySourceUrl(article.getSourceUrl()) - .orElseThrow(ArticleNotFoundException::new); - } - - /** - * 관심사-기사 관계 및 키워드 연결 처리 - */ - private ProcessResult handleInterestAndKeywords(Article article, Interest interest) { - int linkedCount = 0; - int skippedCount = 0; + InterestArticles ia = interestArticlesRepository.findByArticleAndInterest(savedArticle, interest) + .orElseThrow(); - // 1. 관심사-기사 연결 (InterestArticles) - InterestArticles interestArticle = - interestArticlesRepository.findByArticleAndInterest(article, interest) - .orElseGet(() -> { - InterestArticles newLink = new InterestArticles(article, interest); - interestArticlesRepository.save(newLink); - log.info("🔗 [{}] 관심사-기사 연결 완료: {}", interest.getName(), article.getTitle()); - return newLink; - }); - - // 2. 관심사-키워드 연결 (InterestArticlesKeywords) - for (InterestKeyword ik : interest.getKeywords()) { - Keyword keyword = ik.getKeyword(); - int inserted = interestArticleKeywordRepository.insertIgnore( - interestArticle.getId(), keyword.getId() - ); - - if (inserted > 0) { - linkedCount++; - log.info("📎 [{}-{}] 연결 완료: {}", interest.getName(), keyword.getKeyword(), article.getTitle()); - } else { - skippedCount++; + interestArticleKeywordRepository.insertIgnore(ia.getId(), keyword.getId()); + linkedCount++; + } } } - return new ProcessResult(linkedCount, skippedCount); - } - - /** - * 결과 요약 로그 - */ - private void logSummary(int total, int newCount, int linkedCount, int skippedCount) { - log.info("💾 Writer 결과 | 총: {} | 신규 기사: {} | 연결: {} | 스킵(중복): {}", - total, newCount, linkedCount, skippedCount); + log.info("Writer 결과 | 총: {} | 신규 기사: {} | 연결: {}", total, newCount, linkedCount); } - private record ProcessResult(int linkedCount, int skippedCount) {} } \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/common/config/NaverNewsJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/common/config/NaverNewsJobConfig.java index a2c9cdf..67660ad 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/common/config/NaverNewsJobConfig.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/common/config/NaverNewsJobConfig.java @@ -1,7 +1,7 @@ package com.monew.monew_batch.common.config; -import com.monew.monew_api.interest.entity.Interest; -import com.monew.monew_batch.article.dto.ArticleInterestPair; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; import com.monew.monew_batch.article.job.NaverNewsItemProcessor; import com.monew.monew_batch.article.job.NaverNewsItemReader; import com.monew.monew_batch.article.job.NaverNewsItemWriter; @@ -43,7 +43,7 @@ public Job naverNewsJob(JobRepository jobRepository, Step naverNewsStep) { public Step naverNewsStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { return new StepBuilder("naverNewsStep", jobRepository) - .>chunk(1, transactionManager) + .>chunk(1, transactionManager) .reader(reader) .processor(processor) .writer(writer) @@ -51,13 +51,10 @@ public Step naverNewsStep(JobRepository jobRepository, .build(); } - /** - * 스레드 동시성은 TaskExecutor에서 직접 설정 - */ @Bean public TaskExecutor taskExecutor() { SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("naver-news-thread-"); - executor.setConcurrencyLimit(5); + executor.setConcurrencyLimit(2); return executor; } From 710aa0623a276ccd5a95281d856703c39f3333c3 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:56:51 +0900 Subject: [PATCH 133/178] =?UTF-8?q?feat:=20=EB=B0=B1=EC=97=85=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B3=B5=EC=9B=90=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 --- monew-api/build.gradle | 1 + .../article/controller/ArticleController.java | 18 +- .../monew_api/article/dto/NewsBackupData.java | 67 +++++++ .../monew_api/article/entity/Article.java | 4 + .../article/entity/InterestArticles.java | 9 +- .../repository/ArticleJdbcRepository.java | 2 +- .../article/repository/ArticleRepository.java | 13 +- .../InterestArticlesRepository.java | 14 ++ .../article/service/NewsRestoreService.java | 170 ++++++++++++++++++ .../repository/InterestRepository.java | 12 +- .../repository/KeywordRepository.java | 4 - monew-batch/build.gradle | 10 +- .../monew_batch/MonewBatchApplication.java | 7 +- .../ArticleBackupQueryRepository.java | 10 ++ .../ArticleBackupQueryRepositoryImpl.java | 48 +++++ .../scheduler/NewsBackupScheduler.java | 29 +++ .../article/service/NewsBackupService.java | 72 ++++++++ 17 files changed, 457 insertions(+), 33 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/dto/NewsBackupData.java rename {monew-batch/src/main/java/com/monew/monew_batch => monew-api/src/main/java/com/monew/monew_api}/article/repository/ArticleJdbcRepository.java (95%) create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/service/NewsRestoreService.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepository.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepositoryImpl.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NewsBackupScheduler.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/service/NewsBackupService.java diff --git a/monew-api/build.gradle b/monew-api/build.gradle index 32c59c8..b84ba3a 100644 --- a/monew-api/build.gradle +++ b/monew-api/build.gradle @@ -7,6 +7,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + implementation 'software.amazon.awssdk:s3:2.31.7' runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'com.h2database:h2' implementation 'org.mapstruct:mapstruct:1.6.3' diff --git a/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java index 65ad091..2128c2a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java @@ -5,13 +5,16 @@ import com.monew.monew_api.article.dto.ArticleViewDto; import com.monew.monew_api.article.dto.CursorPageResponseArticleDto; import com.monew.monew_api.article.service.ArticleService; +import com.monew.monew_api.article.service.NewsRestoreService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; import java.util.List; @Slf4j @@ -21,6 +24,7 @@ public class ArticleController { private final ArticleService articleService; + private final NewsRestoreService newsRestoreService; /** * 기사 조회 기록 등록 @@ -103,10 +107,12 @@ public ResponseEntity hardDeleteArticle(@PathVariable Long articleId) { return ResponseEntity.noContent().build(); } - // RSS 문제 - // 포폴 - - // S3 - // S3 - // 로직 (A이베 + @GetMapping("/restore") + public ResponseEntity restoreBackup( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to + ) { + newsRestoreService.restoreArticles(from, to); + return ResponseEntity.ok("복원 완료"); + } } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/NewsBackupData.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/NewsBackupData.java new file mode 100644 index 0000000..d809814 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/NewsBackupData.java @@ -0,0 +1,67 @@ +package com.monew.monew_api.article.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.monew.monew_api.article.entity.Article; +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 뉴스 백업 데이터 구조 + * - S3 저장용 DTO + * - 기사 정보 + 연결된 키워드 목록 포함 + */ +@Getter +@Setter +@NoArgsConstructor +public class NewsBackupData { + + private LocalDateTime backupDate; + private List articles; + + @Getter + @Setter + @NoArgsConstructor + public static class ArticleData { + + private String source; + private String sourceUrl; + private String title; + private LocalDateTime publishDate; + private String summary; + + @JsonProperty("keywords") + private List keywords; + + /** + * QueryProjection 기반 생성자 + * - string_agg 결과 문자열을 List으로 변환 + */ + @QueryProjection + public ArticleData(String source, String sourceUrl, String title, + LocalDateTime publishDate, String summary, String keywordsRaw) { + this.source = source; + this.sourceUrl = sourceUrl; + this.title = title; + this.publishDate = publishDate; + this.summary = summary; + this.keywords = Arrays.stream(Optional.ofNullable(keywordsRaw).orElse("").split(",")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .distinct() + .collect(Collectors.toList()); + } + + /** Entity 변환용 헬퍼 */ + public Article toEntity() { + return new Article(source, sourceUrl, title, publishDate, summary); + } + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java index b9323c2..9d90d36 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java @@ -8,6 +8,8 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; /** * 뉴스 기사 테이블 @@ -42,6 +44,8 @@ public class Article extends BaseIdEntity { @Column(name = "is_deleted", nullable = false) private boolean isDeleted = false; + @OneToMany(mappedBy = "article", cascade = CascadeType.ALL, orphanRemoval = true) + private List interestArticles = new ArrayList<>(); public Article(String source, String sourceUrl, String title, LocalDateTime publishDate, String summary) { this.source = source; diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java index 8da7326..f13db04 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java @@ -7,6 +7,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.ArrayList; +import java.util.List; + /** * 기사 - 관심사 연결 테이블 */ @@ -27,8 +30,6 @@ public class InterestArticles extends BaseIdEntity { @JoinColumn(name = "interest_id", nullable = false) private Interest interest; - public InterestArticles(Article article, Interest interest) { - this.article = article; - this.interest = interest; - } + @OneToMany(mappedBy = "interestArticle", cascade = CascadeType.ALL, orphanRemoval = true) + private List interestArticleKeywords = new ArrayList<>(); } diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleJdbcRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleJdbcRepository.java similarity index 95% rename from monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleJdbcRepository.java rename to monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleJdbcRepository.java index 22163fc..3b0bda2 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleJdbcRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleJdbcRepository.java @@ -1,4 +1,4 @@ -package com.monew.monew_batch.article.repository; +package com.monew.monew_api.article.repository; import com.monew.monew_api.article.entity.Article; import lombok.RequiredArgsConstructor; diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java index c58c82e..0281d99 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; public interface ArticleRepository extends JpaRepository, ArticleQueryRepository { @@ -21,15 +22,6 @@ public interface ArticleRepository extends JpaRepository, Article /** 기사 URL로 중복 여부 확인 (뉴스 중복 방지용) */ Optional
findBySourceUrl(String sourceUrl); - /** 논리 삭제된 기사 복구 (isDeleted = false) */ - @Modifying - @Query(""" - UPDATE Article a - SET a.isDeleted = false - WHERE a.sourceUrl = :sourceUrl AND a.isDeleted = true - """) - int restoreIfDeleted(@Param("sourceUrl") String sourceUrl); - /** 여러 기사 논리 삭제 (isDeleted = true) */ @Modifying(clearAutomatically = true) @Query(""" @@ -41,4 +33,7 @@ public interface ArticleRepository extends JpaRepository, Article /** 논리 삭제된 기사 전체 조회 (스케줄러 등에서 사용) */ List
findAllByIsDeletedTrue(); + + @Query("SELECT a.sourceUrl FROM Article a") + Set findAllSourceUrls(); } diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java index 386c707..15811d5 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java @@ -4,6 +4,7 @@ import com.monew.monew_api.article.entity.InterestArticles; import com.monew.monew_api.interest.entity.Interest; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -38,4 +39,17 @@ List findArticleIdsUsedByOtherInterests( /** 특정 기사와 관심사 간의 연결이 이미 존재하는지 확인 */ Optional findByArticleAndInterest(Article article, Interest interest); + + @Modifying + @Query( + value = """ + INSERT INTO interest_articles (interest_id, article_id, created_at, updated_at) + VALUES (:interestId, :articleId, NOW(), NOW()) + ON CONFLICT (interest_id, article_id) DO NOTHING + """, + nativeQuery = true + ) + int insertIgnore( + @Param("interestId") Long interestId, + @Param("articleId") Long articleId); } diff --git a/monew-api/src/main/java/com/monew/monew_api/article/service/NewsRestoreService.java b/monew-api/src/main/java/com/monew/monew_api/article/service/NewsRestoreService.java new file mode 100644 index 0000000..a63434e --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/service/NewsRestoreService.java @@ -0,0 +1,170 @@ +package com.monew.monew_api.article.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.monew.monew_api.article.dto.NewsBackupData; +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.article.repository.*; +import com.monew.monew_api.common.entity.BaseIdEntity; +import com.monew.monew_api.common.exception.article.ArticleNotFoundException; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.repository.InterestRepository; +import com.monew.monew_api.interest.repository.KeywordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.S3Object; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NewsRestoreService { + + private final S3Client s3Client; + private final ObjectMapper objectMapper; + private final ArticleRepository articleRepository; + private final ArticleJdbcRepository articleJdbcRepository; + private final KeywordRepository keywordRepository; + private final InterestRepository interestRepository; + private final InterestArticlesRepository interestArticlesRepository; + private final InterestArticleKeywordRepository interestArticleKeywordRepository; + + @Value("${aws.bucket}") + private String bucketName; + + private static final String PREFIX = "backup/news_backup_"; + private static final DateTimeFormatter FILE_DATE_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss"); + + /** 메인 진입점 */ + @Transactional + public void restoreArticles(LocalDateTime from, LocalDateTime to) { + long start = System.currentTimeMillis(); + log.info("🗃 복원 시작: {} ~ {}", from, to); + + try { + // 1. S3에서 파일 목록 가져오기 + List fileKeys = getBackupFileKeys(from, to); + if (fileKeys.isEmpty()) return; + + // 2. 여러 백업 파일 병합 + List mergedArticles = mergeBackupData(fileKeys); + if (mergedArticles.isEmpty()) return; + + // 3. 이미 존재하는 기사 제외 + List newArticles = filterExistingArticles(mergedArticles); + if (newArticles.isEmpty()) return; + + log.info("📰 신규 기사 {}건 복원 시도", newArticles.size()); + + // 4. 기사 단위 복원 + int restored = 0, skipped = 0; + + for (NewsBackupData.ArticleData data : newArticles) { + boolean success = restoreSingleArticleExact(data); + if (success) restored++; + else skipped++; + } + + log.info("✅ 복원 완료 | 성공: {}건, 스킵: {}건", restored, skipped); + + } finally { + long end = System.currentTimeMillis(); + log.info("⏰ 복원 종료: 총 {}초 소요", (end - start) / 1000.0); + } + } + + /** 지정된 기간의 S3 백업 파일 목록 조회 */ + private List getBackupFileKeys(LocalDateTime from, LocalDateTime to) { + List keys = s3Client.listObjectsV2(b -> b.bucket(bucketName).prefix("backup/")) + .contents().stream() + .map(S3Object::key) + .filter(k -> k.startsWith(PREFIX)) + .filter(k -> { + LocalDateTime date = parseDateFromKey(k); + return !date.isBefore(from) && !date.isAfter(to); + }) + .toList(); + + if (keys.isEmpty()) log.info("📂 복원할 백업 파일이 없습니다."); + return keys; + } + + /** 파일명에서 날짜 추출 */ + private LocalDateTime parseDateFromKey(String key) { + try { + String datePart = key.replace(PREFIX, "").replace(".json", ""); + return LocalDateTime.parse(datePart, FILE_DATE_FORMAT); + } catch (Exception e) { + return LocalDateTime.MIN; + } + } + + /** 여러 백업 파일 병합 */ + private List mergeBackupData(List keys) { + Map merged = new LinkedHashMap<>(); + + for (String key : keys) { + try { + String json = s3Client.getObjectAsBytes(b -> b.bucket(bucketName).key(key)).asUtf8String(); + NewsBackupData backup = objectMapper.readValue(json, NewsBackupData.class); + backup.getArticles().forEach(a -> merged.putIfAbsent(a.getSourceUrl(), a)); + } catch (Exception e) { + log.error("⚠️ 백업 파일 로드 실패: {}", key, e); + } + } + + if (merged.isEmpty()) log.info("📄 병합된 복원 대상이 없습니다."); + return new ArrayList<>(merged.values()); + } + + /** 이미 존재하는 기사 제외 */ + private List filterExistingArticles(List articles) { + Set existingUrls = articleRepository.findAllSourceUrls(); + return articles.stream() + .filter(a -> !existingUrls.contains(a.getSourceUrl())) + .toList(); + } + + /** 기사 복원 (Writer 시점과 동일하되 insertIgnore 적용) */ + private boolean restoreSingleArticleExact(NewsBackupData.ArticleData data) { + try { + boolean inserted = articleJdbcRepository.insertIgnore(data.toEntity()); + if (!inserted) return false; + + // insertIgnore은 영속성 컨텍스으에 반영안됌 -> id로 조회 못함 + Article article = articleRepository.findBySourceUrl(data.getSourceUrl()) + .orElseThrow(ArticleNotFoundException::new); + + List keywords = data.getKeywords(); + for (String keywordName : keywords) { + keywordRepository.findByKeyword(keywordName).ifPresent(keyword -> { + List interests = interestRepository.findAllByKeyword(keyword); + for (Interest interest : interests) { + int result = interestArticlesRepository.insertIgnore(interest.getId(), article.getId()); + if (result > 0) { + log.info("🔗 [{}] 관심사-기사 연결 완료: {}", interest.getName(), article.getTitle()); + } + + interestArticleKeywordRepository.insertIgnore( + interestArticlesRepository.findByArticleAndInterest(article, interest) + .map(BaseIdEntity::getId) + .orElseThrow(), + keyword.getId() + ); + } + }); + } + return true; + } catch (Exception e) { + log.error("⚠️ 기사 [{}] 복원 실패: {}", data.getTitle(), e.getMessage()); + return false; + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java index c94fb86..460c186 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java @@ -1,21 +1,21 @@ package com.monew.monew_api.interest.repository; import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; public interface InterestRepository extends JpaRepository, InterestRepositoryCustom { - boolean existsByName(String name); - - /** 모든 관심사와 해당 관심사에 연결된 키워드들을 함께 조회 */ @Query(""" - SELECT i + SELECT DISTINCT i FROM Interest i JOIN FETCH i.keywords ik - JOIN FETCH ik.keyword + JOIN FETCH ik.keyword k + WHERE k = :keyword """) - List findAllWithKeywords(); + List findAllByKeyword(@Param("keyword") Keyword keyword); } diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/KeywordRepository.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/KeywordRepository.java index 17e0c91..4138e33 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/KeywordRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/KeywordRepository.java @@ -8,9 +8,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; -@Repository public interface KeywordRepository extends JpaRepository { Optional findByKeyword(String keyword); @@ -23,6 +21,4 @@ public interface KeywordRepository extends JpaRepository { + ")" ) List findOrphanKeywordsIn(@Param("keywords") Collection keywords); - - } diff --git a/monew-batch/build.gradle b/monew-batch/build.gradle index 1eae30a..ec3916e 100644 --- a/monew-batch/build.gradle +++ b/monew-batch/build.gradle @@ -8,13 +8,19 @@ dependencies { // implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' implementation project(':monew-api') implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'software.amazon.awssdk:s3:2.31.7' + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' annotationProcessor 'jakarta.annotation:jakarta.annotation-api' annotationProcessor 'jakarta.persistence:jakarta.persistence-api' - runtimeOnly 'org.postgresql:postgresql' - runtimeOnly 'com.h2database:h2' implementation 'org.mapstruct:mapstruct:1.6.3' annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' + + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + + runtimeOnly 'org.postgresql:postgresql' + runtimeOnly 'com.h2database:h2' } \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java index 1ec8c1b..2e47b12 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java @@ -8,7 +8,12 @@ import java.util.TimeZone; -@SpringBootApplication +@SpringBootApplication( + scanBasePackages = { + "com.monew.monew_batch", + "com.monew.monew_api.article.repository", + } +) @EntityScan(basePackages = "com.monew.monew_api") @EnableJpaRepositories(basePackages = "com.monew.monew_api") @EnableScheduling diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepository.java b/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepository.java new file mode 100644 index 0000000..b95ab2e --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepository.java @@ -0,0 +1,10 @@ +package com.monew.monew_batch.article.repository; + +import com.monew.monew_api.article.dto.NewsBackupData; + +import java.util.List; + +public interface ArticleBackupQueryRepository { + + List findAllArticlesForBackup(); +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepositoryImpl.java b/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepositoryImpl.java new file mode 100644 index 0000000..39253db --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepositoryImpl.java @@ -0,0 +1,48 @@ +package com.monew.monew_batch.article.repository; + + +import com.monew.monew_api.article.dto.NewsBackupData; +import com.monew.monew_api.article.dto.QNewsBackupData_ArticleData; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.monew.monew_api.article.entity.QArticle.article; +import static com.monew.monew_api.article.entity.QInterestArticles.interestArticles; +import static com.monew.monew_api.article.entity.QInterestArticleKeyword.interestArticleKeyword; +import static com.monew.monew_api.interest.entity.QKeyword.keyword1; +import static com.querydsl.core.types.dsl.Expressions.stringTemplate; + +/** + * 뉴스 백업용 QueryDSL 리포지토리 + * - 기사와 연결된 키워드를 한 번에 조회 (N+1 방지) + * - string_agg()로 키워드 문자열을 집계 후 DTO에서 분리 처리 + */ +@Repository +@RequiredArgsConstructor +public class ArticleBackupQueryRepositoryImpl implements ArticleBackupQueryRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllArticlesForBackup() { + return queryFactory + .select(new QNewsBackupData_ArticleData( + article.source, + article.sourceUrl, + article.title, + article.publishDate, + article.summary, + stringTemplate("string_agg({0}, ',')", keyword1.keyword) // PostgreSQL 집계 함수 + )) + .from(article) + .join(article.interestArticles, interestArticles) + .join(interestArticles.interestArticleKeywords, interestArticleKeyword) + .join(interestArticleKeyword.keyword, keyword1) + .where(article.isDeleted.isFalse()) + .groupBy(article.id) + .fetch(); + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NewsBackupScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NewsBackupScheduler.java new file mode 100644 index 0000000..8828237 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NewsBackupScheduler.java @@ -0,0 +1,29 @@ +package com.monew.monew_batch.article.scheduler; + +import com.monew.monew_batch.article.service.NewsBackupService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 뉴스 백업 스케줄러 + * - 매일 새벽 4시, S3 자동 백업 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(value = "app.scheduling.enabled", havingValue = "true", matchIfMissing = true) +public class NewsBackupScheduler { + + private final NewsBackupService newsBackupService; + + @Scheduled(cron = "0 20 4 * * *", zone = "Asia/Seoul") +// @Scheduled(fixedRate = 60000) // 테스트용 + public void backupNews() { + log.info("🗄 뉴스 백업 시작"); + newsBackupService.backupAllArticles(); + log.info("🗃 뉴스 백업 완료"); + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/service/NewsBackupService.java b/monew-batch/src/main/java/com/monew/monew_batch/article/service/NewsBackupService.java new file mode 100644 index 0000000..676a0e1 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/service/NewsBackupService.java @@ -0,0 +1,72 @@ +package com.monew.monew_batch.article.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.monew.monew_api.article.dto.NewsBackupData; +import com.monew.monew_batch.article.repository.ArticleBackupQueryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * 뉴스 백업 서비스 + * - JPA fetch join 기반으로 조회된 기사/키워드를 S3에 JSON으로 백업 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NewsBackupService { + + private final ArticleBackupQueryRepository backupQueryRepository; + private final ObjectMapper objectMapper; + private final S3Client s3Client; + + @Value("${aws.bucket}") + private String bucketName; + + private static final String PREFIX = "backup/news_backup_"; + + @Transactional(readOnly = true) + public void backupAllArticles() { + List articles = backupQueryRepository.findAllArticlesForBackup(); + + if (articles.isEmpty()) { + log.info("백업할 뉴스가 없습니다. (isDeleted = false)"); + return; + } + + NewsBackupData backupData = new NewsBackupData(); + backupData.setBackupDate(LocalDateTime.now()); + backupData.setArticles(articles); + + try { + String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(backupData); + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")); + String key = PREFIX + timestamp + ".json"; + + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentType("application/json") + .build(), + RequestBody.fromString(json, StandardCharsets.UTF_8) + ); + + log.info("✅ 뉴스 전체 백업 완료 | 총 {}건 | S3 Key = {}", articles.size(), key); + + } catch (Exception e) { + log.error("❌ 뉴스 백업 실패", e); + throw new RuntimeException("뉴스 백업 실패", e); + } + } +} From 7379afb1fc34f4eaaf02459237e55e280bbccefc Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:57:00 +0900 Subject: [PATCH 134/178] =?UTF-8?q?feat:=20=EA=B4=80=EC=8B=AC=EC=82=AC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=EC=97=B0=EA=B4=80=20=EB=89=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=85=BC=EB=A6=AC=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interest/service/InterestServiceImpl.java | 79 ++++++++++++++++--- 1 file changed, 66 insertions(+), 13 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index 18174d3..424ddbf 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -1,5 +1,8 @@ package com.monew.monew_api.interest.service; +import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_api.article.repository.InterestArticleKeywordRepository; +import com.monew.monew_api.article.repository.InterestArticlesRepository; import com.monew.monew_api.common.exception.interest.InterestDuplicatedException; import com.monew.monew_api.common.exception.interest.InterestNotFoundException; import com.monew.monew_api.common.exception.user.UserNotFoundException; @@ -31,7 +34,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.text.similarity.LevenshteinDistance; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort.Direction; import org.springframework.stereotype.Service; @@ -47,6 +49,10 @@ public class InterestServiceImpl implements InterestService { private final KeywordRepository keywordRepository; private final SubscribeRepository subscribeRepository; + private final ArticleRepository articleRepository; + private final InterestArticlesRepository interestArticlesRepository; + private final InterestArticleKeywordRepository interestArticleKeywordRepository; + private final InterestMapper interestMapper; @Override @@ -156,13 +162,37 @@ public InterestDto updateInterestKeywords( return interestMapper.toInterestDto(interest, keywords, subscribedByMe); } - @Override - @Transactional public void deleteInterest(Long interestId) { Interest interest = interestRepository.findById(interestId) - .orElseThrow(InterestNotFoundException::new); + .orElseThrow(InterestNotFoundException::new); + + List articleIds = interestArticlesRepository.findArticleIdsByInterestId(interestId); + log.info("관심사({})와 연결된 기사 수: {}", interest.getName(), articleIds.size()); + + if (articleIds.isEmpty()) { + interestRepository.delete(interest); + return; + } + + List usedElsewhere = + interestArticlesRepository.findArticleIdsUsedByOtherInterests(articleIds, interestId); + + List toDelete = articleIds.stream() + .filter(id -> !usedElsewhere.contains(id)) + .toList(); + + int deletedCount = toDelete.size(); + int undeletedCount = usedElsewhere.size(); + + if (!toDelete.isEmpty()) { + articleRepository.markAsDeleted(toDelete); + log.info("논리 삭제된 기사 수: {}", deletedCount); + } + + log.info("삭제 제외된 기사 수(다른 관심사에서 사용 중): {}", undeletedCount); interestRepository.delete(interest); + log.info("관심사 삭제 완료: {}", interest.getName()); } @@ -248,17 +278,40 @@ private void updateKeywords( private void removeOrphanKeywords(Interest interest, Map toRemove) { - if (toRemove.isEmpty()) { - return; - } - List removedKeyword = new ArrayList<>(); + if (toRemove.isEmpty()) return; + + List removedKeywords = toRemove.values().stream() + .map(InterestKeyword::getKeyword) + .toList(); + + interest.getKeywords().removeAll(toRemove.values()); + + List removedKeywordIds = removedKeywords.stream() + .map(Keyword::getId) + .toList(); + + List relatedArticleIds = + interestArticleKeywordRepository.findArticleIdsByKeywordIds(removedKeywordIds); + log.info("고아 키워드 관련 기사 수: {}", relatedArticleIds.size()); + + if (!relatedArticleIds.isEmpty()) { + List usedElsewhere = interestArticleKeywordRepository.findArticlesUsedElsewhere( + relatedArticleIds, removedKeywordIds, interest.getId()); + + List toDelete = relatedArticleIds.stream() + .filter(id -> !usedElsewhere.contains(id)) + .toList(); + + if (!toDelete.isEmpty()) { + articleRepository.markAsDeleted(toDelete); + log.info("논리 삭제된 기사 수: {}", toDelete.size()); + } - for (InterestKeyword interestKeyword : toRemove.values()) { - interest.getKeywords().remove(interestKeyword); - removedKeyword.add(interestKeyword.getKeyword()); + log.info("삭제 제외된 기사 수(다른 관심사/키워드 사용 중): {}", usedElsewhere.size()); } - List toDelete = keywordRepository.findOrphanKeywordsIn(removedKeyword); - keywordRepository.deleteAll(toDelete); + List orphanKeywords = keywordRepository.findOrphanKeywordsIn(removedKeywords); + keywordRepository.deleteAll(orphanKeywords); + log.info("고아 키워드 삭제 완료: {}", orphanKeywords.size()); } } From 80c55f219d862c7711eed87bfce5adf18c7f5480 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Mon, 3 Nov 2025 11:34:44 +0900 Subject: [PATCH 135/178] =?UTF-8?q?fix=20:=20rebase=EC=97=90=EC=84=9C=20Co?= =?UTF-8?q?mmentService=EC=97=90=EC=84=9C=20commit:=20ee0994e=20feat:=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=88=84=EB=9D=BD=EB=90=9C=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=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 --- .../comments/service/CommentService.java | 70 +++++++++++-------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index 8d4b4a3..5ca361c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -32,27 +32,32 @@ public class CommentService { // 댓글 작성 @Transactional public CommentDto register(CommentRegisterRequest request) { - log.info("[COMMENT][CREATE][START] userId={}, articleId={}", request.userId(), request.articleId()); - User user = getUserById(request.userId()); - Article article = getArticleById(request.articleId()); - - Comment saved = commentRepository.save(Comment.of(user, article, request.content())); - log.info("[COMMENT][CREATE] userId={}, articleId={}, commentId={}", - user.getId(), article.getId(), saved.getId()); - - eventPublisher.publishEvent( - CommentCreatedEvent.of( - saved.getId(), - saved.getArticleId(), - article.getTitle(), - saved.getUserId(), - user.getNickname(), - saved.getContent(), - saved.getLikeCount(), - saved.getCreatedAt()) - ); - - return CommentDto.from(saved, false); + log.info("[COMMENT][CREATE][START] userId={}, articleId={}", request.userId(), request.articleId()); + User user = getUserById(request.userId()); + Article article = getArticleById(request.articleId()); + + log.info("[COMMENT_COUNT] 댓글 작성 전 카운트: {}", article.getCommentCount()); + Comment saved = commentRepository.save(Comment.of(user, article, request.content())); + log.info("[COMMENT][CREATE] userId={}, articleId={}, commentId={}", + user.getId(), article.getId(), saved.getId()); + + article.increaseCommentCount(); + articleRepository.save(article); + + eventPublisher.publishEvent( + CommentCreatedEvent.of( + saved.getId(), + saved.getArticleId(), + article.getTitle(), + saved.getUserId(), + user.getNickname(), + saved.getContent(), + saved.getLikeCount(), + saved.getCreatedAt()) + ); + + log.info("[COMMENT_COUNT] 댓글 작성 후 카운트: {}", article.getCommentCount()); + return CommentDto.from(saved, false); } // 댓글 수정 @@ -117,17 +122,24 @@ public void dislike(Long userId, Long commentId) { log.info("[COMMENT][DISLIKE] userId={}, commentId={}", userId, commentId); } - // 댓글 논리 삭제 - @Transactional - public void delete(Long commentId) { - log.info("[COMMENT][DELETE][START] commentId={}", commentId); - Comment comment = getCommentById(commentId); + // 댓글 논리 삭제 + @Transactional + public void delete(Long commentId) { + log.info("[COMMENT][DELETE][START] commentId={}", commentId); + Comment comment = getCommentById(commentId); + + Article article = comment.getArticle(); + log.info("[COMMENT_COUNT] 댓글 삭제 전 카운트: {}", article.getCommentCount()); + commentRepository.delete(comment); + + article.decreaseCommentCount(); + articleRepository.save(article); eventPublisher.publishEvent(CommentDeletedEvent.of(commentId)); + log.info("[COMMENT_COUNT] 댓글 삭제 후 카운트: {}", article.getCommentCount()); + log.info("[COMMENT][DELETE] commentId={}", commentId); + } - commentRepository.delete(comment); - log.info("[COMMENT][DELETE] commentId={}", commentId); - } // 댓글 물리 삭제 @Transactional From 32b0eb5daa11cae737183de86688ab8e3aa56d37 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Mon, 3 Nov 2025 11:37:49 +0900 Subject: [PATCH 136/178] =?UTF-8?q?fix=20:=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_api/interest/controller/InterestController.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java b/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java index fccdbed..d2da3d0 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java @@ -67,13 +67,12 @@ public ResponseEntity deleteInterest( @PatchMapping("/{interestId}") public ResponseEntity updateInterestKeywords( - @RequestHeader(name = "Monew-Request-User-Id") Long userId, @PathVariable Long interestId, @RequestBody @Valid InterestUpdateRequest request ) { log.info("[API 요청] PATCH/api/interests/{} - 관심사 키워드 수정 요청 : {}", interestId, request); InterestDto response = interestService - .updateInterestKeywords(request, interestId, userId); + .updateInterestKeywords(request, interestId); log.info("[API 응답] PATCH/api/interests/{} - 관심사 키워드 수정 응답 : {}", interestId, response); return ResponseEntity.status(HttpStatus.OK).body(response); } From 1136fedb96a1759ea6bd800826c8f4f31d5e5f91 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Mon, 3 Nov 2025 11:42:58 +0900 Subject: [PATCH 137/178] =?UTF-8?q?refactor=20:=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95=20=EB=A7=A4=EA=B0=9C=EB=B3=80?= =?UTF-8?q?=EC=88=98=20userId=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=B6=80=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 --- .../request/CursorPageRequestInterestDto.java | 4 +- .../interest/service/InterestService.java | 2 +- .../interest/service/InterestServiceImpl.java | 53 +++++++++---------- 3 files changed, 27 insertions(+), 32 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java index c0d7f1b..807aeda 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java @@ -1,17 +1,17 @@ package com.monew.monew_api.interest.dto.request; import com.monew.monew_api.interest.dto.InterestOrderBy; +import com.querydsl.core.types.Order; import jakarta.validation.constraints.NotNull; import java.time.LocalDateTime; import org.springdoc.core.annotations.ParameterObject; -import org.springframework.data.domain.Sort.Direction; @ParameterObject public record CursorPageRequestInterestDto( String keyword, // 검색어(관심사, 키워드) @NotNull InterestOrderBy orderBy, - @NotNull Direction direction, // 정렬 방향 (ASC, DESC) + @NotNull Order direction, // 정렬 방향 (ASC, DESC) String cursor, // 커서 값 LocalDateTime after, // @NotNull Integer limit // 커서 페이지 크기 diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java index 339f700..2258be6 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java @@ -13,7 +13,7 @@ public interface InterestService { CursorPageResponseInterestDto getInterests(Long userId, CursorPageRequestInterestDto cursorRequest); - InterestDto updateInterestKeywords(InterestUpdateRequest request, Long interestId, Long userId); + InterestDto updateInterestKeywords(InterestUpdateRequest request, Long interestId); void deleteInterest(Long interestId); diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index 18174d3..390534d 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -19,6 +19,7 @@ import com.monew.monew_api.interest.repository.KeywordRepository; import com.monew.monew_api.subscribe.repository.SubscribeRepository; import com.monew.monew_api.subscribe.repository.SubscribeRepository.InterestCountProjection; +import com.querydsl.core.types.Order; import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import java.util.ArrayList; @@ -31,9 +32,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.text.similarity.LevenshteinDistance; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Slice; -import org.springframework.data.domain.Sort.Direction; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -88,16 +87,19 @@ public InterestDto createInterest(InterestRegisterRequest request) { public CursorPageResponseInterestDto getInterests(Long userId, CursorPageRequestInterestDto request) { - final String keyword = (request.keyword() == null) ? null : request.keyword(); + final String keyword = (request.keyword() == null || request.keyword().isBlank()) + ? null : request.keyword(); final InterestOrderBy orderBy = (request.orderBy() == null) ? InterestOrderBy.name : request.orderBy(); - final Direction direction = (request.direction() == null) ? Direction.ASC : request.direction(); + final Order direction = (request.direction() == null)? Order.ASC : request.direction(); final String cursor = request.cursor(); final LocalDateTime after = request.after(); final int limit = request.limit(); Slice slices = interestRepository.findAll( keyword, orderBy, direction, cursor, after, limit); + log.info("REQ userId={}, keyword={}, orderBy={}, direction={}, cursor={}, after={}, limit={}", + userId, keyword, orderBy, direction, cursor, after, limit); List interests = slices.getContent(); @@ -129,23 +131,21 @@ public CursorPageResponseInterestDto getInterests(Long userId, } boolean hasNext = slices.hasNext(); - String nextCursor = calculateNextCursor(interests, orderBy, hasNext); + String nextCursor = calculateNextCursor(interests, countMap, orderBy, hasNext); LocalDateTime nextAfter = calculateNextAfter(interests); - long totalElements = interestRepository.countFilteredTotalElements(keyword, orderBy, direction); + long totalElements = interestRepository.countFilteredTotalElements(keyword); return new CursorPageResponseInterestDto( - interestDtos, nextCursor, nextAfter, interestDtos.size(), totalElements, hasNext); + interestDtos, nextCursor, nextAfter, limit, totalElements, hasNext); } @Override @Transactional public InterestDto updateInterestKeywords( - InterestUpdateRequest request, Long interestId, Long userId) { + InterestUpdateRequest request, Long interestId) { - User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); Interest interest = interestRepository.findById(interestId) .orElseThrow(InterestNotFoundException::new); - boolean subscribedByMe = subscribeRepository.existsByInterestAndUser(interest, user); updateKeywords(interest, request.keywords()); @@ -153,7 +153,7 @@ public InterestDto updateInterestKeywords( .map(ik -> ik.getKeyword().getKeyword()) .collect(Collectors.toList()); - return interestMapper.toInterestDto(interest, keywords, subscribedByMe); + return interestMapper.toInterestDto(interest, keywords, false); } @Override @@ -188,25 +188,20 @@ private double calculateSimilarity(String name1, String name2) { } - private String calculateNextCursor(List interests, InterestOrderBy orderBy, - boolean hasNext) { - if (!hasNext || interests.isEmpty()) { - return null; - } + private String calculateNextCursor( + List interests, + Map countMap, + InterestOrderBy orderBy, + boolean hasNext + ) { + if (!hasNext || interests.isEmpty()) return null; + Interest last = interests.get(interests.size() - 1); - String cursorValue = ""; - switch (orderBy) { - case name: - cursorValue = last.getName(); - break; - case subscriberCount: - cursorValue = String.valueOf(last.getSubscriberCount()); - break; - default: - throw new IllegalArgumentException("invalid order"); - } - return String.valueOf(last.getId()); -// return cursorValue; + + return switch (orderBy) { + case name -> last.getName(); + case subscriberCount -> String.valueOf(last.getId()); + }; } From 88e13a76603afc95178033954802cc9ca6c16940 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Mon, 3 Nov 2025 11:45:35 +0900 Subject: [PATCH 138/178] =?UTF-8?q?fix=20:=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/InterestRepositoryCustom.java | 10 +- .../InterestRepositoryCustomImpl.java | 173 +++++++----------- 2 files changed, 71 insertions(+), 112 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustom.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustom.java index f600928..4ff5852 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustom.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustom.java @@ -2,25 +2,21 @@ import com.monew.monew_api.interest.dto.InterestOrderBy; import com.monew.monew_api.interest.entity.Interest; +import com.querydsl.core.types.Order; import java.time.LocalDateTime; import org.springframework.data.domain.Slice; -import org.springframework.data.domain.Sort.Direction; public interface InterestRepositoryCustom { Slice findAll( String keyword, InterestOrderBy sortBy, - Direction direction, + Order direction, String cursor, LocalDateTime after, int limit ); - long countFilteredTotalElements( - String keyword, - InterestOrderBy sortBy, - Direction direction - ); + Long countFilteredTotalElements(String keyword); } diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java index bc8a87a..eedc14b 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java @@ -3,8 +3,7 @@ import com.monew.monew_api.interest.entity.Interest; import com.monew.monew_api.interest.dto.InterestOrderBy; import com.querydsl.core.BooleanBuilder; -import com.querydsl.core.Tuple; -import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Order; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQuery; @@ -15,13 +14,11 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; -import org.springframework.data.domain.Sort.Direction; import org.springframework.stereotype.Repository; import static com.monew.monew_api.interest.entity.QInterestKeyword.interestKeyword; import static com.monew.monew_api.interest.entity.QKeyword.keyword1; import static com.monew.monew_api.interest.entity.QInterest.interest; -import static com.monew.monew_api.subscribe.entity.QSubscribe.subscribe; @Repository @RequiredArgsConstructor @@ -29,129 +26,96 @@ public class InterestRepositoryCustomImpl implements InterestRepositoryCustom { private final JPAQueryFactory queryFactory; - @Override + @Override public Slice findAll( String searchKeyword, - InterestOrderBy sortBy, - Direction direction, + InterestOrderBy orderBy, + Order direction, String cursor, LocalDateTime after, int limit ) { + BooleanBuilder builder = new BooleanBuilder(); - BooleanBuilder builder = new BooleanBuilder() - .and(containsInterestOrKeyword(searchKeyword)); - if (after != null) { - builder.and(interest.createdAt.goe(after)); + if (searchKeyword != null && !searchKeyword.isBlank()) { + builder.and( + interest.name.containsIgnoreCase(searchKeyword) + .or(keyword1.keyword.containsIgnoreCase(searchKeyword)) + ); } - builder.and(cursorCondition(cursor, sortBy, direction)); - - // id + 정렬값 - Expression sortExpr = sortExpression(sortBy); - - List rows = queryFactory - .selectDistinct(interest.id, sortExpr) - .from(interest) - .leftJoin(interest.keywords, - interestKeyword) - .leftJoin(interestKeyword.keyword, keyword1) - .where(builder) - .orderBy(sortBy(sortBy, direction)) - .limit(limit + 1) - .fetch(); - - boolean hasNext = rows.size() > limit; - if (hasNext) - rows = rows.subList(0, limit); - - // 추출된 id들만 조회 - List ids = rows.stream() - .map(t -> t.get(interest.id)) - .toList(); - - List interests = queryFactory - .selectFrom(interest) - .distinct() - .leftJoin(interest.keywords, interestKeyword).fetchJoin() - .leftJoin(interestKeyword.keyword, keyword1).fetchJoin() - .where(interest.id.in(ids)) - .orderBy(sortBy(sortBy, direction - )) - .fetch(); - return new SliceImpl<>(interests, PageRequest.of(0, limit), hasNext); - } - - - private BooleanExpression containsInterestOrKeyword(String searchKeyword) { - if (searchKeyword == null || searchKeyword.isBlank()) - return null; - return interest.name.containsIgnoreCase(searchKeyword) - .or(keyword1.keyword.containsIgnoreCase(searchKeyword)); - } + if (after != null) { + builder.and(interest.createdAt.gt(after)); + } - private Expression sortExpression(InterestOrderBy sortBy) { - return switch (sortBy) { - case name -> interest.name; - case subscriberCount -> interest.subscriberCount; - }; - } + Order ord = (direction == null || direction == Order.DESC) ? Order.DESC : Order.ASC; + boolean desc = (ord == Order.DESC); - // 커서 조건: cursor는 id로 가정 -> 해당 id 레코드를 읽어 1차 정렬값 + id로 커팅 - private BooleanExpression cursorCondition( - String cursor, InterestOrderBy sortBy, Direction direction) { - if (cursor == null || cursor.isBlank()) - return null; + Long cursorId = null; + String cursorName = null; + Integer cursorCnt = null; - boolean desc = (direction == Direction.DESC); - Long cursorId = Long.valueOf(cursor); + OrderSpecifier[] orderSpec = new OrderSpecifier[]{};; - Interest cursorInterest = queryFactory - .selectFrom(interest) - .where(interest.id.eq(cursorId)) - .fetchOne(); + switch (orderBy) { + case name -> { + cursorName = (cursor == null || cursor.isBlank()) ? null : cursor; - if (cursorInterest == null) - return null; + orderSpec = new OrderSpecifier[]{ + new OrderSpecifier<>(ord, interest.name) + }; - return switch (sortBy) { - case name -> { - String afterName = cursorInterest.getName(); - yield desc - ? interest.name.lt(afterName) - : interest.name.gt(afterName); + if (cursorName != null) { + builder.and(desc ? interest.name.lt(cursorName) : interest.name.gt(cursorName)); + } } + case subscriberCount -> { - int afterCnt = cursorInterest.getSubscriberCount(); - yield desc - ? interest.subscriberCount.lt(afterCnt) - .or(interest.subscriberCount.eq(afterCnt) - .and(interest.id.lt(cursorId))) - : interest.subscriberCount.gt(afterCnt) - .or(interest.subscriberCount.eq(afterCnt) - .and(interest.id.gt(cursorId))); + cursorId = (cursor == null || cursor.isBlank()) ? null : Long.valueOf(cursor); + + if (cursorId != null) { + Interest base = queryFactory.selectFrom(interest) + .where(interest.id.eq(cursorId)) + .fetchOne(); + if (base != null) + cursorCnt = base.getSubscriberCount(); + } + + orderSpec = new OrderSpecifier[]{ + new OrderSpecifier<>(ord, interest.subscriberCount), + new OrderSpecifier<>(ord, interest.id) + }; + + if (cursorId != null && cursorCnt != null) { + BooleanExpression cut = desc + ? interest.subscriberCount.lt(cursorCnt) + .or(interest.subscriberCount.eq(cursorCnt).and(interest.id.lt(cursorId))) + : interest.subscriberCount.gt(cursorCnt) + .or(interest.subscriberCount.eq(cursorCnt).and(interest.id.gt(cursorId))); + builder.and(cut); + } } - }; - } + } - private OrderSpecifier[] sortBy(InterestOrderBy sortBy, Direction direction) { - boolean asc = (direction == Direction.ASC); - return switch (sortBy) { - case name -> asc - ? new OrderSpecifier[]{interest.name.asc(), interest.id.asc()} - : new OrderSpecifier[]{interest.name.desc(), interest.id.desc()}; - case subscriberCount -> asc - ? new OrderSpecifier[]{interest.subscriberCount.asc(), interest.id.asc()} - : new OrderSpecifier[]{interest.subscriberCount.desc(), interest.id.desc()}; - }; - } + List rowsPlusOne = queryFactory + .select(interest) + .from(interest) + .leftJoin(interest.keywords, interestKeyword) + .leftJoin(interestKeyword.keyword, keyword1) + .where(builder) + .groupBy(interest.id) + .orderBy(orderSpec) + .limit(limit + 1) + .fetch(); + boolean hasNext = rowsPlusOne.size() > limit; + List content = hasNext ? rowsPlusOne.subList(0, limit) : rowsPlusOne; + return new SliceImpl<>(content, PageRequest.of(0, limit), hasNext); + } @Override - public long countFilteredTotalElements(String keyword, InterestOrderBy orderBy, - Direction direction) { - + public Long countFilteredTotalElements(String keyword) { JPAQuery query = queryFactory .select(interest.countDistinct()) .from(interest); @@ -170,7 +134,6 @@ public long countFilteredTotalElements(String keyword, InterestOrderBy orderBy, } Long count = query.where(builder).fetchOne(); return (count != null) ? count : 0L; - } } From 54457f1c1163542da2038f7ff44b8409020c4f39 Mon Sep 17 00:00:00 2001 From: DoHanChoi Date: Fri, 31 Oct 2025 09:47:13 +0900 Subject: [PATCH 139/178] =?UTF-8?q?refactor:=20domain.user=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=A5=BC=20common.user=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 12 ++++++++++++ .../monew_api/{domain => common}/user/User.java | 2 +- .../user/controller/UserController.java | 6 +++--- .../{domain => common}/user/dto/UserDto.java | 2 +- .../user/dto/UserLoginRequest.java | 2 +- .../user/dto/UserRegisterRequest.java | 2 +- .../user/dto/UserUpdateRequest.java | 2 +- .../user/repository/UserRepository.java | 4 ++-- .../{domain => common}/user/service/UserService.java | 8 ++++---- 9 files changed, 26 insertions(+), 14 deletions(-) create mode 100644 .claude/settings.local.json rename monew-api/src/main/java/com/monew/monew_api/{domain => common}/user/User.java (96%) rename monew-api/src/main/java/com/monew/monew_api/{domain => common}/user/controller/UserController.java (94%) rename monew-api/src/main/java/com/monew/monew_api/{domain => common}/user/dto/UserDto.java (83%) rename monew-api/src/main/java/com/monew/monew_api/{domain => common}/user/dto/UserLoginRequest.java (92%) rename monew-api/src/main/java/com/monew/monew_api/{domain => common}/user/dto/UserRegisterRequest.java (95%) rename monew-api/src/main/java/com/monew/monew_api/{domain => common}/user/dto/UserUpdateRequest.java (91%) rename monew-api/src/main/java/com/monew/monew_api/{domain => common}/user/repository/UserRepository.java (81%) rename monew-api/src/main/java/com/monew/monew_api/{domain => common}/user/service/UserService.java (96%) diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3ee5b8a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:sprint-project-1196140422.ap-northeast-2.elb.amazonaws.com)", + "Bash(mkdir:*)", + "Bash(./gradlew clean build:*)", + "Bash(./gradlew:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/User.java b/monew-api/src/main/java/com/monew/monew_api/common/user/User.java similarity index 96% rename from monew-api/src/main/java/com/monew/monew_api/domain/user/User.java rename to monew-api/src/main/java/com/monew/monew_api/common/user/User.java index 945a156..2297ba9 100644 --- a/monew-api/src/main/java/com/monew/monew_api/domain/user/User.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/user/User.java @@ -1,4 +1,4 @@ -package com.monew.monew_api.domain.user; +package com.monew.monew_api.common.user; import com.monew.monew_api.common.entity.BaseTimeEntity; import jakarta.persistence.*; diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java b/monew-api/src/main/java/com/monew/monew_api/common/user/controller/UserController.java similarity index 94% rename from monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java rename to monew-api/src/main/java/com/monew/monew_api/common/user/controller/UserController.java index ab9a8eb..8db5ffb 100644 --- a/monew-api/src/main/java/com/monew/monew_api/domain/user/controller/UserController.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/user/controller/UserController.java @@ -1,7 +1,7 @@ -package com.monew.monew_api.domain.user.controller; +package com.monew.monew_api.common.user.controller; -import com.monew.monew_api.domain.user.dto.*; -import com.monew.monew_api.domain.user.service.UserService; +import com.monew.monew_api.common.user.dto.*; +import com.monew.monew_api.common.user.service.UserService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserDto.java b/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserDto.java similarity index 83% rename from monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserDto.java rename to monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserDto.java index 25fbdd0..90c7710 100644 --- a/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserDto.java @@ -1,4 +1,4 @@ -package com.monew.monew_api.domain.user.dto; +package com.monew.monew_api.common.user.dto; import lombok.Builder; import lombok.Getter; diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserLoginRequest.java b/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserLoginRequest.java similarity index 92% rename from monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserLoginRequest.java rename to monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserLoginRequest.java index a4b08bc..58e0ae6 100644 --- a/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserLoginRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserLoginRequest.java @@ -1,4 +1,4 @@ -package com.monew.monew_api.domain.user.dto; +package com.monew.monew_api.common.user.dto; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserRegisterRequest.java similarity index 95% rename from monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserRegisterRequest.java rename to monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserRegisterRequest.java index d6e9532..f90afcc 100644 --- a/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserRegisterRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserRegisterRequest.java @@ -1,4 +1,4 @@ -package com.monew.monew_api.domain.user.dto; +package com.monew.monew_api.common.user.dto; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserUpdateRequest.java b/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserUpdateRequest.java similarity index 91% rename from monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserUpdateRequest.java rename to monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserUpdateRequest.java index cde3a81..293e3fd 100644 --- a/monew-api/src/main/java/com/monew/monew_api/domain/user/dto/UserUpdateRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserUpdateRequest.java @@ -1,4 +1,4 @@ -package com.monew.monew_api.domain.user.dto; +package com.monew.monew_api.common.user.dto; import jakarta.validation.constraints.Size; import lombok.Getter; diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/repository/UserRepository.java b/monew-api/src/main/java/com/monew/monew_api/common/user/repository/UserRepository.java similarity index 81% rename from monew-api/src/main/java/com/monew/monew_api/domain/user/repository/UserRepository.java rename to monew-api/src/main/java/com/monew/monew_api/common/user/repository/UserRepository.java index 61d76a9..9e8402e 100644 --- a/monew-api/src/main/java/com/monew/monew_api/domain/user/repository/UserRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/user/repository/UserRepository.java @@ -1,6 +1,6 @@ -package com.monew.monew_api.domain.user.repository; +package com.monew.monew_api.common.user.repository; -import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.common.user.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; diff --git a/monew-api/src/main/java/com/monew/monew_api/domain/user/service/UserService.java b/monew-api/src/main/java/com/monew/monew_api/common/user/service/UserService.java similarity index 96% rename from monew-api/src/main/java/com/monew/monew_api/domain/user/service/UserService.java rename to monew-api/src/main/java/com/monew/monew_api/common/user/service/UserService.java index da31661..c1af8ff 100644 --- a/monew-api/src/main/java/com/monew/monew_api/domain/user/service/UserService.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/user/service/UserService.java @@ -1,11 +1,11 @@ -package com.monew.monew_api.domain.user.service; +package com.monew.monew_api.common.user.service; import com.monew.monew_api.common.exception.user.UserEmailDuplicateException; import com.monew.monew_api.common.exception.user.UserNotFoundException; import com.monew.monew_api.common.exception.user.UserUnauthorizedException; -import com.monew.monew_api.domain.user.User; -import com.monew.monew_api.domain.user.dto.*; -import com.monew.monew_api.domain.user.repository.UserRepository; +import com.monew.monew_api.common.user.User; +import com.monew.monew_api.common.user.dto.*; +import com.monew.monew_api.common.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; From e378ba4ced811fbe91b8fd5af525231b376a38f1 Mon Sep 17 00:00:00 2001 From: DoHanChoi Date: Mon, 3 Nov 2025 09:25:31 +0900 Subject: [PATCH 140/178] =?UTF-8?q?gfeat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B0=B0=EC=B9=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java # monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java --- .claude/settings.local.json | 12 -- .../user/controller/UserController.java | 12 +- monew-batch/build.gradle | 3 + .../com/monew/monew_batch/JobScheduler.java | 44 +++++++ .../monew_batch/MonewBatchApplication.java | 7 +- .../config/UserDeletionJobConfig.java | 110 ++++++++++++++++++ .../metrics/UserDeletionMetrics.java | 49 ++++++++ .../src/main/resources/application.yml | 13 ++- .../src/test/resources/application-test.yml | 37 ++++++ 9 files changed, 267 insertions(+), 20 deletions(-) delete mode 100644 .claude/settings.local.json create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/config/UserDeletionJobConfig.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/metrics/UserDeletionMetrics.java create mode 100644 monew-batch/src/test/resources/application-test.yml diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 3ee5b8a..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "permissions": { - "allow": [ - "WebFetch(domain:sprint-project-1196140422.ap-northeast-2.elb.amazonaws.com)", - "Bash(mkdir:*)", - "Bash(./gradlew clean build:*)", - "Bash(./gradlew:*)" - ], - "deny": [], - "ask": [] - } -} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/user/controller/UserController.java b/monew-api/src/main/java/com/monew/monew_api/common/user/controller/UserController.java index 8db5ffb..1c3afcc 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/user/controller/UserController.java +++ b/monew-api/src/main/java/com/monew/monew_api/common/user/controller/UserController.java @@ -44,19 +44,19 @@ public ResponseEntity updateUser( return ResponseEntity.ok(response); } - @DeleteMapping("/api/user/{userId}") + @DeleteMapping("/api/users/{userId}") public ResponseEntity softDeleteUser(@PathVariable Long userId) { - log.info("[API 요청] DELETE /api/user/{} - 사용자 삭제 요청", userId); + log.info("[API 요청] DELETE /api/users/{} - 사용자 삭제 요청", userId); userService.softDeleteUser(userId); - log.info("[API 응답] DELETE /api/user/{} - 사용자 삭제 성공", userId); + log.info("[API 응답] DELETE /api/users/{} - 사용자 삭제 성공", userId); return ResponseEntity.noContent().build(); } - @DeleteMapping("/api/user/{userId}/hard") + @DeleteMapping("/api/users/{userId}/hard") public ResponseEntity hardDeleteUser(@PathVariable Long userId) { - log.info("[API 요청] DELETE /api/user/{}/hard - 사용자 영구 삭제 요청", userId); + log.info("[API 요청] DELETE /api/users/{}/hard - 사용자 영구 삭제 요청", userId); userService.hardDeleteUser(userId); - log.info("[API 응답] DELETE /api/user/{}/hard - 사용자 영구 삭제 성공", userId); + log.info("[API 응답] DELETE /api/users/{}/hard - 사용자 영구 삭제 성공", userId); return ResponseEntity.noContent().build(); } } diff --git a/monew-batch/build.gradle b/monew-batch/build.gradle index 1eae30a..f9abbc6 100644 --- a/monew-batch/build.gradle +++ b/monew-batch/build.gradle @@ -3,6 +3,7 @@ plugins { } dependencies { + implementation project(':monew-api') implementation 'org.springframework.boot:spring-boot-starter-batch' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' @@ -17,4 +18,6 @@ dependencies { runtimeOnly 'com.h2database:h2' implementation 'org.mapstruct:mapstruct:1.6.3' annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' + + testImplementation 'org.springframework.batch:spring-batch-test' } \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java new file mode 100644 index 0000000..b5a5cfe --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java @@ -0,0 +1,44 @@ +package com.monew.monew_batch; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JobScheduler { + + private final JobLauncher jobLauncher; + private final Job userDeletionJob; + + /** + * [요구사항] Soft delete 후 1일 경과한 사용자를 영구 삭제 + * [프로토타입] 5초마다 체크하여 5분 경과한 사용자 삭제 + */ + @Scheduled(fixedDelay = 5000) + public void runUserDeletionJob() throws Exception { + log.info("==== Starting User Deletion Job ===="); + + JobParameters parameters = new JobParametersBuilder() + .addLong("runTime", System.currentTimeMillis()) + .toJobParameters(); + + JobExecution exec = jobLauncher.run(userDeletionJob, parameters); + + log.info("==== User Deletion Job Finished ===="); + log.info("Status : {}", exec.getStatus()); + log.info("Exit Status : {}", exec.getExitStatus()); + log.info("Job Instance ID : {}", exec.getJobId()); + log.info("Job Create Time : {}", exec.getCreateTime()); + log.info("Job End Time : {}", exec.getEndTime()); + log.info("Last Updated : {}", exec.getLastUpdated()); + log.info("Failure Exceptions: {}", exec.getFailureExceptions()); + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java index 1ec8c1b..4a45016 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java @@ -3,15 +3,20 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; -@SpringBootApplication +@SpringBootApplication(scanBasePackages = { + "com.monew.monew_batch", + "com.monew.monew_api" +}) @EntityScan(basePackages = "com.monew.monew_api") @EnableJpaRepositories(basePackages = "com.monew.monew_api") @EnableScheduling +@EnableJpaAuditing public class MonewBatchApplication { public static void main(String[] args) { diff --git a/monew-batch/src/main/java/com/monew/monew_batch/config/UserDeletionJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/config/UserDeletionJobConfig.java new file mode 100644 index 0000000..7af7436 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/config/UserDeletionJobConfig.java @@ -0,0 +1,110 @@ +package com.monew.monew_batch.config; + +import com.monew.monew_api.common.user.User; +import com.monew.monew_batch.metrics.UserDeletionMetrics; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import jakarta.persistence.EntityManager; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Map; + +/** + * [요구사항] Soft delete 후 1일(24시간) 경과한 사용자를 영구 삭제 + * [프로토타입] 테스트 환경에서는 5분 후 삭제로 구현 (빠른 테스트를 위함) + */ +@Slf4j +@Configuration +@EnableBatchProcessing +@RequiredArgsConstructor +public class UserDeletionJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final EntityManagerFactory entityManagerFactory; + private final UserDeletionMetrics userDeletionMetrics; + + @Value("${batch.user-deletion.chunk-size:10}") + private int chunkSize; + + // 프로토타입: 5분 + @Value("${batch.user-deletion.retention-minutes:5}") + private int retentionMinutes; + + /** + * 사용자 삭제 Job 정의 + */ + @Bean + public Job userDeletionJob(EntityManager entityManager) { + return new JobBuilder("userDeletionJob", jobRepository) + .start(userDeletionStep(entityManager)) + .build(); + } + + /** + * 사용자 삭제 Step 정의 (Chunk 기반 처리) + */ + @Bean + public Step userDeletionStep(EntityManager entityManager) { + return new StepBuilder("userDeletionStep", jobRepository) + .chunk(chunkSize, transactionManager) + .reader(userDeletionReader(null)) + .writer(userDeletionWriter(entityManager)) + .build(); + } + + /** + * ItemReader: 삭제 대상 사용자 조회 + * [프로토타입] 5분 이전 = deletedAt < (현재 - 5분) + */ + @Bean + @StepScope + public JpaPagingItemReader userDeletionReader( + @Value("#{jobParameters['runTime']}") Long runTime) { + LocalDateTime cutoffDate = LocalDateTime.now().minus(retentionMinutes, ChronoUnit.MINUTES); + + log.info("UserDeletionReader 초기화 - cutoffDate: {}, retentionMinutes: {}", cutoffDate, retentionMinutes); + + return new JpaPagingItemReaderBuilder() + .name("userDeletionReader") + .entityManagerFactory(entityManagerFactory) + .queryString("SELECT u FROM User u WHERE u.deletedAt IS NOT NULL AND u.deletedAt < :cutoffDate") + .parameterValues(Map.of("cutoffDate", cutoffDate)) + .pageSize(chunkSize) + .build(); + } + + /** + * ItemWriter: 사용자 영구 삭제 + * DB의 ON DELETE CASCADE로 연관 데이터 자동 삭제 + */ + @Bean + public ItemWriter userDeletionWriter(EntityManager entityManager) { + return chunk -> { + for (User user : chunk.getItems()) { + User managedUser = entityManager.merge(user); + entityManager.remove(managedUser); + + log.info("사용자 삭제: id={}, email={}", user.getId(), user.getEmail()); + userDeletionMetrics.incrementDeletedUserCount(); + } + log.info("청크 완료: {}명 삭제", chunk.size()); + }; + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/metrics/UserDeletionMetrics.java b/monew-batch/src/main/java/com/monew/monew_batch/metrics/UserDeletionMetrics.java new file mode 100644 index 0000000..f24e37f --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/metrics/UserDeletionMetrics.java @@ -0,0 +1,49 @@ +package com.monew.monew_batch.metrics; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 사용자 삭제 배치 작업에 대한 커스텀 메트릭 + * Spring Actuator를 통해 Prometheus/Grafana로 노출됩니다. + */ +@Slf4j +@Component +public class UserDeletionMetrics { + + private final Counter deletedUserCounter; + + public UserDeletionMetrics(MeterRegistry meterRegistry) { + this.deletedUserCounter = Counter.builder("user.deletion.count") + .description("Total number of permanently deleted users") + .tag("type", "batch") + .register(meterRegistry); + + log.info("UserDeletionMetrics initialized"); + } + + /** + * 삭제된 사용자 수 증가 + */ + public void incrementDeletedUserCount() { + deletedUserCounter.increment(); + log.debug("Deleted user count incremented: current={}", deletedUserCounter.count()); + } + + /** + * 삭제된 사용자 수를 지정된 값만큼 증가 + */ + public void incrementDeletedUserCount(long count) { + deletedUserCounter.increment(count); + log.debug("Deleted user count incremented by {}: current={}", count, deletedUserCounter.count()); + } + + /** + * 현재까지 삭제된 사용자 수 조회 + */ + public double getDeletedUserCount() { + return deletedUserCounter.count(); + } +} diff --git a/monew-batch/src/main/resources/application.yml b/monew-batch/src/main/resources/application.yml index 4ad3b40..c9396ab 100644 --- a/monew-batch/src/main/resources/application.yml +++ b/monew-batch/src/main/resources/application.yml @@ -12,14 +12,25 @@ spring: jdbc: initialize-schema: always +batch: + user-deletion: + chunk-size: 10 + # [요구사항] 1일 후 삭제 + # [프로토타입] 5분으로 설정 + retention-minutes: 5 + management: endpoints: web: exposure: - include: health,info,metrics + include: health,info,metrics,prometheus endpoint: health: show-details: always + prometheus: + metrics: + export: + enabled: true logging: level: diff --git a/monew-batch/src/test/resources/application-test.yml b/monew-batch/src/test/resources/application-test.yml new file mode 100644 index 0000000..67a1581 --- /dev/null +++ b/monew-batch/src/test/resources/application-test.yml @@ -0,0 +1,37 @@ +server: + port: 0 + +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + 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.PostgreSQLDialect + + batch: + job: + enabled: false + jdbc: + initialize-schema: always + +batch: + user-deletion: + chunk-size: 10 + retention-days: 30 + cron: "0 0 0 * * ?" + +logging: + level: + root: WARN + org.hibernate.SQL: DEBUG + org.springframework: WARN + org.springframework.batch: INFO From 8f5b655c00e703199d9702fc38b2218ad204c1e3 Mon Sep 17 00:00:00 2001 From: DoHanChoi Date: Mon, 3 Nov 2025 14:24:17 +0900 Subject: [PATCH 141/178] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B0=B0=EC=B9=98=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/config/DeletionJobConfig.java | 110 ++++++++++++++++++ .../user/metrics/DeletionMetrics.java | 49 ++++++++ .../user/scheduler/DeletionScheduler.java | 44 +++++++ 3 files changed, 203 insertions(+) create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/user/config/DeletionJobConfig.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/user/metrics/DeletionMetrics.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java diff --git a/monew-batch/src/main/java/com/monew/monew_batch/user/config/DeletionJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/user/config/DeletionJobConfig.java new file mode 100644 index 0000000..dd9ffc0 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/user/config/DeletionJobConfig.java @@ -0,0 +1,110 @@ +package com.monew.monew_batch.user.config; + +import com.monew.monew_api.user.entity.User; +import com.monew.monew_batch.user.metrics.DeletionMetrics; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import jakarta.persistence.EntityManager; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Map; + +/** + * [요구사항] Soft delete 후 1일(24시간) 경과한 사용자를 영구 삭제 + * [프로토타입] 테스트 환경에서는 5분 후 삭제로 구현 (빠른 테스트를 위함) + */ +@Slf4j +@Configuration +@EnableBatchProcessing +@RequiredArgsConstructor +public class DeletionJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final EntityManagerFactory entityManagerFactory; + private final DeletionMetrics deletionMetrics; + + @Value("${batch.user-deletion.chunk-size:10}") + private int chunkSize; + + // 프로토타입: 5분 + @Value("${batch.user-deletion.retention-minutes:5}") + private int retentionMinutes; + + /** + * 사용자 삭제 Job 정의 + */ + @Bean + public Job userDeletionJob(EntityManager entityManager) { + return new JobBuilder("userDeletionJob", jobRepository) + .start(userDeletionStep(entityManager)) + .build(); + } + + /** + * 사용자 삭제 Step 정의 (Chunk 기반 처리) + */ + @Bean + public Step userDeletionStep(EntityManager entityManager) { + return new StepBuilder("userDeletionStep", jobRepository) + .chunk(chunkSize, transactionManager) + .reader(userDeletionReader(null)) + .writer(userDeletionWriter(entityManager)) + .build(); + } + + /** + * ItemReader: 삭제 대상 사용자 조회 + * [프로토타입] 5분 이전 = deletedAt < (현재 - 5분) + */ + @Bean + @StepScope + public JpaPagingItemReader userDeletionReader( + @Value("#{jobParameters['runTime']}") Long runTime) { + LocalDateTime cutoffDate = LocalDateTime.now().minus(retentionMinutes, ChronoUnit.MINUTES); + + log.info("UserDeletionReader 초기화 - cutoffDate: {}, retentionMinutes: {}", cutoffDate, retentionMinutes); + + return new JpaPagingItemReaderBuilder() + .name("userDeletionReader") + .entityManagerFactory(entityManagerFactory) + .queryString("SELECT u FROM User u WHERE u.deletedAt IS NOT NULL AND u.deletedAt < :cutoffDate") + .parameterValues(Map.of("cutoffDate", cutoffDate)) + .pageSize(chunkSize) + .build(); + } + + /** + * ItemWriter: 사용자 영구 삭제 + * DB의 ON DELETE CASCADE로 연관 데이터 자동 삭제 + */ + @Bean + public ItemWriter userDeletionWriter(EntityManager entityManager) { + return chunk -> { + for (User user : chunk.getItems()) { + User managedUser = entityManager.merge(user); + entityManager.remove(managedUser); + + log.info("사용자 삭제: id={}, email={}", user.getId(), user.getEmail()); + deletionMetrics.incrementDeletedUserCount(); + } + log.info("청크 완료: {}명 삭제", chunk.size()); + }; + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/user/metrics/DeletionMetrics.java b/monew-batch/src/main/java/com/monew/monew_batch/user/metrics/DeletionMetrics.java new file mode 100644 index 0000000..39ccdde --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/user/metrics/DeletionMetrics.java @@ -0,0 +1,49 @@ +package com.monew.monew_batch.user.metrics; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 사용자 삭제 배치 작업에 대한 커스텀 메트릭 + * Spring Actuator를 통해 Prometheus/Grafana로 노출됩니다. + */ +@Slf4j +@Component +public class DeletionMetrics { + + private final Counter deletedUserCounter; + + public DeletionMetrics(MeterRegistry meterRegistry) { + this.deletedUserCounter = Counter.builder("user.deletion.count") + .description("Total number of permanently deleted users") + .tag("type", "batch") + .register(meterRegistry); + + log.info("DeletionMetrics initialized"); + } + + /** + * 삭제된 사용자 수 증가 + */ + public void incrementDeletedUserCount() { + deletedUserCounter.increment(); + log.debug("Deleted user count incremented: current={}", deletedUserCounter.count()); + } + + /** + * 삭제된 사용자 수를 지정된 값만큼 증가 + */ + public void incrementDeletedUserCount(long count) { + deletedUserCounter.increment(count); + log.debug("Deleted user count incremented by {}: current={}", count, deletedUserCounter.count()); + } + + /** + * 현재까지 삭제된 사용자 수 조회 + */ + public double getDeletedUserCount() { + return deletedUserCounter.count(); + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java new file mode 100644 index 0000000..9480e84 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java @@ -0,0 +1,44 @@ +package com.monew.monew_batch.user.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DeletionScheduler { + + private final JobLauncher jobLauncher; + private final Job userDeletionJob; + + /** + * [요구사항] Soft delete 후 1일 경과한 사용자를 영구 삭제 + * [프로토타입] 5초마다 체크하여 5분 경과한 사용자 삭제 + */ + @Scheduled(fixedDelay = 5000) + public void runUserDeletionJob() throws Exception { + log.info("==== Starting User Deletion Job ===="); + + JobParameters parameters = new JobParametersBuilder() + .addLong("runTime", System.currentTimeMillis()) + .toJobParameters(); + + JobExecution exec = jobLauncher.run(userDeletionJob, parameters); + + log.info("==== User Deletion Job Finished ===="); + log.info("Status : {}", exec.getStatus()); + log.info("Exit Status : {}", exec.getExitStatus()); + log.info("Job Instance ID : {}", exec.getJobId()); + log.info("Job Create Time : {}", exec.getCreateTime()); + log.info("Job End Time : {}", exec.getEndTime()); + log.info("Last Updated : {}", exec.getLastUpdated()); + log.info("Failure Exceptions: {}", exec.getFailureExceptions()); + } +} From d32c9df676d8625dc15102fa8cf4c4350f22a12f Mon Sep 17 00:00:00 2001 From: DoHanChoi Date: Mon, 3 Nov 2025 15:04:43 +0900 Subject: [PATCH 142/178] =?UTF-8?q?refactor:=20user=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/monew/monew_api/comments/entity/Comment.java | 2 +- .../monew/monew_api/comments/entity/CommentLike.java | 2 +- .../monew_api/comments/service/CommentService.java | 5 ++--- .../monew/monew_api/interest/entity/Interest.java | 2 -- .../interest/service/InterestServiceImpl.java | 4 ++-- .../monew_api/notification/entity/Notification.java | 2 +- .../notification/service/NotificationService.java | 4 ++-- .../monew/monew_api/subscribe/entity/Subscribe.java | 2 +- .../subscribe/repository/SubscribeRepository.java | 2 +- .../subscribe/service/SubscribeServiceImpl.java | 4 ++-- .../com/monew/monew_api/{common => }/user/User.java | 2 +- .../{common => }/user/controller/UserController.java | 10 +++++++--- .../monew_api/{common => }/user/dto/UserDto.java | 2 +- .../{common => }/user/dto/UserLoginRequest.java | 2 +- .../{common => }/user/dto/UserRegisterRequest.java | 2 +- .../{common => }/user/dto/UserUpdateRequest.java | 2 +- .../{common => }/user/repository/UserRepository.java | 4 ++-- .../{common => }/user/service/UserService.java | 12 ++++++++---- .../useractivity/mapper/UserActivityMapper.java | 2 +- .../repository/UserActivityRepositoryImpl.java | 2 +- .../service/Impl/UserActivityServiceImpl.java | 4 ++-- .../repository/NotificationRepositoryTest.java | 2 +- .../com/monew/monew_batch/MonewBatchApplication.java | 4 ++-- .../monew_batch/user/config/DeletionJobConfig.java | 2 +- 24 files changed, 43 insertions(+), 38 deletions(-) rename monew-api/src/main/java/com/monew/monew_api/{common => }/user/User.java (96%) rename monew-api/src/main/java/com/monew/monew_api/{common => }/user/controller/UserController.java (88%) rename monew-api/src/main/java/com/monew/monew_api/{common => }/user/dto/UserDto.java (83%) rename monew-api/src/main/java/com/monew/monew_api/{common => }/user/dto/UserLoginRequest.java (92%) rename monew-api/src/main/java/com/monew/monew_api/{common => }/user/dto/UserRegisterRequest.java (95%) rename monew-api/src/main/java/com/monew/monew_api/{common => }/user/dto/UserUpdateRequest.java (91%) rename monew-api/src/main/java/com/monew/monew_api/{common => }/user/repository/UserRepository.java (81%) rename monew-api/src/main/java/com/monew/monew_api/{common => }/user/service/UserService.java (93%) diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java index fe316bb..5a8889e 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java @@ -5,7 +5,7 @@ import com.monew.monew_api.article.entity.Article; import com.monew.monew_api.common.entity.BaseTimeEntity; -import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.user.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java index f0ba96f..4312e7c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java @@ -1,7 +1,7 @@ package com.monew.monew_api.comments.entity; import com.monew.monew_api.common.entity.BaseCreatedEntity; -import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.user.User; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index 49a053f..6cf8ba4 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -10,9 +10,8 @@ import com.monew.monew_api.comments.repository.CommentLikeRepository; import com.monew.monew_api.comments.repository.CommentRepository; import com.monew.monew_api.common.exception.comment.*; -import com.monew.monew_api.domain.user.User; -import com.monew.monew_api.domain.user.repository.UserRepository; - +import com.monew.monew_api.user.User; +import com.monew.monew_api.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java index 0b562a3..9eb5174 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java @@ -1,8 +1,6 @@ package com.monew.monew_api.interest.entity; import com.monew.monew_api.common.entity.BaseTimeEntity; -import com.monew.monew_api.domain.user.User; -import com.monew.monew_api.subscribe.entity.Subscribe; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index 18174d3..52095a8 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -3,8 +3,8 @@ import com.monew.monew_api.common.exception.interest.InterestDuplicatedException; import com.monew.monew_api.common.exception.interest.InterestNotFoundException; import com.monew.monew_api.common.exception.user.UserNotFoundException; -import com.monew.monew_api.domain.user.User; -import com.monew.monew_api.domain.user.repository.UserRepository; +import com.monew.monew_api.user.User; +import com.monew.monew_api.user.repository.UserRepository; import com.monew.monew_api.interest.dto.InterestOrderBy; import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; import com.monew.monew_api.interest.dto.request.InterestRegisterRequest; diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java b/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java index 5ae3670..1692621 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java @@ -1,7 +1,7 @@ package com.monew.monew_api.notification.entity; import com.monew.monew_api.common.entity.BaseTimeEntity; -import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.user.User; import com.monew.monew_api.notification.enums.ResourceType; import jakarta.persistence.*; import lombok.AccessLevel; diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java index 86590f7..c7abfbd 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java @@ -5,8 +5,8 @@ import com.monew.monew_api.common.exception.notification.NotificationAccessDeniedException; import com.monew.monew_api.common.exception.notification.NotificationAlreadyConfirmedException; import com.monew.monew_api.common.exception.notification.NotificationNotFoundException; -import com.monew.monew_api.domain.user.User; -import com.monew.monew_api.domain.user.repository.UserRepository; +import com.monew.monew_api.user.User; +import com.monew.monew_api.user.repository.UserRepository; import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest; import com.monew.monew_api.notification.dto.response.NotificationDto; import com.monew.monew_api.notification.entity.Notification; diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java index 5763252..8cc5a4f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java @@ -1,7 +1,7 @@ package com.monew.monew_api.subscribe.entity; import com.monew.monew_api.common.entity.BaseCreatedEntity; -import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.user.User; import com.monew.monew_api.interest.entity.Interest; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java index c9864d7..1cd54ae 100644 --- a/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java @@ -1,6 +1,6 @@ package com.monew.monew_api.subscribe.repository; -import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.user.User; import com.monew.monew_api.interest.entity.Interest; import com.monew.monew_api.subscribe.entity.Subscribe; import java.util.List; diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java index 7937956..ecaf523 100644 --- a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java @@ -4,8 +4,8 @@ import com.monew.monew_api.common.exception.subscribe.SubscribeDuplicateException; import com.monew.monew_api.common.exception.subscribe.SubscribeNotFoundException; import com.monew.monew_api.common.exception.user.UserNotFoundException; -import com.monew.monew_api.domain.user.User; -import com.monew.monew_api.domain.user.repository.UserRepository; +import com.monew.monew_api.user.User; +import com.monew.monew_api.user.repository.UserRepository; import com.monew.monew_api.interest.entity.Interest; import com.monew.monew_api.interest.repository.InterestRepository; import com.monew.monew_api.subscribe.dto.SubscribeDto; diff --git a/monew-api/src/main/java/com/monew/monew_api/common/user/User.java b/monew-api/src/main/java/com/monew/monew_api/user/User.java similarity index 96% rename from monew-api/src/main/java/com/monew/monew_api/common/user/User.java rename to monew-api/src/main/java/com/monew/monew_api/user/User.java index 2297ba9..85199a5 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/user/User.java +++ b/monew-api/src/main/java/com/monew/monew_api/user/User.java @@ -1,4 +1,4 @@ -package com.monew.monew_api.common.user; +package com.monew.monew_api.user; import com.monew.monew_api.common.entity.BaseTimeEntity; import jakarta.persistence.*; diff --git a/monew-api/src/main/java/com/monew/monew_api/common/user/controller/UserController.java b/monew-api/src/main/java/com/monew/monew_api/user/controller/UserController.java similarity index 88% rename from monew-api/src/main/java/com/monew/monew_api/common/user/controller/UserController.java rename to monew-api/src/main/java/com/monew/monew_api/user/controller/UserController.java index 1c3afcc..7c4bd3a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/user/controller/UserController.java +++ b/monew-api/src/main/java/com/monew/monew_api/user/controller/UserController.java @@ -1,7 +1,11 @@ -package com.monew.monew_api.common.user.controller; +package com.monew.monew_api.user.controller; -import com.monew.monew_api.common.user.dto.*; -import com.monew.monew_api.common.user.service.UserService; +import com.monew.monew_api.user.dto.*; +import com.monew.monew_api.user.dto.UserDto; +import com.monew.monew_api.user.dto.UserLoginRequest; +import com.monew.monew_api.user.dto.UserRegisterRequest; +import com.monew.monew_api.user.dto.UserUpdateRequest; +import com.monew.monew_api.user.service.UserService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserDto.java b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserDto.java similarity index 83% rename from monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserDto.java rename to monew-api/src/main/java/com/monew/monew_api/user/dto/UserDto.java index 90c7710..d229cce 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserDto.java @@ -1,4 +1,4 @@ -package com.monew.monew_api.common.user.dto; +package com.monew.monew_api.user.dto; import lombok.Builder; import lombok.Getter; diff --git a/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserLoginRequest.java b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserLoginRequest.java similarity index 92% rename from monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserLoginRequest.java rename to monew-api/src/main/java/com/monew/monew_api/user/dto/UserLoginRequest.java index 58e0ae6..1dc3cb0 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserLoginRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserLoginRequest.java @@ -1,4 +1,4 @@ -package com.monew.monew_api.common.user.dto; +package com.monew.monew_api.user.dto; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserRegisterRequest.java similarity index 95% rename from monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserRegisterRequest.java rename to monew-api/src/main/java/com/monew/monew_api/user/dto/UserRegisterRequest.java index f90afcc..6db35ef 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserRegisterRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserRegisterRequest.java @@ -1,4 +1,4 @@ -package com.monew.monew_api.common.user.dto; +package com.monew.monew_api.user.dto; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserUpdateRequest.java b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserUpdateRequest.java similarity index 91% rename from monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserUpdateRequest.java rename to monew-api/src/main/java/com/monew/monew_api/user/dto/UserUpdateRequest.java index 293e3fd..6dd9bbb 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/user/dto/UserUpdateRequest.java +++ b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserUpdateRequest.java @@ -1,4 +1,4 @@ -package com.monew.monew_api.common.user.dto; +package com.monew.monew_api.user.dto; import jakarta.validation.constraints.Size; import lombok.Getter; diff --git a/monew-api/src/main/java/com/monew/monew_api/common/user/repository/UserRepository.java b/monew-api/src/main/java/com/monew/monew_api/user/repository/UserRepository.java similarity index 81% rename from monew-api/src/main/java/com/monew/monew_api/common/user/repository/UserRepository.java rename to monew-api/src/main/java/com/monew/monew_api/user/repository/UserRepository.java index 9e8402e..ccacd5a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/user/repository/UserRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/user/repository/UserRepository.java @@ -1,6 +1,6 @@ -package com.monew.monew_api.common.user.repository; +package com.monew.monew_api.user.repository; -import com.monew.monew_api.common.user.User; +import com.monew.monew_api.user.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; diff --git a/monew-api/src/main/java/com/monew/monew_api/common/user/service/UserService.java b/monew-api/src/main/java/com/monew/monew_api/user/service/UserService.java similarity index 93% rename from monew-api/src/main/java/com/monew/monew_api/common/user/service/UserService.java rename to monew-api/src/main/java/com/monew/monew_api/user/service/UserService.java index c1af8ff..d70127e 100644 --- a/monew-api/src/main/java/com/monew/monew_api/common/user/service/UserService.java +++ b/monew-api/src/main/java/com/monew/monew_api/user/service/UserService.java @@ -1,11 +1,15 @@ -package com.monew.monew_api.common.user.service; +package com.monew.monew_api.user.service; import com.monew.monew_api.common.exception.user.UserEmailDuplicateException; import com.monew.monew_api.common.exception.user.UserNotFoundException; import com.monew.monew_api.common.exception.user.UserUnauthorizedException; -import com.monew.monew_api.common.user.User; -import com.monew.monew_api.common.user.dto.*; -import com.monew.monew_api.common.user.repository.UserRepository; +import com.monew.monew_api.user.User; +import com.monew.monew_api.user.dto.*; +import com.monew.monew_api.user.dto.UserDto; +import com.monew.monew_api.user.dto.UserLoginRequest; +import com.monew.monew_api.user.dto.UserRegisterRequest; +import com.monew.monew_api.user.dto.UserUpdateRequest; +import com.monew.monew_api.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java index f87e608..26cc180 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java @@ -2,7 +2,7 @@ import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; -import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.user.User; import com.monew.monew_api.interest.entity.Interest; import com.monew.monew_api.subscribe.entity.Subscribe; import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java index 5ccc5db..bc6a253 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java @@ -22,7 +22,7 @@ import static com.monew.monew_api.article.entity.QArticleView.articleView; import static com.monew.monew_api.comments.entity.QComment.comment; import static com.monew.monew_api.comments.entity.QCommentLike.commentLike; -import static com.monew.monew_api.domain.user.QUser.user; +import static com.monew.monew_api.user.QUser.user; import static com.monew.monew_api.interest.entity.QInterest.interest; import static com.monew.monew_api.interest.entity.QInterestKeyword.interestKeyword; import static com.monew.monew_api.subscribe.entity.QSubscribe.subscribe; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java index 32bff8b..71cb437 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java @@ -4,8 +4,8 @@ import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.common.exception.user.UserNotFoundException; -import com.monew.monew_api.domain.user.User; -import com.monew.monew_api.domain.user.repository.UserRepository; +import com.monew.monew_api.user.User; +import com.monew.monew_api.user.repository.UserRepository; import com.monew.monew_api.subscribe.entity.Subscribe; import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; diff --git a/monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java b/monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java index 6a8c3f0..6e254ee 100644 --- a/monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java +++ b/monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java @@ -1,7 +1,7 @@ package com.monew.monew_api.notification.repository; import com.monew.monew_api.common.config.QuerydslConfig; -import com.monew.monew_api.domain.user.User; +import com.monew.monew_api.user.User; import com.monew.monew_api.notification.entity.Notification; import com.monew.monew_api.notification.enums.ResourceType; import jakarta.persistence.EntityManager; diff --git a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java index 4a45016..da00444 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java @@ -10,8 +10,8 @@ import java.util.TimeZone; @SpringBootApplication(scanBasePackages = { - "com.monew.monew_batch", - "com.monew.monew_api" + "com.monew.monew_batch" +// "com.monew.monew_api" }) @EntityScan(basePackages = "com.monew.monew_api") @EnableJpaRepositories(basePackages = "com.monew.monew_api") diff --git a/monew-batch/src/main/java/com/monew/monew_batch/user/config/DeletionJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/user/config/DeletionJobConfig.java index dd9ffc0..0c96422 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/user/config/DeletionJobConfig.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/user/config/DeletionJobConfig.java @@ -1,6 +1,6 @@ package com.monew.monew_batch.user.config; -import com.monew.monew_api.user.entity.User; +import com.monew.monew_api.user.User; import com.monew.monew_batch.user.metrics.DeletionMetrics; import jakarta.persistence.EntityManagerFactory; import lombok.RequiredArgsConstructor; From 759005e19eee863410f0395fcc0886fefd3a8fe1 Mon Sep 17 00:00:00 2001 From: DoHanChoi Date: Mon, 3 Nov 2025 15:05:03 +0900 Subject: [PATCH 143/178] =?UTF-8?q?refactor:=20user=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/monew/monew_batch/JobScheduler.java | 44 ------- .../config/UserDeletionJobConfig.java | 110 ------------------ .../metrics/UserDeletionMetrics.java | 49 -------- 3 files changed, 203 deletions(-) delete mode 100644 monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java delete mode 100644 monew-batch/src/main/java/com/monew/monew_batch/config/UserDeletionJobConfig.java delete mode 100644 monew-batch/src/main/java/com/monew/monew_batch/metrics/UserDeletionMetrics.java diff --git a/monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java deleted file mode 100644 index b5a5cfe..0000000 --- a/monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.monew.monew_batch; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.JobExecution; -import org.springframework.batch.core.JobParameters; -import org.springframework.batch.core.JobParametersBuilder; -import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -@RequiredArgsConstructor -public class JobScheduler { - - private final JobLauncher jobLauncher; - private final Job userDeletionJob; - - /** - * [요구사항] Soft delete 후 1일 경과한 사용자를 영구 삭제 - * [프로토타입] 5초마다 체크하여 5분 경과한 사용자 삭제 - */ - @Scheduled(fixedDelay = 5000) - public void runUserDeletionJob() throws Exception { - log.info("==== Starting User Deletion Job ===="); - - JobParameters parameters = new JobParametersBuilder() - .addLong("runTime", System.currentTimeMillis()) - .toJobParameters(); - - JobExecution exec = jobLauncher.run(userDeletionJob, parameters); - - log.info("==== User Deletion Job Finished ===="); - log.info("Status : {}", exec.getStatus()); - log.info("Exit Status : {}", exec.getExitStatus()); - log.info("Job Instance ID : {}", exec.getJobId()); - log.info("Job Create Time : {}", exec.getCreateTime()); - log.info("Job End Time : {}", exec.getEndTime()); - log.info("Last Updated : {}", exec.getLastUpdated()); - log.info("Failure Exceptions: {}", exec.getFailureExceptions()); - } -} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/config/UserDeletionJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/config/UserDeletionJobConfig.java deleted file mode 100644 index 7af7436..0000000 --- a/monew-batch/src/main/java/com/monew/monew_batch/config/UserDeletionJobConfig.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.monew.monew_batch.config; - -import com.monew.monew_api.common.user.User; -import com.monew.monew_batch.metrics.UserDeletionMetrics; -import jakarta.persistence.EntityManagerFactory; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.Step; -import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; -import org.springframework.batch.core.job.builder.JobBuilder; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.batch.item.ItemWriter; -import org.springframework.batch.item.database.JpaPagingItemReader; -import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; -import org.springframework.batch.core.configuration.annotation.StepScope; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; -import jakarta.persistence.EntityManager; - -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import java.util.Map; - -/** - * [요구사항] Soft delete 후 1일(24시간) 경과한 사용자를 영구 삭제 - * [프로토타입] 테스트 환경에서는 5분 후 삭제로 구현 (빠른 테스트를 위함) - */ -@Slf4j -@Configuration -@EnableBatchProcessing -@RequiredArgsConstructor -public class UserDeletionJobConfig { - - private final JobRepository jobRepository; - private final PlatformTransactionManager transactionManager; - private final EntityManagerFactory entityManagerFactory; - private final UserDeletionMetrics userDeletionMetrics; - - @Value("${batch.user-deletion.chunk-size:10}") - private int chunkSize; - - // 프로토타입: 5분 - @Value("${batch.user-deletion.retention-minutes:5}") - private int retentionMinutes; - - /** - * 사용자 삭제 Job 정의 - */ - @Bean - public Job userDeletionJob(EntityManager entityManager) { - return new JobBuilder("userDeletionJob", jobRepository) - .start(userDeletionStep(entityManager)) - .build(); - } - - /** - * 사용자 삭제 Step 정의 (Chunk 기반 처리) - */ - @Bean - public Step userDeletionStep(EntityManager entityManager) { - return new StepBuilder("userDeletionStep", jobRepository) - .chunk(chunkSize, transactionManager) - .reader(userDeletionReader(null)) - .writer(userDeletionWriter(entityManager)) - .build(); - } - - /** - * ItemReader: 삭제 대상 사용자 조회 - * [프로토타입] 5분 이전 = deletedAt < (현재 - 5분) - */ - @Bean - @StepScope - public JpaPagingItemReader userDeletionReader( - @Value("#{jobParameters['runTime']}") Long runTime) { - LocalDateTime cutoffDate = LocalDateTime.now().minus(retentionMinutes, ChronoUnit.MINUTES); - - log.info("UserDeletionReader 초기화 - cutoffDate: {}, retentionMinutes: {}", cutoffDate, retentionMinutes); - - return new JpaPagingItemReaderBuilder() - .name("userDeletionReader") - .entityManagerFactory(entityManagerFactory) - .queryString("SELECT u FROM User u WHERE u.deletedAt IS NOT NULL AND u.deletedAt < :cutoffDate") - .parameterValues(Map.of("cutoffDate", cutoffDate)) - .pageSize(chunkSize) - .build(); - } - - /** - * ItemWriter: 사용자 영구 삭제 - * DB의 ON DELETE CASCADE로 연관 데이터 자동 삭제 - */ - @Bean - public ItemWriter userDeletionWriter(EntityManager entityManager) { - return chunk -> { - for (User user : chunk.getItems()) { - User managedUser = entityManager.merge(user); - entityManager.remove(managedUser); - - log.info("사용자 삭제: id={}, email={}", user.getId(), user.getEmail()); - userDeletionMetrics.incrementDeletedUserCount(); - } - log.info("청크 완료: {}명 삭제", chunk.size()); - }; - } -} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/metrics/UserDeletionMetrics.java b/monew-batch/src/main/java/com/monew/monew_batch/metrics/UserDeletionMetrics.java deleted file mode 100644 index f24e37f..0000000 --- a/monew-batch/src/main/java/com/monew/monew_batch/metrics/UserDeletionMetrics.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.monew.monew_batch.metrics; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -/** - * 사용자 삭제 배치 작업에 대한 커스텀 메트릭 - * Spring Actuator를 통해 Prometheus/Grafana로 노출됩니다. - */ -@Slf4j -@Component -public class UserDeletionMetrics { - - private final Counter deletedUserCounter; - - public UserDeletionMetrics(MeterRegistry meterRegistry) { - this.deletedUserCounter = Counter.builder("user.deletion.count") - .description("Total number of permanently deleted users") - .tag("type", "batch") - .register(meterRegistry); - - log.info("UserDeletionMetrics initialized"); - } - - /** - * 삭제된 사용자 수 증가 - */ - public void incrementDeletedUserCount() { - deletedUserCounter.increment(); - log.debug("Deleted user count incremented: current={}", deletedUserCounter.count()); - } - - /** - * 삭제된 사용자 수를 지정된 값만큼 증가 - */ - public void incrementDeletedUserCount(long count) { - deletedUserCounter.increment(count); - log.debug("Deleted user count incremented by {}: current={}", count, deletedUserCounter.count()); - } - - /** - * 현재까지 삭제된 사용자 수 조회 - */ - public double getDeletedUserCount() { - return deletedUserCounter.count(); - } -} From c8e2ffa91018f64e6c3ef0b634e9d88ad47c8063 Mon Sep 17 00:00:00 2001 From: Moonyoung Kang Date: Mon, 3 Nov 2025 15:13:44 +0900 Subject: [PATCH 144/178] =?UTF-8?q?Revert=20"[feat]=20mongoDB=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=9C=20=EC=BA=90=EC=8B=9C=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/event/ArticleViewedEvent.java | 61 ---- .../article/service/ArticleService.java | 16 - .../event/CommentContentEditedEvent.java | 13 - .../comments/event/CommentCreatedEvent.java | 59 +--- .../comments/event/CommentDeletedEvent.java | 18 - .../comments/event/CommentLikedEvent.java | 61 +--- .../comments/event/CommentUnlikedEvent.java | 20 -- .../comments/service/CommentService.java | 97 +++--- .../interest/event/InterestDeletedEvent.java | 17 - .../interest/event/InterestUpdatedEvent.java | 21 -- .../repository/InterestRepository.java | 8 - .../interest/service/InterestServiceImpl.java | 19 -- .../event/SubscriptionAddedEvent.java | 36 -- .../event/SubscriptionRemovedEvent.java | 20 -- .../service/SubscribeServiceImpl.java | 24 +- .../controller/UserActivityController.java | 15 +- .../document/ReverseIndexDocument.java | 78 ----- .../document/UserActivityCacheDocument.java | 4 +- .../dto/CommentLikeActivityDto.java | 2 +- .../useractivity/dto/UserActivityDto.java | 5 +- .../useractivity/event/CacheSaveEvent.java | 16 - .../listener/ArticleViewEventListener.java | 56 ---- .../listener/CacheSaveEventListener.java | 37 --- .../CommentContentEditedEventListener.java | 25 -- .../listener/CommentCreateEventListener.java | 53 --- .../listener/CommentDeletedEventListener.java | 33 -- .../listener/CommentLikedEventListener.java | 43 --- .../listener/CommentUnlikedEventListener.java | 31 -- .../InterestDeletedEventListener.java | 25 -- .../listener/InterestUpdateEventListener.java | 37 --- .../SubscriptionAddedEventListener.java | 34 -- .../SubscriptionRemovedEventListener.java | 26 -- .../mapper/UserActivityDocumentMapper.java | 12 - .../mapper/UserActivityMapper.java | 4 + .../Impl/ReverseIndexRepositoryImpl.java | 50 --- .../Impl/UserActivityCacheRepositoryImpl.java | 197 ----------- .../ReverseIndexCustomRepository.java | 11 - .../repository/ReverseIndexRepository.java | 7 - .../UserActivityCacheCustomRepository.java | 38 --- .../UserActivityCacheRepository.java | 2 +- .../UserActivityRepositoryImpl.java | 3 +- .../service/CacheUpdateService.java | 126 ------- .../service/Impl/CacheUpdateServiceImpl.java | 314 ------------------ .../service/Impl/ReverseIndexServiceImpl.java | 69 ---- .../Impl/UserActivityCacheServiceImpl.java | 61 ---- .../service/Impl/UserActivityServiceImpl.java | 59 +++- .../service/ReverseIndexService.java | 11 - .../service/UserActivityCacheService.java | 11 - .../service/UserActivityService.java | 6 + 49 files changed, 116 insertions(+), 1875 deletions(-) delete mode 100644 monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java rename monew-api/src/main/java/com/monew/monew_api/useractivity/repository/{Impl => }/UserActivityRepositoryImpl.java (98%) delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java delete mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java diff --git a/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java b/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java deleted file mode 100644 index f18bcdc..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.monew.monew_api.article.event; - -import java.time.LocalDateTime; - -/** - * 기사 조회 이벤트 - * 사용자가 기사를 조회했을 때 발행 - * Update 전략 사용 - * @param viewId - * @param userId - * @param createdAt - * @param articleId - * @param source - * @param sourceUrl - * @param articleTitle - * @param articlePublishedDate - * @param articleSummary - * @param articleCommentCount - * @param articleViewCount - * @param occurredAt - */ -public record ArticleViewedEvent( - Long viewId, - Long userId, - LocalDateTime createdAt, - Long articleId, - String source, - String sourceUrl, - String articleTitle, - LocalDateTime articlePublishedDate, - String articleSummary, - Integer articleCommentCount, - Integer articleViewCount, - LocalDateTime occurredAt -) { - public static ArticleViewedEvent of( - Long id, - Long userId, - LocalDateTime createdAt, - Long articleId, - String source, - String sourceUrl, - String articleTitle, - LocalDateTime articlePublishedDate, - String articleSummary, - Integer articleCommentCount, - Integer articleViewCount - ) { - return new ArticleViewedEvent( - id, userId, createdAt, articleId, - source, sourceUrl, articleTitle, - articlePublishedDate, articleSummary, - articleCommentCount, articleViewCount, - LocalDateTime.now() - ); - } - - public Integer getDelta() { - return +1; - } -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java index 61223c6..95c1c03 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java @@ -9,10 +9,8 @@ import com.monew.monew_api.article.repository.ArticleRepository; import com.monew.monew_api.article.repository.ArticleViewRepository; import com.monew.monew_api.common.exception.article.ArticleNotFoundException; -import com.monew.monew_api.article.event.ArticleViewedEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,7 +25,6 @@ public class ArticleService { private final ArticleRepository articleRepository; private final ArticleViewRepository articleViewRepository; - private final ApplicationEventPublisher eventPublisher; /** * 기사 조회 기록 등록 @@ -66,19 +63,6 @@ public ArticleViewDto recordArticleView(Long articleId, Long userId) { ArticleView articleView = new ArticleView(userId, articleId); ArticleView saved = articleViewRepository.save(articleView); article.increaseViewCount(); - eventPublisher.publishEvent( - ArticleViewedEvent.of( - saved.getId(), - saved.getUserId(), - saved.getCreatedAt(), - saved.getArticleId(), - article.getSource(), - article.getSourceUrl(), - article.getTitle(), - article.getPublishDate(), - article.getSummary(), - article.getCommentCount(), - article.getViewCount())); log.info("[조회 기록 성공] 기사 ID: {}, 사용자 ID: {}", articleId, userId); return ArticleViewDto.builder() diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java deleted file mode 100644 index bfe89bc..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.monew.monew_api.comments.event; - -import java.time.LocalDateTime; - -public record CommentContentEditedEvent( - Long commentId, - String newContent, - LocalDateTime occurredAt -) { - public static CommentContentEditedEvent of(Long commentId, String newContent) { - return new CommentContentEditedEvent(commentId, newContent, LocalDateTime.now()); - } -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java index 4705aa3..c673b30 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java @@ -2,57 +2,8 @@ import java.time.LocalDateTime; -/** - * 댓글 작성 이벤트 - * 사용자가 기사에 댓글을 작성했을 때 발행 - * 기사의 commentCount +1 - * Update 전략 사용 - * @param commentId - * @param articleId - * @param articleTitle - * @param userId - * @param userNickname - * @param content - * @param likeCount - * @param createdAt - * @param occurredAt - */ -public record CommentCreatedEvent( - Long commentId, - Long articleId, - String articleTitle, - Long userId, - String userNickname, - String content, - Integer likeCount, - LocalDateTime createdAt, - LocalDateTime occurredAt -) { - - public static CommentCreatedEvent of( - Long commentId, - Long articleId, - String articleTitle, - Long userId, - String userNickname, - String content, - Integer likeCount, - LocalDateTime createdAt - ) { - return new CommentCreatedEvent( - commentId, - articleId, - articleTitle, - userId, - userNickname, - content, - likeCount, - createdAt, - LocalDateTime.now() - ); - } - - public Integer getDelta() { - return 1; - } -} \ No newline at end of file +public record CommentCreatedEvent(Long commentId, + Long userId, + Long articleId, + LocalDateTime createdAt) { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java deleted file mode 100644 index 260a045..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.monew.monew_api.comments.event; - -import java.time.LocalDateTime; - -/** - * 댓글 삭제 이벤트 - * 댓글이 삭제되었을 때 발행 - * @param commentId - * @param occurredAt - */ -public record CommentDeletedEvent( - Long commentId, - LocalDateTime occurredAt -) { - public static CommentDeletedEvent of(Long commentId) { - return new CommentDeletedEvent(commentId, LocalDateTime.now()); - } -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java index 1fd8f3a..7d9f4ad 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java @@ -1,59 +1,6 @@ package com.monew.monew_api.comments.event; -import java.time.LocalDateTime; - -/** - * 댓글 좋아요/취소 이벤트 - * 사용자가 댓글에 좋아요를 누르거나 취소했을 때 발행 - * Update 전략 사용 - * @param likeId - * @param likeCreatedAt - * @param commentId - * @param articleId - * @param articleTitle - * @param commentAuthorId - * @param commentUserNickname - * @param commentContent - * @param commentLikeCount - * @param commentCreatedAt - * @param likedByUserId - * @param likerNickname - * @param occurredAt - */ -public record CommentLikedEvent( - Long likeId, - LocalDateTime likeCreatedAt, - Long commentId, - Long articleId, - String articleTitle, - Long commentAuthorId, - String commentUserNickname, - String commentContent, - Integer commentLikeCount, - LocalDateTime commentCreatedAt, - Long likedByUserId, - String likerNickname, - LocalDateTime occurredAt -) { - public static CommentLikedEvent of( - Long likeId, - LocalDateTime likeCreatedAt, - Long commentId, - Long articleId, - String articleTitle, - Long commentAuthorId, - String commentUserNickname, - String commentContent, - Integer commentLikeCount, - LocalDateTime commentCreatedAt, - Long likedByUserId, - String likerNickname - ) { - return new CommentLikedEvent( - likeId, likeCreatedAt, commentId, articleId, articleTitle, - commentAuthorId, commentUserNickname, commentContent, - commentLikeCount, commentCreatedAt, likedByUserId, likerNickname, - LocalDateTime.now() - ); - } -} \ No newline at end of file +public record CommentLikedEvent(Long commentId, + Long commentAuthorId, + String likerNickname) { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java deleted file mode 100644 index 9d0a731..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.monew.monew_api.comments.event; - -import java.time.LocalDateTime; - -/** - * 댓글 좋아요 취소 이벤트 - * 사용자가 댓글 좋아요를 취소했을 때 발행 - * @param commentId - * @param likedByUserId - * @param occurredAt - */ -public record CommentUnlikedEvent( - Long commentId, - Long likedByUserId, - LocalDateTime occurredAt -) { - public static CommentUnlikedEvent of(Long commentId, Long likedByUserId) { - return new CommentUnlikedEvent(commentId, likedByUserId, LocalDateTime.now()); - } -} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index 5ca361c..49a053f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -5,14 +5,17 @@ import com.monew.monew_api.comments.dto.*; import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; -import com.monew.monew_api.comments.event.*; +import com.monew.monew_api.comments.event.CommentCreatedEvent; +import com.monew.monew_api.comments.event.CommentLikedEvent; import com.monew.monew_api.comments.repository.CommentLikeRepository; import com.monew.monew_api.comments.repository.CommentRepository; import com.monew.monew_api.common.exception.comment.*; import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; + import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,32 +35,24 @@ public class CommentService { // 댓글 작성 @Transactional public CommentDto register(CommentRegisterRequest request) { - log.info("[COMMENT][CREATE][START] userId={}, articleId={}", request.userId(), request.articleId()); - User user = getUserById(request.userId()); - Article article = getArticleById(request.articleId()); - - log.info("[COMMENT_COUNT] 댓글 작성 전 카운트: {}", article.getCommentCount()); - Comment saved = commentRepository.save(Comment.of(user, article, request.content())); - log.info("[COMMENT][CREATE] userId={}, articleId={}, commentId={}", - user.getId(), article.getId(), saved.getId()); - - article.increaseCommentCount(); - articleRepository.save(article); - - eventPublisher.publishEvent( - CommentCreatedEvent.of( - saved.getId(), - saved.getArticleId(), - article.getTitle(), - saved.getUserId(), - user.getNickname(), - saved.getContent(), - saved.getLikeCount(), - saved.getCreatedAt()) - ); - - log.info("[COMMENT_COUNT] 댓글 작성 후 카운트: {}", article.getCommentCount()); - return CommentDto.from(saved, false); + log.info("[COMMENT][CREATE][START] userId={}, articleId={}", request.userId(), request.articleId()); + User user = getUserById(request.userId()); + Article article = getArticleById(request.articleId()); + + log.info("[COMMENT_COUNT] 댓글 작성 전 카운트: {}", article.getCommentCount()); + Comment saved = commentRepository.save(Comment.of(user, article, request.content())); + log.info("[COMMENT][CREATE] userId={}, articleId={}, commentId={}", + user.getId(), article.getId(), saved.getId()); + + eventPublisher.publishEvent( + new CommentCreatedEvent(saved.getId(), user.getId(), article.getId(), saved.getCreatedAt()) + ); + + article.increaseCommentCount(); + articleRepository.save(article); + + log.info("[COMMENT_COUNT] 댓글 작성 후 카운트: {}", article.getCommentCount()); + return CommentDto.from(saved, false); } // 댓글 수정 @@ -72,9 +67,6 @@ public CommentDto update(Long userId, Long commentId, CommentUpdateRequest reque userId, commentId, request.content().length()); boolean likedByMe = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, userId); - - eventPublisher.publishEvent(CommentContentEditedEvent.of(commentId, request.content())); - return CommentDto.from(comment, likedByMe); } @@ -89,19 +81,8 @@ public CommentLikeDto like(Long userId, Long commentId) { comment.increaseLike(); eventPublisher.publishEvent( - CommentLikedEvent.of( - saved.getId(), - saved.getCreatedAt(), - commentId, - comment.getArticle().getId(), - comment.getArticle().getTitle(), - comment.getUserId(), - comment.getUser().getNickname(), - comment.getContent(), - comment.getLikeCount(), - comment.getCreatedAt(), - user.getId(), - user.getNickname())); + new CommentLikedEvent(comment.getId(), comment.getUserId(), user.getNickname())); + log.info("[COMMENT][LIKE] userId={}, commentId={}", userId, commentId); return CommentLikeDto.from(saved); } @@ -117,29 +98,25 @@ public void dislike(Long userId, Long commentId) { commentLikeRepository.deleteByComment_IdAndUser_Id(commentId, userId); commentRepository.decLikeCount(commentId); - eventPublisher.publishEvent(CommentUnlikedEvent.of(commentId, userId)); - log.info("[COMMENT][DISLIKE] userId={}, commentId={}", userId, commentId); } - // 댓글 논리 삭제 - @Transactional - public void delete(Long commentId) { - log.info("[COMMENT][DELETE][START] commentId={}", commentId); - Comment comment = getCommentById(commentId); - - Article article = comment.getArticle(); - log.info("[COMMENT_COUNT] 댓글 삭제 전 카운트: {}", article.getCommentCount()); + // 댓글 논리 삭제 + @Transactional + public void delete(Long commentId) { + log.info("[COMMENT][DELETE][START] commentId={}", commentId); + Comment comment = getCommentById(commentId); - commentRepository.delete(comment); + Article article = comment.getArticle(); + log.info("[COMMENT_COUNT] 댓글 삭제 전 카운트: {}", article.getCommentCount()); - article.decreaseCommentCount(); - articleRepository.save(article); - eventPublisher.publishEvent(CommentDeletedEvent.of(commentId)); - log.info("[COMMENT_COUNT] 댓글 삭제 후 카운트: {}", article.getCommentCount()); - log.info("[COMMENT][DELETE] commentId={}", commentId); - } + commentRepository.delete(comment); + article.decreaseCommentCount(); + articleRepository.save(article); + log.info("[COMMENT_COUNT] 댓글 삭제 후 카운트: {}", article.getCommentCount()); + log.info("[COMMENT][DELETE] commentId={}", commentId); + } // 댓글 물리 삭제 @Transactional diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java deleted file mode 100644 index ccc2b06..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.monew.monew_api.interest.event; - -import java.time.LocalDateTime; - -/** - * Interest 삭제 이벤트 - * @param interestId - * @param occurredAt - */ -public record InterestDeletedEvent( - Long interestId, - LocalDateTime occurredAt -) { - public static InterestDeletedEvent of(Long interestId) { - return new InterestDeletedEvent(interestId, LocalDateTime.now()); - } -} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java deleted file mode 100644 index 02a84e8..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.monew.monew_api.interest.event; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * Interest 정보 변경 이벤트 - * Interest 정보를 수정했을 때 발행 - * @param interestId - * @param newKeywords - * @param occurredAt - */ -public record InterestUpdatedEvent( - Long interestId, - List newKeywords, - LocalDateTime occurredAt -) { - public static InterestUpdatedEvent of(Long interestId, List newKeywords) { - return new InterestUpdatedEvent(interestId, newKeywords, LocalDateTime.now()); - } -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java index cc160d7..c94fb86 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java @@ -1,12 +1,10 @@ package com.monew.monew_api.interest.repository; import com.monew.monew_api.interest.entity.Interest; -import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import java.util.List; -import java.util.Optional; public interface InterestRepository extends JpaRepository, InterestRepositoryCustom { @@ -20,10 +18,4 @@ public interface InterestRepository extends JpaRepository, Inter JOIN FETCH ik.keyword """) List findAllWithKeywords(); - - /** 특정 관심사와 해당 관심사에 연결된 키워드들을 함께 조회 - * (이벤트에서 필요해서 추가했어요) - */ - @EntityGraph(attributePaths = {"keywords", "keywords.keyword"}) - Optional findById(Long id); } diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index 6d83166..18174d3 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -5,8 +5,6 @@ import com.monew.monew_api.common.exception.user.UserNotFoundException; import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; -import com.monew.monew_api.interest.event.InterestDeletedEvent; -import com.monew.monew_api.interest.event.InterestUpdatedEvent; import com.monew.monew_api.interest.dto.InterestOrderBy; import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; import com.monew.monew_api.interest.dto.request.InterestRegisterRequest; @@ -50,7 +48,6 @@ public class InterestServiceImpl implements InterestService { private final SubscribeRepository subscribeRepository; private final InterestMapper interestMapper; - private final ApplicationEventPublisher eventPublisher; @Override @Transactional @@ -156,18 +153,6 @@ public InterestDto updateInterestKeywords( .map(ik -> ik.getKeyword().getKeyword()) .collect(Collectors.toList()); - // ⭐️⭐️구독 여부 가져오는 코드 추가 필요!! - - // 키워드 수정 이벤트 발행 - eventPublisher.publishEvent( - InterestUpdatedEvent.of( - interest.getId(), - keywords - )); - - - log.info("interestId = {}, 관심사 키워드 수정 완료 : {}", interestId, keywords); - return interestMapper.toInterestDto(interest, keywords, subscribedByMe); } @@ -178,10 +163,6 @@ public void deleteInterest(Long interestId) { .orElseThrow(InterestNotFoundException::new); interestRepository.delete(interest); - - eventPublisher.publishEvent(InterestDeletedEvent.of(interest.getId())); - - log.info("관심사 삭제 완료 : interestId = {}", interestId); } diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java deleted file mode 100644 index b616832..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.monew.monew_api.subscribe.event; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * 구독 추가 이벤트 - * @param userId - * @param subscriptionId - * @param interestId - * @param interestName - * @param interestKeywords - * @param interestSubscriberCount - * @param createdAt - * @param occurredAt - */ -public record SubscriptionAddedEvent( - Long userId, - Long subscriptionId, - Long interestId, - String interestName, - List interestKeywords, - Integer interestSubscriberCount, - LocalDateTime createdAt, - LocalDateTime occurredAt -) { - public static SubscriptionAddedEvent of( - Long userId, Long subscriptionId, Long interestId, String interestName, - List interestKeywords, Integer interestSubscriberCount, LocalDateTime createdAt - ) { - return new SubscriptionAddedEvent( - userId, subscriptionId, interestId, interestName, interestKeywords, - interestSubscriberCount, createdAt, LocalDateTime.now() - ); - } -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java deleted file mode 100644 index 9978c85..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.monew.monew_api.subscribe.event; - -import java.time.LocalDateTime; - -/** - * 구독 제거 이벤트 - * @param userId - * @param subscriptionId - * @param occurredAt - */ -public record SubscriptionRemovedEvent( - Long userId, - Long subscriptionId, - Long interestId, - LocalDateTime occurredAt -) { - public static SubscriptionRemovedEvent of(Long userId, Long subscriptionId, Long interestId) { - return new SubscriptionRemovedEvent(userId, subscriptionId, interestId, LocalDateTime.now()); - } -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java index b77e630..7937956 100644 --- a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java @@ -10,18 +10,13 @@ import com.monew.monew_api.interest.repository.InterestRepository; import com.monew.monew_api.subscribe.dto.SubscribeDto; import com.monew.monew_api.subscribe.entity.Subscribe; -import com.monew.monew_api.subscribe.event.SubscriptionAddedEvent; -import com.monew.monew_api.subscribe.event.SubscriptionRemovedEvent; import com.monew.monew_api.subscribe.mapper.SubscribeMapper; import com.monew.monew_api.subscribe.repository.SubscribeRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Slf4j @Service @RequiredArgsConstructor @@ -30,8 +25,8 @@ public class SubscribeServiceImpl implements SubscribeService { private final InterestRepository interestRepository; private final UserRepository userRepository; private final SubscribeRepository subscribeRepository; + private final SubscribeMapper subscribeMapper; - private final ApplicationEventPublisher eventPublisher; @Override @Transactional @@ -51,20 +46,6 @@ public SubscribeDto createSubscribe(Long interestId, Long userId) { Subscribe subscribe = Subscribe.create(interest, user); Subscribe saved = subscribeRepository.save(subscribe); - /* 이벤트 발행 keyword의 내용 그대로 캐시에 저장되어서 조회했습니다! */ - List keywordNames = interest.getKeywords().stream() - .map(ik -> ik.getKeyword().getKeyword()) - .toList(); - eventPublisher.publishEvent(SubscriptionAddedEvent.of( - user.getId(), - saved.getId(), - interest.getId(), - interest.getName(), - keywordNames, - interest.getSubscriberCount(), - saved.getCreatedAt() - )); - return subscribeMapper.toSubscribeDto(saved); } @@ -80,9 +61,6 @@ public void deleteSubscribe(Long interestId, Long userId) { .orElseThrow(SubscribeNotFoundException::new); subscribeRepository.delete(subscribe); - - eventPublisher.publishEvent(SubscriptionRemovedEvent.of(user.getId(), subscribe.getId(), interest.getId())); - log.info("현재 관심사 구독자 수 : {}", interest.getSubscriberCount()); interest.cancelSubscriberCount(); log.info("관심사 구독 취소 후 구독자 수: {}", interest.getSubscriberCount()); diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java index 818a688..189d200 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java @@ -1,7 +1,6 @@ package com.monew.monew_api.useractivity.controller; import com.monew.monew_api.useractivity.dto.UserActivityDto; -import com.monew.monew_api.useractivity.service.UserActivityCacheService; import com.monew.monew_api.useractivity.service.UserActivityService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,26 +17,18 @@ public class UserActivityController { private final UserActivityService userActivityService; - private final UserActivityCacheService userActivityCacheService; + /* - userActivityCacheService. mongoDB 사용 시 getUserActivityWithCache 메서드 - - userActivityService. 단일 쿼리 사용시 getUserActivitySingleQuery 메서드 (네이티브 쿼리) 여러 쿼리 사용시 getUserActivity 메서드 */ @GetMapping("/{userId}") public ResponseEntity getUserActivity(@PathVariable String userId) { - log.info("[활동내역 API 요청]: userId={}", userId); - -// UserActivityDto activity = userActivityService.getUserActivitySingleQuery(userId); - UserActivityDto activity = userActivityCacheService.getUserActivityWithCache(userId); + log.info("활동내역 조회 요청: userId={}", userId); - log.info("[활동내역 API 응답]: userId={}, Subscriptions_size={}, Comments_size={}, CommentLikes_size={}, ArticleViews_size={}", - activity.getId(), activity.getSubscriptions().size(), activity.getComments().size(), - activity.getCommentLikes().size(), activity.getArticleViews().size()); + UserActivityDto activity = userActivityService.getUserActivitySingleQuery(userId); return ResponseEntity.ok(activity); } diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java deleted file mode 100644 index 8061093..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.monew.monew_api.useractivity.document; - -import jakarta.persistence.Id; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.springframework.data.mongodb.core.index.Indexed; -import org.springframework.data.mongodb.core.mapping.Document; - -import java.time.LocalDateTime; -import java.util.HashSet; -import java.util.Set; - -/* - 역인덱스 문서 - - key : id 패턴 "도메인_{id}_행동" - - 댓글 작성자: "comment_{id}_author" -> {userIds} - - 댓글 좋아요: "comment_{id}_likes" -> {userIds} - - 기사 조회: "article_{id}_views" -> {userIds} - */ -@Document(collection = "reverse_indexes") -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ReverseIndexDocument { - - @Id - private String id; - - @Builder.Default - private Set userIds = new HashSet<>(); - - private LocalDateTime createdAt; - - @Indexed(name = "index_ttl", expireAfter = "1h") - private LocalDateTime updatedAt; - - /** - * 댓글 작성자 역인덱스 키 생성 - * - * @param commentId 댓글 ID - * @return "comment_{commentId}_author" - */ - public static String makeCommentAuthorKey(Long commentId) { - return "comment_" + commentId + "_author"; - } - - /** - * 댓글 좋아요 역인덱스 키 생성 - * - * @param commentId 댓글 ID - * @return "comment_{commentId}_likes" - */ - public static String makeCommentLikesKey(Long commentId) { - return "comment_" + commentId + "_likes"; - } - - /** - * 기사 조회 역인덱스 키 생성 - * - * @param articleId 기사 ID - * @return "article_{articleId}_views" - */ - public static String makeArticleViewsKey(Long articleId) { - return "article_" + articleId + "_views"; - } - - /** - * Interest 구독자 역인덱스 키 생성 - * @param interestId - * @return - */ - public static String makeInterestSubscribersKey(Long interestId) { - return "interest_" + interestId + "_subs"; - } -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java index ed35f32..51aa1bf 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java @@ -34,6 +34,6 @@ public class UserActivityCacheDocument { private List commentLikes; private List articleViews; - @Indexed(name = "cache_ttl", expireAfter = "1h") - private LocalDateTime updatedAt; + @Indexed(expireAfter = "1h") + private LocalDateTime cachedAt; } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java index 068cf1c..8126f29 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java @@ -35,7 +35,7 @@ public class CommentLikeActivityDto { @JsonAlias({"article_title"}) private String articleTitle; - @JsonProperty("commentAuthorId") + @JsonProperty("commentUserId") @JsonAlias({"comment_user_id"}) private String commentUserId; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java index d46b2fd..da3b95c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java @@ -1,9 +1,6 @@ package com.monew.monew_api.useractivity.dto; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.time.LocalDateTime; import java.util.List; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java deleted file mode 100644 index 8760828..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.monew.monew_api.useractivity.event; - -import com.monew.monew_api.useractivity.dto.UserActivityDto; - -/** - * 캐시 저장 이벤트 - * PostgreSQL 조회 후 MongoDB에 비동기 캐시 저장 - * useractivity 내부, create 전략 - * @param userId - * @param data - */ -public record CacheSaveEvent( - String userId, - UserActivityDto data -) { -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java deleted file mode 100644 index 94dbf59..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.monew.monew_api.useractivity.listener; - -import com.monew.monew_api.article.event.ArticleViewedEvent; -import com.monew.monew_api.useractivity.service.CacheUpdateService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -/** - * 기사 조회 이벤트 리스너 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class ArticleViewEventListener { - - private final CacheUpdateService cacheUpdateService; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(ArticleViewedEvent event) { - log.info("[Listener] 기사 조회 이벤트 수신: articleId={}, userId={}", - event.articleId(), event.userId()); - - try { - // 1. 조회수 증가 (기존 조회한 사람들) - cacheUpdateService.incrementArticleViewCount( - event.articleId(), - event.getDelta() - ); - - // 2. 조회한 사람 캐시에 추가 + 역인덱스 생성 - cacheUpdateService.addArticleView( - event.viewId(), - event.userId(), - event.createdAt(), - event.articleId(), - event.source(), - event.sourceUrl(), - event.articleTitle(), - event.articlePublishedDate(), - event.articleSummary(), - event.articleCommentCount(), - event.articleViewCount() - ); - - log.info("[Listener] 기사 조회 캐시 업데이트 완료: articleId={}", event.articleId()); - - } catch (Exception e) { - log.error("[Listener] 기사 조회 캐시 업데이트 실패: articleId={}", event.articleId(), e); - } - } -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java deleted file mode 100644 index b0ff91e..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.monew.monew_api.useractivity.listener; - -import com.monew.monew_api.useractivity.event.CacheSaveEvent; -import com.monew.monew_api.useractivity.service.CacheUpdateService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -/** - * 캐시 저장 이벤트 리스너 - * PostgreSQL 조회 후 MongoDB에 비동기 저장 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class CacheSaveEventListener { - - private final CacheUpdateService cacheUpdateService; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(CacheSaveEvent event) { - log.info("[Listener] 캐시 저장 이벤트 수신: userId={}", event.userId()); - - try { - cacheUpdateService.saveCache( - event.userId(), - event.data() - ); - } catch (Exception e) { - log.error("[Listener] 캐시 저장 실패: userId={}", event.userId(), e); - } - } -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java deleted file mode 100644 index 73bbcec..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.monew.monew_api.useractivity.listener; - -import com.monew.monew_api.comments.event.CommentContentEditedEvent; -import com.monew.monew_api.useractivity.service.CacheUpdateService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Slf4j -@Component -@RequiredArgsConstructor -public class CommentContentEditedEventListener { - - private final CacheUpdateService cacheUpdateService; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(CommentContentEditedEvent e) { - cacheUpdateService.updateCommentContent(e.commentId(), e.newContent()); - log.info("[Listener] CommentContentEdited handled: commentId={}", e.commentId()); - } -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java deleted file mode 100644 index 86d3ef6..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.monew.monew_api.useractivity.listener; - -import com.monew.monew_api.comments.event.CommentCreatedEvent; -import com.monew.monew_api.useractivity.service.CacheUpdateService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -/** - * 댓글 작성 이벤트 리스너 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class CommentCreateEventListener { - - private final CacheUpdateService cacheUpdateService; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(CommentCreatedEvent event) { - log.info("[Listener] 댓글 작성 이벤트 수신: commentId={}, userId={}, articleId={}", - event.commentId(), event.userId(), event.articleId()); - - try { - // 1. 기사 댓글수 증가 (기존 조회한 사람들) - cacheUpdateService.incrementArticleCommentCount( - event.articleId(), - event.getDelta() - ); - - // 2. 작성자 캐시에 댓글 추가 + 역인덱스 생성 - cacheUpdateService.addComment( - event.commentId(), - event.userId(), - event.userNickname(), - event.articleId(), - event.articleTitle(), - event.content(), - event.likeCount(), - event.createdAt() - ); - - log.info("[Listener] 댓글 작성 캐시 업데이트 완료: commentId={}", event.commentId()); - - } catch (Exception e) { - log.error("[Listener] 댓글 작성 캐시 업데이트 실패: commentId={}", event.commentId(), e); - } - } -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java deleted file mode 100644 index 51a9c15..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.monew.monew_api.useractivity.listener; - -import com.monew.monew_api.comments.event.CommentDeletedEvent; -import com.monew.monew_api.useractivity.service.CacheUpdateService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -/** - * 댓글 삭제 이벤트 리스너 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class CommentDeletedEventListener { - - private final CacheUpdateService cacheUpdateService; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(CommentDeletedEvent event) { - log.info("[Listener] 댓글 삭제 이벤트 수신: commentId={}", event.commentId()); - - try { - cacheUpdateService.removeComment(event.commentId()); - } catch (Exception e) { - log.error("[Listener] 댓글 삭제 캐시 처리 실패: commentId={}", event.commentId(), e); - } - } -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java deleted file mode 100644 index cc88a3d..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.monew.monew_api.useractivity.listener; - -import com.monew.monew_api.comments.event.CommentLikedEvent; -import com.monew.monew_api.useractivity.service.CacheUpdateService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -/** - * 댓글 좋아요 이벤트 리스너 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class CommentLikedEventListener { - - private final CacheUpdateService cacheUpdateService; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(CommentLikedEvent e) { - cacheUpdateService.addCommentLike( - e.likeId(), - e.likedByUserId(), - e.likeCreatedAt(), - e.commentId(), - e.articleId(), - e.articleTitle(), - e.commentAuthorId(), - e.commentUserNickname(), - e.commentContent(), - e.commentLikeCount(), - e.commentCreatedAt() - ); - cacheUpdateService.updateCommentLikeCount(e.commentId(), +1); - - log.info("[Listener] CommentLikedEvent handled: commentId={}, likedBy={}", - e.commentId(), e.likedByUserId()); - } -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java deleted file mode 100644 index 2cd67d6..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.monew.monew_api.useractivity.listener; - -import com.monew.monew_api.comments.event.CommentUnlikedEvent; -import com.monew.monew_api.useractivity.service.CacheUpdateService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -/** - * 댓글 좋아요 취소 이벤트 리스너 - * 사용자가 댓글 좋아요를 취소했을 때 캐시 업데이트 수행 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class CommentUnlikedEventListener { - - private final CacheUpdateService cacheUpdateService; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(CommentUnlikedEvent e) { - cacheUpdateService.updateCommentLikeCount(e.commentId(), -1); - cacheUpdateService.removeCommentLike(e.likedByUserId(), e.commentId()); - log.info("[Listener] CommentUnlikedEvent handled: commentId={}, likedBy={}", - e.commentId(), e.likedByUserId()); - } -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java deleted file mode 100644 index 50c662b..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.monew.monew_api.useractivity.listener; - -import com.monew.monew_api.interest.event.InterestDeletedEvent; -import com.monew.monew_api.useractivity.service.CacheUpdateService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Slf4j -@Component -@RequiredArgsConstructor -public class InterestDeletedEventListener { - - private final CacheUpdateService cacheUpdateService; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(InterestDeletedEvent e) { - cacheUpdateService.removeInterest(e.interestId()); - log.info("[Listener] InterestDeleted handled: interestId={}", e.interestId()); - } -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java deleted file mode 100644 index 003c0fb..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.monew.monew_api.useractivity.listener; - -import com.monew.monew_api.interest.event.InterestUpdatedEvent; -import com.monew.monew_api.useractivity.service.CacheUpdateService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -/** - * Interest 정보 변경 이벤트 리스너 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class InterestUpdateEventListener { - - private final CacheUpdateService cacheUpdateService; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(InterestUpdatedEvent event) { - log.info("[Listener] Interest 정보 변경 이벤트 수신: interestId={}", - event.interestId()); - - try { - cacheUpdateService.updateInterestKeyword( - event.interestId(), - event.newKeywords() - ); - } catch (Exception e) { - log.error("[Listener] Interest 정보 캐시 업데이트 실패: interestId={}", event.interestId(), e); - } - } -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java deleted file mode 100644 index 62e4f80..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.monew.monew_api.useractivity.listener; - -import com.monew.monew_api.useractivity.service.CacheUpdateService; -import com.monew.monew_api.subscribe.event.SubscriptionAddedEvent; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Slf4j -@Component -@RequiredArgsConstructor -public class SubscriptionAddedEventListener { - - private final CacheUpdateService cacheUpdateService; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(SubscriptionAddedEvent e) { - cacheUpdateService.addSubscription( - e.userId(), - e.subscriptionId(), - e.interestId(), - e.interestName(), - e.interestKeywords(), - e.interestSubscriberCount(), - e.createdAt() - ); - log.info("[Listener] SubscriptionAdded handled: userId={}, subId={}, interestId={}", - e.userId(), e.subscriptionId(), e.interestId()); - } -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java deleted file mode 100644 index a9e13ff..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.monew.monew_api.useractivity.listener; - -import com.monew.monew_api.useractivity.service.CacheUpdateService; -import com.monew.monew_api.subscribe.event.SubscriptionRemovedEvent; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Slf4j -@Component -@RequiredArgsConstructor -public class SubscriptionRemovedEventListener { - - private final CacheUpdateService cacheUpdateService; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(SubscriptionRemovedEvent e) { - cacheUpdateService.removeSubscription(e.userId(), e.subscriptionId(), e.interestId()); - log.info("[Listener] SubscriptionRemoved handled: userId={}, subId={}", - e.userId(), e.subscriptionId()); - } -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java deleted file mode 100644 index 251a9ea..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.monew.monew_api.useractivity.mapper; - -import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; -import com.monew.monew_api.useractivity.dto.UserActivityDto; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; - -@Mapper(componentModel = "spring") -public interface UserActivityDocumentMapper { - @Mapping(target = "updatedAt", expression = "java(java.time.LocalDateTime.now())") - UserActivityCacheDocument toDocument(UserActivityDto dto); -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java index c738350..f87e608 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java @@ -62,6 +62,7 @@ default UserActivityDto toUserActivityDto( @Mapping(target = "commentId", expression = "java(String.valueOf(commentLike.getComment().getId()))") @Mapping(target = "articleId", expression = "java(String.valueOf(commentLike.getComment().getArticle().getId()))") @Mapping(target = "articleTitle", source = "comment.article.title") + @Mapping(target = "commentUserId", expression = "java(String.valueOf(commentLike.getComment().getUser().getId()))") @Mapping(target = "commentUserNickname", source = "comment.user.nickname") @Mapping(target = "commentContent", source = "comment.content") @Mapping(target = "commentLikeCount", source = "comment.likeCount") @@ -70,6 +71,9 @@ default UserActivityDto toUserActivityDto( List toCommentLikeDtos(List commentLikes); + @Mapping(target = "cachedAt", expression = "java(java.time.LocalDateTime.now())") + UserActivityCacheDocument toDocument(UserActivityDto dto); + UserActivityDto toDto(UserActivityCacheDocument document); default List mapKeywords(Interest interest) { diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java deleted file mode 100644 index 9a92a9f..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.monew.monew_api.useractivity.repository.Impl; - -import com.monew.monew_api.useractivity.document.ReverseIndexDocument; -import com.monew.monew_api.useractivity.repository.ReverseIndexCustomRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.Set; -import java.util.stream.Collectors; - -@Repository -@RequiredArgsConstructor -public class ReverseIndexRepositoryImpl implements ReverseIndexCustomRepository { - - private final MongoTemplate mongoTemplate; - - @Override - public void addUser(String indexKey, String userId) { - Query q = Query.query(Criteria.where("_id").is(indexKey)); - Update u = new Update() - .addToSet("userIds", userId) - .set("updatedAt", LocalDateTime.now()); - mongoTemplate.upsert(q, u, ReverseIndexDocument.class); - } - - @Override - public void removeUser(String indexKey, String userId) { - Query q = Query.query(Criteria.where("_id").is(indexKey)); - Update u = new Update() - .pull("userIds", userId) - .set("updatedAt", LocalDateTime.now()); - mongoTemplate.updateFirst(q, u, ReverseIndexDocument.class); - } - - @Override - public Set findUserIdsByKeys(Set indexKeys) { - if (indexKeys.isEmpty()) return Collections.emptySet(); - Query q = Query.query(Criteria.where("_id").in(indexKeys)); - return mongoTemplate.find(q, ReverseIndexDocument.class) - .stream() - .flatMap(doc -> doc.getUserIds().stream()) - .collect(Collectors.toSet()); - } -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java deleted file mode 100644 index ae69fbc..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.monew.monew_api.useractivity.repository.Impl; - -import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; -import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; -import com.monew.monew_api.useractivity.dto.CommentActivityDto; -import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto; -import com.monew.monew_api.useractivity.dto.SubscribesActivityDto; -import com.monew.monew_api.useractivity.repository.UserActivityCacheCustomRepository; -import com.mongodb.BasicDBObject; -import com.mongodb.client.result.UpdateResult; -import lombok.RequiredArgsConstructor; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Set; - -import static org.springframework.data.mongodb.core.query.Criteria.where; - -@Repository -@RequiredArgsConstructor -public class UserActivityCacheRepositoryImpl implements UserActivityCacheCustomRepository { - - private final MongoTemplate mongo; - - @Override - public long incCommentLikeCount(Set userIds, String commentId, int delta) { - if (userIds.isEmpty()) return 0; - - var q1 = Query.query(where("_id").in(userIds).and("comments.id").is(commentId)); - var u1 = new Update() - .inc("comments.$.likeCount", delta) - .set("updatedAt", LocalDateTime.now()); - UpdateResult r1 = mongo.updateMulti(q1, u1, UserActivityCacheDocument.class); - - var q2 = Query.query(where("_id").in(userIds).and("commentLikes.commentId").is(commentId)); - var u2 = new Update().inc("commentLikes.$.commentLikeCount", delta) - .set("updatedAt", LocalDateTime.now()); - UpdateResult r2 = mongo.updateMulti(q2, u2, UserActivityCacheDocument.class); - - return r1.getModifiedCount() + r2.getModifiedCount(); - } - - @Override - public long incArticleViewCount(Set userIds, String articleId, int delta) { - if (userIds.isEmpty()) return 0; - var q = Query.query(where("_id").in(userIds).and("articleViews.articleId").is(articleId)); - var u = new Update() - .inc("articleViews.$.articleViewCount", delta) - .set("updatedAt", LocalDateTime.now()); - return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); - } - - @Override - public long incArticleCommentCount(Set userIds, String articleId, int delta) { - if (userIds.isEmpty()) return 0; - var q = Query.query(where("_id").in(userIds).and("articleViews.articleId").is(articleId)); - var u = new Update() - .inc("articleViews.$.articleCommentCount", delta) - .set("updatedAt", LocalDateTime.now()); - return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); - } - - @Override - public long pushCommentLike(String userId, CommentLikeActivityDto dto, int keepLatest) { - var q = Query.query(where("_id").is(userId)); - var u = new Update() - .push("commentLikes") - .atPosition(0) - .slice(keepLatest) - .each(dto) - .set("updatedAt", LocalDateTime.now()); - return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); - } - - @Override - public long pullCommentLike(String userId, String commentId) { - var q = Query.query(where("_id").is(userId)); - var u = new Update() - .pull("commentLikes", new BasicDBObject("commentId", commentId)) - .set("updatedAt", LocalDateTime.now()); - return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); - } - - @Override - public long pushComment(String userId, CommentActivityDto dto, int keepLatest) { - var q = Query.query(where("_id").is(userId)); - var u = new Update() - .push("comments") - .atPosition(0) - .slice(keepLatest) - .each(dto) - .set("updatedAt", LocalDateTime.now()); - return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); - } - - @Override - public long updateCommentContentForUsers(Set userIds, String commentId, String newContent) { - if (userIds.isEmpty()) return 0; - - var q1 = Query.query(where("_id").in(userIds).and("comments.id").is(commentId)); - var u1 = new Update() - .set("comments.$.content", newContent) - .set("updatedAt", LocalDateTime.now()); - var r1 = mongo.updateMulti(q1, u1, UserActivityCacheDocument.class); - - var q2 = Query.query(where("_id").in(userIds).and("commentLikes.commentId").is(commentId)); - var u2 = new Update() - .set("commentLikes.$[l].commentContent", newContent) - .set("updatedAt", LocalDateTime.now()); - u2.filterArray(where("l.commentId").is(commentId)); - var r2 = mongo.updateMulti(q2, u2, UserActivityCacheDocument.class); - - return r1.getModifiedCount() + r2.getModifiedCount(); - } - - - @Override - public long removeCommentEverywhere(Set userIds, String commentId) { - if (userIds.isEmpty()) return 0; - var q = Query.query(where("_id").in(userIds)); - var u = new Update() - .pull("comments", new BasicDBObject("id", commentId)) - .pull("commentLikes", new BasicDBObject("commentId", commentId)) - .set("updatedAt", LocalDateTime.now()); - return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); - } - - @Override - public long pushArticleView(String userId, ArticleViewActivityDto dto, int keepLatest) { - var q = Query.query(where("_id").is(userId)); - var u = new Update() - .push("articleViews") - .atPosition(0) - .slice(keepLatest) - .each(dto) - .set("updatedAt", LocalDateTime.now()); - return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); - } - - @Override - public long updateInterestKeywords(String interestId, List newKeywords) { - var q = Query.query(where("subscriptions.interestId").is(interestId)); - var u = new Update() - .set("subscriptions.$[it].interestKeywords", newKeywords) - .set("updatedAt", LocalDateTime.now()); - u.filterArray(where("it.interestId").is(interestId)); - return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); - } - - @Override - public long removeInterestEverywhere(Set userIds, String interestId) { - if (userIds.isEmpty()) return 0; - - Query q = Query.query(Criteria.where("_id").in(userIds)); - Update u = new Update() - .pull("subscriptions", new BasicDBObject("interestId", interestId)) - .set("updatedAt", LocalDateTime.now()); - - return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); - } - - @Override - public long addSubscription(String userId, SubscribesActivityDto dto) { - var q = Query.query(where("_id").is(userId)); - - if (dto.getId() != null) { - var pullExisting = new Update() - .pull("subscriptions", Query.query(where("id").is(dto.getId())).getQueryObject()) - .set("updatedAt", LocalDateTime.now()); - mongo.updateFirst(q, pullExisting, UserActivityCacheDocument.class); - } - - var push = new Update() - .push("subscriptions") - .atPosition(0) - .slice(10) - .each(dto) - .set("updatedAt", LocalDateTime.now()); - - var result = mongo.updateFirst(q, push, UserActivityCacheDocument.class); - return result.getModifiedCount(); - } - - @Override - public long removeSubscription(String userId, String subscriptionId) { - var q = Query.query(where("_id").is(userId)); - var u = new Update() - .pull("subscriptions", Query.query(where("id").is(subscriptionId)).getQueryObject()) - .set("updatedAt", LocalDateTime.now()); - return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); - } -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java deleted file mode 100644 index 4583392..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.monew.monew_api.useractivity.repository; - -import java.util.Set; - -public interface ReverseIndexCustomRepository { - void addUser(String indexKey, String userId); - - void removeUser(String indexKey, String userId); - - Set findUserIdsByKeys(Set indexKeys); -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java deleted file mode 100644 index 8080a8d..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.monew.monew_api.useractivity.repository; - -import com.monew.monew_api.useractivity.document.ReverseIndexDocument; -import org.springframework.data.mongodb.repository.MongoRepository; - -public interface ReverseIndexRepository extends MongoRepository, ReverseIndexCustomRepository { -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java deleted file mode 100644 index f506111..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.monew.monew_api.useractivity.repository; - -import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; -import com.monew.monew_api.useractivity.dto.CommentActivityDto; -import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto; -import com.monew.monew_api.useractivity.dto.SubscribesActivityDto; - -import java.util.List; -import java.util.Set; - -public interface UserActivityCacheCustomRepository { - - long incCommentLikeCount(Set userIds, String commentId, int delta); - - long incArticleViewCount(Set userIds, String articleId, int delta); - - long incArticleCommentCount(Set userIds, String articleId, int delta); - - long pushCommentLike(String userId, CommentLikeActivityDto dto, int keepLatest); - - long pullCommentLike(String userId, String commentId); - - long pushComment(String userId, CommentActivityDto dto, int keepLatest); - - long updateCommentContentForUsers(Set userIds, String commentId, String newContent); - - long removeCommentEverywhere(Set userIds, String commentId); - - long pushArticleView(String userId, ArticleViewActivityDto dto, int keepLatest); - - long updateInterestKeywords(String interestId, List newKeywords); - - long removeInterestEverywhere(Set userIds, String interestId); - - long addSubscription(String userId, SubscribesActivityDto dto); - - long removeSubscription(String userId, String subscriptionId); -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java index e6e2e1d..eb59229 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java @@ -5,5 +5,5 @@ import org.springframework.stereotype.Repository; @Repository -public interface UserActivityCacheRepository extends MongoRepository, UserActivityCacheCustomRepository { +public interface UserActivityCacheRepository extends MongoRepository { } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java similarity index 98% rename from monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java rename to monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java index 1adf5b4..5ccc5db 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java @@ -1,11 +1,10 @@ -package com.monew.monew_api.useractivity.repository.Impl; +package com.monew.monew_api.useractivity.repository; import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.interest.entity.QKeyword; import com.monew.monew_api.subscribe.entity.Subscribe; import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; -import com.monew.monew_api.useractivity.repository.UserActivityRepository; import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; import com.querydsl.core.types.Projections; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java deleted file mode 100644 index a74bed0..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java +++ /dev/null @@ -1,126 +0,0 @@ -package com.monew.monew_api.useractivity.service; - -import com.monew.monew_api.useractivity.dto.UserActivityDto; -import org.hibernate.mapping.Set; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * 캐시 업데이트 서비스 인터페이스 - */ -public interface CacheUpdateService { - - /** - * 댓글 좋아요수 증가/감소 - */ - void updateCommentLikeCount(Long commentId, Integer delta); - - /** - * 기사 조회수 증가 - */ - void incrementArticleViewCount(Long articleId, Integer delta); - - /** - * 기사 댓글수 증가 - */ - void incrementArticleCommentCount(Long articleId, Integer delta); - - /** - * Interest 정보 업데이트 - */ - void updateInterestKeyword(Long interestId, List newKeywords); - - /** - * Interest 삭제 처리 - * @param interestId - */ - void removeInterest(Long interestId); - - /** - * 구독 추가 - */ - void addSubscription(Long userId, Long subscriptionId, Long interestId, String interestName, - List interestKeywords, Integer interestSubscriberCount, LocalDateTime createdAt); - - /** - * 구독 취소 - */ - void removeSubscription(Long userId, Long subscriptionId, Long interestId); - - /** - * 댓글 생성 시 캐시 데이터 + 역인덱스 업데이트 - * @param id - * @param userId - * @param userNickname - * @param articleId - * @param articleTitle - * @param content - * @param likeCount - * @param createdAt - */ - void addComment(Long id, Long userId, String userNickname, Long articleId, String articleTitle, - String content, Integer likeCount, LocalDateTime createdAt); - - /** - * 좋아요 생성 시 캐시 데이터 + 역인덱스 업데이트 - * @param id - * @param userId - * @param createdAt - * @param commentId - * @param articleId - * @param articleTitle - * @param commentUserId - * @param commentUserNickname - * @param commentContent - * @param commentLikeCount - * @param commentCreatedAt - */ - void addCommentLike(Long id, Long userId, LocalDateTime createdAt, Long commentId, Long articleId, String articleTitle, - Long commentUserId, String commentUserNickname, String commentContent, Integer commentLikeCount, - LocalDateTime commentCreatedAt); - - /** - * 댓글 내용 수정 시 캐시 데이터 + 역인덱스 업데이트 - * @param commentId - * @param newContent - */ - void updateCommentContent(Long commentId, String newContent); - - /** - * 기사 조회 생성 시 캐시 데이터 + 역인덱스 업데이트 - * @param id - * @param userId - * @param createdAt - * @param articleId - * @param source - * @param sourceUrl - * @param articleTitle - * @param articlePublishedDate - * @param articleSummary - * @param articleCommentCount - * @param articleViewCount - */ - void addArticleView(Long id, - Long userId, LocalDateTime createdAt, - Long articleId, String source, String sourceUrl, - String articleTitle, LocalDateTime articlePublishedDate, - String articleSummary, Integer articleCommentCount, - Integer articleViewCount); - - /** - * 좋아요 삭제 처리 - * @param userId - * @param commentId - */ - void removeCommentLike(Long userId, Long commentId); - /** - * 댓글 삭제 처리 - */ - void removeComment(Long commentId); - - /** - * 캐시 저장 (PostgreSQL 조회 후 비동기 저장) - */ - void saveCache(String userId, UserActivityDto data); -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java deleted file mode 100644 index a4bfa5d..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java +++ /dev/null @@ -1,314 +0,0 @@ -package com.monew.monew_api.useractivity.service.Impl; - -import com.monew.monew_api.useractivity.document.ReverseIndexDocument; -import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; -import com.monew.monew_api.useractivity.dto.*; -import com.monew.monew_api.useractivity.mapper.UserActivityDocumentMapper; -import com.monew.monew_api.useractivity.repository.UserActivityCacheRepository; -import com.monew.monew_api.useractivity.service.CacheUpdateService; -import com.monew.monew_api.useractivity.service.ReverseIndexService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Set; - -/** - * 캐시 업데이트 서비스 구현체 - * MongoDB 캐시를 부분 업데이트 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class CacheUpdateServiceImpl implements CacheUpdateService { - - private final ReverseIndexService reverseIndexService; - private final UserActivityCacheRepository cacheRepository; - private final UserActivityDocumentMapper documentMapper; - - @Override - public void updateCommentLikeCount(Long commentId, Integer delta) { - Set userIds = reverseIndexService.getUserIds(Set.of( - ReverseIndexDocument.makeCommentAuthorKey(commentId), - ReverseIndexDocument.makeCommentLikesKey(commentId) - )); - if (userIds.isEmpty()) { - log.debug("[CacheUpdate] 영향 사용자 없음: commentId={}", commentId); - return; - } - long modified = cacheRepository.incCommentLikeCount(userIds, commentId.toString(), delta); - log.info("[CacheUpdate] 댓글 좋아요수 업데이트: commentId={}, delta={}, users={}, modified={}", - commentId, delta, userIds.size(), modified); - } - - @Override - public void incrementArticleViewCount(Long articleId, Integer delta) { - Set viewers = reverseIndexService.getUserIds( - ReverseIndexDocument.makeArticleViewsKey(articleId) - ); - if (viewers.isEmpty()) { - log.debug("[CacheUpdate] 영향 사용자 없음: articleId={}", articleId); - return; - } - long modified = cacheRepository.incArticleViewCount(viewers, articleId.toString(), delta); - log.info("[CacheUpdate] 기사 조회수 업데이트: articleId={}, delta={}, users={}, modified={}", - articleId, delta, viewers.size(), modified); - } - - @Override - public void incrementArticleCommentCount(Long articleId, Integer delta) { - Set viewers = reverseIndexService.getUserIds( - ReverseIndexDocument.makeArticleViewsKey(articleId) - ); - if (viewers.isEmpty()) { - log.debug("[CacheUpdate] 영향 사용자 없음: articleId={}", articleId); - return; - } - long modified = cacheRepository.incArticleCommentCount(viewers, articleId.toString(), delta); - log.info("[CacheUpdate] 기사 댓글수 업데이트: articleId={}, delta={}, users={}, modified={}", - articleId, delta, viewers.size(), modified); - } - - - @Override - public void addComment(Long id, Long userId, String userNickname, - Long articleId, String articleTitle, String content, - Integer likeCount, LocalDateTime createdAt) { - - String uid = userId.toString(); - CommentActivityDto dto = CommentActivityDto.builder() - .id(id.toString()) - .userId(uid) - .userNickname(userNickname) - .articleId(articleId.toString()) - .articleTitle(articleTitle) - .content(content) - .likeCount(likeCount) - .createdAt(createdAt) - .build(); - - - long modified = cacheRepository.pushComment(uid, dto, 10); - if (modified == 0) { - log.warn("[CacheUpdate] 캐시 없음(만료?): userId={}, commentId={}", uid, id); - } - reverseIndexService.addUser(ReverseIndexDocument.makeCommentAuthorKey(id), uid); - log.info("[CacheUpdate] 댓글 추가: commentId={}, userId={}, modified={}", id, uid, modified); - } - - @Override - public void addCommentLike(Long id, Long userId, LocalDateTime createdAt, - Long commentId, Long articleId, String articleTitle, - Long commentUserId, String commentUserNickname, - String commentContent, Integer commentLikeCount, - LocalDateTime commentCreatedAt) { - String uid = userId.toString(); - CommentLikeActivityDto dto = CommentLikeActivityDto.builder() - .id(id.toString()) - .createdAt(createdAt) - .commentId(commentId.toString()) - .articleId(articleId.toString()) - .articleTitle(articleTitle) - .commentUserId(commentUserId.toString()) - .commentUserNickname(commentUserNickname) - .commentContent(commentContent) - .commentLikeCount(commentLikeCount) - .commentCreatedAt(commentCreatedAt) - .build(); - - long modified = cacheRepository.pushCommentLike(uid, dto, 10); - if (modified == 0) { - log.warn("[CacheUpdate] 캐시 없음(만료?): userId={}, likeId={}", uid, id); - } - reverseIndexService.addUser(ReverseIndexDocument.makeCommentLikesKey(commentId), uid); - log.info("[CacheUpdate] 댓글 좋아요 추가: commentId={}, userId={}, modified={}", commentId, uid, modified); - } - - @Override - public void updateCommentContent(Long commentId, String newContent) { - Set userIds = reverseIndexService.getUserIds(Set.of( - ReverseIndexDocument.makeCommentAuthorKey(commentId), - ReverseIndexDocument.makeCommentLikesKey(commentId) - )); - if (userIds.isEmpty()) { - log.debug("[CacheUpdate] 댓글 내용 수정 영향 사용자 없음: commentId={}", commentId); - return; - } - long modified = cacheRepository.updateCommentContentForUsers(userIds, commentId.toString(), newContent); - log.info("[CacheUpdate] 댓글 내용 수정 반영: commentId={}, users={}, modified={}", - commentId, userIds.size(), modified); - } - - @Override - public void removeCommentLike(Long userId, Long commentId) { - String uid = userId.toString(); - long modified = cacheRepository.pullCommentLike(uid, commentId.toString()); - reverseIndexService.removeUser(ReverseIndexDocument.makeCommentLikesKey(commentId), uid); - log.info("[CacheUpdate] 댓글 좋아요 제거: commentId={}, userId={}, modified={}", commentId, uid, modified); - } - - @Override - public void addArticleView(Long id, Long userId, LocalDateTime createdAt, - Long articleId, String source, String sourceUrl, - String articleTitle, LocalDateTime articlePublishedDate, - String articleSummary, Integer articleCommentCount, - Integer articleViewCount) { - String uid = userId.toString(); - ArticleViewActivityDto dto = ArticleViewActivityDto.builder() - .id(id.toString()) - .viewedBy(uid) - .createdAt(createdAt) - .articleId(articleId.toString()) - .source(source) - .sourceUrl(sourceUrl) - .articleTitle(articleTitle) - .articlePublishedDate(articlePublishedDate) - .articleSummary(articleSummary) - .articleCommentCount(articleCommentCount) - .articleViewCount(articleViewCount) - .build(); - - long modified = cacheRepository.pushArticleView(uid, dto, 10); - if (modified == 0) { - log.warn("[CacheUpdate] 캐시 없음(만료?): userId={}, viewId={}", uid, id); - } - reverseIndexService.addUser(ReverseIndexDocument.makeArticleViewsKey(articleId), uid); - log.info("[CacheUpdate] 기사 조회 추가: articleId={}, userId={}, modified={}", articleId, uid, modified); - } - - /* - * version 이전인 경우에만 관심사 키워드 업데이트 - */ - @Override - public void updateInterestKeyword(Long interestId, List newKeywords) { - String iid = String.valueOf(interestId); - long modified = cacheRepository.updateInterestKeywords(iid, newKeywords); - log.info("[CacheUpdate] Interest 키워드 갱신(set): interestId={}, modified={}", iid, modified); - } - - @Override - public void removeInterest(Long interestId) { - String id = String.valueOf(interestId); - - Set userIds = reverseIndexService.getUserIds( - ReverseIndexDocument.makeInterestSubscribersKey(interestId) - ); - - long modified = 0; - if (!userIds.isEmpty()) { - modified = cacheRepository.removeInterestEverywhere(userIds, id); - } - - reverseIndexService.deleteIndexes(Set.of(ReverseIndexDocument.makeInterestSubscribersKey(interestId))); - - log.info("[CacheUpdate] 관심사 삭제 반영: interestId={}, users={}, modified={}", id, userIds.size(), modified); - } - - @Override - public void addSubscription(Long userId, - Long subscriptionId, - Long interestId, - String interestName, - List interestKeywords, - Integer interestSubscriberCount, - LocalDateTime createdAt) { - - String uid = String.valueOf(userId); - SubscribesActivityDto dto = SubscribesActivityDto.builder() - .id(String.valueOf(subscriptionId)) - .interestId(String.valueOf(interestId)) - .interestName(interestName) - .interestKeywords(interestKeywords) - .interestSubscriberCount(interestSubscriberCount) - .createdAt(createdAt) - .build(); - - long modified = cacheRepository.addSubscription(uid, dto); - reverseIndexService.addUser(ReverseIndexDocument.makeInterestSubscribersKey(interestId), uid); - log.info("[CacheUpdate] 구독 추가: userId={}, subId={}, interestId={}, modified={}", - uid, subscriptionId, interestId, modified); - } - - @Override - public void removeSubscription(Long userId, Long subscriptionId, Long interestId) { - String uid = String.valueOf(userId); - long modified = cacheRepository.removeSubscription(uid, subscriptionId.toString()); - reverseIndexService.removeUser(ReverseIndexDocument.makeInterestSubscribersKey(interestId), uid); - log.info("[CacheUpdate] 구독 제거: userId={}, subId={}, modified={}", uid, subscriptionId, modified); - } - - @Override - public void removeComment(Long commentId) { - Set userIds = reverseIndexService.getUserIds(Set.of( - ReverseIndexDocument.makeCommentAuthorKey(commentId), - ReverseIndexDocument.makeCommentLikesKey(commentId) - )); - if (userIds.isEmpty()) { - log.debug("[CacheUpdate] 영향 사용자 없음: commentId={}", commentId); - return; - } - long modified = cacheRepository.removeCommentEverywhere(userIds, commentId.toString()); - log.info("[CacheUpdate] 댓글 삭제 캐시 반영: commentId={}, users={}, modified={}", - commentId, userIds.size(), modified); - - reverseIndexService.deleteIndexes(Set.of( - ReverseIndexDocument.makeCommentAuthorKey(commentId), - ReverseIndexDocument.makeCommentLikesKey(commentId) - )); - } - - @Override - public void saveCache(String userId, UserActivityDto data) { - log.info("[CacheUpdate] 캐시 저장 시작: userId={}", userId); - UserActivityCacheDocument doc = documentMapper.toDocument(data); - cacheRepository.save(doc); - log.debug("[CacheUpdate] 캐시 저장 완료: userId={}", userId); - - buildReverseIndexes(userId, data); - log.info("[CacheUpdate] 캐시 및 역인덱스 저장 완료: userId={}", userId); - } - - /** - * 역인덱스 초기 생성 - */ - private void buildReverseIndexes(String userId, UserActivityDto data) { - data.getComments().forEach(comment -> { - reverseIndexService.addUser( - ReverseIndexDocument.makeCommentAuthorKey(Long.parseLong(comment.getId())), - userId - ); - }); - - data.getCommentLikes().forEach(like -> { - reverseIndexService.addUser( - ReverseIndexDocument.makeCommentLikesKey(Long.parseLong(like.getCommentId())), - userId - ); - }); - - data.getArticleViews().forEach(view -> { - reverseIndexService.addUser( - ReverseIndexDocument.makeArticleViewsKey(Long.parseLong(view.getArticleId())), - userId - ); - }); - - data.getSubscriptions().forEach(sub -> { - reverseIndexService.addUser( - ReverseIndexDocument.makeInterestSubscribersKey(Long.parseLong(sub.getInterestId())), - userId - ); - }); - - log.info("[CacheUpdate] 역인덱스 생성 완료: userId={}, 댓글작성={}개, 좋아요={}개, 기사조회={}개, 구독={}개", - userId, - data.getComments().size(), - data.getCommentLikes().size(), - data.getArticleViews().size(), - data.getSubscriptions().size()); - - - } -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java deleted file mode 100644 index f5a80ee..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.monew.monew_api.useractivity.service.Impl; - -import com.monew.monew_api.useractivity.document.ReverseIndexDocument; -import com.monew.monew_api.useractivity.repository.ReverseIndexRepository; -import com.monew.monew_api.useractivity.service.ReverseIndexService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Collections; -import java.util.Set; - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class ReverseIndexServiceImpl implements ReverseIndexService { - - private final ReverseIndexRepository reverseIndexRepository; - - /** - * 역인덱스에 사용자 추가 - * document key 형태 그대로 - * indexKey (예: "comment_123_likes") - */ - @Override - @Transactional - public void addUser(String indexKey, String userId) { - reverseIndexRepository.addUser(indexKey, userId); - log.debug("[ReverseIndex] add: key={}, user={}", indexKey, userId); - } - - /** - * 역인덱스에서 사용자 제거 - */ - public void removeUser(String indexKey, String userId) { - reverseIndexRepository.removeUser(indexKey, userId); - log.debug("[ReverseIndex] remove: key={}, user={}", indexKey, userId); - } - - /** - * 역인덱스에서 영향받는 사용자 ID 조회 - */ - @Override - public Set getUserIds(String indexKey) { - return reverseIndexRepository.findById(indexKey) - .map(ReverseIndexDocument::getUserIds) - .orElse(Collections.emptySet()); - } - - /** - * 여러 인덱스 키에서 사용자 ID 조회 - */ - @Override - public Set getUserIds(Set indexKeys) { - return reverseIndexRepository.findUserIdsByKeys(indexKeys); - } - - /** - * 역인덱스 일괄 삭제 - */ - @Override - @Transactional - public void deleteIndexes(Set indexKeys) { - reverseIndexRepository.deleteAllById(indexKeys); - log.debug("[ReverseIndex] deleteIndexes: {}개", indexKeys.size()); - } -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java deleted file mode 100644 index ea7cfa1..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.monew.monew_api.useractivity.service.Impl; - -import com.monew.monew_api.useractivity.event.CacheSaveEvent; -import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; -import com.monew.monew_api.useractivity.dto.UserActivityDto; -import com.monew.monew_api.useractivity.mapper.UserActivityMapper; -import com.monew.monew_api.useractivity.repository.UserActivityCacheRepository; -import com.monew.monew_api.useractivity.service.UserActivityCacheService; -import com.monew.monew_api.useractivity.service.UserActivityService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -/** - * 캐시 기반 사용자 활동 조회 서비스 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class UserActivityCacheServiceImpl implements UserActivityCacheService { - - private final UserActivityCacheRepository cacheRepository; - private final UserActivityService userActivityService; - private final UserActivityMapper mapper; - private final ApplicationEventPublisher eventPublisher; - - /** - * 캐시 기반 사용자 활동 조회 - * 1. MongoDB 캐시 조회 - * 2. Cache Hit → 반환 - * 3. Cache Miss → PostgreSQL 조회 → 비동기 캐시 저장 - */ - @Transactional(readOnly = true) - public UserActivityDto getUserActivityWithCache(String userId) { - log.info("[UserActivityCache] 사용자 활동 조회 시작 (캐시): userId={}", userId); - - Optional cached = cacheRepository.findById(userId); - - if (cached.isPresent()) { - log.info("캐시 히트: userId={}", userId); - return mapper.toDto(cached.get()); - } - - log.info("[UserActivityCache] 캐시 미스: userId={} - PostgreSQL 조회", userId); - UserActivityDto result = userActivityService.getUserActivity(userId); - - try { - eventPublisher.publishEvent(new CacheSaveEvent(userId, result)); - log.info("[UserActivityCache] 캐시 저장 이벤트 발행: userId={}", userId); - } catch (Exception e) { - log.error("[UserActivityCache] 캐시 저장 이벤트 발행 실패: userId={}", userId, e); - } - - log.info("[UserActivityCache] 사용자 활동 조회 완료 (캐시): userId={}", userId); - return result; - } -} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java index e527b79..32bff8b 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java @@ -1,15 +1,18 @@ package com.monew.monew_api.useractivity.service.Impl; +import com.fasterxml.jackson.databind.ObjectMapper; import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.common.exception.user.UserNotFoundException; import com.monew.monew_api.domain.user.User; import com.monew.monew_api.domain.user.repository.UserRepository; import com.monew.monew_api.subscribe.entity.Subscribe; +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; import com.monew.monew_api.useractivity.dto.UserActivityDto; import com.monew.monew_api.useractivity.mapper.UserActivityMapper; import com.monew.monew_api.useractivity.mapper.UserActivityRawMapper; +import com.monew.monew_api.useractivity.repository.UserActivityCacheRepository; import com.monew.monew_api.useractivity.repository.UserActivityRepository; import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; import com.monew.monew_api.useractivity.service.UserActivityService; @@ -19,21 +22,25 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; @Slf4j @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class UserActivityServiceImpl implements UserActivityService { private final UserRepository userRepository; private final UserActivityRepository activityRepository; + private final UserActivityCacheRepository cacheRepository; private final UserActivityMapper mapper; private final UserActivityRawMapper rawMapper; + private final ObjectMapper objectMapper; @Override @Transactional(readOnly = true) public UserActivityDto getUserActivity(String userId) { - log.info("[UserActivity] 사용자 활동내역 조회 시작: userId={}", userId); + log.info("사용자 활동내역 조회 시작: userId={}", userId); Long userIdLong = Long.parseLong(userId); @@ -41,20 +48,20 @@ public UserActivityDto getUserActivity(String userId) { .orElseThrow(UserNotFoundException::new); List subscriptions = activityRepository.findSubscriptionsByUserId(userIdLong); - log.info("[UserActivity] 구독 정보 조회 완료: {}건", subscriptions.size()); + log.info("구독 정보 조회 완료: {}건", subscriptions.size()); List comments = activityRepository.findRecentCommentsByUserId(userIdLong); - log.info("[UserActivity] 최근 댓글 조회 완료: {}건", comments.size()); + log.info("최근 댓글 조회 완료: {}건", comments.size()); List likes = activityRepository.findRecentLikesByUserId(userIdLong); - log.info("[UserActivity] 최근 좋아요 조회 완료: {}건", likes.size()); + log.info("최근 좋아요 조회 완료: {}건", likes.size()); List views = activityRepository.findRecentViewsByUserId(userIdLong); - log.info("[UserActivity] 최근 조회 기사 조회 완료: {}건", views.size()); + log.info("최근 조회 기사 조회 완료: {}건", views.size()); UserActivityDto result = mapper.toUserActivityDto(user, subscriptions, comments, likes, views); - log.info("[UserActivity] 사용자 활동내역 조회 완료: userId={}", userId); + log.info("사용자 활동내역 조회 완료: userId={}", userId); return result; } @@ -64,20 +71,21 @@ public UserActivityDto getUserActivity(String userId) { @Override @Transactional(readOnly = true) public UserActivityDto getUserActivitySingleQuery(String userId) { - log.info("[UserActivity] 사용자 활동내역 조회 시작 (단일 쿼리 - Record): userId={}", userId); + log.info("사용자 활동내역 조회 시작 (단일 쿼리 - Record): userId={}", userId); Long userIdLong = Long.parseLong(userId); UserActivityRaw raw = activityRepository.findUserActivityRaw(userIdLong); if (raw == null) { - log.error("[UserActivity] 사용자 활동 데이터를 찾을 수 없음: userId={}", userId); + log.error("사용자 활동 데이터를 찾을 수 없음: userId={}", userId); throw new UserNotFoundException(); } + // 2. Record → DTO 변환 UserActivityDto result = rawMapper.toDto(raw); - log.info("[UserActivity] 사용자 활동내역 조회 완료 (단일 쿼리): userId={}, 구독: {}건, 댓글: {}건, 좋아요: {}건, 조회: {}건", + log.info("사용자 활동내역 조회 완료 (단일 쿼리): userId={}, 구독: {}건, 댓글: {}건, 좋아요: {}건, 조회: {}건", userId, result.getSubscriptions().size(), result.getComments().size(), @@ -86,4 +94,37 @@ public UserActivityDto getUserActivitySingleQuery(String userId) { return result; } + + @Override + public UserActivityDto getUserActivityWithCache(String userId) { + log.info("사용자 활동내역 조회 시작 (캐시): userId={}", userId); + + Optional cached = cacheRepository.findById(userId); + + if (cached.isPresent()) { + log.info("Cache HIT: userId={}", userId); + log.info("사용자 활동내역 조회 완료 (캐시)"); + return mapper.toDto(cached.get()); + } + + log.info("Cache MISS: userId={}", userId); + + UserActivityDto result = getUserActivitySingleQuery(userId); + + saveToCache(result); + + log.info("사용자 활동내역 조회 완료 (캐시): userId={}", userId); + return result; + } + + private void saveToCache(UserActivityDto dto) { + try { + UserActivityCacheDocument document = mapper.toDocument(dto); + cacheRepository.save(document); + log.info("MongoDB 캐시 저장 완료: userId={}", dto.getId()); + } catch (Exception e) { + log.error("MongoDB 캐시 저장 실패: userId={}", dto.getId(), e); + throw new RuntimeException("캐시 저장에 실패했습니다.", e); + } + } } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java deleted file mode 100644 index fec6f92..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.monew.monew_api.useractivity.service; - -import java.util.Set; - -public interface ReverseIndexService { - void addUser(String indexKey, String userId); - void removeUser(String indexKey, String userId); - Set getUserIds(String indexKey); - Set getUserIds(Set indexKeys); - void deleteIndexes(Set indexKeys); -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java deleted file mode 100644 index ed091f5..0000000 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.monew.monew_api.useractivity.service; - -import com.monew.monew_api.useractivity.dto.UserActivityDto; - -public interface UserActivityCacheService { - /** - * 사용자 활동내역 조회 (캐시 적용) - * MongoDB 캐시 확인 → 없으면 PostgreSQL 조회 → 캐시 저장 - */ - UserActivityDto getUserActivityWithCache(String userId); -} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java index 418cc34..cc1cb55 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java @@ -12,4 +12,10 @@ public interface UserActivityService { * PostgreSQL에서 직접 조회 */ UserActivityDto getUserActivitySingleQuery(String userId); + + /** + * 사용자 활동내역 조회 (캐시 적용) ✅ 새로 추가! + * MongoDB 캐시 확인 → 없으면 PostgreSQL 조회 → 캐시 저장 + */ + UserActivityDto getUserActivityWithCache(String userId); } \ No newline at end of file From 4b58250a241b9736a8f5e6422cb930ae3341ebce Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Mon, 3 Nov 2025 16:19:55 +0900 Subject: [PATCH 145/178] =?UTF-8?q?refactor:=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=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 --- .../monew/monew_api/interest/service/InterestServiceImpl.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index c605074..4a67c75 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -163,6 +163,8 @@ public InterestDto updateInterestKeywords( return interestMapper.toInterestDto(interest, keywords, false); } + @Override + @Transactional public void deleteInterest(Long interestId) { Interest interest = interestRepository.findById(interestId) .orElseThrow(InterestNotFoundException::new); From 1e4e6cb10a6f6ede7e8ab6135b889583b3b3d50b Mon Sep 17 00:00:00 2001 From: truuuely Date: Mon, 3 Nov 2025 16:17:08 +0900 Subject: [PATCH 146/178] =?UTF-8?q?fix:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=83=9D=EC=84=B1=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EB=82=B4=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/eventlistener/NotificationEventListener.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/eventlistener/NotificationEventListener.java b/monew-api/src/main/java/com/monew/monew_api/notification/eventlistener/NotificationEventListener.java index 410c652..633b869 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/eventlistener/NotificationEventListener.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/eventlistener/NotificationEventListener.java @@ -21,7 +21,7 @@ public void handleCommentLiked(CommentLikedEvent event) { try { notificationService.createCommentLikeNotification(event); } catch (Exception e) { - log.error("알림 생성 실패: {}", event.commentId(), e); + log.error("좋아요 알림 생성 실패: {}", event.commentId(), e); } } } From fef43484ed01775595318e8f95ed009910c72d93 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Mon, 3 Nov 2025 17:18:25 +0900 Subject: [PATCH 147/178] =?UTF-8?q?fix=20:=20revert=20PR#29,=20-=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=ED=8C=8C=EC=9D=BC=20-=20CommentService.ja?= =?UTF-8?q?va,=20InterestRepository.java,=20InterestServiceImpl.java=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/event/ArticleViewedEvent.java | 61 ++++ .../article/service/ArticleService.java | 16 + .../event/CommentContentEditedEvent.java | 13 + .../comments/event/CommentCreatedEvent.java | 59 +++- .../comments/event/CommentDeletedEvent.java | 18 + .../comments/event/CommentLikedEvent.java | 61 +++- .../comments/event/CommentUnlikedEvent.java | 20 ++ .../comments/service/CommentService.java | 61 ++-- .../interest/event/InterestDeletedEvent.java | 17 + .../interest/event/InterestUpdatedEvent.java | 21 ++ .../repository/InterestRepository.java | 28 +- .../interest/service/InterestServiceImpl.java | 12 +- .../event/SubscriptionAddedEvent.java | 36 ++ .../event/SubscriptionRemovedEvent.java | 20 ++ .../service/SubscribeServiceImpl.java | 24 +- .../controller/UserActivityController.java | 15 +- .../document/ReverseIndexDocument.java | 78 +++++ .../document/UserActivityCacheDocument.java | 4 +- .../dto/CommentLikeActivityDto.java | 2 +- .../useractivity/dto/UserActivityDto.java | 5 +- .../useractivity/event/CacheSaveEvent.java | 16 + .../listener/ArticleViewEventListener.java | 56 ++++ .../listener/CacheSaveEventListener.java | 37 +++ .../CommentContentEditedEventListener.java | 25 ++ .../listener/CommentCreateEventListener.java | 53 +++ .../listener/CommentDeletedEventListener.java | 33 ++ .../listener/CommentLikedEventListener.java | 43 +++ .../listener/CommentUnlikedEventListener.java | 31 ++ .../InterestDeletedEventListener.java | 25 ++ .../listener/InterestUpdateEventListener.java | 37 +++ .../SubscriptionAddedEventListener.java | 34 ++ .../SubscriptionRemovedEventListener.java | 26 ++ .../mapper/UserActivityDocumentMapper.java | 12 + .../mapper/UserActivityMapper.java | 4 - .../Impl/ReverseIndexRepositoryImpl.java | 50 +++ .../Impl/UserActivityCacheRepositoryImpl.java | 197 +++++++++++ .../UserActivityRepositoryImpl.java | 3 +- .../ReverseIndexCustomRepository.java | 11 + .../repository/ReverseIndexRepository.java | 7 + .../UserActivityCacheCustomRepository.java | 38 +++ .../UserActivityCacheRepository.java | 2 +- .../service/CacheUpdateService.java | 126 +++++++ .../service/Impl/CacheUpdateServiceImpl.java | 314 ++++++++++++++++++ .../service/Impl/ReverseIndexServiceImpl.java | 69 ++++ .../Impl/UserActivityCacheServiceImpl.java | 61 ++++ .../service/Impl/UserActivityServiceImpl.java | 59 +--- .../service/ReverseIndexService.java | 11 + .../service/UserActivityCacheService.java | 11 + .../service/UserActivityService.java | 6 - 49 files changed, 1858 insertions(+), 110 deletions(-) create mode 100644 monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java rename monew-api/src/main/java/com/monew/monew_api/useractivity/repository/{ => Impl}/UserActivityRepositoryImpl.java (98%) create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java create mode 100644 monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java diff --git a/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java b/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java new file mode 100644 index 0000000..f18bcdc --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java @@ -0,0 +1,61 @@ +package com.monew.monew_api.article.event; + +import java.time.LocalDateTime; + +/** + * 기사 조회 이벤트 + * 사용자가 기사를 조회했을 때 발행 + * Update 전략 사용 + * @param viewId + * @param userId + * @param createdAt + * @param articleId + * @param source + * @param sourceUrl + * @param articleTitle + * @param articlePublishedDate + * @param articleSummary + * @param articleCommentCount + * @param articleViewCount + * @param occurredAt + */ +public record ArticleViewedEvent( + Long viewId, + Long userId, + LocalDateTime createdAt, + Long articleId, + String source, + String sourceUrl, + String articleTitle, + LocalDateTime articlePublishedDate, + String articleSummary, + Integer articleCommentCount, + Integer articleViewCount, + LocalDateTime occurredAt +) { + public static ArticleViewedEvent of( + Long id, + Long userId, + LocalDateTime createdAt, + Long articleId, + String source, + String sourceUrl, + String articleTitle, + LocalDateTime articlePublishedDate, + String articleSummary, + Integer articleCommentCount, + Integer articleViewCount + ) { + return new ArticleViewedEvent( + id, userId, createdAt, articleId, + source, sourceUrl, articleTitle, + articlePublishedDate, articleSummary, + articleCommentCount, articleViewCount, + LocalDateTime.now() + ); + } + + public Integer getDelta() { + return +1; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java index 95c1c03..61223c6 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java @@ -9,8 +9,10 @@ import com.monew.monew_api.article.repository.ArticleRepository; import com.monew.monew_api.article.repository.ArticleViewRepository; import com.monew.monew_api.common.exception.article.ArticleNotFoundException; +import com.monew.monew_api.article.event.ArticleViewedEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,6 +27,7 @@ public class ArticleService { private final ArticleRepository articleRepository; private final ArticleViewRepository articleViewRepository; + private final ApplicationEventPublisher eventPublisher; /** * 기사 조회 기록 등록 @@ -63,6 +66,19 @@ public ArticleViewDto recordArticleView(Long articleId, Long userId) { ArticleView articleView = new ArticleView(userId, articleId); ArticleView saved = articleViewRepository.save(articleView); article.increaseViewCount(); + eventPublisher.publishEvent( + ArticleViewedEvent.of( + saved.getId(), + saved.getUserId(), + saved.getCreatedAt(), + saved.getArticleId(), + article.getSource(), + article.getSourceUrl(), + article.getTitle(), + article.getPublishDate(), + article.getSummary(), + article.getCommentCount(), + article.getViewCount())); log.info("[조회 기록 성공] 기사 ID: {}, 사용자 ID: {}", articleId, userId); return ArticleViewDto.builder() diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java new file mode 100644 index 0000000..bfe89bc --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java @@ -0,0 +1,13 @@ +package com.monew.monew_api.comments.event; + +import java.time.LocalDateTime; + +public record CommentContentEditedEvent( + Long commentId, + String newContent, + LocalDateTime occurredAt +) { + public static CommentContentEditedEvent of(Long commentId, String newContent) { + return new CommentContentEditedEvent(commentId, newContent, LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java index c673b30..4705aa3 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java @@ -2,8 +2,57 @@ import java.time.LocalDateTime; -public record CommentCreatedEvent(Long commentId, - Long userId, - Long articleId, - LocalDateTime createdAt) { -} +/** + * 댓글 작성 이벤트 + * 사용자가 기사에 댓글을 작성했을 때 발행 + * 기사의 commentCount +1 + * Update 전략 사용 + * @param commentId + * @param articleId + * @param articleTitle + * @param userId + * @param userNickname + * @param content + * @param likeCount + * @param createdAt + * @param occurredAt + */ +public record CommentCreatedEvent( + Long commentId, + Long articleId, + String articleTitle, + Long userId, + String userNickname, + String content, + Integer likeCount, + LocalDateTime createdAt, + LocalDateTime occurredAt +) { + + public static CommentCreatedEvent of( + Long commentId, + Long articleId, + String articleTitle, + Long userId, + String userNickname, + String content, + Integer likeCount, + LocalDateTime createdAt + ) { + return new CommentCreatedEvent( + commentId, + articleId, + articleTitle, + userId, + userNickname, + content, + likeCount, + createdAt, + LocalDateTime.now() + ); + } + + public Integer getDelta() { + return 1; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java new file mode 100644 index 0000000..260a045 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java @@ -0,0 +1,18 @@ +package com.monew.monew_api.comments.event; + +import java.time.LocalDateTime; + +/** + * 댓글 삭제 이벤트 + * 댓글이 삭제되었을 때 발행 + * @param commentId + * @param occurredAt + */ +public record CommentDeletedEvent( + Long commentId, + LocalDateTime occurredAt +) { + public static CommentDeletedEvent of(Long commentId) { + return new CommentDeletedEvent(commentId, LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java index 7d9f4ad..1fd8f3a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java @@ -1,6 +1,59 @@ package com.monew.monew_api.comments.event; -public record CommentLikedEvent(Long commentId, - Long commentAuthorId, - String likerNickname) { -} +import java.time.LocalDateTime; + +/** + * 댓글 좋아요/취소 이벤트 + * 사용자가 댓글에 좋아요를 누르거나 취소했을 때 발행 + * Update 전략 사용 + * @param likeId + * @param likeCreatedAt + * @param commentId + * @param articleId + * @param articleTitle + * @param commentAuthorId + * @param commentUserNickname + * @param commentContent + * @param commentLikeCount + * @param commentCreatedAt + * @param likedByUserId + * @param likerNickname + * @param occurredAt + */ +public record CommentLikedEvent( + Long likeId, + LocalDateTime likeCreatedAt, + Long commentId, + Long articleId, + String articleTitle, + Long commentAuthorId, + String commentUserNickname, + String commentContent, + Integer commentLikeCount, + LocalDateTime commentCreatedAt, + Long likedByUserId, + String likerNickname, + LocalDateTime occurredAt +) { + public static CommentLikedEvent of( + Long likeId, + LocalDateTime likeCreatedAt, + Long commentId, + Long articleId, + String articleTitle, + Long commentAuthorId, + String commentUserNickname, + String commentContent, + Integer commentLikeCount, + LocalDateTime commentCreatedAt, + Long likedByUserId, + String likerNickname + ) { + return new CommentLikedEvent( + likeId, likeCreatedAt, commentId, articleId, articleTitle, + commentAuthorId, commentUserNickname, commentContent, + commentLikeCount, commentCreatedAt, likedByUserId, likerNickname, + LocalDateTime.now() + ); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java new file mode 100644 index 0000000..9d0a731 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java @@ -0,0 +1,20 @@ +package com.monew.monew_api.comments.event; + +import java.time.LocalDateTime; + +/** + * 댓글 좋아요 취소 이벤트 + * 사용자가 댓글 좋아요를 취소했을 때 발행 + * @param commentId + * @param likedByUserId + * @param occurredAt + */ +public record CommentUnlikedEvent( + Long commentId, + Long likedByUserId, + LocalDateTime occurredAt +) { + public static CommentUnlikedEvent of(Long commentId, Long likedByUserId) { + return new CommentUnlikedEvent(commentId, likedByUserId, LocalDateTime.now()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index 6cf8ba4..25239d5 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -5,8 +5,7 @@ import com.monew.monew_api.comments.dto.*; import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; -import com.monew.monew_api.comments.event.CommentCreatedEvent; -import com.monew.monew_api.comments.event.CommentLikedEvent; +import com.monew.monew_api.comments.event.*; import com.monew.monew_api.comments.repository.CommentLikeRepository; import com.monew.monew_api.comments.repository.CommentRepository; import com.monew.monew_api.common.exception.comment.*; @@ -14,7 +13,6 @@ import com.monew.monew_api.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; - import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,24 +32,32 @@ public class CommentService { // 댓글 작성 @Transactional public CommentDto register(CommentRegisterRequest request) { - log.info("[COMMENT][CREATE][START] userId={}, articleId={}", request.userId(), request.articleId()); - User user = getUserById(request.userId()); - Article article = getArticleById(request.articleId()); + log.info("[COMMENT][CREATE][START] userId={}, articleId={}", request.userId(), request.articleId()); + User user = getUserById(request.userId()); + Article article = getArticleById(request.articleId()); log.info("[COMMENT_COUNT] 댓글 작성 전 카운트: {}", article.getCommentCount()); Comment saved = commentRepository.save(Comment.of(user, article, request.content())); log.info("[COMMENT][CREATE] userId={}, articleId={}, commentId={}", user.getId(), article.getId(), saved.getId()); - eventPublisher.publishEvent( - new CommentCreatedEvent(saved.getId(), user.getId(), article.getId(), saved.getCreatedAt()) - ); - article.increaseCommentCount(); articleRepository.save(article); - log.info("[COMMENT_COUNT] 댓글 작성 후 카운트: {}", article.getCommentCount()); - return CommentDto.from(saved, false); + eventPublisher.publishEvent( + CommentCreatedEvent.of( + saved.getId(), + saved.getArticleId(), + article.getTitle(), + saved.getUserId(), + user.getNickname(), + saved.getContent(), + saved.getLikeCount(), + saved.getCreatedAt()) + ); + + log.info("[COMMENT_COUNT] 댓글 작성 후 카운트: {}", article.getCommentCount()); + return CommentDto.from(saved, false); } // 댓글 수정 @@ -66,6 +72,9 @@ public CommentDto update(Long userId, Long commentId, CommentUpdateRequest reque userId, commentId, request.content().length()); boolean likedByMe = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, userId); + + eventPublisher.publishEvent(CommentContentEditedEvent.of(commentId, request.content())); + return CommentDto.from(comment, likedByMe); } @@ -80,8 +89,19 @@ public CommentLikeDto like(Long userId, Long commentId) { comment.increaseLike(); eventPublisher.publishEvent( - new CommentLikedEvent(comment.getId(), comment.getUserId(), user.getNickname())); - + CommentLikedEvent.of( + saved.getId(), + saved.getCreatedAt(), + commentId, + comment.getArticle().getId(), + comment.getArticle().getTitle(), + comment.getUserId(), + comment.getUser().getNickname(), + comment.getContent(), + comment.getLikeCount(), + comment.getCreatedAt(), + user.getId(), + user.getNickname())); log.info("[COMMENT][LIKE] userId={}, commentId={}", userId, commentId); return CommentLikeDto.from(saved); } @@ -97,6 +117,8 @@ public void dislike(Long userId, Long commentId) { commentLikeRepository.deleteByComment_IdAndUser_Id(commentId, userId); commentRepository.decLikeCount(commentId); + eventPublisher.publishEvent(CommentUnlikedEvent.of(commentId, userId)); + log.info("[COMMENT][DISLIKE] userId={}, commentId={}", userId, commentId); } @@ -111,11 +133,12 @@ public void delete(Long commentId) { commentRepository.delete(comment); - article.decreaseCommentCount(); - articleRepository.save(article); - log.info("[COMMENT_COUNT] 댓글 삭제 후 카운트: {}", article.getCommentCount()); - log.info("[COMMENT][DELETE] commentId={}", commentId); - } + article.decreaseCommentCount(); + articleRepository.save(article); + eventPublisher.publishEvent(CommentDeletedEvent.of(commentId)); + log.info("[COMMENT_COUNT] 댓글 삭제 후 카운트: {}", article.getCommentCount()); + log.info("[COMMENT][DELETE] commentId={}", commentId); + } // 댓글 물리 삭제 @Transactional diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java new file mode 100644 index 0000000..ccc2b06 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.interest.event; + +import java.time.LocalDateTime; + +/** + * Interest 삭제 이벤트 + * @param interestId + * @param occurredAt + */ +public record InterestDeletedEvent( + Long interestId, + LocalDateTime occurredAt +) { + public static InterestDeletedEvent of(Long interestId) { + return new InterestDeletedEvent(interestId, LocalDateTime.now()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java new file mode 100644 index 0000000..02a84e8 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java @@ -0,0 +1,21 @@ +package com.monew.monew_api.interest.event; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Interest 정보 변경 이벤트 + * Interest 정보를 수정했을 때 발행 + * @param interestId + * @param newKeywords + * @param occurredAt + */ +public record InterestUpdatedEvent( + Long interestId, + List newKeywords, + LocalDateTime occurredAt +) { + public static InterestUpdatedEvent of(Long interestId, List newKeywords) { + return new InterestUpdatedEvent(interestId, newKeywords, LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java index 460c186..4a137ff 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java @@ -1,21 +1,29 @@ package com.monew.monew_api.interest.repository; import com.monew.monew_api.interest.entity.Interest; -import com.monew.monew_api.interest.entity.Keyword; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface InterestRepository extends JpaRepository, InterestRepositoryCustom { - @Query(""" - SELECT DISTINCT i - FROM Interest i - JOIN FETCH i.keywords ik - JOIN FETCH ik.keyword k - WHERE k = :keyword - """) - List findAllByKeyword(@Param("keyword") Keyword keyword); + @Query(""" + SELECT DISTINCT i + FROM Interest i + JOIN FETCH i.keywords ik + JOIN FETCH ik.keyword k + WHERE k = :keyword + """) + List findAllWithKeywords(); + + /** + * 특정 관심사와 해당 관심사에 연결된 키워드들을 함께 조회 + * author : 정영진 + * 캐시 데이터 업데이트에 필요 + */ + @EntityGraph(attributePaths = {"keywords", "keywords.keyword"}) + Optional findById(Long id); } diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index c605074..ed00fb2 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -5,9 +5,9 @@ import com.monew.monew_api.article.repository.InterestArticlesRepository; import com.monew.monew_api.common.exception.interest.InterestDuplicatedException; import com.monew.monew_api.common.exception.interest.InterestNotFoundException; -import com.monew.monew_api.common.exception.user.UserNotFoundException; -import com.monew.monew_api.user.User; import com.monew.monew_api.user.repository.UserRepository; +import com.monew.monew_api.interest.event.InterestDeletedEvent; +import com.monew.monew_api.interest.event.InterestUpdatedEvent; import com.monew.monew_api.interest.dto.InterestOrderBy; import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; import com.monew.monew_api.interest.dto.request.InterestRegisterRequest; @@ -35,6 +35,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.text.similarity.LevenshteinDistance; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -54,6 +55,7 @@ public class InterestServiceImpl implements InterestService { private final InterestArticleKeywordRepository interestArticleKeywordRepository; private final InterestMapper interestMapper; + private final ApplicationEventPublisher eventPublisher; @Override @Transactional @@ -160,6 +162,10 @@ public InterestDto updateInterestKeywords( .map(ik -> ik.getKeyword().getKeyword()) .collect(Collectors.toList()); + // 키워드 수정 이벤트 발행 + eventPublisher.publishEvent(InterestUpdatedEvent.of(interest.getId(), keywords)); + + log.info("interestId = {}, 관심사 키워드 수정 완료 : {}", interestId, keywords); return interestMapper.toInterestDto(interest, keywords, false); } @@ -193,6 +199,8 @@ public void deleteInterest(Long interestId) { log.info("삭제 제외된 기사 수(다른 관심사에서 사용 중): {}", undeletedCount); interestRepository.delete(interest); + eventPublisher.publishEvent(InterestDeletedEvent.of(interest.getId())); + log.info("관심사 삭제 완료: {}", interest.getName()); } diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java new file mode 100644 index 0000000..b616832 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java @@ -0,0 +1,36 @@ +package com.monew.monew_api.subscribe.event; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 구독 추가 이벤트 + * @param userId + * @param subscriptionId + * @param interestId + * @param interestName + * @param interestKeywords + * @param interestSubscriberCount + * @param createdAt + * @param occurredAt + */ +public record SubscriptionAddedEvent( + Long userId, + Long subscriptionId, + Long interestId, + String interestName, + List interestKeywords, + Integer interestSubscriberCount, + LocalDateTime createdAt, + LocalDateTime occurredAt +) { + public static SubscriptionAddedEvent of( + Long userId, Long subscriptionId, Long interestId, String interestName, + List interestKeywords, Integer interestSubscriberCount, LocalDateTime createdAt + ) { + return new SubscriptionAddedEvent( + userId, subscriptionId, interestId, interestName, interestKeywords, + interestSubscriberCount, createdAt, LocalDateTime.now() + ); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java new file mode 100644 index 0000000..9978c85 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java @@ -0,0 +1,20 @@ +package com.monew.monew_api.subscribe.event; + +import java.time.LocalDateTime; + +/** + * 구독 제거 이벤트 + * @param userId + * @param subscriptionId + * @param occurredAt + */ +public record SubscriptionRemovedEvent( + Long userId, + Long subscriptionId, + Long interestId, + LocalDateTime occurredAt +) { + public static SubscriptionRemovedEvent of(Long userId, Long subscriptionId, Long interestId) { + return new SubscriptionRemovedEvent(userId, subscriptionId, interestId, LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java index ecaf523..aac32f1 100644 --- a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java @@ -10,13 +10,18 @@ import com.monew.monew_api.interest.repository.InterestRepository; import com.monew.monew_api.subscribe.dto.SubscribeDto; import com.monew.monew_api.subscribe.entity.Subscribe; +import com.monew.monew_api.subscribe.event.SubscriptionAddedEvent; +import com.monew.monew_api.subscribe.event.SubscriptionRemovedEvent; import com.monew.monew_api.subscribe.mapper.SubscribeMapper; import com.monew.monew_api.subscribe.repository.SubscribeRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Slf4j @Service @RequiredArgsConstructor @@ -25,8 +30,8 @@ public class SubscribeServiceImpl implements SubscribeService { private final InterestRepository interestRepository; private final UserRepository userRepository; private final SubscribeRepository subscribeRepository; - private final SubscribeMapper subscribeMapper; + private final ApplicationEventPublisher eventPublisher; @Override @Transactional @@ -46,6 +51,20 @@ public SubscribeDto createSubscribe(Long interestId, Long userId) { Subscribe subscribe = Subscribe.create(interest, user); Subscribe saved = subscribeRepository.save(subscribe); + /* 이벤트 발행 keyword의 내용 그대로 캐시에 저장되어서 조회했습니다! */ + List keywordNames = interest.getKeywords().stream() + .map(ik -> ik.getKeyword().getKeyword()) + .toList(); + eventPublisher.publishEvent(SubscriptionAddedEvent.of( + user.getId(), + saved.getId(), + interest.getId(), + interest.getName(), + keywordNames, + interest.getSubscriberCount(), + saved.getCreatedAt() + )); + return subscribeMapper.toSubscribeDto(saved); } @@ -61,6 +80,9 @@ public void deleteSubscribe(Long interestId, Long userId) { .orElseThrow(SubscribeNotFoundException::new); subscribeRepository.delete(subscribe); + + eventPublisher.publishEvent(SubscriptionRemovedEvent.of(user.getId(), subscribe.getId(), interest.getId())); + log.info("현재 관심사 구독자 수 : {}", interest.getSubscriberCount()); interest.cancelSubscriberCount(); log.info("관심사 구독 취소 후 구독자 수: {}", interest.getSubscriberCount()); diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java index 189d200..818a688 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java @@ -1,6 +1,7 @@ package com.monew.monew_api.useractivity.controller; import com.monew.monew_api.useractivity.dto.UserActivityDto; +import com.monew.monew_api.useractivity.service.UserActivityCacheService; import com.monew.monew_api.useractivity.service.UserActivityService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,18 +18,26 @@ public class UserActivityController { private final UserActivityService userActivityService; - + private final UserActivityCacheService userActivityCacheService; /* + userActivityCacheService. mongoDB 사용 시 getUserActivityWithCache 메서드 + + userActivityService. 단일 쿼리 사용시 getUserActivitySingleQuery 메서드 (네이티브 쿼리) 여러 쿼리 사용시 getUserActivity 메서드 */ @GetMapping("/{userId}") public ResponseEntity getUserActivity(@PathVariable String userId) { - log.info("활동내역 조회 요청: userId={}", userId); + log.info("[활동내역 API 요청]: userId={}", userId); + +// UserActivityDto activity = userActivityService.getUserActivitySingleQuery(userId); + UserActivityDto activity = userActivityCacheService.getUserActivityWithCache(userId); - UserActivityDto activity = userActivityService.getUserActivitySingleQuery(userId); + log.info("[활동내역 API 응답]: userId={}, Subscriptions_size={}, Comments_size={}, CommentLikes_size={}, ArticleViews_size={}", + activity.getId(), activity.getSubscriptions().size(), activity.getComments().size(), + activity.getCommentLikes().size(), activity.getArticleViews().size()); return ResponseEntity.ok(activity); } diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java new file mode 100644 index 0000000..8061093 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java @@ -0,0 +1,78 @@ +package com.monew.monew_api.useractivity.document; + +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +/* + 역인덱스 문서 + - key : id 패턴 "도메인_{id}_행동" + - 댓글 작성자: "comment_{id}_author" -> {userIds} + - 댓글 좋아요: "comment_{id}_likes" -> {userIds} + - 기사 조회: "article_{id}_views" -> {userIds} + */ +@Document(collection = "reverse_indexes") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReverseIndexDocument { + + @Id + private String id; + + @Builder.Default + private Set userIds = new HashSet<>(); + + private LocalDateTime createdAt; + + @Indexed(name = "index_ttl", expireAfter = "1h") + private LocalDateTime updatedAt; + + /** + * 댓글 작성자 역인덱스 키 생성 + * + * @param commentId 댓글 ID + * @return "comment_{commentId}_author" + */ + public static String makeCommentAuthorKey(Long commentId) { + return "comment_" + commentId + "_author"; + } + + /** + * 댓글 좋아요 역인덱스 키 생성 + * + * @param commentId 댓글 ID + * @return "comment_{commentId}_likes" + */ + public static String makeCommentLikesKey(Long commentId) { + return "comment_" + commentId + "_likes"; + } + + /** + * 기사 조회 역인덱스 키 생성 + * + * @param articleId 기사 ID + * @return "article_{articleId}_views" + */ + public static String makeArticleViewsKey(Long articleId) { + return "article_" + articleId + "_views"; + } + + /** + * Interest 구독자 역인덱스 키 생성 + * @param interestId + * @return + */ + public static String makeInterestSubscribersKey(Long interestId) { + return "interest_" + interestId + "_subs"; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java index 51aa1bf..ed35f32 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java @@ -34,6 +34,6 @@ public class UserActivityCacheDocument { private List commentLikes; private List articleViews; - @Indexed(expireAfter = "1h") - private LocalDateTime cachedAt; + @Indexed(name = "cache_ttl", expireAfter = "1h") + private LocalDateTime updatedAt; } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java index 8126f29..068cf1c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java @@ -35,7 +35,7 @@ public class CommentLikeActivityDto { @JsonAlias({"article_title"}) private String articleTitle; - @JsonProperty("commentUserId") + @JsonProperty("commentAuthorId") @JsonAlias({"comment_user_id"}) private String commentUserId; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java index da3b95c..d46b2fd 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java @@ -1,6 +1,9 @@ package com.monew.monew_api.useractivity.dto; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.List; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java new file mode 100644 index 0000000..8760828 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java @@ -0,0 +1,16 @@ +package com.monew.monew_api.useractivity.event; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; + +/** + * 캐시 저장 이벤트 + * PostgreSQL 조회 후 MongoDB에 비동기 캐시 저장 + * useractivity 내부, create 전략 + * @param userId + * @param data + */ +public record CacheSaveEvent( + String userId, + UserActivityDto data +) { +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java new file mode 100644 index 0000000..94dbf59 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java @@ -0,0 +1,56 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.article.event.ArticleViewedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 기사 조회 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ArticleViewEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ArticleViewedEvent event) { + log.info("[Listener] 기사 조회 이벤트 수신: articleId={}, userId={}", + event.articleId(), event.userId()); + + try { + // 1. 조회수 증가 (기존 조회한 사람들) + cacheUpdateService.incrementArticleViewCount( + event.articleId(), + event.getDelta() + ); + + // 2. 조회한 사람 캐시에 추가 + 역인덱스 생성 + cacheUpdateService.addArticleView( + event.viewId(), + event.userId(), + event.createdAt(), + event.articleId(), + event.source(), + event.sourceUrl(), + event.articleTitle(), + event.articlePublishedDate(), + event.articleSummary(), + event.articleCommentCount(), + event.articleViewCount() + ); + + log.info("[Listener] 기사 조회 캐시 업데이트 완료: articleId={}", event.articleId()); + + } catch (Exception e) { + log.error("[Listener] 기사 조회 캐시 업데이트 실패: articleId={}", event.articleId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java new file mode 100644 index 0000000..b0ff91e --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java @@ -0,0 +1,37 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.useractivity.event.CacheSaveEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 캐시 저장 이벤트 리스너 + * PostgreSQL 조회 후 MongoDB에 비동기 저장 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CacheSaveEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CacheSaveEvent event) { + log.info("[Listener] 캐시 저장 이벤트 수신: userId={}", event.userId()); + + try { + cacheUpdateService.saveCache( + event.userId(), + event.data() + ); + } catch (Exception e) { + log.error("[Listener] 캐시 저장 실패: userId={}", event.userId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java new file mode 100644 index 0000000..73bbcec --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java @@ -0,0 +1,25 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentContentEditedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentContentEditedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentContentEditedEvent e) { + cacheUpdateService.updateCommentContent(e.commentId(), e.newContent()); + log.info("[Listener] CommentContentEdited handled: commentId={}", e.commentId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java new file mode 100644 index 0000000..86d3ef6 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java @@ -0,0 +1,53 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentCreatedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 댓글 작성 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentCreateEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentCreatedEvent event) { + log.info("[Listener] 댓글 작성 이벤트 수신: commentId={}, userId={}, articleId={}", + event.commentId(), event.userId(), event.articleId()); + + try { + // 1. 기사 댓글수 증가 (기존 조회한 사람들) + cacheUpdateService.incrementArticleCommentCount( + event.articleId(), + event.getDelta() + ); + + // 2. 작성자 캐시에 댓글 추가 + 역인덱스 생성 + cacheUpdateService.addComment( + event.commentId(), + event.userId(), + event.userNickname(), + event.articleId(), + event.articleTitle(), + event.content(), + event.likeCount(), + event.createdAt() + ); + + log.info("[Listener] 댓글 작성 캐시 업데이트 완료: commentId={}", event.commentId()); + + } catch (Exception e) { + log.error("[Listener] 댓글 작성 캐시 업데이트 실패: commentId={}", event.commentId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java new file mode 100644 index 0000000..51a9c15 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java @@ -0,0 +1,33 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentDeletedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 댓글 삭제 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentDeletedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentDeletedEvent event) { + log.info("[Listener] 댓글 삭제 이벤트 수신: commentId={}", event.commentId()); + + try { + cacheUpdateService.removeComment(event.commentId()); + } catch (Exception e) { + log.error("[Listener] 댓글 삭제 캐시 처리 실패: commentId={}", event.commentId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java new file mode 100644 index 0000000..cc88a3d --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java @@ -0,0 +1,43 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentLikedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 댓글 좋아요 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentLikedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentLikedEvent e) { + cacheUpdateService.addCommentLike( + e.likeId(), + e.likedByUserId(), + e.likeCreatedAt(), + e.commentId(), + e.articleId(), + e.articleTitle(), + e.commentAuthorId(), + e.commentUserNickname(), + e.commentContent(), + e.commentLikeCount(), + e.commentCreatedAt() + ); + cacheUpdateService.updateCommentLikeCount(e.commentId(), +1); + + log.info("[Listener] CommentLikedEvent handled: commentId={}, likedBy={}", + e.commentId(), e.likedByUserId()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java new file mode 100644 index 0000000..2cd67d6 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java @@ -0,0 +1,31 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentUnlikedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 댓글 좋아요 취소 이벤트 리스너 + * 사용자가 댓글 좋아요를 취소했을 때 캐시 업데이트 수행 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentUnlikedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentUnlikedEvent e) { + cacheUpdateService.updateCommentLikeCount(e.commentId(), -1); + cacheUpdateService.removeCommentLike(e.likedByUserId(), e.commentId()); + log.info("[Listener] CommentUnlikedEvent handled: commentId={}, likedBy={}", + e.commentId(), e.likedByUserId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java new file mode 100644 index 0000000..50c662b --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java @@ -0,0 +1,25 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.interest.event.InterestDeletedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class InterestDeletedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(InterestDeletedEvent e) { + cacheUpdateService.removeInterest(e.interestId()); + log.info("[Listener] InterestDeleted handled: interestId={}", e.interestId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java new file mode 100644 index 0000000..003c0fb --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java @@ -0,0 +1,37 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.interest.event.InterestUpdatedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Interest 정보 변경 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class InterestUpdateEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(InterestUpdatedEvent event) { + log.info("[Listener] Interest 정보 변경 이벤트 수신: interestId={}", + event.interestId()); + + try { + cacheUpdateService.updateInterestKeyword( + event.interestId(), + event.newKeywords() + ); + } catch (Exception e) { + log.error("[Listener] Interest 정보 캐시 업데이트 실패: interestId={}", event.interestId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java new file mode 100644 index 0000000..62e4f80 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java @@ -0,0 +1,34 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import com.monew.monew_api.subscribe.event.SubscriptionAddedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SubscriptionAddedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(SubscriptionAddedEvent e) { + cacheUpdateService.addSubscription( + e.userId(), + e.subscriptionId(), + e.interestId(), + e.interestName(), + e.interestKeywords(), + e.interestSubscriberCount(), + e.createdAt() + ); + log.info("[Listener] SubscriptionAdded handled: userId={}, subId={}, interestId={}", + e.userId(), e.subscriptionId(), e.interestId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java new file mode 100644 index 0000000..a9e13ff --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java @@ -0,0 +1,26 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import com.monew.monew_api.subscribe.event.SubscriptionRemovedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SubscriptionRemovedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(SubscriptionRemovedEvent e) { + cacheUpdateService.removeSubscription(e.userId(), e.subscriptionId(), e.interestId()); + log.info("[Listener] SubscriptionRemoved handled: userId={}, subId={}", + e.userId(), e.subscriptionId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java new file mode 100644 index 0000000..251a9ea --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java @@ -0,0 +1,12 @@ +package com.monew.monew_api.useractivity.mapper; + +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import com.monew.monew_api.useractivity.dto.UserActivityDto; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface UserActivityDocumentMapper { + @Mapping(target = "updatedAt", expression = "java(java.time.LocalDateTime.now())") + UserActivityCacheDocument toDocument(UserActivityDto dto); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java index 26cc180..f23ffd9 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java @@ -62,7 +62,6 @@ default UserActivityDto toUserActivityDto( @Mapping(target = "commentId", expression = "java(String.valueOf(commentLike.getComment().getId()))") @Mapping(target = "articleId", expression = "java(String.valueOf(commentLike.getComment().getArticle().getId()))") @Mapping(target = "articleTitle", source = "comment.article.title") - @Mapping(target = "commentUserId", expression = "java(String.valueOf(commentLike.getComment().getUser().getId()))") @Mapping(target = "commentUserNickname", source = "comment.user.nickname") @Mapping(target = "commentContent", source = "comment.content") @Mapping(target = "commentLikeCount", source = "comment.likeCount") @@ -71,9 +70,6 @@ default UserActivityDto toUserActivityDto( List toCommentLikeDtos(List commentLikes); - @Mapping(target = "cachedAt", expression = "java(java.time.LocalDateTime.now())") - UserActivityCacheDocument toDocument(UserActivityDto dto); - UserActivityDto toDto(UserActivityCacheDocument document); default List mapKeywords(Interest interest) { diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java new file mode 100644 index 0000000..9a92a9f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java @@ -0,0 +1,50 @@ +package com.monew.monew_api.useractivity.repository.Impl; + +import com.monew.monew_api.useractivity.document.ReverseIndexDocument; +import com.monew.monew_api.useractivity.repository.ReverseIndexCustomRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +@Repository +@RequiredArgsConstructor +public class ReverseIndexRepositoryImpl implements ReverseIndexCustomRepository { + + private final MongoTemplate mongoTemplate; + + @Override + public void addUser(String indexKey, String userId) { + Query q = Query.query(Criteria.where("_id").is(indexKey)); + Update u = new Update() + .addToSet("userIds", userId) + .set("updatedAt", LocalDateTime.now()); + mongoTemplate.upsert(q, u, ReverseIndexDocument.class); + } + + @Override + public void removeUser(String indexKey, String userId) { + Query q = Query.query(Criteria.where("_id").is(indexKey)); + Update u = new Update() + .pull("userIds", userId) + .set("updatedAt", LocalDateTime.now()); + mongoTemplate.updateFirst(q, u, ReverseIndexDocument.class); + } + + @Override + public Set findUserIdsByKeys(Set indexKeys) { + if (indexKeys.isEmpty()) return Collections.emptySet(); + Query q = Query.query(Criteria.where("_id").in(indexKeys)); + return mongoTemplate.find(q, ReverseIndexDocument.class) + .stream() + .flatMap(doc -> doc.getUserIds().stream()) + .collect(Collectors.toSet()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java new file mode 100644 index 0000000..ae69fbc --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java @@ -0,0 +1,197 @@ +package com.monew.monew_api.useractivity.repository.Impl; + +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.dto.CommentActivityDto; +import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto; +import com.monew.monew_api.useractivity.dto.SubscribesActivityDto; +import com.monew.monew_api.useractivity.repository.UserActivityCacheCustomRepository; +import com.mongodb.BasicDBObject; +import com.mongodb.client.result.UpdateResult; +import lombok.RequiredArgsConstructor; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Repository +@RequiredArgsConstructor +public class UserActivityCacheRepositoryImpl implements UserActivityCacheCustomRepository { + + private final MongoTemplate mongo; + + @Override + public long incCommentLikeCount(Set userIds, String commentId, int delta) { + if (userIds.isEmpty()) return 0; + + var q1 = Query.query(where("_id").in(userIds).and("comments.id").is(commentId)); + var u1 = new Update() + .inc("comments.$.likeCount", delta) + .set("updatedAt", LocalDateTime.now()); + UpdateResult r1 = mongo.updateMulti(q1, u1, UserActivityCacheDocument.class); + + var q2 = Query.query(where("_id").in(userIds).and("commentLikes.commentId").is(commentId)); + var u2 = new Update().inc("commentLikes.$.commentLikeCount", delta) + .set("updatedAt", LocalDateTime.now()); + UpdateResult r2 = mongo.updateMulti(q2, u2, UserActivityCacheDocument.class); + + return r1.getModifiedCount() + r2.getModifiedCount(); + } + + @Override + public long incArticleViewCount(Set userIds, String articleId, int delta) { + if (userIds.isEmpty()) return 0; + var q = Query.query(where("_id").in(userIds).and("articleViews.articleId").is(articleId)); + var u = new Update() + .inc("articleViews.$.articleViewCount", delta) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long incArticleCommentCount(Set userIds, String articleId, int delta) { + if (userIds.isEmpty()) return 0; + var q = Query.query(where("_id").in(userIds).and("articleViews.articleId").is(articleId)); + var u = new Update() + .inc("articleViews.$.articleCommentCount", delta) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long pushCommentLike(String userId, CommentLikeActivityDto dto, int keepLatest) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .push("commentLikes") + .atPosition(0) + .slice(keepLatest) + .each(dto) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long pullCommentLike(String userId, String commentId) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .pull("commentLikes", new BasicDBObject("commentId", commentId)) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long pushComment(String userId, CommentActivityDto dto, int keepLatest) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .push("comments") + .atPosition(0) + .slice(keepLatest) + .each(dto) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long updateCommentContentForUsers(Set userIds, String commentId, String newContent) { + if (userIds.isEmpty()) return 0; + + var q1 = Query.query(where("_id").in(userIds).and("comments.id").is(commentId)); + var u1 = new Update() + .set("comments.$.content", newContent) + .set("updatedAt", LocalDateTime.now()); + var r1 = mongo.updateMulti(q1, u1, UserActivityCacheDocument.class); + + var q2 = Query.query(where("_id").in(userIds).and("commentLikes.commentId").is(commentId)); + var u2 = new Update() + .set("commentLikes.$[l].commentContent", newContent) + .set("updatedAt", LocalDateTime.now()); + u2.filterArray(where("l.commentId").is(commentId)); + var r2 = mongo.updateMulti(q2, u2, UserActivityCacheDocument.class); + + return r1.getModifiedCount() + r2.getModifiedCount(); + } + + + @Override + public long removeCommentEverywhere(Set userIds, String commentId) { + if (userIds.isEmpty()) return 0; + var q = Query.query(where("_id").in(userIds)); + var u = new Update() + .pull("comments", new BasicDBObject("id", commentId)) + .pull("commentLikes", new BasicDBObject("commentId", commentId)) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long pushArticleView(String userId, ArticleViewActivityDto dto, int keepLatest) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .push("articleViews") + .atPosition(0) + .slice(keepLatest) + .each(dto) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long updateInterestKeywords(String interestId, List newKeywords) { + var q = Query.query(where("subscriptions.interestId").is(interestId)); + var u = new Update() + .set("subscriptions.$[it].interestKeywords", newKeywords) + .set("updatedAt", LocalDateTime.now()); + u.filterArray(where("it.interestId").is(interestId)); + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long removeInterestEverywhere(Set userIds, String interestId) { + if (userIds.isEmpty()) return 0; + + Query q = Query.query(Criteria.where("_id").in(userIds)); + Update u = new Update() + .pull("subscriptions", new BasicDBObject("interestId", interestId)) + .set("updatedAt", LocalDateTime.now()); + + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long addSubscription(String userId, SubscribesActivityDto dto) { + var q = Query.query(where("_id").is(userId)); + + if (dto.getId() != null) { + var pullExisting = new Update() + .pull("subscriptions", Query.query(where("id").is(dto.getId())).getQueryObject()) + .set("updatedAt", LocalDateTime.now()); + mongo.updateFirst(q, pullExisting, UserActivityCacheDocument.class); + } + + var push = new Update() + .push("subscriptions") + .atPosition(0) + .slice(10) + .each(dto) + .set("updatedAt", LocalDateTime.now()); + + var result = mongo.updateFirst(q, push, UserActivityCacheDocument.class); + return result.getModifiedCount(); + } + + @Override + public long removeSubscription(String userId, String subscriptionId) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .pull("subscriptions", Query.query(where("id").is(subscriptionId)).getQueryObject()) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java similarity index 98% rename from monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java rename to monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java index bc6a253..7335e30 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepositoryImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java @@ -1,10 +1,11 @@ -package com.monew.monew_api.useractivity.repository; +package com.monew.monew_api.useractivity.repository.Impl; import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.interest.entity.QKeyword; import com.monew.monew_api.subscribe.entity.Subscribe; import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.repository.UserActivityRepository; import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; import com.querydsl.core.types.Projections; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java new file mode 100644 index 0000000..4583392 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java @@ -0,0 +1,11 @@ +package com.monew.monew_api.useractivity.repository; + +import java.util.Set; + +public interface ReverseIndexCustomRepository { + void addUser(String indexKey, String userId); + + void removeUser(String indexKey, String userId); + + Set findUserIdsByKeys(Set indexKeys); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java new file mode 100644 index 0000000..8080a8d --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java @@ -0,0 +1,7 @@ +package com.monew.monew_api.useractivity.repository; + +import com.monew.monew_api.useractivity.document.ReverseIndexDocument; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface ReverseIndexRepository extends MongoRepository, ReverseIndexCustomRepository { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java new file mode 100644 index 0000000..f506111 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java @@ -0,0 +1,38 @@ +package com.monew.monew_api.useractivity.repository; + +import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.dto.CommentActivityDto; +import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto; +import com.monew.monew_api.useractivity.dto.SubscribesActivityDto; + +import java.util.List; +import java.util.Set; + +public interface UserActivityCacheCustomRepository { + + long incCommentLikeCount(Set userIds, String commentId, int delta); + + long incArticleViewCount(Set userIds, String articleId, int delta); + + long incArticleCommentCount(Set userIds, String articleId, int delta); + + long pushCommentLike(String userId, CommentLikeActivityDto dto, int keepLatest); + + long pullCommentLike(String userId, String commentId); + + long pushComment(String userId, CommentActivityDto dto, int keepLatest); + + long updateCommentContentForUsers(Set userIds, String commentId, String newContent); + + long removeCommentEverywhere(Set userIds, String commentId); + + long pushArticleView(String userId, ArticleViewActivityDto dto, int keepLatest); + + long updateInterestKeywords(String interestId, List newKeywords); + + long removeInterestEverywhere(Set userIds, String interestId); + + long addSubscription(String userId, SubscribesActivityDto dto); + + long removeSubscription(String userId, String subscriptionId); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java index eb59229..e6e2e1d 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java @@ -5,5 +5,5 @@ import org.springframework.stereotype.Repository; @Repository -public interface UserActivityCacheRepository extends MongoRepository { +public interface UserActivityCacheRepository extends MongoRepository, UserActivityCacheCustomRepository { } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java new file mode 100644 index 0000000..a74bed0 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java @@ -0,0 +1,126 @@ +package com.monew.monew_api.useractivity.service; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; +import org.hibernate.mapping.Set; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 캐시 업데이트 서비스 인터페이스 + */ +public interface CacheUpdateService { + + /** + * 댓글 좋아요수 증가/감소 + */ + void updateCommentLikeCount(Long commentId, Integer delta); + + /** + * 기사 조회수 증가 + */ + void incrementArticleViewCount(Long articleId, Integer delta); + + /** + * 기사 댓글수 증가 + */ + void incrementArticleCommentCount(Long articleId, Integer delta); + + /** + * Interest 정보 업데이트 + */ + void updateInterestKeyword(Long interestId, List newKeywords); + + /** + * Interest 삭제 처리 + * @param interestId + */ + void removeInterest(Long interestId); + + /** + * 구독 추가 + */ + void addSubscription(Long userId, Long subscriptionId, Long interestId, String interestName, + List interestKeywords, Integer interestSubscriberCount, LocalDateTime createdAt); + + /** + * 구독 취소 + */ + void removeSubscription(Long userId, Long subscriptionId, Long interestId); + + /** + * 댓글 생성 시 캐시 데이터 + 역인덱스 업데이트 + * @param id + * @param userId + * @param userNickname + * @param articleId + * @param articleTitle + * @param content + * @param likeCount + * @param createdAt + */ + void addComment(Long id, Long userId, String userNickname, Long articleId, String articleTitle, + String content, Integer likeCount, LocalDateTime createdAt); + + /** + * 좋아요 생성 시 캐시 데이터 + 역인덱스 업데이트 + * @param id + * @param userId + * @param createdAt + * @param commentId + * @param articleId + * @param articleTitle + * @param commentUserId + * @param commentUserNickname + * @param commentContent + * @param commentLikeCount + * @param commentCreatedAt + */ + void addCommentLike(Long id, Long userId, LocalDateTime createdAt, Long commentId, Long articleId, String articleTitle, + Long commentUserId, String commentUserNickname, String commentContent, Integer commentLikeCount, + LocalDateTime commentCreatedAt); + + /** + * 댓글 내용 수정 시 캐시 데이터 + 역인덱스 업데이트 + * @param commentId + * @param newContent + */ + void updateCommentContent(Long commentId, String newContent); + + /** + * 기사 조회 생성 시 캐시 데이터 + 역인덱스 업데이트 + * @param id + * @param userId + * @param createdAt + * @param articleId + * @param source + * @param sourceUrl + * @param articleTitle + * @param articlePublishedDate + * @param articleSummary + * @param articleCommentCount + * @param articleViewCount + */ + void addArticleView(Long id, + Long userId, LocalDateTime createdAt, + Long articleId, String source, String sourceUrl, + String articleTitle, LocalDateTime articlePublishedDate, + String articleSummary, Integer articleCommentCount, + Integer articleViewCount); + + /** + * 좋아요 삭제 처리 + * @param userId + * @param commentId + */ + void removeCommentLike(Long userId, Long commentId); + /** + * 댓글 삭제 처리 + */ + void removeComment(Long commentId); + + /** + * 캐시 저장 (PostgreSQL 조회 후 비동기 저장) + */ + void saveCache(String userId, UserActivityDto data); +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java new file mode 100644 index 0000000..a4bfa5d --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java @@ -0,0 +1,314 @@ +package com.monew.monew_api.useractivity.service.Impl; + +import com.monew.monew_api.useractivity.document.ReverseIndexDocument; +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import com.monew.monew_api.useractivity.dto.*; +import com.monew.monew_api.useractivity.mapper.UserActivityDocumentMapper; +import com.monew.monew_api.useractivity.repository.UserActivityCacheRepository; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import com.monew.monew_api.useractivity.service.ReverseIndexService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +/** + * 캐시 업데이트 서비스 구현체 + * MongoDB 캐시를 부분 업데이트 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CacheUpdateServiceImpl implements CacheUpdateService { + + private final ReverseIndexService reverseIndexService; + private final UserActivityCacheRepository cacheRepository; + private final UserActivityDocumentMapper documentMapper; + + @Override + public void updateCommentLikeCount(Long commentId, Integer delta) { + Set userIds = reverseIndexService.getUserIds(Set.of( + ReverseIndexDocument.makeCommentAuthorKey(commentId), + ReverseIndexDocument.makeCommentLikesKey(commentId) + )); + if (userIds.isEmpty()) { + log.debug("[CacheUpdate] 영향 사용자 없음: commentId={}", commentId); + return; + } + long modified = cacheRepository.incCommentLikeCount(userIds, commentId.toString(), delta); + log.info("[CacheUpdate] 댓글 좋아요수 업데이트: commentId={}, delta={}, users={}, modified={}", + commentId, delta, userIds.size(), modified); + } + + @Override + public void incrementArticleViewCount(Long articleId, Integer delta) { + Set viewers = reverseIndexService.getUserIds( + ReverseIndexDocument.makeArticleViewsKey(articleId) + ); + if (viewers.isEmpty()) { + log.debug("[CacheUpdate] 영향 사용자 없음: articleId={}", articleId); + return; + } + long modified = cacheRepository.incArticleViewCount(viewers, articleId.toString(), delta); + log.info("[CacheUpdate] 기사 조회수 업데이트: articleId={}, delta={}, users={}, modified={}", + articleId, delta, viewers.size(), modified); + } + + @Override + public void incrementArticleCommentCount(Long articleId, Integer delta) { + Set viewers = reverseIndexService.getUserIds( + ReverseIndexDocument.makeArticleViewsKey(articleId) + ); + if (viewers.isEmpty()) { + log.debug("[CacheUpdate] 영향 사용자 없음: articleId={}", articleId); + return; + } + long modified = cacheRepository.incArticleCommentCount(viewers, articleId.toString(), delta); + log.info("[CacheUpdate] 기사 댓글수 업데이트: articleId={}, delta={}, users={}, modified={}", + articleId, delta, viewers.size(), modified); + } + + + @Override + public void addComment(Long id, Long userId, String userNickname, + Long articleId, String articleTitle, String content, + Integer likeCount, LocalDateTime createdAt) { + + String uid = userId.toString(); + CommentActivityDto dto = CommentActivityDto.builder() + .id(id.toString()) + .userId(uid) + .userNickname(userNickname) + .articleId(articleId.toString()) + .articleTitle(articleTitle) + .content(content) + .likeCount(likeCount) + .createdAt(createdAt) + .build(); + + + long modified = cacheRepository.pushComment(uid, dto, 10); + if (modified == 0) { + log.warn("[CacheUpdate] 캐시 없음(만료?): userId={}, commentId={}", uid, id); + } + reverseIndexService.addUser(ReverseIndexDocument.makeCommentAuthorKey(id), uid); + log.info("[CacheUpdate] 댓글 추가: commentId={}, userId={}, modified={}", id, uid, modified); + } + + @Override + public void addCommentLike(Long id, Long userId, LocalDateTime createdAt, + Long commentId, Long articleId, String articleTitle, + Long commentUserId, String commentUserNickname, + String commentContent, Integer commentLikeCount, + LocalDateTime commentCreatedAt) { + String uid = userId.toString(); + CommentLikeActivityDto dto = CommentLikeActivityDto.builder() + .id(id.toString()) + .createdAt(createdAt) + .commentId(commentId.toString()) + .articleId(articleId.toString()) + .articleTitle(articleTitle) + .commentUserId(commentUserId.toString()) + .commentUserNickname(commentUserNickname) + .commentContent(commentContent) + .commentLikeCount(commentLikeCount) + .commentCreatedAt(commentCreatedAt) + .build(); + + long modified = cacheRepository.pushCommentLike(uid, dto, 10); + if (modified == 0) { + log.warn("[CacheUpdate] 캐시 없음(만료?): userId={}, likeId={}", uid, id); + } + reverseIndexService.addUser(ReverseIndexDocument.makeCommentLikesKey(commentId), uid); + log.info("[CacheUpdate] 댓글 좋아요 추가: commentId={}, userId={}, modified={}", commentId, uid, modified); + } + + @Override + public void updateCommentContent(Long commentId, String newContent) { + Set userIds = reverseIndexService.getUserIds(Set.of( + ReverseIndexDocument.makeCommentAuthorKey(commentId), + ReverseIndexDocument.makeCommentLikesKey(commentId) + )); + if (userIds.isEmpty()) { + log.debug("[CacheUpdate] 댓글 내용 수정 영향 사용자 없음: commentId={}", commentId); + return; + } + long modified = cacheRepository.updateCommentContentForUsers(userIds, commentId.toString(), newContent); + log.info("[CacheUpdate] 댓글 내용 수정 반영: commentId={}, users={}, modified={}", + commentId, userIds.size(), modified); + } + + @Override + public void removeCommentLike(Long userId, Long commentId) { + String uid = userId.toString(); + long modified = cacheRepository.pullCommentLike(uid, commentId.toString()); + reverseIndexService.removeUser(ReverseIndexDocument.makeCommentLikesKey(commentId), uid); + log.info("[CacheUpdate] 댓글 좋아요 제거: commentId={}, userId={}, modified={}", commentId, uid, modified); + } + + @Override + public void addArticleView(Long id, Long userId, LocalDateTime createdAt, + Long articleId, String source, String sourceUrl, + String articleTitle, LocalDateTime articlePublishedDate, + String articleSummary, Integer articleCommentCount, + Integer articleViewCount) { + String uid = userId.toString(); + ArticleViewActivityDto dto = ArticleViewActivityDto.builder() + .id(id.toString()) + .viewedBy(uid) + .createdAt(createdAt) + .articleId(articleId.toString()) + .source(source) + .sourceUrl(sourceUrl) + .articleTitle(articleTitle) + .articlePublishedDate(articlePublishedDate) + .articleSummary(articleSummary) + .articleCommentCount(articleCommentCount) + .articleViewCount(articleViewCount) + .build(); + + long modified = cacheRepository.pushArticleView(uid, dto, 10); + if (modified == 0) { + log.warn("[CacheUpdate] 캐시 없음(만료?): userId={}, viewId={}", uid, id); + } + reverseIndexService.addUser(ReverseIndexDocument.makeArticleViewsKey(articleId), uid); + log.info("[CacheUpdate] 기사 조회 추가: articleId={}, userId={}, modified={}", articleId, uid, modified); + } + + /* + * version 이전인 경우에만 관심사 키워드 업데이트 + */ + @Override + public void updateInterestKeyword(Long interestId, List newKeywords) { + String iid = String.valueOf(interestId); + long modified = cacheRepository.updateInterestKeywords(iid, newKeywords); + log.info("[CacheUpdate] Interest 키워드 갱신(set): interestId={}, modified={}", iid, modified); + } + + @Override + public void removeInterest(Long interestId) { + String id = String.valueOf(interestId); + + Set userIds = reverseIndexService.getUserIds( + ReverseIndexDocument.makeInterestSubscribersKey(interestId) + ); + + long modified = 0; + if (!userIds.isEmpty()) { + modified = cacheRepository.removeInterestEverywhere(userIds, id); + } + + reverseIndexService.deleteIndexes(Set.of(ReverseIndexDocument.makeInterestSubscribersKey(interestId))); + + log.info("[CacheUpdate] 관심사 삭제 반영: interestId={}, users={}, modified={}", id, userIds.size(), modified); + } + + @Override + public void addSubscription(Long userId, + Long subscriptionId, + Long interestId, + String interestName, + List interestKeywords, + Integer interestSubscriberCount, + LocalDateTime createdAt) { + + String uid = String.valueOf(userId); + SubscribesActivityDto dto = SubscribesActivityDto.builder() + .id(String.valueOf(subscriptionId)) + .interestId(String.valueOf(interestId)) + .interestName(interestName) + .interestKeywords(interestKeywords) + .interestSubscriberCount(interestSubscriberCount) + .createdAt(createdAt) + .build(); + + long modified = cacheRepository.addSubscription(uid, dto); + reverseIndexService.addUser(ReverseIndexDocument.makeInterestSubscribersKey(interestId), uid); + log.info("[CacheUpdate] 구독 추가: userId={}, subId={}, interestId={}, modified={}", + uid, subscriptionId, interestId, modified); + } + + @Override + public void removeSubscription(Long userId, Long subscriptionId, Long interestId) { + String uid = String.valueOf(userId); + long modified = cacheRepository.removeSubscription(uid, subscriptionId.toString()); + reverseIndexService.removeUser(ReverseIndexDocument.makeInterestSubscribersKey(interestId), uid); + log.info("[CacheUpdate] 구독 제거: userId={}, subId={}, modified={}", uid, subscriptionId, modified); + } + + @Override + public void removeComment(Long commentId) { + Set userIds = reverseIndexService.getUserIds(Set.of( + ReverseIndexDocument.makeCommentAuthorKey(commentId), + ReverseIndexDocument.makeCommentLikesKey(commentId) + )); + if (userIds.isEmpty()) { + log.debug("[CacheUpdate] 영향 사용자 없음: commentId={}", commentId); + return; + } + long modified = cacheRepository.removeCommentEverywhere(userIds, commentId.toString()); + log.info("[CacheUpdate] 댓글 삭제 캐시 반영: commentId={}, users={}, modified={}", + commentId, userIds.size(), modified); + + reverseIndexService.deleteIndexes(Set.of( + ReverseIndexDocument.makeCommentAuthorKey(commentId), + ReverseIndexDocument.makeCommentLikesKey(commentId) + )); + } + + @Override + public void saveCache(String userId, UserActivityDto data) { + log.info("[CacheUpdate] 캐시 저장 시작: userId={}", userId); + UserActivityCacheDocument doc = documentMapper.toDocument(data); + cacheRepository.save(doc); + log.debug("[CacheUpdate] 캐시 저장 완료: userId={}", userId); + + buildReverseIndexes(userId, data); + log.info("[CacheUpdate] 캐시 및 역인덱스 저장 완료: userId={}", userId); + } + + /** + * 역인덱스 초기 생성 + */ + private void buildReverseIndexes(String userId, UserActivityDto data) { + data.getComments().forEach(comment -> { + reverseIndexService.addUser( + ReverseIndexDocument.makeCommentAuthorKey(Long.parseLong(comment.getId())), + userId + ); + }); + + data.getCommentLikes().forEach(like -> { + reverseIndexService.addUser( + ReverseIndexDocument.makeCommentLikesKey(Long.parseLong(like.getCommentId())), + userId + ); + }); + + data.getArticleViews().forEach(view -> { + reverseIndexService.addUser( + ReverseIndexDocument.makeArticleViewsKey(Long.parseLong(view.getArticleId())), + userId + ); + }); + + data.getSubscriptions().forEach(sub -> { + reverseIndexService.addUser( + ReverseIndexDocument.makeInterestSubscribersKey(Long.parseLong(sub.getInterestId())), + userId + ); + }); + + log.info("[CacheUpdate] 역인덱스 생성 완료: userId={}, 댓글작성={}개, 좋아요={}개, 기사조회={}개, 구독={}개", + userId, + data.getComments().size(), + data.getCommentLikes().size(), + data.getArticleViews().size(), + data.getSubscriptions().size()); + + + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java new file mode 100644 index 0000000..f5a80ee --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java @@ -0,0 +1,69 @@ +package com.monew.monew_api.useractivity.service.Impl; + +import com.monew.monew_api.useractivity.document.ReverseIndexDocument; +import com.monew.monew_api.useractivity.repository.ReverseIndexRepository; +import com.monew.monew_api.useractivity.service.ReverseIndexService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.Set; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReverseIndexServiceImpl implements ReverseIndexService { + + private final ReverseIndexRepository reverseIndexRepository; + + /** + * 역인덱스에 사용자 추가 + * document key 형태 그대로 + * indexKey (예: "comment_123_likes") + */ + @Override + @Transactional + public void addUser(String indexKey, String userId) { + reverseIndexRepository.addUser(indexKey, userId); + log.debug("[ReverseIndex] add: key={}, user={}", indexKey, userId); + } + + /** + * 역인덱스에서 사용자 제거 + */ + public void removeUser(String indexKey, String userId) { + reverseIndexRepository.removeUser(indexKey, userId); + log.debug("[ReverseIndex] remove: key={}, user={}", indexKey, userId); + } + + /** + * 역인덱스에서 영향받는 사용자 ID 조회 + */ + @Override + public Set getUserIds(String indexKey) { + return reverseIndexRepository.findById(indexKey) + .map(ReverseIndexDocument::getUserIds) + .orElse(Collections.emptySet()); + } + + /** + * 여러 인덱스 키에서 사용자 ID 조회 + */ + @Override + public Set getUserIds(Set indexKeys) { + return reverseIndexRepository.findUserIdsByKeys(indexKeys); + } + + /** + * 역인덱스 일괄 삭제 + */ + @Override + @Transactional + public void deleteIndexes(Set indexKeys) { + reverseIndexRepository.deleteAllById(indexKeys); + log.debug("[ReverseIndex] deleteIndexes: {}개", indexKeys.size()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java new file mode 100644 index 0000000..ea7cfa1 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java @@ -0,0 +1,61 @@ +package com.monew.monew_api.useractivity.service.Impl; + +import com.monew.monew_api.useractivity.event.CacheSaveEvent; +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import com.monew.monew_api.useractivity.dto.UserActivityDto; +import com.monew.monew_api.useractivity.mapper.UserActivityMapper; +import com.monew.monew_api.useractivity.repository.UserActivityCacheRepository; +import com.monew.monew_api.useractivity.service.UserActivityCacheService; +import com.monew.monew_api.useractivity.service.UserActivityService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * 캐시 기반 사용자 활동 조회 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserActivityCacheServiceImpl implements UserActivityCacheService { + + private final UserActivityCacheRepository cacheRepository; + private final UserActivityService userActivityService; + private final UserActivityMapper mapper; + private final ApplicationEventPublisher eventPublisher; + + /** + * 캐시 기반 사용자 활동 조회 + * 1. MongoDB 캐시 조회 + * 2. Cache Hit → 반환 + * 3. Cache Miss → PostgreSQL 조회 → 비동기 캐시 저장 + */ + @Transactional(readOnly = true) + public UserActivityDto getUserActivityWithCache(String userId) { + log.info("[UserActivityCache] 사용자 활동 조회 시작 (캐시): userId={}", userId); + + Optional cached = cacheRepository.findById(userId); + + if (cached.isPresent()) { + log.info("캐시 히트: userId={}", userId); + return mapper.toDto(cached.get()); + } + + log.info("[UserActivityCache] 캐시 미스: userId={} - PostgreSQL 조회", userId); + UserActivityDto result = userActivityService.getUserActivity(userId); + + try { + eventPublisher.publishEvent(new CacheSaveEvent(userId, result)); + log.info("[UserActivityCache] 캐시 저장 이벤트 발행: userId={}", userId); + } catch (Exception e) { + log.error("[UserActivityCache] 캐시 저장 이벤트 발행 실패: userId={}", userId, e); + } + + log.info("[UserActivityCache] 사용자 활동 조회 완료 (캐시): userId={}", userId); + return result; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java index 71cb437..bdf0714 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java @@ -1,18 +1,15 @@ package com.monew.monew_api.useractivity.service.Impl; -import com.fasterxml.jackson.databind.ObjectMapper; import com.monew.monew_api.comments.entity.Comment; import com.monew.monew_api.comments.entity.CommentLike; import com.monew.monew_api.common.exception.user.UserNotFoundException; import com.monew.monew_api.user.User; import com.monew.monew_api.user.repository.UserRepository; import com.monew.monew_api.subscribe.entity.Subscribe; -import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; import com.monew.monew_api.useractivity.dto.UserActivityDto; import com.monew.monew_api.useractivity.mapper.UserActivityMapper; import com.monew.monew_api.useractivity.mapper.UserActivityRawMapper; -import com.monew.monew_api.useractivity.repository.UserActivityCacheRepository; import com.monew.monew_api.useractivity.repository.UserActivityRepository; import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; import com.monew.monew_api.useractivity.service.UserActivityService; @@ -22,25 +19,21 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.Optional; @Slf4j @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class UserActivityServiceImpl implements UserActivityService { private final UserRepository userRepository; private final UserActivityRepository activityRepository; - private final UserActivityCacheRepository cacheRepository; private final UserActivityMapper mapper; private final UserActivityRawMapper rawMapper; - private final ObjectMapper objectMapper; @Override @Transactional(readOnly = true) public UserActivityDto getUserActivity(String userId) { - log.info("사용자 활동내역 조회 시작: userId={}", userId); + log.info("[UserActivity] 사용자 활동내역 조회 시작: userId={}", userId); Long userIdLong = Long.parseLong(userId); @@ -48,20 +41,20 @@ public UserActivityDto getUserActivity(String userId) { .orElseThrow(UserNotFoundException::new); List subscriptions = activityRepository.findSubscriptionsByUserId(userIdLong); - log.info("구독 정보 조회 완료: {}건", subscriptions.size()); + log.info("[UserActivity] 구독 정보 조회 완료: {}건", subscriptions.size()); List comments = activityRepository.findRecentCommentsByUserId(userIdLong); - log.info("최근 댓글 조회 완료: {}건", comments.size()); + log.info("[UserActivity] 최근 댓글 조회 완료: {}건", comments.size()); List likes = activityRepository.findRecentLikesByUserId(userIdLong); - log.info("최근 좋아요 조회 완료: {}건", likes.size()); + log.info("[UserActivity] 최근 좋아요 조회 완료: {}건", likes.size()); List views = activityRepository.findRecentViewsByUserId(userIdLong); - log.info("최근 조회 기사 조회 완료: {}건", views.size()); + log.info("[UserActivity] 최근 조회 기사 조회 완료: {}건", views.size()); UserActivityDto result = mapper.toUserActivityDto(user, subscriptions, comments, likes, views); - log.info("사용자 활동내역 조회 완료: userId={}", userId); + log.info("[UserActivity] 사용자 활동내역 조회 완료: userId={}", userId); return result; } @@ -71,21 +64,20 @@ public UserActivityDto getUserActivity(String userId) { @Override @Transactional(readOnly = true) public UserActivityDto getUserActivitySingleQuery(String userId) { - log.info("사용자 활동내역 조회 시작 (단일 쿼리 - Record): userId={}", userId); + log.info("[UserActivity] 사용자 활동내역 조회 시작 (단일 쿼리 - Record): userId={}", userId); Long userIdLong = Long.parseLong(userId); UserActivityRaw raw = activityRepository.findUserActivityRaw(userIdLong); if (raw == null) { - log.error("사용자 활동 데이터를 찾을 수 없음: userId={}", userId); + log.error("[UserActivity] 사용자 활동 데이터를 찾을 수 없음: userId={}", userId); throw new UserNotFoundException(); } - // 2. Record → DTO 변환 UserActivityDto result = rawMapper.toDto(raw); - log.info("사용자 활동내역 조회 완료 (단일 쿼리): userId={}, 구독: {}건, 댓글: {}건, 좋아요: {}건, 조회: {}건", + log.info("[UserActivity] 사용자 활동내역 조회 완료 (단일 쿼리): userId={}, 구독: {}건, 댓글: {}건, 좋아요: {}건, 조회: {}건", userId, result.getSubscriptions().size(), result.getComments().size(), @@ -94,37 +86,4 @@ public UserActivityDto getUserActivitySingleQuery(String userId) { return result; } - - @Override - public UserActivityDto getUserActivityWithCache(String userId) { - log.info("사용자 활동내역 조회 시작 (캐시): userId={}", userId); - - Optional cached = cacheRepository.findById(userId); - - if (cached.isPresent()) { - log.info("Cache HIT: userId={}", userId); - log.info("사용자 활동내역 조회 완료 (캐시)"); - return mapper.toDto(cached.get()); - } - - log.info("Cache MISS: userId={}", userId); - - UserActivityDto result = getUserActivitySingleQuery(userId); - - saveToCache(result); - - log.info("사용자 활동내역 조회 완료 (캐시): userId={}", userId); - return result; - } - - private void saveToCache(UserActivityDto dto) { - try { - UserActivityCacheDocument document = mapper.toDocument(dto); - cacheRepository.save(document); - log.info("MongoDB 캐시 저장 완료: userId={}", dto.getId()); - } catch (Exception e) { - log.error("MongoDB 캐시 저장 실패: userId={}", dto.getId(), e); - throw new RuntimeException("캐시 저장에 실패했습니다.", e); - } - } } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java new file mode 100644 index 0000000..fec6f92 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java @@ -0,0 +1,11 @@ +package com.monew.monew_api.useractivity.service; + +import java.util.Set; + +public interface ReverseIndexService { + void addUser(String indexKey, String userId); + void removeUser(String indexKey, String userId); + Set getUserIds(String indexKey); + Set getUserIds(Set indexKeys); + void deleteIndexes(Set indexKeys); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java new file mode 100644 index 0000000..ed091f5 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java @@ -0,0 +1,11 @@ +package com.monew.monew_api.useractivity.service; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; + +public interface UserActivityCacheService { + /** + * 사용자 활동내역 조회 (캐시 적용) + * MongoDB 캐시 확인 → 없으면 PostgreSQL 조회 → 캐시 저장 + */ + UserActivityDto getUserActivityWithCache(String userId); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java index cc1cb55..418cc34 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java @@ -12,10 +12,4 @@ public interface UserActivityService { * PostgreSQL에서 직접 조회 */ UserActivityDto getUserActivitySingleQuery(String userId); - - /** - * 사용자 활동내역 조회 (캐시 적용) ✅ 새로 추가! - * MongoDB 캐시 확인 → 없으면 PostgreSQL 조회 → 캐시 저장 - */ - UserActivityDto getUserActivityWithCache(String userId); } \ No newline at end of file From 29957ed1307b9510180c931a95cd76cf7b83ccdb Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Mon, 3 Nov 2025 17:40:53 +0900 Subject: [PATCH 148/178] =?UTF-8?q?refactor=20:=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=EC=97=90=20javadoc=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 --- .../document/ReverseIndexDocument.java | 4 +- .../ReverseIndexCustomRepository.java | 10 ++ .../UserActivityCacheCustomRepository.java | 99 +++++++++++++++- .../repository/UserActivityRepository.java | 34 ++++++ .../service/CacheUpdateService.java | 107 ++++++++++++------ .../service/Impl/CacheUpdateServiceImpl.java | 2 + .../Impl/UserActivityCacheServiceImpl.java | 2 +- .../service/ReverseIndexService.java | 28 +++++ .../service/UserActivityCacheService.java | 1 + .../service/UserActivityService.java | 1 + 10 files changed, 248 insertions(+), 40 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java index 8061093..9da4f28 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java @@ -69,8 +69,8 @@ public static String makeArticleViewsKey(Long articleId) { /** * Interest 구독자 역인덱스 키 생성 - * @param interestId - * @return + * @param interestId 관심사 ID + * @return "interest_{interestId}_subs" */ public static String makeInterestSubscribersKey(Long interestId) { return "interest_" + interestId + "_subs"; diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java index 4583392..bd93e79 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java @@ -3,8 +3,18 @@ import java.util.Set; public interface ReverseIndexCustomRepository { + /** + * 특정 인덱스 키에 사용자 ID 추가 + * @param indexKey + * @param userId + */ void addUser(String indexKey, String userId); + /** + * 특정 인덱스 키에서 사용자 ID 제거 + * @param indexKey + * @param userId + */ void removeUser(String indexKey, String userId); Set findUserIdsByKeys(Set indexKeys); diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java index f506111..e837cc9 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java @@ -9,30 +9,127 @@ import java.util.Set; public interface UserActivityCacheCustomRepository { - + /** + * 댓글 좋아요 수 증감 + * + * @param userIds 댓글 좋아요 수를 업데이트할 사용자 ID 집합 + * @param commentId 댓글 ID + * @param delta 증감 값 (1, -1) + * @return 업데이트된 캐시 데이터 수 + */ long incCommentLikeCount(Set userIds, String commentId, int delta); + /** + * 기사 조회수 증감 + * + * @param userIds 기사 조회수을 업데이트할 사용자 ID 집합 + * @param articleId 기사 ID + * @param delta 증감 값 (1) + * @return 업데이트된 캐시 데이터 수 + */ long incArticleViewCount(Set userIds, String articleId, int delta); + /** + * 기사 댓글수 증감 + * + * @param userIds 기사 댓글수를 업데이트할 사용자 ID 집합 + * @param articleId 기사 ID + * @param delta 증감 값 (1) + * @return 업데이트된 캐시 데이터 수 + */ long incArticleCommentCount(Set userIds, String articleId, int delta); + /** + * 댓글 좋아요 추가 + * + * @param userId 사용자 ID + * @param dto 댓글 좋아요 활동 DTO + * @param keepLatest 유지할 최신 항목 수 + * @return 업데이트된 캐시 데이터 수 + */ long pushCommentLike(String userId, CommentLikeActivityDto dto, int keepLatest); + /** + * 댓글 좋아요 제거 + * + * @param userId 사용자 ID + * @param commentId 댓글 ID + * @return 업데이트된 캐시 데이터 수 + */ long pullCommentLike(String userId, String commentId); + /** + * 댓글 추가 + * + * @param userId 사용자 ID + * @param dto 댓글 활동 DTO + * @param keepLatest 유지할 최신 항목 수 + * @return 업데이트된 캐시 데이터 수 + */ long pushComment(String userId, CommentActivityDto dto, int keepLatest); + /** + * 댓글 내용 수정 + * + * @param userIds 사용자 ID 집합 + * @param commentId 댓글 ID + * @param newContent 새로운 댓글 내용 + * @return 업데이트된 캐시 데이터 수 + */ long updateCommentContentForUsers(Set userIds, String commentId, String newContent); + /** + * 모든 사용자에 대해 댓글 제거 + * + * @param userIds 사용자 ID 집합 + * @param commentId 댓글 ID + * @return 업데이트된 캐시 데이터 수 + */ long removeCommentEverywhere(Set userIds, String commentId); + /** + * 기사 조회 활동 추가 + * + * @param userId 사용자 ID + * @param dto 기사 조회 활동 DTO + * @param keepLatest 유지할 최신 항목 수 + * @return 업데이트된 캐시 데이터 수 + */ long pushArticleView(String userId, ArticleViewActivityDto dto, int keepLatest); + /** + * Interest 키워드 업데이트 + * + * @param interestId 관심사 ID + * @param newKeywords 새로운 키워드 리스트 + * @return 업데이트된 캐시 데이터 수 + */ long updateInterestKeywords(String interestId, List newKeywords); + /** + * 모든 사용자에 대해 Interest 제거 + * + * @param userIds 사용자 ID 집합 + * @param interestId 관심사 ID + * @return 업데이트된 캐시 데이터 수 + */ long removeInterestEverywhere(Set userIds, String interestId); + /** + * 구독 추가 + * + * @param userId 사용자 ID + * @param dto 구독 활동 DTO + * @return 업데이트된 캐시 데이터 수 + */ long addSubscription(String userId, SubscribesActivityDto dto); + /** + * 구독 제거 + * + * @param userId 사용자 ID + * @param subscriptionId 구독 ID + * @return 업데이트된 캐시 데이터 수 + */ long removeSubscription(String userId, String subscriptionId); } diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java index 27409fd..1421760 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java @@ -15,17 +15,51 @@ public interface UserActivityRepository { /* 활동 내역을 4개의 쿼리로 처리 + UserActivityDto { + User + findSubscriptionsByUserId() + findRecentCommentsByUserId() + findRecentLikesByUserId() + findRecentViewsByUserId() + } 형태로 구성 + */ + + /** + * 사용자의 구독 정보 조회 + * @param userId 사용자 ID + * @return 구독 정보 리스트 */ List findSubscriptionsByUserId(Long userId); + /** + * 사용자의 최근 댓글 조회 + * @param userId 사용자 ID + * @return 댓글 리스트 + */ List findRecentCommentsByUserId(Long userId); + /** + * 사용자의 최근 댓글 좋아요 조회 + * @param userId 사용자 ID + * @return 댓글 좋아요 리스트 + */ List findRecentLikesByUserId(Long userId); + /** + * 사용자의 최근 기사 조회 + * @param userId 사용자 ID + * @return 기사 조회 리스트 + */ List findRecentViewsByUserId(Long userId); /* record 사용한 단일 쿼리 */ + + /** + * 사용자 활동내역 단일 쿼리 조회 + * @param userId 사용자 ID + * @return 사용자 활동내역 프로젝션 + */ UserActivityRaw findUserActivityRaw(Long userId); } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java index a74bed0..0399f1f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java @@ -1,7 +1,6 @@ package com.monew.monew_api.useractivity.service; import com.monew.monew_api.useractivity.dto.UserActivityDto; -import org.hibernate.mapping.Set; import java.time.LocalDateTime; import java.util.List; @@ -13,68 +12,95 @@ public interface CacheUpdateService { /** * 댓글 좋아요수 증가/감소 + * + * @param commentId 댓글 ID + * @param delta 증가/감소 값 */ void updateCommentLikeCount(Long commentId, Integer delta); /** * 기사 조회수 증가 + * + * @param articleId 기사 ID + * @param delta 증가 값 */ void incrementArticleViewCount(Long articleId, Integer delta); /** * 기사 댓글수 증가 + * + * @param articleId 기사 ID + * @param delta 증가 값 */ void incrementArticleCommentCount(Long articleId, Integer delta); /** * Interest 정보 업데이트 + * + * @param interestId 업데이트할 Interest ID + * @param newKeywords 새로운 키워드 리스트 */ void updateInterestKeyword(Long interestId, List newKeywords); /** * Interest 삭제 처리 - * @param interestId + * + * @param interestId 삭제할 Interest ID */ void removeInterest(Long interestId); /** * 구독 추가 + * + * @param userId 사용자 ID + * @param subscriptionId 구독 ID + * @param interestId 관심사 ID + * @param interestName 관심사 이름 + * @param interestKeywords 관심사 키워드 리스트 + * @param interestSubscriberCount 관심사 구독자 수 + * @param createdAt 구독 생성 일시 */ void addSubscription(Long userId, Long subscriptionId, Long interestId, String interestName, List interestKeywords, Integer interestSubscriberCount, LocalDateTime createdAt); /** * 구독 취소 + * + * @param userId 사용자 ID + * @param subscriptionId 구독 ID + * @param interestId 관심사 ID */ void removeSubscription(Long userId, Long subscriptionId, Long interestId); /** * 댓글 생성 시 캐시 데이터 + 역인덱스 업데이트 - * @param id - * @param userId - * @param userNickname - * @param articleId - * @param articleTitle - * @param content - * @param likeCount - * @param createdAt + * + * @param id 댓글 아이디 + * @param userId 댓글 작성자 아이디 + * @param userNickname 댓글 작성자 닉네임 + * @param articleId 댓글이 작성된 기사 아이디 + * @param articleTitle 댓글이 작성된 기사 제목 + * @param content 댓글 내용 + * @param likeCount 댓글 좋아요 수 + * @param createdAt 댓글 작성 일시 */ void addComment(Long id, Long userId, String userNickname, Long articleId, String articleTitle, String content, Integer likeCount, LocalDateTime createdAt); /** * 좋아요 생성 시 캐시 데이터 + 역인덱스 업데이트 - * @param id - * @param userId - * @param createdAt - * @param commentId - * @param articleId - * @param articleTitle - * @param commentUserId - * @param commentUserNickname - * @param commentContent - * @param commentLikeCount - * @param commentCreatedAt + * + * @param id 좋아요 아이디 + * @param userId 좋아요를 누른 사용자 아이디 + * @param createdAt 좋아요 생성 일시 + * @param commentId 좋아요가 눌린 댓글 아이디 + * @param articleId 좋아요가 눌린 댓글이 속한 기사 아이디 + * @param articleTitle 좋아요가 눌린 댓글이 속한 기사 제목 + * @param commentUserId 좋아요가 눌린 댓글 작성자 아이디 + * @param commentUserNickname 좋아요가 눌린 댓글 작성자 닉네임 + * @param commentContent 좋아요가 눌린 댓글 내용 + * @param commentLikeCount 좋아요가 눌린 댓글의 현재 좋아요 수 + * @param commentCreatedAt 좋아요가 눌린 댓글 작성 일시 */ void addCommentLike(Long id, Long userId, LocalDateTime createdAt, Long commentId, Long articleId, String articleTitle, Long commentUserId, String commentUserNickname, String commentContent, Integer commentLikeCount, @@ -82,24 +108,26 @@ void addCommentLike(Long id, Long userId, LocalDateTime createdAt, Long commentI /** * 댓글 내용 수정 시 캐시 데이터 + 역인덱스 업데이트 - * @param commentId - * @param newContent + * + * @param commentId 댓글 ID + * @param newContent 새로운 댓글 내용 */ void updateCommentContent(Long commentId, String newContent); /** * 기사 조회 생성 시 캐시 데이터 + 역인덱스 업데이트 - * @param id - * @param userId - * @param createdAt - * @param articleId - * @param source - * @param sourceUrl - * @param articleTitle - * @param articlePublishedDate - * @param articleSummary - * @param articleCommentCount - * @param articleViewCount + * + * @param id 기사 조회 아이디 + * @param userId 기사 조회한 사용자 아이디 + * @param createdAt 기사 조회 일시 + * @param articleId 조회된 기사 아이디 + * @param source 기사 출처 + * @param sourceUrl 기사 출처 URL + * @param articleTitle 기사 제목 + * @param articlePublishedDate 기사 게시 일시 + * @param articleSummary 기사 요약 + * @param articleCommentCount 댓글 수 + * @param articleViewCount 조회 수 */ void addArticleView(Long id, Long userId, LocalDateTime createdAt, @@ -110,17 +138,24 @@ void addArticleView(Long id, /** * 좋아요 삭제 처리 - * @param userId - * @param commentId + * + * @param userId 좋아요를 취소한 사용자 ID + * @param commentId 좋아요가 취소된 댓글 ID */ void removeCommentLike(Long userId, Long commentId); + /** * 댓글 삭제 처리 + * + * @param commentId 삭제할 댓글 ID */ void removeComment(Long commentId); /** * 캐시 저장 (PostgreSQL 조회 후 비동기 저장) + * + * @param userId 사용자 ID + * @param data 사용자 활동 데이터 */ void saveCache(String userId, UserActivityDto data); } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java index a4bfa5d..5d9720a 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java @@ -272,6 +272,8 @@ public void saveCache(String userId, UserActivityDto data) { /** * 역인덱스 초기 생성 + * @param userId 사용자 ID + * @param data 사용자 활동 내역 */ private void buildReverseIndexes(String userId, UserActivityDto data) { data.getComments().forEach(comment -> { diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java index ea7cfa1..d598b13 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java @@ -46,7 +46,7 @@ public UserActivityDto getUserActivityWithCache(String userId) { } log.info("[UserActivityCache] 캐시 미스: userId={} - PostgreSQL 조회", userId); - UserActivityDto result = userActivityService.getUserActivity(userId); + UserActivityDto result = userActivityService.getUserActivitySingleQuery(userId); try { eventPublisher.publishEvent(new CacheSaveEvent(userId, result)); diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java index fec6f92..ef312d1 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java @@ -3,9 +3,37 @@ import java.util.Set; public interface ReverseIndexService { + /** + * 특정 인덱스 키에 사용자 ID 추가 + * @param indexKey cacheDB key 값 + * @param userId 사용자 ID + */ void addUser(String indexKey, String userId); + + /** + * 특정 인덱스 키에서 사용자 ID 제거 + * @param indexKey cacheDB key 값 + * @param userId 사용자 ID + */ void removeUser(String indexKey, String userId); + + /** + * 특정 인덱스 키에 해당하는 모든 사용자 ID 조회 + * @param indexKey cacheDB key 값 + * @return 사용자 ID 집합 + */ Set getUserIds(String indexKey); + + /** + * 여러 인덱스 키에 해당하는 모든 사용자 ID 조회 + * @param indexKeys cacheDB key 값 집합 + * @return 사용자 ID 집합 + */ Set getUserIds(Set indexKeys); + + /** + * 여러 인덱스 키 삭제 + * @param indexKeys cacheDB key 값 집합 + */ void deleteIndexes(Set indexKeys); } diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java index ed091f5..b3a762f 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java @@ -6,6 +6,7 @@ public interface UserActivityCacheService { /** * 사용자 활동내역 조회 (캐시 적용) * MongoDB 캐시 확인 → 없으면 PostgreSQL 조회 → 캐시 저장 + * @param userId 사용자 ID */ UserActivityDto getUserActivityWithCache(String userId); } diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java index 418cc34..0f5551c 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java @@ -10,6 +10,7 @@ public interface UserActivityService { /** * 사용자 활동내역 조회 (단일 쿼리) * PostgreSQL에서 직접 조회 + * @param userId 사용자 ID */ UserActivityDto getUserActivitySingleQuery(String userId); } \ No newline at end of file From 40124e0d4bfcd4bd7e8e96a0290dfa1ed4cd7ea1 Mon Sep 17 00:00:00 2001 From: truuuely Date: Mon, 3 Nov 2025 19:05:48 +0900 Subject: [PATCH 149/178] =?UTF-8?q?chore:=20batch=20=EB=82=B4=20applicatio?= =?UTF-8?q?n-prod.yml=EC=97=90=20=EA=B8=B0=EC=82=AC=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EC=83=9D=EC=84=B1=EC=9A=A9=20MONEW=5FA?= =?UTF-8?q?PI=5FURL=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배포 후 생성된 URL을 환경변수 파일에 추가 필요 --- monew-batch/src/main/resources/application-prod.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/monew-batch/src/main/resources/application-prod.yml b/monew-batch/src/main/resources/application-prod.yml index be0d5be..34ee632 100644 --- a/monew-batch/src/main/resources/application-prod.yml +++ b/monew-batch/src/main/resources/application-prod.yml @@ -62,4 +62,8 @@ aws: s3: access-key: ${AWS_ACCESS_KEY} secret-key: ${AWS_SECRET_KEY} - bucket: monew-backup \ No newline at end of file + bucket: monew-backup + +monew: + api: + url: ${MONEW_API_URL} # 배포 후 추가 필요 \ No newline at end of file From 4590b01ecf7dd5c4f0af946fdaf83572c0459b2b Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Mon, 3 Nov 2025 19:09:05 +0900 Subject: [PATCH 150/178] =?UTF-8?q?fix=20:=20InterestRepository.java=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EB=AA=85=20=EC=88=98=EC=A0=95=20(?= =?UTF-8?q?=20=EC=B6=A9=EB=8F=8C=ED=95=A0=20=EB=95=8C=20=EC=9E=98=EB=AA=BB?= =?UTF-8?q?=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.=20)=20,=20UserActivityCon?= =?UTF-8?q?troller.java=20=ED=97=A4=EB=8D=94=20=EB=B0=9B=EB=8A=94=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20CommentService=EC=97=90=EC=84=9C=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EB=B6=80=EB=B6=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?(=20=EC=A2=8B=EC=95=84=EC=9A=94=20+2=20=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monew_api/comments/service/CommentService.java | 3 ++- .../interest/repository/InterestRepository.java | 4 +++- .../controller/UserActivityController.java | 10 +++++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java index 25239d5..db96bc2 100644 --- a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -86,7 +86,6 @@ public CommentLikeDto like(Long userId, Long commentId) { Comment comment = getCommentById(commentId); log.info("[COMMENT][LIKE] 엔티티 조회 완료 - user={}, comment={}", user.getId(), comment.getId()); CommentLike saved = commentLikeRepository.save(CommentLike.of(user, comment)); - comment.increaseLike(); eventPublisher.publishEvent( CommentLikedEvent.of( @@ -102,6 +101,8 @@ public CommentLikeDto like(Long userId, Long commentId) { comment.getCreatedAt(), user.getId(), user.getNickname())); + + comment.increaseLike(); log.info("[COMMENT][LIKE] userId={}, commentId={}", userId, commentId); return CommentLikeDto.from(saved); } diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java index 4a137ff..ba24328 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java @@ -1,9 +1,11 @@ package com.monew.monew_api.interest.repository; import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -17,7 +19,7 @@ public interface InterestRepository extends JpaRepository, Inter JOIN FETCH ik.keyword k WHERE k = :keyword """) - List findAllWithKeywords(); + List findAllByKeyword(@Param("keyword") Keyword keyword); /** * 특정 관심사와 해당 관심사에 연결된 키워드들을 함께 조회 diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java index 818a688..15529c9 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java @@ -6,10 +6,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; -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.*; @Slf4j @RestController @@ -29,7 +26,10 @@ public class UserActivityController { 여러 쿼리 사용시 getUserActivity 메서드 */ @GetMapping("/{userId}") - public ResponseEntity getUserActivity(@PathVariable String userId) { + public ResponseEntity getUserActivity( + @PathVariable String userId, + @RequestHeader(value = "MoNew-Request-User-ID", required = false) Long requesterId + ) { log.info("[활동내역 API 요청]: userId={}", userId); // UserActivityDto activity = userActivityService.getUserActivitySingleQuery(userId); From 39a6f1816b99f82abc03fd19025919f86ca5b098 Mon Sep 17 00:00:00 2001 From: truuuely Date: Mon, 3 Nov 2025 19:19:31 +0900 Subject: [PATCH 151/178] =?UTF-8?q?feat:=20=EA=B8=B0=EC=82=AC=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20=EC=99=84=EB=A3=8C=20=ED=9B=84=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20api=20=EC=9E=90=EB=8F=99=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기사 수집 step 완료시 관심사별 새로운 기사 수를 집계한 newLinkCountsByInterestId 맵을 JobExecutionContext에 저장 - JobListener의 afterJob()이 naverNewsStep 등 모든 Job이 완전히 끝난 후 호출됨 - afterJob()에서 알림 생성 api 호출 --- .../ArticleNotificationRequestListener.java | 47 +++++++++++++++++++ .../article/job/NaverNewsItemWriter.java | 38 ++++++++++----- .../common/config/NaverNewsJobConfig.java | 3 ++ 3 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java new file mode 100644 index 0000000..78abb68 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java @@ -0,0 +1,47 @@ +package com.monew.monew_batch.article.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ArticleNotificationRequestListener implements JobExecutionListener { + + private final RestTemplate restTemplate; + + @Value("${monew.api.url}") + private String monewApiUrl; + + @Override + public void afterJob(JobExecution jobExecution) { + if (jobExecution.getStatus() != BatchStatus.COMPLETED) { + return; + } + + @SuppressWarnings("unchecked") + Map stats = + (Map) jobExecution.getExecutionContext().get("newLinkCountsByInterestId"); + + if (stats == null || stats.isEmpty()) { + log.info("전송할 알림 데이터 없음"); + return; + } + + try { + String apiUrl = monewApiUrl + "/api/internal/notifications/articles-registered"; + restTemplate.postForEntity(apiUrl, stats, Void.class); + log.info("✅ 관심사별 신규 기사 통계 전송 완료: {}개 관심사", stats.size()); + } catch (Exception e) { + log.error("❌ API 서버 알림 요청 실패", e); + } + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java index a719037..462d0ba 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java @@ -6,38 +6,37 @@ import com.monew.monew_api.article.repository.ArticleRepository; import com.monew.monew_api.article.repository.InterestArticleKeywordRepository; import com.monew.monew_api.article.repository.InterestArticlesRepository; -import com.monew.monew_api.common.exception.article.ArticleNotFoundException; import com.monew.monew_api.interest.entity.Interest; -import com.monew.monew_api.interest.entity.InterestKeyword; import com.monew.monew_api.interest.entity.Keyword; import com.monew.monew_api.interest.repository.InterestRepository; import com.monew.monew_batch.article.dto.ArticleKeywordPair; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.item.Chunk; import org.springframework.batch.item.ItemWriter; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; -import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; @Slf4j @Component +@StepScope @RequiredArgsConstructor -public class NaverNewsItemWriter implements ItemWriter> { +public class NaverNewsItemWriter implements ItemWriter>, StepExecutionListener { private final ArticleJdbcRepository articleJdbcRepository; private final ArticleRepository articleRepository; private final InterestRepository interestRepository; private final InterestArticlesRepository interestArticlesRepository; private final InterestArticleKeywordRepository interestArticleKeywordRepository; - private final RestTemplate restTemplate; - @Value("${monew.api.url}") - private String monewApiUrl; + private final Map newLinkCountsByInterestId = new ConcurrentHashMap<>(); @Override public void write(Chunk> chunk) { @@ -57,7 +56,10 @@ public void write(Chunk> chunk) { List relatedInterests = interestRepository.findAllByKeyword(keyword); for (Interest interest : relatedInterests) { - interestArticlesRepository.insertIgnore(interest.getId(), savedArticle.getId()); + int insertedRow = interestArticlesRepository.insertIgnore(interest.getId(), savedArticle.getId()); + if (insertedRow > 0) { + newLinkCountsByInterestId.merge(interest.getId(), 1, Integer::sum); + } InterestArticles ia = interestArticlesRepository.findByArticleAndInterest(savedArticle, interest) .orElseThrow(); @@ -71,4 +73,18 @@ public void write(Chunk> chunk) { log.info("Writer 결과 | 총: {} | 신규 기사: {} | 연결: {}", total, newCount, linkedCount); } -} \ No newline at end of file + // 스텝이 모두 끝난 후 한 번만 JobExecutionContext에 데이터 저장 + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + log.info("Writer Step 완료 - 관심사별 누적 데이터: {}", newLinkCountsByInterestId); + + if (!newLinkCountsByInterestId.isEmpty()) { + stepExecution.getJobExecution() + .getExecutionContext() + .put("newLinkCountsByInterestId", newLinkCountsByInterestId); + log.info("JobExecutionContext에 최종 집계 데이터 저장 완료."); + } + + return ExitStatus.COMPLETED; + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/common/config/NaverNewsJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/common/config/NaverNewsJobConfig.java index 67660ad..e1f7b82 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/common/config/NaverNewsJobConfig.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/common/config/NaverNewsJobConfig.java @@ -2,6 +2,7 @@ import com.monew.monew_api.interest.entity.Keyword; import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.job.ArticleNotificationRequestListener; import com.monew.monew_batch.article.job.NaverNewsItemProcessor; import com.monew.monew_batch.article.job.NaverNewsItemReader; import com.monew.monew_batch.article.job.NaverNewsItemWriter; @@ -31,11 +32,13 @@ public class NaverNewsJobConfig { private final NaverNewsItemReader reader; private final NaverNewsItemProcessor processor; private final NaverNewsItemWriter writer; + private final ArticleNotificationRequestListener listener; @Bean public Job naverNewsJob(JobRepository jobRepository, Step naverNewsStep) { return new JobBuilder("naverNewsJob", jobRepository) .start(naverNewsStep) + .listener(listener) .build(); } From d951eb56c94a8b48a7e6f2b723975561385d777c Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Mon, 3 Nov 2025 16:07:39 +0900 Subject: [PATCH 152/178] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EB=B3=80=EC=88=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java --- .../interest/service/InterestServiceImpl.java | 80 +++---------------- 1 file changed, 10 insertions(+), 70 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index 4a67c75..617d596 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -1,13 +1,7 @@ package com.monew.monew_api.interest.service; -import com.monew.monew_api.article.repository.ArticleRepository; -import com.monew.monew_api.article.repository.InterestArticleKeywordRepository; -import com.monew.monew_api.article.repository.InterestArticlesRepository; import com.monew.monew_api.common.exception.interest.InterestDuplicatedException; import com.monew.monew_api.common.exception.interest.InterestNotFoundException; -import com.monew.monew_api.common.exception.user.UserNotFoundException; -import com.monew.monew_api.user.User; -import com.monew.monew_api.user.repository.UserRepository; import com.monew.monew_api.interest.dto.InterestOrderBy; import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; import com.monew.monew_api.interest.dto.request.InterestRegisterRequest; @@ -45,14 +39,9 @@ public class InterestServiceImpl implements InterestService { private final InterestRepository interestRepository; - private final UserRepository userRepository; private final KeywordRepository keywordRepository; private final SubscribeRepository subscribeRepository; - private final ArticleRepository articleRepository; - private final InterestArticlesRepository interestArticlesRepository; - private final InterestArticleKeywordRepository interestArticleKeywordRepository; - private final InterestMapper interestMapper; @Override @@ -167,35 +156,9 @@ public InterestDto updateInterestKeywords( @Transactional public void deleteInterest(Long interestId) { Interest interest = interestRepository.findById(interestId) - .orElseThrow(InterestNotFoundException::new); - - List articleIds = interestArticlesRepository.findArticleIdsByInterestId(interestId); - log.info("관심사({})와 연결된 기사 수: {}", interest.getName(), articleIds.size()); - - if (articleIds.isEmpty()) { - interestRepository.delete(interest); - return; - } - - List usedElsewhere = - interestArticlesRepository.findArticleIdsUsedByOtherInterests(articleIds, interestId); - - List toDelete = articleIds.stream() - .filter(id -> !usedElsewhere.contains(id)) - .toList(); - - int deletedCount = toDelete.size(); - int undeletedCount = usedElsewhere.size(); - - if (!toDelete.isEmpty()) { - articleRepository.markAsDeleted(toDelete); - log.info("논리 삭제된 기사 수: {}", deletedCount); - } - - log.info("삭제 제외된 기사 수(다른 관심사에서 사용 중): {}", undeletedCount); + .orElseThrow(InterestNotFoundException::new); interestRepository.delete(interest); - log.info("관심사 삭제 완료: {}", interest.getName()); } @@ -276,40 +239,17 @@ private void updateKeywords( private void removeOrphanKeywords(Interest interest, Map toRemove) { - if (toRemove.isEmpty()) return; - - List removedKeywords = toRemove.values().stream() - .map(InterestKeyword::getKeyword) - .toList(); - - interest.getKeywords().removeAll(toRemove.values()); - - List removedKeywordIds = removedKeywords.stream() - .map(Keyword::getId) - .toList(); - - List relatedArticleIds = - interestArticleKeywordRepository.findArticleIdsByKeywordIds(removedKeywordIds); - log.info("고아 키워드 관련 기사 수: {}", relatedArticleIds.size()); - - if (!relatedArticleIds.isEmpty()) { - List usedElsewhere = interestArticleKeywordRepository.findArticlesUsedElsewhere( - relatedArticleIds, removedKeywordIds, interest.getId()); - - List toDelete = relatedArticleIds.stream() - .filter(id -> !usedElsewhere.contains(id)) - .toList(); - - if (!toDelete.isEmpty()) { - articleRepository.markAsDeleted(toDelete); - log.info("논리 삭제된 기사 수: {}", toDelete.size()); - } + if (toRemove.isEmpty()) { + return; + } + List removedKeyword = new ArrayList<>(); - log.info("삭제 제외된 기사 수(다른 관심사/키워드 사용 중): {}", usedElsewhere.size()); + for (InterestKeyword interestKeyword : toRemove.values()) { + interest.getKeywords().remove(interestKeyword); + removedKeyword.add(interestKeyword.getKeyword()); } - List orphanKeywords = keywordRepository.findOrphanKeywordsIn(removedKeywords); - keywordRepository.deleteAll(orphanKeywords); - log.info("고아 키워드 삭제 완료: {}", orphanKeywords.size()); + List toDelete = keywordRepository.findOrphanKeywordsIn(removedKeyword); + keywordRepository.deleteAll(toDelete); } } From cb623f5f1a0a7783b9a8148370b1475074850cde Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Mon, 3 Nov 2025 18:56:30 +0900 Subject: [PATCH 153/178] =?UTF-8?q?refactor:=20=EA=B5=AC=EB=8F=85=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/monew/monew_api/interest/entity/Interest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java index 9eb5174..f053d3d 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java @@ -51,6 +51,7 @@ public void addSubscriberCount(){ } public void cancelSubscriberCount(){ + if(this.subscriberCount > 0){} this.subscriberCount--; } } From cc83569efd0f092d346bfca405d4873d8efc7755 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Mon, 3 Nov 2025 18:58:50 +0900 Subject: [PATCH 154/178] =?UTF-8?q?refactor:=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=AA=85=20=EB=8F=99=EC=9D=BC=ED=95=B4=EC=84=9C=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=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 --- .../com/monew/monew_api/interest/mapper/InterestMapper.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java b/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java index 5949f49..0b523ee 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java @@ -13,7 +13,10 @@ @Mapper(componentModel = "spring") public interface InterestMapper { - InterestDto toInterestDto(Interest interest, List keywords, Boolean subscribedByMe); + @Mapping(source = "interest.name", target = "name") + @Mapping(source = "keywords", target = "keywords") + @Mapping(source = "subscribedByMe", target = "subscribedByMe") + InterestDto toDto(Interest interest, List keywords, Boolean subscribedByMe); @Mapping(target = "subscriberCount", source = "subscriberCount") InterestDto toInterestDto(Interest interest, List keywords, Boolean subscribedByMe, From dcf5693c1ef5a6bcc53245647cc9ffc4d3f9b166 Mon Sep 17 00:00:00 2001 From: yelim-Lee Date: Mon, 3 Nov 2025 21:03:47 +0900 Subject: [PATCH 155/178] =?UTF-8?q?feat=20:=20=EA=B4=80=EC=8B=AC=EC=82=AC?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=B6=80=EB=B6=84=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java --- .../interest/service/InterestServiceImpl.java | 143 +++++++++++------- 1 file changed, 92 insertions(+), 51 deletions(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index 617d596..1daa841 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -1,7 +1,13 @@ package com.monew.monew_api.interest.service; +import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_api.article.repository.InterestArticleKeywordRepository; +import com.monew.monew_api.article.repository.InterestArticlesRepository; import com.monew.monew_api.common.exception.interest.InterestDuplicatedException; import com.monew.monew_api.common.exception.interest.InterestNotFoundException; +import com.monew.monew_api.common.exception.user.UserNotFoundException; +import com.monew.monew_api.user.User; +import com.monew.monew_api.user.repository.UserRepository; import com.monew.monew_api.interest.dto.InterestOrderBy; import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; import com.monew.monew_api.interest.dto.request.InterestRegisterRequest; @@ -42,6 +48,10 @@ public class InterestServiceImpl implements InterestService { private final KeywordRepository keywordRepository; private final SubscribeRepository subscribeRepository; + private final ArticleRepository articleRepository; + private final InterestArticlesRepository interestArticlesRepository; + private final InterestArticleKeywordRepository interestArticleKeywordRepository; + private final InterestMapper interestMapper; @Override @@ -75,7 +85,7 @@ public InterestDto createInterest(InterestRegisterRequest request) { .map(ik -> ik.getKeyword().getKeyword()) .collect(Collectors.toList()); - return interestMapper.toInterestDto(savedInterest, keywords, false); + return interestMapper.toDto(savedInterest, keywords, false); } @Override @@ -87,7 +97,7 @@ public CursorPageResponseInterestDto getInterests(Long userId, ? null : request.keyword(); final InterestOrderBy orderBy = (request.orderBy() == null) ? InterestOrderBy.name : request.orderBy(); - final Order direction = (request.direction() == null)? Order.ASC : request.direction(); + final Order direction = (request.direction() == null) ? Order.ASC : request.direction(); final String cursor = request.cursor(); final LocalDateTime after = request.after(); final int limit = request.limit(); @@ -104,35 +114,40 @@ public CursorPageResponseInterestDto getInterests(Long userId, // 내가 구독중인 관심사 ID Set subscribedIds = subscribeRepository.findSubscribedByInterestIds(userId, interestIds); - // 관심사별 구독자 수 벌크 집계 - Map countMap = subscribeRepository.countByInterestIds(interestIds).stream() - .collect(Collectors.toMap( - InterestCountProjection::getInterestId, - InterestCountProjection::getCount - )); - // dto 채우기 List interestDtos = new ArrayList<>(interests.size()); for (Interest interest : interests) { List keywords = interest.getKeywords().stream() .map(ik -> ik.getKeyword().getKeyword()) .toList(); - + int subscriberCount = interest.getSubscriberCount(); boolean subscribedByMe = subscribedIds.contains(interest.getId()); + InterestDto dto = interestMapper.toInterestDto(interest, keywords, subscribedByMe, + subscriberCount); - Long countLong = countMap.getOrDefault(interest.getId(), 0L); - int subscriberCount = Math.toIntExact(countLong); - interestDtos.add( - interestMapper.toInterestDto(interest, keywords, subscribedByMe, subscriberCount)); + log.info("DBG dto id={}, name={}, subscriberCount={} subscribedByMe={}", + dto.id(), dto.name(), dto.subscriberCount(), dto.subscribedByMe()); + interestDtos.add(dto); } - boolean hasNext = slices.hasNext(); - String nextCursor = calculateNextCursor(interests, countMap, orderBy, hasNext); - LocalDateTime nextAfter = calculateNextAfter(interests); long totalElements = interestRepository.countFilteredTotalElements(keyword); + boolean hasNext = slices.hasNext(); + + String nextCursor = null; + LocalDateTime nextAfter = null; + if (hasNext) { + Interest last = interests.get(interests.size() - 1); + + if (request.orderBy() == InterestOrderBy.name) { + nextCursor = last.getName(); + } else if (request.orderBy() == InterestOrderBy.subscriberCount) { + nextCursor = String.valueOf(last.getId()); + } + nextAfter = last.getCreatedAt(); + } - return new CursorPageResponseInterestDto( - interestDtos, nextCursor, nextAfter, limit, totalElements, hasNext); + return new CursorPageResponseInterestDto(interestDtos, nextCursor, nextAfter, + slices.getSize(), totalElements, hasNext); } @Override @@ -149,7 +164,7 @@ public InterestDto updateInterestKeywords( .map(ik -> ik.getKeyword().getKeyword()) .collect(Collectors.toList()); - return interestMapper.toInterestDto(interest, keywords, false); + return interestMapper.toDto(interest, keywords, false); } @Override @@ -158,7 +173,33 @@ public void deleteInterest(Long interestId) { Interest interest = interestRepository.findById(interestId) .orElseThrow(InterestNotFoundException::new); + List articleIds = interestArticlesRepository.findArticleIdsByInterestId(interestId); + log.info("관심사({})와 연결된 기사 수: {}", interest.getName(), articleIds.size()); + + if (articleIds.isEmpty()) { + interestRepository.delete(interest); + return; + } + + List usedElsewhere = + interestArticlesRepository.findArticleIdsUsedByOtherInterests(articleIds, interestId); + + List toDelete = articleIds.stream() + .filter(id -> !usedElsewhere.contains(id)) + .toList(); + + int deletedCount = toDelete.size(); + int undeletedCount = usedElsewhere.size(); + + if (!toDelete.isEmpty()) { + articleRepository.markAsDeleted(toDelete); + log.info("논리 삭제된 기사 수: {}", deletedCount); + } + + log.info("삭제 제외된 기사 수(다른 관심사에서 사용 중): {}", undeletedCount); + interestRepository.delete(interest); + log.info("관심사 삭제 완료: {}", interest.getName()); } @@ -184,31 +225,6 @@ private double calculateSimilarity(String name1, String name2) { } - private String calculateNextCursor( - List interests, - Map countMap, - InterestOrderBy orderBy, - boolean hasNext - ) { - if (!hasNext || interests.isEmpty()) return null; - - Interest last = interests.get(interests.size() - 1); - - return switch (orderBy) { - case name -> last.getName(); - case subscriberCount -> String.valueOf(last.getId()); - }; - } - - - private LocalDateTime calculateNextAfter(List interests) { - if (!interests.isEmpty()) { - return interests.get(interests.size() - 1).getCreatedAt(); - } - return null; - } - - private void updateKeywords( Interest interest, @Size(min = 1, max = 10) List requestKeywords) { @@ -242,14 +258,39 @@ private void removeOrphanKeywords(Interest interest, Map removedKeyword = new ArrayList<>(); - for (InterestKeyword interestKeyword : toRemove.values()) { - interest.getKeywords().remove(interestKeyword); - removedKeyword.add(interestKeyword.getKeyword()); + List removedKeywords = toRemove.values().stream() + .map(InterestKeyword::getKeyword) + .toList(); + + interest.getKeywords().removeAll(toRemove.values()); + + List removedKeywordIds = removedKeywords.stream() + .map(Keyword::getId) + .toList(); + + List relatedArticleIds = + interestArticleKeywordRepository.findArticleIdsByKeywordIds(removedKeywordIds); + log.info("고아 키워드 관련 기사 수: {}", relatedArticleIds.size()); + + if (!relatedArticleIds.isEmpty()) { + List usedElsewhere = interestArticleKeywordRepository.findArticlesUsedElsewhere( + relatedArticleIds, removedKeywordIds, interest.getId()); + + List toDelete = relatedArticleIds.stream() + .filter(id -> !usedElsewhere.contains(id)) + .toList(); + + if (!toDelete.isEmpty()) { + articleRepository.markAsDeleted(toDelete); + log.info("논리 삭제된 기사 수: {}", toDelete.size()); + } + + log.info("삭제 제외된 기사 수(다른 관심사/키워드 사용 중): {}", usedElsewhere.size()); } - List toDelete = keywordRepository.findOrphanKeywordsIn(removedKeyword); - keywordRepository.deleteAll(toDelete); + List orphanKeywords = keywordRepository.findOrphanKeywordsIn(removedKeywords); + keywordRepository.deleteAll(orphanKeywords); + log.info("고아 키워드 삭제 완료: {}", orphanKeywords.size()); } } From 0ec251048b1c8d95270e4357b4ce129e2ac8baf1 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Tue, 4 Nov 2025 12:09:16 +0900 Subject: [PATCH 156/178] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8F=AC=20Dockerfile?= =?UTF-8?q?,=20docker-compose.prod.yml=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile.api | 104 +++++++++++++++++++++++ Dockerfile.batch | 85 +++++++++++++++++++ Dockerfile.monitor | 87 +++++++++++++++++++ Dockerfile.multi | 120 ++++++++++++++++++++++++++ docker-compose.prod.yml | 184 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 580 insertions(+) create mode 100644 Dockerfile.api create mode 100644 Dockerfile.batch create mode 100644 Dockerfile.monitor create mode 100644 Dockerfile.multi create mode 100644 docker-compose.prod.yml diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..765330c --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,104 @@ +# ==================================== +# Dockerfile for API Module +# ==================================== + +# Stage 1: Build +FROM gradle:8.5-jdk17-alpine AS builder + +# 빌드 인자 선언 (docker-compose.prod.yml에서 전달) +ARG BUILD_DATE +ARG VCS_REF +ARG VERSION + +WORKDIR /app + +# 루트 프로젝트 설정 파일 복사 +#COPY settings.gradle ./ +#COPY build.gradle ./ +#COPY gradlew ./ +#COPY gradle gradle/ + +COPY settings.gradle ./ +COPY build.gradle ./ +COPY gradlew ./ +COPY gradle gradle/ +COPY monew-monitor/build.gradle monew-monitor/ + +# 2) CRLF 제거 + 실행권한 + 존재 확인 +RUN sed -i 's/\r$//' gradlew && chmod +x gradlew && \ + ls -al && ls -al gradle/wrapper && head -n1 gradlew && \ + ./gradlew --version + +# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱) +COPY monew-api/build.gradle monew-api/build.gradle +COPY monew-batch/build.gradle monew-batch/build.gradle +COPY monew-monitor/build.gradle monew-monitor/build.gradle + +# Gradle wrapper 실행 권한 +RUN chmod +x gradlew || true + +# 의존성 다운로드 (캐시 레이어) +RUN ./gradlew dependencies --no-daemon || true + +# API 모듈 소스 코드 복사 +COPY monew-api/ monew-api/ + +# API 모듈만 빌드 (테스트 제외) +RUN ./gradlew :monew-api:bootJar -x test --no-daemon + +# JAR 파일 위치 확인 및 복사 +RUN mkdir -p /app/build && \ + find monew-api/build/libs -name "*.jar" -not -name "*-plain.jar" -exec cp {} /app/build/app.jar \; + +# Stage 2: Runtime +FROM eclipse-temurin:17-jre-alpine + +# 빌드 인자 다시 선언 (runtime stage에서 사용) +ARG BUILD_DATE +ARG VCS_REF +ARG VERSION + +WORKDIR /app + +# 이미지 메타데이터 레이블 추가 +LABEL org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.revision="${VCS_REF}" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.title="Monew API" \ + org.opencontainers.image.description="Monew API Server" + +# 보안을 위한 non-root 사용자 생성 +RUN addgroup -S spring && adduser -S spring -G spring + +# 타임존 설정 +RUN apk add --no-cache tzdata wget && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +# 빌드 스테이지에서 JAR 파일 복사 +COPY --from=builder /app/build/app.jar app.jar + +# 소유권 변경 +RUN chown spring:spring app.jar + +# 사용자 변경 +USER spring:spring + +# 헬스체크 설정 +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 + +# JVM 옵션 설정 (환경변수로 오버라이드 가능) +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:InitialRAMPercentage=50.0 \ + -XX:+UseG1GC \ + -XX:+OptimizeStringConcat \ + -Djava.security.egd=file:/dev/./urandom" + +# 애플리케이션 포트 +EXPOSE 8080 + +# 애플리케이션 실행 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/Dockerfile.batch b/Dockerfile.batch new file mode 100644 index 0000000..a518c53 --- /dev/null +++ b/Dockerfile.batch @@ -0,0 +1,85 @@ +# ==================================== +# Dockerfile for Batch Module +# ==================================== + +# Stage 1: Build +FROM gradle:8.5-jdk17-alpine AS builder + +WORKDIR /app + +# Gradle wrapper 및 설정 파일 복사 +#COPY monew-batch/build.gradle settings.gradle ./ +#COPY build.gradle ./ +#COPY gradlew* ./ +#COPY gradle gradle/ + +COPY settings.gradle ./ +COPY build.gradle ./ +COPY gradlew ./ +COPY gradle gradle/ +COPY monew-monitor/build.gradle monew-monitor/ + +# 2) CRLF 제거 + 실행권한 + 래퍼 존재/버전 확인 +RUN sed -i 's/\r$//' gradlew && chmod +x gradlew && \ + ls -al && ls -al gradle/wrapper && head -n1 gradlew && \ + ./gradlew --version + +# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱) +COPY monew-api/build.gradle monew-api/ +COPY monew-batch/build.gradle monew-batch/ +COPY monew-monitor/build.gradle monew-monitor/ + +# Gradle wrapper 실행 권한 +RUN chmod +x gradlew || true + +# 의존성 다운로드 (캐시 레이어) +RUN ./gradlew dependencies --no-daemon || true + +# 전체 소스 코드 복사 +#COPY monew-batch . +COPY monew-api/ monew-api/ +COPY monew-batch/ monew-batch/ + +# Batch 모듈만 빌드 (테스트 제외) +RUN ./gradlew :monew-batch:bootJar -x test --no-daemon + +# JAR 파일 위치 확인 및 복사 +RUN mkdir -p /app/build && \ + find monew-batch/build/libs -name "*.jar" -not -name "*-plain.jar" -exec cp {} /app/build/app.jar \; + +# Stage 2: Runtime +FROM eclipse-temurin:17-jre-alpine + +WORKDIR /app + +# 보안을 위한 non-root 사용자 생성 +RUN addgroup -S spring && adduser -S spring -G spring + +# 타임존 설정 +RUN apk add --no-cache tzdata && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +# 빌드 스테이지에서 JAR 파일 복사 +COPY --from=builder /app/build/app.jar app.jar + +# 소유권 변경 +RUN chown spring:spring app.jar + +# 사용자 변경 +USER spring:spring + +# JVM 옵션 설정 (배치 작업용 최적화) +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:InitialRAMPercentage=50.0 \ + -XX:+UseG1GC \ + -XX:MaxGCPauseMillis=200 \ + -Djava.security.egd=file:/dev/./urandom" + +# 애플리케이션 포트 +EXPOSE 8081 + +# 애플리케이션 실행 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/Dockerfile.monitor b/Dockerfile.monitor new file mode 100644 index 0000000..34679de --- /dev/null +++ b/Dockerfile.monitor @@ -0,0 +1,87 @@ +# ==================================== +# Dockerfile for Monitor Module +# ==================================== + +# Stage 1: Build +FROM gradle:8.5-jdk17-alpine AS builder + +WORKDIR /app + +# Gradle wrapper 및 설정 파일 복사 +#COPY monew-monitor/build.gradle settings.gradle ./ +#COPY build.gradle ./ +#COPY gradlew* ./ +#COPY gradle gradle/ + +COPY settings.gradle ./ +COPY build.gradle ./ +COPY gradlew ./ +COPY gradle gradle/ +COPY monew-monitor/build.gradle monew-monitor/ + +# 2) CRLF 제거 + 실행권한 + 래퍼 존재/버전 확인 +RUN sed -i 's/\r$//' gradlew && chmod +x gradlew && \ + ls -al && ls -al gradle/wrapper && head -n1 gradlew && \ + ./gradlew --version + +# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱) +COPY monew-api/build.gradle monew-api/ +COPY monew-batch/build.gradle monew-batch/ +COPY monew-monitor/build.gradle monew-monitor/ + +# Gradle wrapper 실행 권한 +RUN chmod +x gradlew || true + +# 의존성 다운로드 (캐시 레이어) +RUN ./gradlew dependencies --no-daemon || true + +# 전체 소스 코드 복사 +#COPY monew-monitor . +COPY monew-monitor/ monew-monitor/ + +# Monitor 모듈만 빌드 (테스트 제외) +RUN ./gradlew :monew-monitor:bootJar -x test --no-daemon + +# JAR 파일 위치 확인 및 복사 +RUN mkdir -p /app/build && \ + find monew-monitor/build/libs -name "*.jar" -not -name "*-plain.jar" -exec cp {} /app/build/app.jar \; + +# Stage 2: Runtime +FROM eclipse-temurin:17-jre-alpine + +WORKDIR /app + +# 보안을 위한 non-root 사용자 생성 +RUN addgroup -S spring && adduser -S spring -G spring + +# 타임존 설정 +RUN apk add --no-cache tzdata wget && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +# 빌드 스테이지에서 JAR 파일 복사 +COPY --from=builder /app/build/app.jar app.jar + +# 소유권 변경 +RUN chown spring:spring app.jar + +# 사용자 변경 +USER spring:spring + +# 헬스체크 설정 +HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8082/actuator/health || exit 1 + +# JVM 옵션 설정 (모니터링 경량화) +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=70.0 \ + -XX:InitialRAMPercentage=40.0 \ + -XX:+UseG1GC \ + -Djava.security.egd=file:/dev/./urandom" + +# 애플리케이션 포트 +EXPOSE 8082 + +# 애플리케이션 실행 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/Dockerfile.multi b/Dockerfile.multi new file mode 100644 index 0000000..9aa5176 --- /dev/null +++ b/Dockerfile.multi @@ -0,0 +1,120 @@ +# ==================================== +# Multi-Module Multi-Stage Dockerfile +# 한 번에 모든 모듈을 빌드하는 통합 Dockerfile +# ==================================== + +# Stage 1: Build All Modules +FROM gradle:8.5-jdk17-alpine AS builder + +WORKDIR /app + +# Gradle wrapper 및 설정 파일 복사 +COPY build.gradle settings.gradle ./ +COPY gradlew* ./ +COPY gradle gradle/ + +# CRLF 방지 + 실행권한 +RUN sed -i 's/\r$//' gradlew && chmod +x gradlew + +# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱) +COPY monew-api/build.gradle monew-api/ +COPY monew-batch/build.gradle monew-batch/ +COPY monew-monitor/build.gradle monew-monitor/ + +# Gradle wrapper 실행 권한 +RUN chmod +x gradlew || true + +# 의존성 다운로드 (캐시 레이어) +RUN gradle dependencies --no-daemon || true + +# 전체 소스 코드 복사 +COPY . . + +# 모든 모듈 빌드 (테스트 제외) +RUN gradle build -x test --no-daemon + +# Stage 2: API Runtime +FROM eclipse-temurin:17-jre-alpine AS api + +WORKDIR /app + +RUN addgroup -S spring && adduser -S spring -G spring + +RUN apk add --no-cache tzdata wget && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +COPY --from=builder /app/monew-api/build/libs/*[!plain].jar app.jar + +RUN chown spring:spring app.jar +USER spring:spring + +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 + +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:InitialRAMPercentage=50.0 \ + -XX:+UseG1GC \ + -Djava.security.egd=file:/dev/./urandom" + +EXPOSE 8080 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] + +# Stage 3: Batch Runtime +FROM eclipse-temurin:17-jre-alpine AS batch + +WORKDIR /app + +RUN addgroup -S spring && adduser -S spring -G spring + +RUN apk add --no-cache tzdata && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +COPY --from=builder /app/monew-batch/build/libs/*[!plain].jar app.jar + +RUN chown spring:spring app.jar +USER spring:spring + +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:+UseG1GC \ + -XX:MaxGCPauseMillis=200 \ + -Djava.security.egd=file:/dev/./urandom" + +EXPOSE 8081 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] + +# Stage 4: Monitor Runtime +FROM eclipse-temurin:17-jre-alpine AS monitor + +WORKDIR /app + +RUN addgroup -S spring && adduser -S spring -G spring + +RUN apk add --no-cache tzdata wget && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +COPY --from=builder /app/monew-monitor/build/libs/*[!plain].jar app.jar + +RUN chown spring:spring app.jar +USER spring:spring + +HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8082/actuator/health || exit 1 + +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=70.0 \ + -XX:+UseG1GC \ + -Djava.security.egd=file:/dev/./urandom" + +EXPOSE 8082 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..f82dc91 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,184 @@ +version: '3.8' + +services: + # API Application (Production) + api: + build: + context: . + dockerfile: Dockerfile.api + args: + - BUILD_DATE=${BUILD_DATE} + - VCS_REF=${VCS_REF} + - VERSION=${VERSION:-latest} + image: monew-api:${VERSION:-latest} + container_name: monew-api-prod + environment: + SPRING_PROFILES_ACTIVE: prod + DB_URL: ${DB_URL} + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + MongoDB_URI: ${MongoDB_URI} + AWS_S3_ACCESS_KEY: ${AWS_S3_ACCESS_KEY} + AWS_S3_SECRET_KEY: ${AWS_S3_SECRET_KEY} + AWS_S3_REGION: ${AWS_S3_REGION} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} + JAVA_OPTS: "-Xmx1g -Xms512m -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0" + ports: + - "${API_PORT:-8080}:8080" + networks: + - monew-network + restart: always + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + deploy: + resources: + limits: + cpus: '2' + memory: 1536M + reservations: + cpus: '0.5' + memory: 512M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Batch Application (Production) + batch: + build: + context: . + dockerfile: Dockerfile.batch + args: + - BUILD_DATE=${BUILD_DATE} + - VCS_REF=${VCS_REF} + - VERSION=${VERSION:-latest} + image: monew-batch:${VERSION:-latest} + container_name: monew-batch-prod + environment: + SPRING_PROFILES_ACTIVE: prod + DB_URL: ${DB_URL} + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + AWS_S3_ACCESS_KEY: ${AWS_S3_ACCESS_KEY} + AWS_S3_SECRET_KEY: ${AWS_S3_SECRET_KEY} + AWS_S3_REGION: ${AWS_S3_REGION} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} + NAVER_CLIENT_ID: ${NAVER_CLIENT_ID} + NAVER_CLIENT_SECRET: ${NAVER_CLIENT_SECRET} + JAVA_OPTS: "-Xmx1g -Xms512m -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0" + ports: + - "${BATCH_PORT:-8081}:8081" + depends_on: + - api + networks: + - monew-network + restart: always + deploy: + resources: + limits: + cpus: '2' + memory: 1536M + reservations: + cpus: '0.5' + memory: 512M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Monitor Application (Production) + monitor: + build: + context: . + dockerfile: Dockerfile.monitor + args: + - BUILD_DATE=${BUILD_DATE} + - VCS_REF=${VCS_REF} + - VERSION=${VERSION:-latest} + image: monew-monitor:${VERSION:-latest} + container_name: monew-monitor-prod + environment: + SPRING_PROFILES_ACTIVE: prod + JAVA_OPTS: "-Xmx512m -Xms256m -XX:+UseContainerSupport -XX:MaxRAMPercentage=70.0" + ports: + - "${MONITOR_PORT:-8082}:8082" + depends_on: + - api + - batch + networks: + - monew-network + restart: always + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8082/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + deploy: + resources: + limits: + cpus: '1' + memory: 768M + reservations: + cpus: '0.25' + memory: 256M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Nginx (Optional - Reverse Proxy) + nginx: + image: nginx:alpine + container_name: monew-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + depends_on: + - api + - batch + - monitor + networks: + - monew-network + restart: always + + # Prometheus (Metrics Collector) + prometheus: + image: prom/prometheus:latest + container_name: monew-prometheus + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + networks: + - monew-network + restart: always + + # Grafana (Visualization) + grafana: + image: grafana/grafana:latest + container_name: monew-grafana + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + ports: + - "3000:3000" + depends_on: + - prometheus + networks: + - monew-network + restart: always + +networks: + monew-network: + driver: bridge \ No newline at end of file From d652f60e0221ffe7446604758d76e4a1cff1fc66 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 4 Nov 2025 13:39:47 +0900 Subject: [PATCH 157/178] =?UTF-8?q?chore:=20jacoco=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?-=20root=20build.gradle=EC=97=90=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20git=20workflows=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 34 ++++++++++++++++++++++++++++++++++ build.gradle | 25 +++++++++++++++++++++---- 2 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3858ed3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Run Tests and Upload Coverage + +on: + push: + branches: [ main, develop ] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run Tests and Coverage + run: ./gradlew clean test jacocoTestReport + + - name: Upload to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: | + monew-api/build/reports/jacoco/test/jacocoTestReport.xml + monew-batch/build/reports/jacoco/test/jacocoTestReport.xml + fail_ci_if_error: true \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7d24310..810670e 100644 --- a/build.gradle +++ b/build.gradle @@ -15,12 +15,32 @@ java { subprojects { apply plugin: 'java' + apply plugin: 'jacoco' apply plugin: 'io.spring.dependency-management' repositories { mavenCentral() } + jacoco { + toolVersion = "0.8.10" + } + + test { + useJUnitPlatform() + finalizedBy jacocoTestReport // 테스트 후 커버리지 자동 생성 + } + + jacocoTestReport { + dependsOn test + reports { + xml.required = true + csv.required = false + html.required = true + html.outputLocation = layout.buildDirectory.dir("jacocoHtml") + } + } + configurations { compileOnly { extendsFrom annotationProcessor @@ -32,10 +52,7 @@ subprojects { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'io.micrometer:micrometer-core' implementation 'org.springframework.boot:spring-boot-starter-actuator' } - - tasks.named('test') { - useJUnitPlatform() - } } \ No newline at end of file From cfa623d4b1e814514f4a2d95d45d9800522d3fb2 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 4 Nov 2025 13:40:48 +0900 Subject: [PATCH 158/178] =?UTF-8?q?feat:=20Matric=20=EC=B6=94=EA=B0=80=20-?= =?UTF-8?q?=20=EB=89=B4=EC=8A=A4=20=EC=88=98=EC=A7=91=20=ED=86=B5=EA=B3=84?= =?UTF-8?q?=20Matric=20=EC=B6=94=EA=B0=80=20-=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=B0=B1=EC=97=85=20=ED=86=B5=EA=B3=84=20Matric=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EB=89=B4=EC=8A=A4=20=EB=AC=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20Matric=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/job/NaverNewsItemWriter.java | 11 ++++-- .../article/matric/NewsBatchMetrics.java | 35 +++++++++++++++++++ .../scheduler/ArticleCleanupScheduler.java | 4 +++ .../article/service/NewsBackupService.java | 4 +++ 4 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/matric/NewsBatchMetrics.java diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java index 462d0ba..f0434ec 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java @@ -10,6 +10,7 @@ import com.monew.monew_api.interest.entity.Keyword; import com.monew.monew_api.interest.repository.InterestRepository; import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.matric.NewsBatchMetrics; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.ExitStatus; @@ -35,13 +36,16 @@ public class NaverNewsItemWriter implements ItemWriter> private final InterestRepository interestRepository; private final InterestArticlesRepository interestArticlesRepository; private final InterestArticleKeywordRepository interestArticleKeywordRepository; + private final NewsBatchMetrics metrics; private final Map newLinkCountsByInterestId = new ConcurrentHashMap<>(); + private int total = 0; + private int newCount = 0; + private int linkedCount = 0; + @Override public void write(Chunk> chunk) { - int total = 0, newCount = 0, linkedCount = 0; - for (List batch : chunk) { for (ArticleKeywordPair pair : batch) { total++; @@ -85,6 +89,9 @@ public ExitStatus afterStep(StepExecution stepExecution) { log.info("JobExecutionContext에 최종 집계 데이터 저장 완료."); } + metrics.recordArticles(total, newCount, linkedCount); + log.info("Prometheus 메트릭 기록 완료 | total={}, new={}, linked={}", total, newCount, linkedCount); + return ExitStatus.COMPLETED; } } diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/matric/NewsBatchMetrics.java b/monew-batch/src/main/java/com/monew/monew_batch/article/matric/NewsBatchMetrics.java new file mode 100644 index 0000000..bd5ccbc --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/matric/NewsBatchMetrics.java @@ -0,0 +1,35 @@ +package com.monew.monew_batch.article.matric; + +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 배치 통계 수집 메트릭 + * - Prometheus에서 batch.*.* 메트릭 이름으로 노출됨 + */ +@Component +@RequiredArgsConstructor +public class NewsBatchMetrics { + + private final MeterRegistry meterRegistry; + + public void recordArticles(int total, int newCount, int linkedCount) { + meterRegistry.counter("batch.articles.total").increment(total); + meterRegistry.counter("batch.articles.new").increment(newCount); + meterRegistry.counter("batch.articles.linked").increment(linkedCount); + } + + public void recordBackup(boolean success, int count) { + if (success) { + meterRegistry.counter("batch.backup.success").increment(); + meterRegistry.counter("batch.backup.count").increment(count); + } else { + meterRegistry.counter("batch.backup.fail").increment(); + } + } + + public void recordCleanup(int deletedCount) { + meterRegistry.counter("batch.cleanup.deleted").increment(deletedCount); + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java index d0caafc..8a8c96f 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java @@ -2,6 +2,7 @@ import com.monew.monew_api.article.entity.Article; import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_batch.article.matric.NewsBatchMetrics; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -16,6 +17,7 @@ public class ArticleCleanupScheduler { private final ArticleRepository articleRepository; + private final NewsBatchMetrics metrics; /** * 매일 새벽 4시에 is_deleted = true인 뉴스들을 물리 삭제 @@ -33,6 +35,8 @@ public void deleteSoftDeletedArticles() { int total = deletedArticles.size(); articleRepository.deleteAll(deletedArticles); + + metrics.recordCleanup(total); log.info("🗑 물리 삭제 완료 | 총 {}건 (FK CASCADE 포함)", total); } diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/service/NewsBackupService.java b/monew-batch/src/main/java/com/monew/monew_batch/article/service/NewsBackupService.java index 676a0e1..2ae608d 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/service/NewsBackupService.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/service/NewsBackupService.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.monew.monew_api.article.dto.NewsBackupData; +import com.monew.monew_batch.article.matric.NewsBatchMetrics; import com.monew.monew_batch.article.repository.ArticleBackupQueryRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,6 +30,7 @@ public class NewsBackupService { private final ArticleBackupQueryRepository backupQueryRepository; private final ObjectMapper objectMapper; private final S3Client s3Client; + private final NewsBatchMetrics metrics; @Value("${aws.bucket}") private String bucketName; @@ -63,9 +65,11 @@ public void backupAllArticles() { ); log.info("✅ 뉴스 전체 백업 완료 | 총 {}건 | S3 Key = {}", articles.size(), key); + metrics.recordBackup(true, articles.size()); } catch (Exception e) { log.error("❌ 뉴스 백업 실패", e); + metrics.recordBackup(false, 0); throw new RuntimeException("뉴스 백업 실패", e); } } From 37679d421db8aa6855dffe2baa6043d32a17413b Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 4 Nov 2025 13:41:04 +0900 Subject: [PATCH 159/178] =?UTF-8?q?refactor:=20mapper=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=EC=BD=94=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 --- .../monew/monew_api/interest/service/InterestServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java index f5e397e..7fd6c91 100644 --- a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -170,7 +170,7 @@ public InterestDto updateInterestKeywords( eventPublisher.publishEvent(InterestUpdatedEvent.of(interest.getId(), keywords)); log.info("interestId = {}, 관심사 키워드 수정 완료 : {}", interestId, keywords); - return interestMapper.toInterestDto(interest, keywords, false); + return interestMapper.toDto(interest, keywords, false); } @Override From 89e18ab6a3859f840c9657b21e891572e698a40f Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 4 Nov 2025 13:41:29 +0900 Subject: [PATCH 160/178] =?UTF-8?q?chore:=20prometheus.yml=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 --- .../src/main/resources/prometheus.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 monew-monitor/src/main/resources/prometheus.yml diff --git a/monew-monitor/src/main/resources/prometheus.yml b/monew-monitor/src/main/resources/prometheus.yml new file mode 100644 index 0000000..606a695 --- /dev/null +++ b/monew-monitor/src/main/resources/prometheus.yml @@ -0,0 +1,18 @@ +global: + scrape_interval: 10s # 10초마다 메트릭 수집 + +scrape_configs: + - job_name: 'monew-api' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['host.docker.internal:8080'] + + - job_name: 'monew-batch' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['host.docker.internal:8081'] + + - job_name: 'monew-monitor' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['host.docker.internal:8082'] \ No newline at end of file From c455b42986c407fc97ee1a9a47a7f6c0090bf98b Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:03:29 +0900 Subject: [PATCH 161/178] =?UTF-8?q?chore:=20H2=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EA=B0=95=EC=A0=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index 810670e..10cd703 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,7 @@ subprojects { test { useJUnitPlatform() finalizedBy jacocoTestReport // 테스트 후 커버리지 자동 생성 + systemProperty 'spring.profiles.active', 'test' // H2 프로필 강제 적용 } jacocoTestReport { From 82fcc2447f1850358be09620c29b97bf841064d8 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:15:04 +0900 Subject: [PATCH 162/178] =?UTF-8?q?chore:=20=EB=8D=94=EB=AF=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 --- .github/workflows/test.yml | 11 +++++++++++ monew-api/src/test/resources/application-test.yml | 14 +++++++++++++- .../src/test/resources/application-test.yml | 13 +++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3858ed3..53659db 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,17 @@ on: jobs: test: runs-on: ubuntu-latest + + env: + SPRING_PROFILES_ACTIVE: test + AWS_S3_ACCESS_KEY: dummy-access + AWS_S3_SECRET_KEY: dummy-secret + AWS_S3_REGION: ap-northeast-2 + AWS_S3_BUCKET: test-bucket + NAVER_CLIENT_ID: dummy + NAVER_CLIENT_SECRET: dummy + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + steps: - name: Checkout uses: actions/checkout@v4 diff --git a/monew-api/src/test/resources/application-test.yml b/monew-api/src/test/resources/application-test.yml index 85e22be..eacf609 100644 --- a/monew-api/src/test/resources/application-test.yml +++ b/monew-api/src/test/resources/application-test.yml @@ -26,4 +26,16 @@ logging: level: root: WARN org.hibernate.SQL: DEBUG - org.springframework: WARN \ No newline at end of file + org.springframework: WARN + +# 더미들 +aws: + accessKeyId: dummy-access + secretKey: dummy-secret + region: ap-northeast-2 + bucket: test-bucket + +naver: + url: https://openapi.naver.com/v1/search/news.json + client-id: dummy + client-secret: dummy \ No newline at end of file diff --git a/monew-batch/src/test/resources/application-test.yml b/monew-batch/src/test/resources/application-test.yml index 67a1581..340c28a 100644 --- a/monew-batch/src/test/resources/application-test.yml +++ b/monew-batch/src/test/resources/application-test.yml @@ -35,3 +35,16 @@ logging: org.hibernate.SQL: DEBUG org.springframework: WARN org.springframework.batch: INFO + + +# 더미들 +aws: + accessKeyId: dummy-access + secretKey: dummy-secret + region: ap-northeast-2 + bucket: test-bucket + +naver: + url: https://openapi.naver.com/v1/search/news.json + client-id: dummy + client-secret: dummy From ff6ac2725df172409915746eee91cbee1b18a1c4 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:34:15 +0900 Subject: [PATCH 163/178] =?UTF-8?q?refactor:=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=8C=80=EA=B1=B0=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?-=20batch=20=EA=B8=B0=EB=B3=B8=20yml=EC=97=90=20monew.api.url?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20-=20api,=20batch=EC=9D=98=20test=20yml?= =?UTF-8?q?=EC=97=90=20=EB=8D=94=EB=AF=B8=20=EC=82=AD=EC=A0=9C=20-=20workf?= =?UTF-8?q?lows=EC=9D=98=20test.yml=EC=97=90=20env=20=EB=82=B4=EC=9A=A9=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 --- .github/workflows/test.yml | 12 ++++++------ monew-api/src/test/resources/application-test.yml | 14 +------------- monew-batch/src/main/resources/application.yml | 6 +++++- .../src/test/resources/application-test.yml | 15 +-------------- 4 files changed, 13 insertions(+), 34 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 53659db..3a159f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,12 +11,12 @@ jobs: env: SPRING_PROFILES_ACTIVE: test - AWS_S3_ACCESS_KEY: dummy-access - AWS_S3_SECRET_KEY: dummy-secret - AWS_S3_REGION: ap-northeast-2 - AWS_S3_BUCKET: test-bucket - NAVER_CLIENT_ID: dummy - NAVER_CLIENT_SECRET: dummy + AWS_S3_ACCESS_KEY: ${{ secrets.AWS_S3_ACCESS_KEY }} + AWS_S3_SECRET_KEY: ${{ secrets.AWS_S3_SECRET_KEY }} + AWS_S3_REGION: ${{ secrets.AWS_S3_REGION }} + AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} + NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }} + NAVER_CLIENT_SECRET: ${{ secrets.NAVER_CLIENT_SECRET }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} steps: diff --git a/monew-api/src/test/resources/application-test.yml b/monew-api/src/test/resources/application-test.yml index eacf609..85e22be 100644 --- a/monew-api/src/test/resources/application-test.yml +++ b/monew-api/src/test/resources/application-test.yml @@ -26,16 +26,4 @@ logging: level: root: WARN org.hibernate.SQL: DEBUG - org.springframework: WARN - -# 더미들 -aws: - accessKeyId: dummy-access - secretKey: dummy-secret - region: ap-northeast-2 - bucket: test-bucket - -naver: - url: https://openapi.naver.com/v1/search/news.json - client-id: dummy - client-secret: dummy \ No newline at end of file + org.springframework: WARN \ No newline at end of file diff --git a/monew-batch/src/main/resources/application.yml b/monew-batch/src/main/resources/application.yml index a3e89bb..c6df6fe 100644 --- a/monew-batch/src/main/resources/application.yml +++ b/monew-batch/src/main/resources/application.yml @@ -45,4 +45,8 @@ aws: naver: url: https://openapi.naver.com/v1/search/news.json client-id: ${NAVER_CLIENT_ID} - client-secret: ${NAVER_CLIENT_SECRET} \ No newline at end of file + client-secret: ${NAVER_CLIENT_SECRET} + +monew: + api: + url: http://localhost:8080 \ No newline at end of file diff --git a/monew-batch/src/test/resources/application-test.yml b/monew-batch/src/test/resources/application-test.yml index 340c28a..dc96d13 100644 --- a/monew-batch/src/test/resources/application-test.yml +++ b/monew-batch/src/test/resources/application-test.yml @@ -34,17 +34,4 @@ logging: root: WARN org.hibernate.SQL: DEBUG org.springframework: WARN - org.springframework.batch: INFO - - -# 더미들 -aws: - accessKeyId: dummy-access - secretKey: dummy-secret - region: ap-northeast-2 - bucket: test-bucket - -naver: - url: https://openapi.naver.com/v1/search/news.json - client-id: dummy - client-secret: dummy + org.springframework.batch: INFO \ No newline at end of file From dfdb0cdda5261a606be5996e9f70b4c46d5ea61b Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:39:52 +0900 Subject: [PATCH 164/178] =?UTF-8?q?chore:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EB=B9=88=20Mock=EC=9C=BC=EB=A1=9C=20=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/monew/monew_api/MonewApiApplicationTests.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/monew-api/src/test/java/com/monew/monew_api/MonewApiApplicationTests.java b/monew-api/src/test/java/com/monew/monew_api/MonewApiApplicationTests.java index 4dfa58a..d2b4853 100644 --- a/monew-api/src/test/java/com/monew/monew_api/MonewApiApplicationTests.java +++ b/monew-api/src/test/java/com/monew/monew_api/MonewApiApplicationTests.java @@ -1,13 +1,16 @@ package com.monew.monew_api; +import com.monew.monew_api.common.config.AWSConfig; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; -@SpringBootTest +@SpringBootTest(classes = MonewApiApplication.class) class MonewApiApplicationTests { - @Test - void contextLoads() { - } + @MockBean + private AWSConfig awsConfig; + @Test + void contextLoads() {} } From cb30602eeb92345bb8013c08a5b58c2e0c45490d Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:45:57 +0900 Subject: [PATCH 165/178] =?UTF-8?q?remove:=20ApplicationTests=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 --- .../monew_api/MonewApiApplicationTests.java | 16 ---------------- .../monew_batch/MonewBatchApplicationTests.java | 13 ------------- 2 files changed, 29 deletions(-) delete mode 100644 monew-api/src/test/java/com/monew/monew_api/MonewApiApplicationTests.java delete mode 100644 monew-batch/src/test/java/com/monew/monew_batch/MonewBatchApplicationTests.java diff --git a/monew-api/src/test/java/com/monew/monew_api/MonewApiApplicationTests.java b/monew-api/src/test/java/com/monew/monew_api/MonewApiApplicationTests.java deleted file mode 100644 index d2b4853..0000000 --- a/monew-api/src/test/java/com/monew/monew_api/MonewApiApplicationTests.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.monew.monew_api; - -import com.monew.monew_api.common.config.AWSConfig; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; - -@SpringBootTest(classes = MonewApiApplication.class) -class MonewApiApplicationTests { - - @MockBean - private AWSConfig awsConfig; - - @Test - void contextLoads() {} -} diff --git a/monew-batch/src/test/java/com/monew/monew_batch/MonewBatchApplicationTests.java b/monew-batch/src/test/java/com/monew/monew_batch/MonewBatchApplicationTests.java deleted file mode 100644 index 0aa33e0..0000000 --- a/monew-batch/src/test/java/com/monew/monew_batch/MonewBatchApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.monew.monew_batch; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class MonewBatchApplicationTests { - - @Test - void contextLoads() { - } - -} From 1b22dba72ec1dd9f950ebf90767b2c3b5fa66abd Mon Sep 17 00:00:00 2001 From: truuuely Date: Tue, 4 Nov 2025 15:07:31 +0900 Subject: [PATCH 166/178] =?UTF-8?q?feat:=20=EA=B8=B0=EC=82=AC=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EB=B9=84=EB=8F=99=EA=B8=B0=EB=A1=9C=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 --- .../service/NotificationService.java | 4 +- .../monew_batch/MonewBatchApplication.java | 2 + .../ArticleNotificationRequestListener.java | 21 ++--------- .../service/NotificationAsyncService.java | 37 +++++++++++++++++++ 4 files changed, 44 insertions(+), 20 deletions(-) create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/notification/service/NotificationAsyncService.java diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java index 08fa07e..2f6e2e3 100644 --- a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java +++ b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java @@ -5,8 +5,6 @@ import com.monew.monew_api.common.exception.notification.NotificationAccessDeniedException; import com.monew.monew_api.common.exception.notification.NotificationAlreadyConfirmedException; import com.monew.monew_api.common.exception.notification.NotificationNotFoundException; -import com.monew.monew_api.user.User; -import com.monew.monew_api.user.repository.UserRepository; import com.monew.monew_api.interest.entity.Interest; import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest; import com.monew.monew_api.notification.dto.response.NotificationDto; @@ -15,6 +13,8 @@ import com.monew.monew_api.notification.repository.NotificationRepository; import com.monew.monew_api.subscribe.entity.Subscribe; import com.monew.monew_api.subscribe.repository.SubscribeRepository; +import com.monew.monew_api.user.User; +import com.monew.monew_api.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; diff --git a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java index 51a74af..c092f5c 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java @@ -5,6 +5,7 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @@ -19,6 +20,7 @@ @EnableJpaRepositories(basePackages = "com.monew.monew_api") @EnableScheduling @EnableJpaAuditing +@EnableAsync public class MonewBatchApplication { public static void main(String[] args) { diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java index 78abb68..c369b96 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java @@ -1,13 +1,12 @@ package com.monew.monew_batch.article.job; +import com.monew.monew_batch.notification.service.NotificationAsyncService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.BatchStatus; import org.springframework.batch.core.JobExecution; import org.springframework.batch.core.JobExecutionListener; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; import java.util.Map; @@ -16,10 +15,7 @@ @RequiredArgsConstructor public class ArticleNotificationRequestListener implements JobExecutionListener { - private final RestTemplate restTemplate; - - @Value("${monew.api.url}") - private String monewApiUrl; + private final NotificationAsyncService notificationAsyncService; @Override public void afterJob(JobExecution jobExecution) { @@ -31,17 +27,6 @@ public void afterJob(JobExecution jobExecution) { Map stats = (Map) jobExecution.getExecutionContext().get("newLinkCountsByInterestId"); - if (stats == null || stats.isEmpty()) { - log.info("전송할 알림 데이터 없음"); - return; - } - - try { - String apiUrl = monewApiUrl + "/api/internal/notifications/articles-registered"; - restTemplate.postForEntity(apiUrl, stats, Void.class); - log.info("✅ 관심사별 신규 기사 통계 전송 완료: {}개 관심사", stats.size()); - } catch (Exception e) { - log.error("❌ API 서버 알림 요청 실패", e); - } + notificationAsyncService.sendNotification(stats); } } \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/notification/service/NotificationAsyncService.java b/monew-batch/src/main/java/com/monew/monew_batch/notification/service/NotificationAsyncService.java new file mode 100644 index 0000000..99b48f3 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/notification/service/NotificationAsyncService.java @@ -0,0 +1,37 @@ +package com.monew.monew_batch.notification.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationAsyncService { + + private final RestTemplate restTemplate; + + @Value("${monew.api.url}") + private String monewApiUrl; + + @Async + public void sendNotification(Map stats) { + if (stats == null || stats.isEmpty()) { + log.info("전송할 알림 데이터 없음"); + return; + } + + try { + String apiUrl = monewApiUrl + "/api/internal/notifications/articles-registered"; + restTemplate.postForEntity(apiUrl, stats, Void.class); + log.info("✅ 관심사별 신규 기사 통계 전송 완료: {}개 관심사", stats.size()); + } catch (Exception e) { + log.error("❌ API 서버 알림 요청 실패", e); + } + } +} From aab75dde78c9685c41ea4a3a992f0976fd7b56d0 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Tue, 4 Nov 2025 12:09:16 +0900 Subject: [PATCH 167/178] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8F=AC=20Dockerfile?= =?UTF-8?q?,=20docker-compose.prod.yml=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile.api | 104 +++++++++++++++++++++++ Dockerfile.batch | 85 +++++++++++++++++++ Dockerfile.monitor | 87 +++++++++++++++++++ Dockerfile.multi | 120 ++++++++++++++++++++++++++ docker-compose.prod.yml | 184 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 580 insertions(+) create mode 100644 Dockerfile.api create mode 100644 Dockerfile.batch create mode 100644 Dockerfile.monitor create mode 100644 Dockerfile.multi create mode 100644 docker-compose.prod.yml diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..765330c --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,104 @@ +# ==================================== +# Dockerfile for API Module +# ==================================== + +# Stage 1: Build +FROM gradle:8.5-jdk17-alpine AS builder + +# 빌드 인자 선언 (docker-compose.prod.yml에서 전달) +ARG BUILD_DATE +ARG VCS_REF +ARG VERSION + +WORKDIR /app + +# 루트 프로젝트 설정 파일 복사 +#COPY settings.gradle ./ +#COPY build.gradle ./ +#COPY gradlew ./ +#COPY gradle gradle/ + +COPY settings.gradle ./ +COPY build.gradle ./ +COPY gradlew ./ +COPY gradle gradle/ +COPY monew-monitor/build.gradle monew-monitor/ + +# 2) CRLF 제거 + 실행권한 + 존재 확인 +RUN sed -i 's/\r$//' gradlew && chmod +x gradlew && \ + ls -al && ls -al gradle/wrapper && head -n1 gradlew && \ + ./gradlew --version + +# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱) +COPY monew-api/build.gradle monew-api/build.gradle +COPY monew-batch/build.gradle monew-batch/build.gradle +COPY monew-monitor/build.gradle monew-monitor/build.gradle + +# Gradle wrapper 실행 권한 +RUN chmod +x gradlew || true + +# 의존성 다운로드 (캐시 레이어) +RUN ./gradlew dependencies --no-daemon || true + +# API 모듈 소스 코드 복사 +COPY monew-api/ monew-api/ + +# API 모듈만 빌드 (테스트 제외) +RUN ./gradlew :monew-api:bootJar -x test --no-daemon + +# JAR 파일 위치 확인 및 복사 +RUN mkdir -p /app/build && \ + find monew-api/build/libs -name "*.jar" -not -name "*-plain.jar" -exec cp {} /app/build/app.jar \; + +# Stage 2: Runtime +FROM eclipse-temurin:17-jre-alpine + +# 빌드 인자 다시 선언 (runtime stage에서 사용) +ARG BUILD_DATE +ARG VCS_REF +ARG VERSION + +WORKDIR /app + +# 이미지 메타데이터 레이블 추가 +LABEL org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.revision="${VCS_REF}" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.title="Monew API" \ + org.opencontainers.image.description="Monew API Server" + +# 보안을 위한 non-root 사용자 생성 +RUN addgroup -S spring && adduser -S spring -G spring + +# 타임존 설정 +RUN apk add --no-cache tzdata wget && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +# 빌드 스테이지에서 JAR 파일 복사 +COPY --from=builder /app/build/app.jar app.jar + +# 소유권 변경 +RUN chown spring:spring app.jar + +# 사용자 변경 +USER spring:spring + +# 헬스체크 설정 +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 + +# JVM 옵션 설정 (환경변수로 오버라이드 가능) +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:InitialRAMPercentage=50.0 \ + -XX:+UseG1GC \ + -XX:+OptimizeStringConcat \ + -Djava.security.egd=file:/dev/./urandom" + +# 애플리케이션 포트 +EXPOSE 8080 + +# 애플리케이션 실행 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/Dockerfile.batch b/Dockerfile.batch new file mode 100644 index 0000000..a518c53 --- /dev/null +++ b/Dockerfile.batch @@ -0,0 +1,85 @@ +# ==================================== +# Dockerfile for Batch Module +# ==================================== + +# Stage 1: Build +FROM gradle:8.5-jdk17-alpine AS builder + +WORKDIR /app + +# Gradle wrapper 및 설정 파일 복사 +#COPY monew-batch/build.gradle settings.gradle ./ +#COPY build.gradle ./ +#COPY gradlew* ./ +#COPY gradle gradle/ + +COPY settings.gradle ./ +COPY build.gradle ./ +COPY gradlew ./ +COPY gradle gradle/ +COPY monew-monitor/build.gradle monew-monitor/ + +# 2) CRLF 제거 + 실행권한 + 래퍼 존재/버전 확인 +RUN sed -i 's/\r$//' gradlew && chmod +x gradlew && \ + ls -al && ls -al gradle/wrapper && head -n1 gradlew && \ + ./gradlew --version + +# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱) +COPY monew-api/build.gradle monew-api/ +COPY monew-batch/build.gradle monew-batch/ +COPY monew-monitor/build.gradle monew-monitor/ + +# Gradle wrapper 실행 권한 +RUN chmod +x gradlew || true + +# 의존성 다운로드 (캐시 레이어) +RUN ./gradlew dependencies --no-daemon || true + +# 전체 소스 코드 복사 +#COPY monew-batch . +COPY monew-api/ monew-api/ +COPY monew-batch/ monew-batch/ + +# Batch 모듈만 빌드 (테스트 제외) +RUN ./gradlew :monew-batch:bootJar -x test --no-daemon + +# JAR 파일 위치 확인 및 복사 +RUN mkdir -p /app/build && \ + find monew-batch/build/libs -name "*.jar" -not -name "*-plain.jar" -exec cp {} /app/build/app.jar \; + +# Stage 2: Runtime +FROM eclipse-temurin:17-jre-alpine + +WORKDIR /app + +# 보안을 위한 non-root 사용자 생성 +RUN addgroup -S spring && adduser -S spring -G spring + +# 타임존 설정 +RUN apk add --no-cache tzdata && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +# 빌드 스테이지에서 JAR 파일 복사 +COPY --from=builder /app/build/app.jar app.jar + +# 소유권 변경 +RUN chown spring:spring app.jar + +# 사용자 변경 +USER spring:spring + +# JVM 옵션 설정 (배치 작업용 최적화) +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:InitialRAMPercentage=50.0 \ + -XX:+UseG1GC \ + -XX:MaxGCPauseMillis=200 \ + -Djava.security.egd=file:/dev/./urandom" + +# 애플리케이션 포트 +EXPOSE 8081 + +# 애플리케이션 실행 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/Dockerfile.monitor b/Dockerfile.monitor new file mode 100644 index 0000000..34679de --- /dev/null +++ b/Dockerfile.monitor @@ -0,0 +1,87 @@ +# ==================================== +# Dockerfile for Monitor Module +# ==================================== + +# Stage 1: Build +FROM gradle:8.5-jdk17-alpine AS builder + +WORKDIR /app + +# Gradle wrapper 및 설정 파일 복사 +#COPY monew-monitor/build.gradle settings.gradle ./ +#COPY build.gradle ./ +#COPY gradlew* ./ +#COPY gradle gradle/ + +COPY settings.gradle ./ +COPY build.gradle ./ +COPY gradlew ./ +COPY gradle gradle/ +COPY monew-monitor/build.gradle monew-monitor/ + +# 2) CRLF 제거 + 실행권한 + 래퍼 존재/버전 확인 +RUN sed -i 's/\r$//' gradlew && chmod +x gradlew && \ + ls -al && ls -al gradle/wrapper && head -n1 gradlew && \ + ./gradlew --version + +# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱) +COPY monew-api/build.gradle monew-api/ +COPY monew-batch/build.gradle monew-batch/ +COPY monew-monitor/build.gradle monew-monitor/ + +# Gradle wrapper 실행 권한 +RUN chmod +x gradlew || true + +# 의존성 다운로드 (캐시 레이어) +RUN ./gradlew dependencies --no-daemon || true + +# 전체 소스 코드 복사 +#COPY monew-monitor . +COPY monew-monitor/ monew-monitor/ + +# Monitor 모듈만 빌드 (테스트 제외) +RUN ./gradlew :monew-monitor:bootJar -x test --no-daemon + +# JAR 파일 위치 확인 및 복사 +RUN mkdir -p /app/build && \ + find monew-monitor/build/libs -name "*.jar" -not -name "*-plain.jar" -exec cp {} /app/build/app.jar \; + +# Stage 2: Runtime +FROM eclipse-temurin:17-jre-alpine + +WORKDIR /app + +# 보안을 위한 non-root 사용자 생성 +RUN addgroup -S spring && adduser -S spring -G spring + +# 타임존 설정 +RUN apk add --no-cache tzdata wget && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +# 빌드 스테이지에서 JAR 파일 복사 +COPY --from=builder /app/build/app.jar app.jar + +# 소유권 변경 +RUN chown spring:spring app.jar + +# 사용자 변경 +USER spring:spring + +# 헬스체크 설정 +HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8082/actuator/health || exit 1 + +# JVM 옵션 설정 (모니터링 경량화) +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=70.0 \ + -XX:InitialRAMPercentage=40.0 \ + -XX:+UseG1GC \ + -Djava.security.egd=file:/dev/./urandom" + +# 애플리케이션 포트 +EXPOSE 8082 + +# 애플리케이션 실행 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/Dockerfile.multi b/Dockerfile.multi new file mode 100644 index 0000000..9aa5176 --- /dev/null +++ b/Dockerfile.multi @@ -0,0 +1,120 @@ +# ==================================== +# Multi-Module Multi-Stage Dockerfile +# 한 번에 모든 모듈을 빌드하는 통합 Dockerfile +# ==================================== + +# Stage 1: Build All Modules +FROM gradle:8.5-jdk17-alpine AS builder + +WORKDIR /app + +# Gradle wrapper 및 설정 파일 복사 +COPY build.gradle settings.gradle ./ +COPY gradlew* ./ +COPY gradle gradle/ + +# CRLF 방지 + 실행권한 +RUN sed -i 's/\r$//' gradlew && chmod +x gradlew + +# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱) +COPY monew-api/build.gradle monew-api/ +COPY monew-batch/build.gradle monew-batch/ +COPY monew-monitor/build.gradle monew-monitor/ + +# Gradle wrapper 실행 권한 +RUN chmod +x gradlew || true + +# 의존성 다운로드 (캐시 레이어) +RUN gradle dependencies --no-daemon || true + +# 전체 소스 코드 복사 +COPY . . + +# 모든 모듈 빌드 (테스트 제외) +RUN gradle build -x test --no-daemon + +# Stage 2: API Runtime +FROM eclipse-temurin:17-jre-alpine AS api + +WORKDIR /app + +RUN addgroup -S spring && adduser -S spring -G spring + +RUN apk add --no-cache tzdata wget && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +COPY --from=builder /app/monew-api/build/libs/*[!plain].jar app.jar + +RUN chown spring:spring app.jar +USER spring:spring + +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 + +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:InitialRAMPercentage=50.0 \ + -XX:+UseG1GC \ + -Djava.security.egd=file:/dev/./urandom" + +EXPOSE 8080 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] + +# Stage 3: Batch Runtime +FROM eclipse-temurin:17-jre-alpine AS batch + +WORKDIR /app + +RUN addgroup -S spring && adduser -S spring -G spring + +RUN apk add --no-cache tzdata && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +COPY --from=builder /app/monew-batch/build/libs/*[!plain].jar app.jar + +RUN chown spring:spring app.jar +USER spring:spring + +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:+UseG1GC \ + -XX:MaxGCPauseMillis=200 \ + -Djava.security.egd=file:/dev/./urandom" + +EXPOSE 8081 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] + +# Stage 4: Monitor Runtime +FROM eclipse-temurin:17-jre-alpine AS monitor + +WORKDIR /app + +RUN addgroup -S spring && adduser -S spring -G spring + +RUN apk add --no-cache tzdata wget && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +COPY --from=builder /app/monew-monitor/build/libs/*[!plain].jar app.jar + +RUN chown spring:spring app.jar +USER spring:spring + +HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8082/actuator/health || exit 1 + +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=70.0 \ + -XX:+UseG1GC \ + -Djava.security.egd=file:/dev/./urandom" + +EXPOSE 8082 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..f82dc91 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,184 @@ +version: '3.8' + +services: + # API Application (Production) + api: + build: + context: . + dockerfile: Dockerfile.api + args: + - BUILD_DATE=${BUILD_DATE} + - VCS_REF=${VCS_REF} + - VERSION=${VERSION:-latest} + image: monew-api:${VERSION:-latest} + container_name: monew-api-prod + environment: + SPRING_PROFILES_ACTIVE: prod + DB_URL: ${DB_URL} + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + MongoDB_URI: ${MongoDB_URI} + AWS_S3_ACCESS_KEY: ${AWS_S3_ACCESS_KEY} + AWS_S3_SECRET_KEY: ${AWS_S3_SECRET_KEY} + AWS_S3_REGION: ${AWS_S3_REGION} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} + JAVA_OPTS: "-Xmx1g -Xms512m -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0" + ports: + - "${API_PORT:-8080}:8080" + networks: + - monew-network + restart: always + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + deploy: + resources: + limits: + cpus: '2' + memory: 1536M + reservations: + cpus: '0.5' + memory: 512M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Batch Application (Production) + batch: + build: + context: . + dockerfile: Dockerfile.batch + args: + - BUILD_DATE=${BUILD_DATE} + - VCS_REF=${VCS_REF} + - VERSION=${VERSION:-latest} + image: monew-batch:${VERSION:-latest} + container_name: monew-batch-prod + environment: + SPRING_PROFILES_ACTIVE: prod + DB_URL: ${DB_URL} + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + AWS_S3_ACCESS_KEY: ${AWS_S3_ACCESS_KEY} + AWS_S3_SECRET_KEY: ${AWS_S3_SECRET_KEY} + AWS_S3_REGION: ${AWS_S3_REGION} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} + NAVER_CLIENT_ID: ${NAVER_CLIENT_ID} + NAVER_CLIENT_SECRET: ${NAVER_CLIENT_SECRET} + JAVA_OPTS: "-Xmx1g -Xms512m -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0" + ports: + - "${BATCH_PORT:-8081}:8081" + depends_on: + - api + networks: + - monew-network + restart: always + deploy: + resources: + limits: + cpus: '2' + memory: 1536M + reservations: + cpus: '0.5' + memory: 512M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Monitor Application (Production) + monitor: + build: + context: . + dockerfile: Dockerfile.monitor + args: + - BUILD_DATE=${BUILD_DATE} + - VCS_REF=${VCS_REF} + - VERSION=${VERSION:-latest} + image: monew-monitor:${VERSION:-latest} + container_name: monew-monitor-prod + environment: + SPRING_PROFILES_ACTIVE: prod + JAVA_OPTS: "-Xmx512m -Xms256m -XX:+UseContainerSupport -XX:MaxRAMPercentage=70.0" + ports: + - "${MONITOR_PORT:-8082}:8082" + depends_on: + - api + - batch + networks: + - monew-network + restart: always + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8082/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + deploy: + resources: + limits: + cpus: '1' + memory: 768M + reservations: + cpus: '0.25' + memory: 256M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Nginx (Optional - Reverse Proxy) + nginx: + image: nginx:alpine + container_name: monew-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + depends_on: + - api + - batch + - monitor + networks: + - monew-network + restart: always + + # Prometheus (Metrics Collector) + prometheus: + image: prom/prometheus:latest + container_name: monew-prometheus + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + networks: + - monew-network + restart: always + + # Grafana (Visualization) + grafana: + image: grafana/grafana:latest + container_name: monew-grafana + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + ports: + - "3000:3000" + depends_on: + - prometheus + networks: + - monew-network + restart: always + +networks: + monew-network: + driver: bridge \ No newline at end of file From 9d09bd270e6f49f20ce00c3c4ac73917806b6b2a Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Tue, 4 Nov 2025 16:09:49 +0900 Subject: [PATCH 168/178] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20Dockerfile,?= =?UTF-8?q?=20docker-compose.prod.yml=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=EC=8B=A4=ED=96=89=20=EA=B0=80=EB=8A=A5=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile.api | 3 +++ Dockerfile.batch | 5 ++++- docker-compose.prod.yml | 29 +++++++---------------------- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/Dockerfile.api b/Dockerfile.api index 765330c..9e959d1 100644 --- a/Dockerfile.api +++ b/Dockerfile.api @@ -79,6 +79,9 @@ RUN apk add --no-cache tzdata wget && \ # 빌드 스테이지에서 JAR 파일 복사 COPY --from=builder /app/build/app.jar app.jar +# 로그 디렉토리 생성 및 권한 설정 +RUN mkdir -p /app/.logs && chown -R spring:spring /app/.logs + # 소유권 변경 RUN chown spring:spring app.jar diff --git a/Dockerfile.batch b/Dockerfile.batch index a518c53..47a2f5d 100644 --- a/Dockerfile.batch +++ b/Dockerfile.batch @@ -17,7 +17,7 @@ COPY settings.gradle ./ COPY build.gradle ./ COPY gradlew ./ COPY gradle gradle/ -COPY monew-monitor/build.gradle monew-monitor/ +#COPY monew-monitor/build.gradle monew-monitor/ # 2) CRLF 제거 + 실행권한 + 래퍼 존재/버전 확인 RUN sed -i 's/\r$//' gradlew && chmod +x gradlew && \ @@ -64,6 +64,9 @@ RUN apk add --no-cache tzdata && \ # 빌드 스테이지에서 JAR 파일 복사 COPY --from=builder /app/build/app.jar app.jar +# 로그 디렉토리 생성 및 권한 설정 +RUN mkdir -p /app/.logs && chown -R spring:spring /app/.logs + # 소유권 변경 RUN chown spring:spring app.jar diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f82dc91..f26cc98 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -29,7 +29,7 @@ services: - monew-network restart: always healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health"] + test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health" ] interval: 30s timeout: 10s retries: 3 @@ -70,6 +70,7 @@ services: AWS_S3_BUCKET: ${AWS_S3_BUCKET} NAVER_CLIENT_ID: ${NAVER_CLIENT_ID} NAVER_CLIENT_SECRET: ${NAVER_CLIENT_SECRET} + MONEW_API_URL: ${MONEW_API_URL} JAVA_OPTS: "-Xmx1g -Xms512m -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0" ports: - "${BATCH_PORT:-8081}:8081" @@ -91,7 +92,8 @@ services: options: max-size: "10m" max-file: "3" - + volumes: + - ./logs/batch:/app/.logs # Monitor Application (Production) monitor: build: @@ -115,7 +117,7 @@ services: - monew-network restart: always healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8082/actuator/health"] + test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8082/actuator/health" ] interval: 30s timeout: 10s retries: 3 @@ -134,30 +136,13 @@ services: max-size: "10m" max-file: "3" - # Nginx (Optional - Reverse Proxy) - nginx: - image: nginx:alpine - container_name: monew-nginx - ports: - - "80:80" - - "443:443" - volumes: - - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - - ./nginx/ssl:/etc/nginx/ssl:ro - depends_on: - - api - - batch - - monitor - networks: - - monew-network - restart: always - # Prometheus (Metrics Collector) prometheus: image: prom/prometheus:latest container_name: monew-prometheus volumes: - - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml +# - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml + - ./monew-monitor/src/main/resources/prometheus.yml:/etc/prometheus/prometheus.yml:ro ports: - "9090:9090" networks: From bdc3bb5f1e0399ef33546fd08da28b55e0c632af Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Tue, 4 Nov 2025 16:10:28 +0900 Subject: [PATCH 169/178] =?UTF-8?q?fix:=20application.yml=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=EB=A1=9C(prod)=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monew-api/src/main/resources/application.yml | 2 +- monew-batch/src/main/resources/application-prod.yml | 4 ++-- monew-batch/src/main/resources/application.yml | 2 +- monew-monitor/src/main/resources/application.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/monew-api/src/main/resources/application.yml b/monew-api/src/main/resources/application.yml index 43f3418..7bec6d5 100644 --- a/monew-api/src/main/resources/application.yml +++ b/monew-api/src/main/resources/application.yml @@ -2,7 +2,7 @@ spring: application: name: monew-api profiles: - active: dev + active: prod config: import: optional:file:../.env[.properties],optional:file:.env[.properties] diff --git a/monew-batch/src/main/resources/application-prod.yml b/monew-batch/src/main/resources/application-prod.yml index 34ee632..e39c3ae 100644 --- a/monew-batch/src/main/resources/application-prod.yml +++ b/monew-batch/src/main/resources/application-prod.yml @@ -60,8 +60,8 @@ logging: aws: s3: - access-key: ${AWS_ACCESS_KEY} - secret-key: ${AWS_SECRET_KEY} + access-key: ${AWS_S3_ACCESS_KEY} + secret-key: ${AWS_S3_SECRET_KEY} bucket: monew-backup monew: diff --git a/monew-batch/src/main/resources/application.yml b/monew-batch/src/main/resources/application.yml index c6df6fe..213e480 100644 --- a/monew-batch/src/main/resources/application.yml +++ b/monew-batch/src/main/resources/application.yml @@ -2,7 +2,7 @@ spring: application: name: monew-batch profiles: - active: dev + active: prod config: import: optional:file:../.env[.properties],optional:file:.env[.properties] diff --git a/monew-monitor/src/main/resources/application.yml b/monew-monitor/src/main/resources/application.yml index 1a294bd..11ff4de 100644 --- a/monew-monitor/src/main/resources/application.yml +++ b/monew-monitor/src/main/resources/application.yml @@ -2,7 +2,7 @@ spring: config: import: optional:file:.env[.properties] profiles: - active: dev + active: prod application: name: monew-monitor From 852eb5b5be41c1cc178ebff3d107a818e590c364 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Tue, 4 Nov 2025 16:33:13 +0900 Subject: [PATCH 170/178] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=20=EB=94=94?= =?UTF-8?q?=EB=A0=89=ED=86=A0=EB=A6=AC=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=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 --- Dockerfile.batch | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile.batch b/Dockerfile.batch index 9212103..9184668 100644 --- a/Dockerfile.batch +++ b/Dockerfile.batch @@ -63,6 +63,9 @@ RUN apk add --no-cache tzdata && \ # 빌드 스테이지에서 JAR 파일 복사 COPY --from=builder /app/build/app.jar app.jar +# 로그 디렉토리 생성 및 권한 설정 +RUN mkdir -p /app/.logs && chown -R spring:spring /app/.logs + # 소유권 변경 RUN chown spring:spring app.jar From c9151e12f77c6c772cd7bc06fa32f25020049ee8 Mon Sep 17 00:00:00 2001 From: userjin2123 Date: Tue, 4 Nov 2025 15:00:10 +0900 Subject: [PATCH 171/178] =?UTF-8?q?feat=20:=20UserActivityMapper=EC=97=90?= =?UTF-8?q?=EC=84=9C=20CommentLikeActivityDto.commentUserId=20=EC=9D=98=20?= =?UTF-8?q?=EB=A7=B5=ED=95=91=EC=9D=84=20=EB=AA=85=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=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 --- .../monew/monew_api/useractivity/mapper/UserActivityMapper.java | 1 + 1 file changed, 1 insertion(+) diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java index f23ffd9..399bc6e 100644 --- a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java @@ -62,6 +62,7 @@ default UserActivityDto toUserActivityDto( @Mapping(target = "commentId", expression = "java(String.valueOf(commentLike.getComment().getId()))") @Mapping(target = "articleId", expression = "java(String.valueOf(commentLike.getComment().getArticle().getId()))") @Mapping(target = "articleTitle", source = "comment.article.title") + @Mapping(target = "commentUserId", source = "comment.user.id") @Mapping(target = "commentUserNickname", source = "comment.user.nickname") @Mapping(target = "commentContent", source = "comment.content") @Mapping(target = "commentLikeCount", source = "comment.likeCount") From 04b4c058b9e2795fece4cd0c51750b69a4385b4a Mon Sep 17 00:00:00 2001 From: truuuely Date: Wed, 5 Nov 2025 09:11:30 +0900 Subject: [PATCH 172/178] =?UTF-8?q?fix:=20=EA=B8=B0=EC=82=AC=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20=EC=9D=BC=EB=B6=80=20=EC=8B=A4=ED=8C=A8=EC=8B=9C?= =?UTF-8?q?=EC=97=90=EB=8F=84=20=EC=95=8C=EB=A6=BC=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20ArticleNotificationRequestListene?= =?UTF-8?q?r=20=EB=82=B4=EB=B6=80=EC=9D=98=20=EC=A1=B0=EA=B1=B4=EB=AC=B8?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/job/ArticleNotificationRequestListener.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java index c369b96..be8e943 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java @@ -3,7 +3,6 @@ import com.monew.monew_batch.notification.service.NotificationAsyncService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.BatchStatus; import org.springframework.batch.core.JobExecution; import org.springframework.batch.core.JobExecutionListener; import org.springframework.stereotype.Component; @@ -19,10 +18,6 @@ public class ArticleNotificationRequestListener implements JobExecutionListener @Override public void afterJob(JobExecution jobExecution) { - if (jobExecution.getStatus() != BatchStatus.COMPLETED) { - return; - } - @SuppressWarnings("unchecked") Map stats = (Map) jobExecution.getExecutionContext().get("newLinkCountsByInterestId"); From 53633727f02adfb05d343eb096df917fab79da11 Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Wed, 5 Nov 2025 09:34:37 +0900 Subject: [PATCH 173/178] =?UTF-8?q?feat:=20=EC=A1=B0=EC=84=A0,=20=EC=97=B0?= =?UTF-8?q?=ED=95=A9,=20=ED=95=9C=EA=B5=AD=20RSS=20=EC=88=98=EC=A7=91=20?= =?UTF-8?q?=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 --- monew-batch/build.gradle | 1 + .../article/config/ChosunJobConfig.java | 59 ++++++++ .../article/config/HankyungJobConfig.java | 59 ++++++++ .../article/config/YonhapJobConfig.java | 59 ++++++++ .../processor/ChosunArticleItemProcessor.java | 114 ++++++++++++++++ .../HankyungArticleItemProcessor.java | 126 ++++++++++++++++++ .../processor/YonhapArticleItemProcessor.java | 114 ++++++++++++++++ .../article/properties/ChosunProperties.java | 16 +++ .../properties/HankyungProperties.java | 16 +++ .../article/properties/YonhapProperties.java | 16 +++ .../src/main/resources/application.yml | 38 ++++++ 11 files changed, 618 insertions(+) create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/config/ChosunJobConfig.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/config/HankyungJobConfig.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/config/YonhapJobConfig.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/ChosunArticleItemProcessor.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/HankyungArticleItemProcessor.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/YonhapArticleItemProcessor.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/properties/ChosunProperties.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/properties/HankyungProperties.java create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/properties/YonhapProperties.java diff --git a/monew-batch/build.gradle b/monew-batch/build.gradle index 98d2cc3..7700994 100644 --- a/monew-batch/build.gradle +++ b/monew-batch/build.gradle @@ -10,6 +10,7 @@ dependencies { implementation project(':monew-api') implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'software.amazon.awssdk:s3:2.31.7' + implementation 'com.rometools:rome:1.18.0' implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/config/ChosunJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/article/config/ChosunJobConfig.java new file mode 100644 index 0000000..f8b2cd4 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/config/ChosunJobConfig.java @@ -0,0 +1,59 @@ +package com.monew.monew_batch.article.config; + +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.job.processor.ChosunArticleItemProcessor; +import com.monew.monew_batch.article.job.ArticleItemReader; +import com.monew.monew_batch.article.job.ArticleItemWriter; +import com.monew.monew_batch.article.properties.ChosunProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; + +@Configuration +@EnableBatchProcessing +@RequiredArgsConstructor +@EnableConfigurationProperties(ChosunProperties.class) +public class ChosunJobConfig { + + private final ArticleItemReader reader; // 키워드 재사용 + private final ChosunArticleItemProcessor processor; + private final ArticleItemWriter writer; + + @Bean + public Job chosunRssJob(JobRepository jobRepository, Step chosunRssStep) { + return new JobBuilder("chosunRssJob", jobRepository) + .start(chosunRssStep) + .build(); + } + + @Bean + public Step chosunRssStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("chosunRssStep", jobRepository) + .>chunk(1, transactionManager) + .reader(reader) + .processor(processor) + .writer(writer) + .taskExecutor(taskExecutor()) + .build(); + } + + @Bean(name = "chosunTaskExecutor") + public TaskExecutor taskExecutor() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("chosun-news-thread-"); + executor.setConcurrencyLimit(2); + return executor; + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/config/HankyungJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/article/config/HankyungJobConfig.java new file mode 100644 index 0000000..e9b6984 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/config/HankyungJobConfig.java @@ -0,0 +1,59 @@ +package com.monew.monew_batch.article.config; + +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.job.processor.HankyungArticleItemProcessor; +import com.monew.monew_batch.article.job.ArticleItemReader; +import com.monew.monew_batch.article.job.ArticleItemWriter; +import com.monew.monew_batch.article.properties.HankyungProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; + +@Configuration +@EnableBatchProcessing +@RequiredArgsConstructor +@EnableConfigurationProperties(HankyungProperties.class) +public class HankyungJobConfig { + + private final ArticleItemReader reader; // 기존 KeywordReader 재사용 + private final HankyungArticleItemProcessor processor; + private final ArticleItemWriter writer; + + @Bean + public Job hankyungRssJob(JobRepository jobRepository, Step hankyungRssStep) { + return new JobBuilder("hankyungRssJob", jobRepository) + .start(hankyungRssStep) + .build(); + } + + @Bean + public Step hankyungRssStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("hankyungRssStep", jobRepository) + .>chunk(1, transactionManager) + .reader(reader) + .processor(processor) + .writer(writer) + .taskExecutor(taskExecutor()) + .build(); + } + + @Bean(name = "hankyungTaskExecutor") + public TaskExecutor taskExecutor() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("hankyung-news-thread-"); + executor.setConcurrencyLimit(2); + return executor; + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/config/YonhapJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/article/config/YonhapJobConfig.java new file mode 100644 index 0000000..87557db --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/config/YonhapJobConfig.java @@ -0,0 +1,59 @@ +package com.monew.monew_batch.article.config; + +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.job.ArticleItemReader; +import com.monew.monew_batch.article.job.ArticleItemWriter; +import com.monew.monew_batch.article.job.processor.YonhapArticleItemProcessor; +import com.monew.monew_batch.article.properties.YonhapProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; + +@Configuration +@EnableBatchProcessing +@RequiredArgsConstructor +@EnableConfigurationProperties(YonhapProperties.class) +public class YonhapJobConfig { + + private final ArticleItemReader reader; // 키워드 재사용 + private final YonhapArticleItemProcessor processor; + private final ArticleItemWriter writer; + + @Bean + public Job yonhapRssJob(JobRepository jobRepository, Step yonhapRssStep) { + return new JobBuilder("yonhapRssJob", jobRepository) + .start(yonhapRssStep) + .build(); + } + + @Bean + public Step yonhapRssStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("yonhapRssStep", jobRepository) + .>chunk(1, transactionManager) + .reader(reader) + .processor(processor) + .writer(writer) + .taskExecutor(taskExecutor()) + .build(); + } + + @Bean(name = "yonhapTaskExecutor") + public TaskExecutor taskExecutor() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("yonhap-news-thread-"); + executor.setConcurrencyLimit(2); + return executor; + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/ChosunArticleItemProcessor.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/ChosunArticleItemProcessor.java new file mode 100644 index 0000000..e3c48d3 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/ChosunArticleItemProcessor.java @@ -0,0 +1,114 @@ +package com.monew.monew_batch.article.job.processor; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.properties.ChosunProperties; +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.io.SyndFeedInput; +import com.rometools.rome.io.XmlReader; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import java.net.URL; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ChosunArticleItemProcessor implements ItemProcessor> { + + private final ChosunProperties properties; + + @Override + public List process(Keyword keyword) { + String keywordText = keyword.getKeyword(); + log.info("[조선일보 RSS] '{}' 관련 뉴스 수집 시작", keywordText); + + List pairs = new ArrayList<>(); + + for (String feedUrl : properties.getFeeds()) { + try { + SyndFeed feed = fetchFeed(feedUrl); + int totalEntries = feed.getEntries().size(); + int matchedCount = processFeedEntries(feed, keywordText, keyword, pairs); + + log.info("[조선일보 RSS] {} 에서 전체 {}건 중 {}건 매칭", feedUrl, totalEntries, matchedCount); + + } catch (Exception e) { + log.warn("[조선일보 RSS] 피드 파싱 실패: {}", feedUrl, e); + } + } + + log.info("[조선일보 RSS] '{}' 관련 뉴스 최종 {}건 수집 완료", keywordText, pairs.size()); + return pairs.isEmpty() ? Collections.emptyList() : pairs; + } + + /** RSS 피드를 불러와 SyndFeed 객체로 반환 */ + private SyndFeed fetchFeed(String feedUrl) throws Exception { + return new SyndFeedInput().build(new XmlReader(new URL(feedUrl))); + } + + /** 하나의 피드 내 모든 엔트리를 순회하며 필터링 및 수집 */ + private int processFeedEntries(SyndFeed feed, String keywordText, Keyword keyword, List pairs) { + int before = pairs.size(); + + feed.getEntries().forEach(entry -> { + String title = safeText(entry.getTitle()); + String desc = extractDescription(entry); + + // 본문이 비어있으면 스킵 + if (desc.isBlank()) return; + + // 제목 또는 본문에 키워드 포함 시만 수집 + if (!containsKeyword(title, desc, keywordText)) return; + + String link = Optional.ofNullable(entry.getLink()).orElse(""); + LocalDateTime pubDate = parsePublishedDate(entry.getPublishedDate()); + + String articleSourceName = properties.getArticleSource().name(); + Article article = new Article(articleSourceName, link, title, pubDate, desc); + pairs.add(new ArticleKeywordPair(article, keyword)); + }); + + return pairs.size() - before; // 매칭된 건수 반환 + } + + /** title 또는 description 중 하나라도 키워드 포함 여부 검사 */ + private boolean containsKeyword(String title, String desc, String keyword) { + String lower = keyword.toLowerCase(); + return title.toLowerCase().contains(lower) || desc.toLowerCase().contains(lower); + } + + /** description 추출 및 HTML 정리 */ + private String extractDescription(com.rometools.rome.feed.synd.SyndEntry entry) { + return Optional.ofNullable(entry.getDescription()) + .map(d -> cleanText(d.getValue())) + .orElse("") // description이 없으면 빈 문자열 반환 → processFeedEntries에서 스킵 처리됨 + .trim(); + } + + /** null-safe로 title 텍스트 반환 */ + private String safeText(String text) { + return Optional.ofNullable(text).orElse("").trim(); + } + + /** HTML 태그 제거 후 공백 정리 */ + private String cleanText(String text) { + return text.replaceAll("<[^>]*>", "").trim(); + } + + /** 발행일 파싱 (없을 경우 현재 시각 사용) */ + private LocalDateTime parsePublishedDate(java.util.Date publishedDate) { + return Optional.ofNullable(publishedDate) + .map(d -> d.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()) + .orElse(LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/HankyungArticleItemProcessor.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/HankyungArticleItemProcessor.java new file mode 100644 index 0000000..b7c68fa --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/HankyungArticleItemProcessor.java @@ -0,0 +1,126 @@ +package com.monew.monew_batch.article.job.processor; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.properties.HankyungProperties; +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.io.SyndFeedInput; +import com.rometools.rome.io.XmlReader; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class HankyungArticleItemProcessor implements ItemProcessor> { + + private final HankyungProperties properties; + + @Override + public List process(Keyword keyword) { + String keywordText = keyword.getKeyword(); + log.info("[한국경제 RSS] '{}' 관련 뉴스 수집 시작", keywordText); + + List pairs = new ArrayList<>(); + + for (String feedUrl : properties.getFeeds()) { + try { + SyndFeed feed = fetchFeed(feedUrl); + int totalEntries = feed.getEntries().size(); + int matchedCount = processFeedEntries(feed, keywordText, keyword, pairs); + + log.info("[한국경제 RSS] {} 에서 전체 {}건 중 {}건 매칭", feedUrl, totalEntries, matchedCount); + + } catch (Exception e) { + log.warn("[한국경제 RSS] 피드 파싱 실패: {}", feedUrl, e); + } + } + + log.info("[한국경제 RSS] '{}' 관련 뉴스 최종 {}건 수집 완료", keywordText, pairs.size()); + return pairs.isEmpty() ? Collections.emptyList() : pairs; + } + + /** RSS 피드를 불러와 SyndFeed 객체로 반환 */ + private SyndFeed fetchFeed(String feedUrl) throws Exception { + for (int i = 0; i < 3; i++) { + try { + Thread.sleep(800); // 요청 간 간격 확보 + URLConnection connection = new URL(feedUrl).openConnection(); + connection.setRequestProperty("User-Agent", "MoNewBatchBot/1.0 (+https://monew.com)"); + return new SyndFeedInput().build(new XmlReader(connection)); + } catch (IOException e) { + if (i < 2) { + log.warn("[한국경제 RSS] 요청 제한 (429) 발생 - 재시도 {}회차: {}", i + 1, feedUrl); + Thread.sleep(1000); + } else { + throw e; + } + } + } + return null; + } + + /** 하나의 피드 내 모든 엔트리를 순회하며 필터링 및 수집 */ + private int processFeedEntries(SyndFeed feed, String keywordText, Keyword keyword, List pairs) { + int before = pairs.size(); + + feed.getEntries().forEach(entry -> { + String title = safeText(entry.getTitle()); + String desc = extractDescription(entry); + + // 본문이 비어있으면 스킵 + if (desc.isBlank()) return; + + // 제목 또는 본문에 키워드 포함 시만 수집 + if (!containsKeyword(title, desc, keywordText)) return; + + String link = Optional.ofNullable(entry.getLink()).orElse(""); + LocalDateTime pubDate = parsePublishedDate(entry.getPublishedDate()); + + String articleSourceName = properties.getArticleSource().name(); + Article article = new Article(articleSourceName, link, title, pubDate, desc); + pairs.add(new ArticleKeywordPair(article, keyword)); + }); + + return pairs.size() - before; + } + + private boolean containsKeyword(String title, String desc, String keyword) { + String lower = keyword.toLowerCase(); + return title.toLowerCase().contains(lower) || desc.toLowerCase().contains(lower); + } + + private String extractDescription(com.rometools.rome.feed.synd.SyndEntry entry) { + return Optional.ofNullable(entry.getDescription()) + .map(d -> cleanText(d.getValue())) + .orElse("") + .trim(); + } + + private String safeText(String text) { + return Optional.ofNullable(text).orElse("").trim(); + } + + private String cleanText(String text) { + return text.replaceAll("<[^>]*>", "").trim(); + } + + private LocalDateTime parsePublishedDate(java.util.Date publishedDate) { + return Optional.ofNullable(publishedDate) + .map(d -> d.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()) + .orElse(LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/YonhapArticleItemProcessor.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/YonhapArticleItemProcessor.java new file mode 100644 index 0000000..4e7793a --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/YonhapArticleItemProcessor.java @@ -0,0 +1,114 @@ +package com.monew.monew_batch.article.job.processor; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.properties.YonhapProperties; +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.io.SyndFeedInput; +import com.rometools.rome.io.XmlReader; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import java.net.URL; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class YonhapArticleItemProcessor implements ItemProcessor> { + + private final YonhapProperties properties; + + @Override + public List process(Keyword keyword) { + String keywordText = keyword.getKeyword(); + log.info("[연합뉴스TV RSS] '{}' 관련 뉴스 수집 시작", keywordText); + + List pairs = new ArrayList<>(); + + for (String feedUrl : properties.getFeeds()) { + try { + SyndFeed feed = fetchFeed(feedUrl); + int totalEntries = feed.getEntries().size(); + int matchedCount = processFeedEntries(feed, keywordText, keyword, pairs); + + log.info("[연합뉴스TV RSS] {} 에서 전체 {}건 중 {}건 매칭", feedUrl, totalEntries, matchedCount); + + } catch (Exception e) { + log.warn("[연합뉴스TV RSS] 피드 파싱 실패: {}", feedUrl, e); + } + } + + log.info("[연합뉴스TV RSS] '{}' 관련 뉴스 최종 {}건 수집 완료", keywordText, pairs.size()); + return pairs.isEmpty() ? Collections.emptyList() : pairs; + } + + /** RSS 피드를 불러와 SyndFeed 객체로 반환 */ + private SyndFeed fetchFeed(String feedUrl) throws Exception { + return new SyndFeedInput().build(new XmlReader(new URL(feedUrl))); + } + + /** 하나의 피드 내 모든 엔트리를 순회하며 필터링 및 수집 */ + private int processFeedEntries(SyndFeed feed, String keywordText, Keyword keyword, List pairs) { + int before = pairs.size(); + + feed.getEntries().forEach(entry -> { + String title = safeText(entry.getTitle()); + String desc = extractDescription(entry); + + // 본문이 비어있으면 스킵 + if (desc.isBlank()) return; + + // 제목 또는 본문에 키워드 포함 시만 수집 + if (!containsKeyword(title, desc, keywordText)) return; + + String link = Optional.ofNullable(entry.getLink()).orElse(""); + LocalDateTime pubDate = parsePublishedDate(entry.getPublishedDate()); + + String articleSourceName = properties.getArticleSource().name(); + Article article = new Article(articleSourceName, link, title, pubDate, desc); + pairs.add(new ArticleKeywordPair(article, keyword)); + }); + + return pairs.size() - before; // 매칭된 건수 반환 + } + + /** title 또는 description 중 하나라도 키워드 포함 여부 검사 */ + private boolean containsKeyword(String title, String desc, String keyword) { + String lower = keyword.toLowerCase(); + return title.toLowerCase().contains(lower) || desc.toLowerCase().contains(lower); + } + + /** description 추출 및 HTML 정리 */ + private String extractDescription(com.rometools.rome.feed.synd.SyndEntry entry) { + return Optional.ofNullable(entry.getDescription()) + .map(d -> cleanText(d.getValue())) + .orElse("") + .trim(); + } + + /** null-safe로 title 텍스트 반환 */ + private String safeText(String text) { + return Optional.ofNullable(text).orElse("").trim(); + } + + /** HTML 태그 제거 후 공백 정리 */ + private String cleanText(String text) { + return text.replaceAll("<[^>]*>", "").trim(); + } + + /** 발행일 파싱 (없을 경우 현재 시각 사용) */ + private LocalDateTime parsePublishedDate(java.util.Date publishedDate) { + return Optional.ofNullable(publishedDate) + .map(d -> d.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()) + .orElse(LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/properties/ChosunProperties.java b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/ChosunProperties.java new file mode 100644 index 0000000..ade84f3 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/ChosunProperties.java @@ -0,0 +1,16 @@ +package com.monew.monew_batch.article.properties; + +import com.monew.monew_batch.article.enums.ArticleSource; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "rss.chosun") +public class ChosunProperties { + private final List feeds; + private final ArticleSource articleSource = ArticleSource.CHOSUN; +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/properties/HankyungProperties.java b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/HankyungProperties.java new file mode 100644 index 0000000..019138d --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/HankyungProperties.java @@ -0,0 +1,16 @@ +package com.monew.monew_batch.article.properties; + +import com.monew.monew_batch.article.enums.ArticleSource; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "rss.hankyung") +public class HankyungProperties { + private final List feeds; + private final ArticleSource articleSource = ArticleSource.HANKYUNG; +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/properties/YonhapProperties.java b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/YonhapProperties.java new file mode 100644 index 0000000..e6b4a31 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/YonhapProperties.java @@ -0,0 +1,16 @@ +package com.monew.monew_batch.article.properties; + +import com.monew.monew_batch.article.enums.ArticleSource; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "rss.yonhap") +public class YonhapProperties { + private final List feeds; + private final ArticleSource articleSource = ArticleSource.YEONHAP; +} diff --git a/monew-batch/src/main/resources/application.yml b/monew-batch/src/main/resources/application.yml index 213e480..fb32dda 100644 --- a/monew-batch/src/main/resources/application.yml +++ b/monew-batch/src/main/resources/application.yml @@ -47,6 +47,44 @@ naver: client-id: ${NAVER_CLIENT_ID} client-secret: ${NAVER_CLIENT_SECRET} +rss: + chosun: + feeds: + - https://www.chosun.com/arc/outboundfeeds/rss/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/politics/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/economy/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/national/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/international/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/culture-life/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/opinion/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/sports/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/entertainments/?outputType=xml + + hankyung: + feeds: + - https://www.hankyung.com/feed/economy + - https://www.hankyung.com/feed/finance + - https://www.hankyung.com/feed/realestate + - https://www.hankyung.com/feed/it + - https://www.hankyung.com/feed/politics + - https://www.hankyung.com/feed/international + - https://www.hankyung.com/feed/society + - https://www.hankyung.com/feed/life + - https://www.hankyung.com/feed/opinion + - https://www.hankyung.com/feed/sports + - https://www.hankyung.com/feed/entertainment + + yonhap: + feeds: + - https://www.yna.co.kr/rss/politics.xml + - https://www.yna.co.kr/rss/economy.xml + - https://www.yna.co.kr/rss/society.xml + - https://www.yna.co.kr/rss/culture.xml + - https://www.yna.co.kr/rss/sports.xml + - https://www.yna.co.kr/rss/northkorea.xml + - https://www.yna.co.kr/rss/international.xml + - https://www.yna.co.kr/rss/local.xml + monew: api: url: http://localhost:8080 \ No newline at end of file From ea39ff209304f1184b6c47cc14f43be14720b49f Mon Sep 17 00:00:00 2001 From: JaehyeokLim <217686834+JaehyeokLim@users.noreply.github.com> Date: Wed, 5 Nov 2025 09:35:29 +0900 Subject: [PATCH 174/178] =?UTF-8?q?rename:=20News=20->=20Article=EB=A1=9C?= =?UTF-8?q?=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=ED=86=B5=EC=9D=BC=20=EB=B0=8F?= =?UTF-8?q?=20config=EB=82=98=20processor=20=EB=93=B1=20=EC=98=AC=EB=B0=94?= =?UTF-8?q?=EB=A5=B8=20=EC=9C=84=EC=B9=98=EB=A1=9C=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...BackupData.java => ArticleBackupData.java} | 2 +- .../article/service/NewsRestoreService.java | 20 +++---- .../config/NaverJobConfig.java} | 22 ++++---- ...ItemReader.java => ArticleItemReader.java} | 4 +- ...ItemWriter.java => ArticleItemWriter.java} | 6 +-- .../NaverArticleItemProcessor.java} | 11 ++-- ...hMetrics.java => ArticleBatchMetrics.java} | 2 +- ...piProperties.java => NaverProperties.java} | 2 +- .../ArticleBackupQueryRepository.java | 4 +- .../ArticleBackupQueryRepositoryImpl.java | 8 +-- ...eduler.java => AricleBackupScheduler.java} | 12 ++--- .../scheduler/AricleBatchScheduler.java | 52 +++++++++++++++++++ .../scheduler/ArticleCleanupScheduler.java | 6 +-- .../article/scheduler/NaverNewsScheduler.java | 34 ------------ ...pService.java => AricleBackupService.java} | 14 ++--- 15 files changed, 108 insertions(+), 91 deletions(-) rename monew-api/src/main/java/com/monew/monew_api/article/dto/{NewsBackupData.java => ArticleBackupData.java} (98%) rename monew-batch/src/main/java/com/monew/monew_batch/{common/config/NaverNewsJobConfig.java => article/config/NaverJobConfig.java} (78%) rename monew-batch/src/main/java/com/monew/monew_batch/article/job/{NaverNewsItemReader.java => ArticleItemReader.java} (83%) rename monew-batch/src/main/java/com/monew/monew_batch/article/job/{NaverNewsItemWriter.java => ArticleItemWriter.java} (94%) rename monew-batch/src/main/java/com/monew/monew_batch/article/job/{NaverNewsItemProcessor.java => processor/NaverArticleItemProcessor.java} (89%) rename monew-batch/src/main/java/com/monew/monew_batch/article/matric/{NewsBatchMetrics.java => ArticleBatchMetrics.java} (97%) rename monew-batch/src/main/java/com/monew/monew_batch/article/properties/{NaverApiProperties.java => NaverProperties.java} (93%) rename monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/{NewsBackupScheduler.java => AricleBackupScheduler.java} (67%) create mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBatchScheduler.java delete mode 100644 monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java rename monew-batch/src/main/java/com/monew/monew_batch/article/service/{NewsBackupService.java => AricleBackupService.java} (84%) diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/NewsBackupData.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleBackupData.java similarity index 98% rename from monew-api/src/main/java/com/monew/monew_api/article/dto/NewsBackupData.java rename to monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleBackupData.java index d809814..85ce1ba 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/dto/NewsBackupData.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleBackupData.java @@ -21,7 +21,7 @@ @Getter @Setter @NoArgsConstructor -public class NewsBackupData { +public class ArticleBackupData { private LocalDateTime backupDate; private List articles; diff --git a/monew-api/src/main/java/com/monew/monew_api/article/service/NewsRestoreService.java b/monew-api/src/main/java/com/monew/monew_api/article/service/NewsRestoreService.java index a63434e..a9c6284 100644 --- a/monew-api/src/main/java/com/monew/monew_api/article/service/NewsRestoreService.java +++ b/monew-api/src/main/java/com/monew/monew_api/article/service/NewsRestoreService.java @@ -1,7 +1,7 @@ package com.monew.monew_api.article.service; import com.fasterxml.jackson.databind.ObjectMapper; -import com.monew.monew_api.article.dto.NewsBackupData; +import com.monew.monew_api.article.dto.ArticleBackupData; import com.monew.monew_api.article.entity.Article; import com.monew.monew_api.article.repository.*; import com.monew.monew_api.common.entity.BaseIdEntity; @@ -38,7 +38,7 @@ public class NewsRestoreService { @Value("${aws.bucket}") private String bucketName; - private static final String PREFIX = "backup/news_backup_"; + private static final String PREFIX = "backup/article_backup_"; private static final DateTimeFormatter FILE_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss"); @@ -54,11 +54,11 @@ public void restoreArticles(LocalDateTime from, LocalDateTime to) { if (fileKeys.isEmpty()) return; // 2. 여러 백업 파일 병합 - List mergedArticles = mergeBackupData(fileKeys); + List mergedArticles = mergeBackupData(fileKeys); if (mergedArticles.isEmpty()) return; // 3. 이미 존재하는 기사 제외 - List newArticles = filterExistingArticles(mergedArticles); + List newArticles = filterExistingArticles(mergedArticles); if (newArticles.isEmpty()) return; log.info("📰 신규 기사 {}건 복원 시도", newArticles.size()); @@ -66,7 +66,7 @@ public void restoreArticles(LocalDateTime from, LocalDateTime to) { // 4. 기사 단위 복원 int restored = 0, skipped = 0; - for (NewsBackupData.ArticleData data : newArticles) { + for (ArticleBackupData.ArticleData data : newArticles) { boolean success = restoreSingleArticleExact(data); if (success) restored++; else skipped++; @@ -107,13 +107,13 @@ private LocalDateTime parseDateFromKey(String key) { } /** 여러 백업 파일 병합 */ - private List mergeBackupData(List keys) { - Map merged = new LinkedHashMap<>(); + private List mergeBackupData(List keys) { + Map merged = new LinkedHashMap<>(); for (String key : keys) { try { String json = s3Client.getObjectAsBytes(b -> b.bucket(bucketName).key(key)).asUtf8String(); - NewsBackupData backup = objectMapper.readValue(json, NewsBackupData.class); + ArticleBackupData backup = objectMapper.readValue(json, ArticleBackupData.class); backup.getArticles().forEach(a -> merged.putIfAbsent(a.getSourceUrl(), a)); } catch (Exception e) { log.error("⚠️ 백업 파일 로드 실패: {}", key, e); @@ -125,7 +125,7 @@ private List mergeBackupData(List keys) { } /** 이미 존재하는 기사 제외 */ - private List filterExistingArticles(List articles) { + private List filterExistingArticles(List articles) { Set existingUrls = articleRepository.findAllSourceUrls(); return articles.stream() .filter(a -> !existingUrls.contains(a.getSourceUrl())) @@ -133,7 +133,7 @@ private List filterExistingArticles(List { +public class ArticleItemReader implements ItemReader { private final KeywordRepository keywordRepository; private List items; diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleItemWriter.java similarity index 94% rename from monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java rename to monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleItemWriter.java index f0434ec..621f84b 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemWriter.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleItemWriter.java @@ -10,7 +10,7 @@ import com.monew.monew_api.interest.entity.Keyword; import com.monew.monew_api.interest.repository.InterestRepository; import com.monew.monew_batch.article.dto.ArticleKeywordPair; -import com.monew.monew_batch.article.matric.NewsBatchMetrics; +import com.monew.monew_batch.article.matric.ArticleBatchMetrics; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.ExitStatus; @@ -29,14 +29,14 @@ @Component @StepScope @RequiredArgsConstructor -public class NaverNewsItemWriter implements ItemWriter>, StepExecutionListener { +public class ArticleItemWriter implements ItemWriter>, StepExecutionListener { private final ArticleJdbcRepository articleJdbcRepository; private final ArticleRepository articleRepository; private final InterestRepository interestRepository; private final InterestArticlesRepository interestArticlesRepository; private final InterestArticleKeywordRepository interestArticleKeywordRepository; - private final NewsBatchMetrics metrics; + private final ArticleBatchMetrics metrics; private final Map newLinkCountsByInterestId = new ConcurrentHashMap<>(); diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemProcessor.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/NaverArticleItemProcessor.java similarity index 89% rename from monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemProcessor.java rename to monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/NaverArticleItemProcessor.java index 3e91efb..f29e289 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/job/NaverNewsItemProcessor.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/NaverArticleItemProcessor.java @@ -1,9 +1,9 @@ -package com.monew.monew_batch.article.job; +package com.monew.monew_batch.article.job.processor; import com.monew.monew_api.article.entity.Article; import com.monew.monew_api.interest.entity.Keyword; import com.monew.monew_batch.article.dto.ArticleKeywordPair; -import com.monew.monew_batch.article.properties.NaverApiProperties; +import com.monew.monew_batch.article.properties.NaverProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.item.ItemProcessor; @@ -22,14 +22,14 @@ @Slf4j @Component @RequiredArgsConstructor -public class NaverNewsItemProcessor implements ItemProcessor> { +public class NaverArticleItemProcessor implements ItemProcessor> { private static final int DISPLAY_COUNT = 10; private static final String SORT_TYPE = "sim"; private static final int REQUEST_DELAY_MS = 200; private final RestTemplate restTemplate; - private final NaverApiProperties properties; + private final NaverProperties properties; @Override public List process(Keyword keyword) { @@ -50,7 +50,8 @@ public List process(Keyword keyword) { String pubDateStr = (String) item.get("pubDate"); LocalDateTime publishDate = parsePublishDate(pubDateStr); - Article article = new Article("Naver", link, title, publishDate, desc); + String articleSourceName = properties.getArticleSource().name(); + Article article = new Article(articleSourceName, link, title, publishDate, desc); pairs.add(new ArticleKeywordPair(article, keyword)); } diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/matric/NewsBatchMetrics.java b/monew-batch/src/main/java/com/monew/monew_batch/article/matric/ArticleBatchMetrics.java similarity index 97% rename from monew-batch/src/main/java/com/monew/monew_batch/article/matric/NewsBatchMetrics.java rename to monew-batch/src/main/java/com/monew/monew_batch/article/matric/ArticleBatchMetrics.java index bd5ccbc..a77d6cc 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/matric/NewsBatchMetrics.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/matric/ArticleBatchMetrics.java @@ -10,7 +10,7 @@ */ @Component @RequiredArgsConstructor -public class NewsBatchMetrics { +public class ArticleBatchMetrics { private final MeterRegistry meterRegistry; diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverApiProperties.java b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverProperties.java similarity index 93% rename from monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverApiProperties.java rename to monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverProperties.java index 817cf7e..63c3ede 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverApiProperties.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverProperties.java @@ -8,7 +8,7 @@ @Getter @RequiredArgsConstructor @ConfigurationProperties(prefix = "naver") -public class NaverApiProperties { +public class NaverProperties { private final String url; private final String clientId; private final String clientSecret; diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepository.java b/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepository.java index b95ab2e..7921ad6 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepository.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepository.java @@ -1,10 +1,10 @@ package com.monew.monew_batch.article.repository; -import com.monew.monew_api.article.dto.NewsBackupData; +import com.monew.monew_api.article.dto.ArticleBackupData; import java.util.List; public interface ArticleBackupQueryRepository { - List findAllArticlesForBackup(); + List findAllArticlesForBackup(); } diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepositoryImpl.java b/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepositoryImpl.java index 39253db..8c3de0c 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepositoryImpl.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepositoryImpl.java @@ -1,8 +1,8 @@ package com.monew.monew_batch.article.repository; -import com.monew.monew_api.article.dto.NewsBackupData; -import com.monew.monew_api.article.dto.QNewsBackupData_ArticleData; +import com.monew.monew_api.article.dto.ArticleBackupData; +import com.monew.monew_api.article.dto.QArticleBackupData_ArticleData; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -27,9 +27,9 @@ public class ArticleBackupQueryRepositoryImpl implements ArticleBackupQueryRepos private final JPAQueryFactory queryFactory; @Override - public List findAllArticlesForBackup() { + public List findAllArticlesForBackup() { return queryFactory - .select(new QNewsBackupData_ArticleData( + .select(new QArticleBackupData_ArticleData( article.source, article.sourceUrl, article.title, diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NewsBackupScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBackupScheduler.java similarity index 67% rename from monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NewsBackupScheduler.java rename to monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBackupScheduler.java index 8828237..1dfe588 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NewsBackupScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBackupScheduler.java @@ -1,6 +1,6 @@ package com.monew.monew_batch.article.scheduler; -import com.monew.monew_batch.article.service.NewsBackupService; +import com.monew.monew_batch.article.service.AricleBackupService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -15,15 +15,15 @@ @Component @RequiredArgsConstructor @ConditionalOnProperty(value = "app.scheduling.enabled", havingValue = "true", matchIfMissing = true) -public class NewsBackupScheduler { +public class AricleBackupScheduler { - private final NewsBackupService newsBackupService; + private final AricleBackupService aricleBackupService; - @Scheduled(cron = "0 20 4 * * *", zone = "Asia/Seoul") -// @Scheduled(fixedRate = 60000) // 테스트용 +// @Scheduled(cron = "0 20 4 * * *", zone = "Asia/Seoul") + @Scheduled(fixedRate = 600000) // 테스트용 public void backupNews() { log.info("🗄 뉴스 백업 시작"); - newsBackupService.backupAllArticles(); + aricleBackupService.backupAllArticles(); log.info("🗃 뉴스 백업 완료"); } } diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBatchScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBatchScheduler.java new file mode 100644 index 0000000..1077cf3 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBatchScheduler.java @@ -0,0 +1,52 @@ +package com.monew.monew_batch.article.scheduler; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@EnableScheduling +public class AricleBatchScheduler { + + private final JobLauncher jobLauncher; + private final Job naverNewsJob; + private final Job chosunRssJob; + private final Job hankyungRssJob; + private final Job yonhapRssJob; + + public AricleBatchScheduler( + JobLauncher jobLauncher, + @Qualifier("naverNewsJob") Job naverNewsJob, + @Qualifier("chosunRssJob") Job chosunRssJob, + @Qualifier("hankyungRssJob") Job hankyungRssJob, + @Qualifier("yonhapRssJob") Job yonhapRssJob + ) { + this.jobLauncher = jobLauncher; + this.naverNewsJob = naverNewsJob; + this.chosunRssJob = chosunRssJob; + this.hankyungRssJob = hankyungRssJob; + this.yonhapRssJob = yonhapRssJob; + } + +// @Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul") + @Scheduled(fixedRate = 600000) // 테스트용 + public void runJob() throws Exception { + log.info("🕒 [Batch Scheduler] 뉴스 수집 Job 실행"); + + JobParameters params = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(naverNewsJob, params); + jobLauncher.run(chosunRssJob, params); + jobLauncher.run(yonhapRssJob, params); +// jobLauncher.run(hankyungRssJob, params); // 한경은 불안정함(엄격한 속도 제한과 아이피 제한), 사용 불가능 + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java index 8a8c96f..e7b78c9 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java @@ -2,7 +2,7 @@ import com.monew.monew_api.article.entity.Article; import com.monew.monew_api.article.repository.ArticleRepository; -import com.monew.monew_batch.article.matric.NewsBatchMetrics; +import com.monew.monew_batch.article.matric.ArticleBatchMetrics; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -17,14 +17,14 @@ public class ArticleCleanupScheduler { private final ArticleRepository articleRepository; - private final NewsBatchMetrics metrics; + private final ArticleBatchMetrics metrics; /** * 매일 새벽 4시에 is_deleted = true인 뉴스들을 물리 삭제 */ @Transactional @Scheduled(cron = "0 10 4 * * *", zone = "Asia/Seoul") -// @Scheduled(fixedRate = 60000) // 테스트용 +// @Scheduled(fixedRate = 600000) // 테스트용 public void deleteSoftDeletedArticles() { log.info("🧹 [ArticleCleanupScheduler] 논리 삭제된 뉴스 정리 시작"); List
deletedArticles = articleRepository.findAllByIsDeletedTrue(); diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java deleted file mode 100644 index 2a58d8c..0000000 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/NaverNewsScheduler.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.monew.monew_batch.article.scheduler; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.JobParameters; -import org.springframework.batch.core.JobParametersBuilder; -import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -@EnableScheduling -@RequiredArgsConstructor -public class NaverNewsScheduler { - - private final JobLauncher jobLauncher; - private final Job naverNewsJob; - - @Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul") -// @Scheduled(fixedRate = 60000) // 테스트용 - public void runJob() throws Exception { - log.info("🕒 [Batch Scheduler] 네이버 뉴스 수집 Job 실행"); - - JobParameters params = new JobParametersBuilder() - .addLong("timestamp", System.currentTimeMillis()) - .toJobParameters(); - - jobLauncher.run(naverNewsJob, params); - } - -} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/service/NewsBackupService.java b/monew-batch/src/main/java/com/monew/monew_batch/article/service/AricleBackupService.java similarity index 84% rename from monew-batch/src/main/java/com/monew/monew_batch/article/service/NewsBackupService.java rename to monew-batch/src/main/java/com/monew/monew_batch/article/service/AricleBackupService.java index 2ae608d..04fb3d3 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/article/service/NewsBackupService.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/service/AricleBackupService.java @@ -1,8 +1,8 @@ package com.monew.monew_batch.article.service; import com.fasterxml.jackson.databind.ObjectMapper; -import com.monew.monew_api.article.dto.NewsBackupData; -import com.monew.monew_batch.article.matric.NewsBatchMetrics; +import com.monew.monew_api.article.dto.ArticleBackupData; +import com.monew.monew_batch.article.matric.ArticleBatchMetrics; import com.monew.monew_batch.article.repository.ArticleBackupQueryRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,28 +25,28 @@ @Slf4j @Service @RequiredArgsConstructor -public class NewsBackupService { +public class AricleBackupService { private final ArticleBackupQueryRepository backupQueryRepository; private final ObjectMapper objectMapper; private final S3Client s3Client; - private final NewsBatchMetrics metrics; + private final ArticleBatchMetrics metrics; @Value("${aws.bucket}") private String bucketName; - private static final String PREFIX = "backup/news_backup_"; + private static final String PREFIX = "backup/article_backup_"; @Transactional(readOnly = true) public void backupAllArticles() { - List articles = backupQueryRepository.findAllArticlesForBackup(); + List articles = backupQueryRepository.findAllArticlesForBackup(); if (articles.isEmpty()) { log.info("백업할 뉴스가 없습니다. (isDeleted = false)"); return; } - NewsBackupData backupData = new NewsBackupData(); + ArticleBackupData backupData = new ArticleBackupData(); backupData.setBackupDate(LocalDateTime.now()); backupData.setArticles(articles); From 0f2e4c9ef490ec0717ef6d8f233121667f1e7ed1 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 Date: Thu, 6 Nov 2025 09:18:40 +0900 Subject: [PATCH 175/178] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8F=AC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20User=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=B0=B0=ED=8F=AC=EC=9A=A9=EC=9C=BC=EB=A1=9C=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 | 1 + .../src/main/resources/application-prod.yml | 2 +- .../src/main/resources/logback-spring.xml | 7 +++ .../monew_batch/MonewBatchApplication.java | 6 ++- .../user/scheduler/DeletionScheduler.java | 3 +- .../src/main/resources/prometheus.yml | 48 ++++++++++++++++--- 6 files changed, 58 insertions(+), 9 deletions(-) diff --git a/build.gradle b/build.gradle index 10cd703..576d6aa 100644 --- a/build.gradle +++ b/build.gradle @@ -55,5 +55,6 @@ subprojects { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'io.micrometer:micrometer-core' implementation 'org.springframework.boot:spring-boot-starter-actuator' + runtimeOnly "io.micrometer:micrometer-registry-prometheus" } } \ No newline at end of file diff --git a/monew-api/src/main/resources/application-prod.yml b/monew-api/src/main/resources/application-prod.yml index 2b99fbb..f27ca4f 100644 --- a/monew-api/src/main/resources/application-prod.yml +++ b/monew-api/src/main/resources/application-prod.yml @@ -35,7 +35,7 @@ management: endpoints: web: exposure: - include: health, info + include: health, info, metrics, prometheus endpoint: health: show-details: never diff --git a/monew-api/src/main/resources/logback-spring.xml b/monew-api/src/main/resources/logback-spring.xml index 45c63f7..3b23cdf 100644 --- a/monew-api/src/main/resources/logback-spring.xml +++ b/monew-api/src/main/resources/logback-spring.xml @@ -32,8 +32,15 @@ + + + ${LOG_PATTERN} + + + + diff --git a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java index c092f5c..daaab0d 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java @@ -14,7 +14,11 @@ scanBasePackages = { "com.monew.monew_batch", "com.monew.monew_api.article.repository", - } + }, + exclude = { + org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration.class, + org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration.class + } ) @EntityScan(basePackages = "com.monew.monew_api") @EnableJpaRepositories(basePackages = "com.monew.monew_api") diff --git a/monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java index 9480e84..35f33ce 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java @@ -22,7 +22,8 @@ public class DeletionScheduler { * [요구사항] Soft delete 후 1일 경과한 사용자를 영구 삭제 * [프로토타입] 5초마다 체크하여 5분 경과한 사용자 삭제 */ - @Scheduled(fixedDelay = 5000) + // @Scheduled(fixedDelay = 50000) + @Scheduled(cron = "0 10 5 * * *", zone = "Asia/Seoul") public void runUserDeletionJob() throws Exception { log.info("==== Starting User Deletion Job ===="); diff --git a/monew-monitor/src/main/resources/prometheus.yml b/monew-monitor/src/main/resources/prometheus.yml index 606a695..600957b 100644 --- a/monew-monitor/src/main/resources/prometheus.yml +++ b/monew-monitor/src/main/resources/prometheus.yml @@ -1,18 +1,54 @@ +#global: +# scrape_interval: 10s # 10초마다 메트릭 수집 + +# local +#scrape_configs: +# - job_name: 'monew-api' +# metrics_path: '/actuator/prometheus' +# static_configs: +# - targets: ['host.docker.internal:8080'] +# +# - job_name: 'monew-batch' +# metrics_path: '/actuator/prometheus' +# static_configs: +# - targets: ['host.docker.internal:8081'] +# +# - job_name: 'monew-monitor' +# metrics_path: '/actuator/prometheus' +# static_configs: +# - targets: ['host.docker.internal:8082'] + + +# (로컬용은 그대로 주석 유지) global: scrape_interval: 10s # 10초마다 메트릭 수집 +# 배포 scrape_configs: + # 0) Prometheus 자기 자신 (프리픽스 사용하므로 metrics_path 변경) + - job_name: 'prometheus' + metrics_path: /prometheus/metrics + static_configs: + - targets: ['localhost:9090'] + scheme: http + + # 1) monew-api - job_name: 'monew-api' - metrics_path: '/actuator/prometheus' + metrics_path: /actuator/prometheus static_configs: - - targets: ['host.docker.internal:8080'] + - targets: ['monew-alb-721921608.ap-northeast-2.elb.amazonaws.com'] + scheme: http + # 2) monew-batch - job_name: 'monew-batch' - metrics_path: '/actuator/prometheus' + metrics_path: /batch/actuator/prometheus static_configs: - - targets: ['host.docker.internal:8081'] + - targets: ['monew-alb-721921608.ap-northeast-2.elb.amazonaws.com'] + scheme: http + # 3) monew-monitor - job_name: 'monew-monitor' - metrics_path: '/actuator/prometheus' + metrics_path: /monitor/actuator/prometheus static_configs: - - targets: ['host.docker.internal:8082'] \ No newline at end of file + - targets: ['monew-alb-721921608.ap-northeast-2.elb.amazonaws.com'] + scheme: http \ No newline at end of file From 3d2e1f7167025de786ae00696a9efdae25b726f7 Mon Sep 17 00:00:00 2001 From: chanhyeok0201 <91006942+chanhyeok0201@users.noreply.github.com> Date: Fri, 7 Nov 2025 18:33:17 +0900 Subject: [PATCH 176/178] Update README.md --- README.md | 249 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 247 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ba4ab45..2565f04 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,247 @@ -# monew-backend -Spring Boot · JPA · Batch · PostgreSQL · MongoDB · AWS S3 기반 뉴스 통합 및 백업·복구 백엔드 시스템 +# 🧑‍💻 MONEW + +> 협업 문서: [Notion 팀 문서 바로가기](https://www.notion.so/2-29228815f6f280ca8005d12bc9670225?pvs=21) +> + +--- + +--- + +## 📘 프로젝트 소개 + +**여러 뉴스 API를 통합하여 사용자에게 맞춤형 뉴스를 제공하고, 의견을 나눌 수 있는 소셜 기능을 갖춘 서비스** + +- **프로젝트 기간:** 2025.10.20 ~ 2025.11.07 +- **목표:** 관심사를 등록하면 자동으로 관련 뉴스를 수집·갱신해주는 개인 맞춤형 뉴스 동기화 플랫폼 +- **핵심 기능:** + - 관심사 기반 뉴스 자동 수집 (OpenAPI 연동) + - 사용자 활동 (댓글, 좋아요, 알림 등) 통합 관리 + - 스케줄러 기반 기사 동기화 및 복구 기능 구현 + +## 👥 팀원 구성 + +| 이름 | 역할 | 개인 GitHub | +| --- | --- | --- | +| 강문영 | 알림 관련 API | [🔗 GitHub](https://github.com/truuuely) | +| 김찬혁 | 댓글 관련 API | [🔗 GitHub](https://github.com/chanhyeok0201) | +| 이예림 | 관심사관련 API | [🔗 GitHub](https://github.com/yeahlimm) | +| 임재혁 | 뉴스 기사 관련 API | [🔗 GitHub](https://github.com/JaehyeokLim) | +| 정영진 | 활동 내역 관련 API | [🔗 GitHub](https://github.com/userjin2123) | +| 최도한 | 사용자 관련 API | [🔗 GitHub](https://github.com/DoHanChoi) | + +--- + +## 🛠 기술 스택 + +| 구분 | 사용 기술 | +| --- | --- | +| Language & Core | java17 | +| Framework / Runtime | Spring Boot 3.x, Spring Batch | +| Database & ORM | PostgreSQL, Spring Data JPA, QueryDSL, MongoDB | +| Build & Dependency Management | Gradle | +| DevOps / Infra | AWS (ECS, S3, RDS), GitHub Actions, Docker | +| API & Documentation | Spring REST Docs, Swagger (OpenAPI 3) | +| Monitoring & Metrics | Spring Actuator, Prometheus , Grafana | +| Utilities & Others | Lombok, Validation (Jakarta Bean Validation), Logback (MDC Logging) | + +--- + +## 🔍 팀원별 구현 기능 + +### 🧩 강문영 + +- **알림 관리** + - 작성한 댓글 좋아요 시 알림 + - 관심사가 포함된 기사 수집 시 알림 + +--- + +### 🔒 김찬혁 + +- **댓글 관리** + - 기사별 댓글 작성 + - 본인 댓글 수정/삭제 + - 댓글 좋아요 추가/삭제 + +--- + +### 🎓 이예림 + +- **관심사 관리** + - 관심사 추가 기능 구현 + - 관심사 구독 기능 구현 + +--- + +### 🧾 임재혁 + +- **뉴스 기사 관리** + - NAVER, HANKYUNG, CHOSUN, YEONHAP 출처의 기사 수집 + - 기사와 관심사 키워드를 연동 + - S3에 백업하여 복구 가능 + +--- + +### ⏰ 정영진 + +- **활동 내역 관리** + - 작성한 댓글 조회 + - 좋아요 누른 댓글 조회 + - 최근 조회한 뉴스 기사 확인 + +--- + +### ⏰ 최도한 + +- **사용자 관리** + - 로그인 및 회원가입 (email, 닉네임, 비밀번호 입력) + - 회원가입 시 유효성 검사 + +--- + +📁 파일 구조 + +``` +monew-backend/ +├── .github/ +│ └── workflows/ +│ ├── deploy-api.yml +│ ├── deploy-batch.yml +│ └── deploy-monitor.yml +│ +├── monew-api/ +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── java/com/monew/monew_api/ +│ │ │ │ ├── article/ +│ │ │ │ │ ├── controller/ +│ │ │ │ │ ├── dto/ +│ │ │ │ │ ├── entity/ +│ │ │ │ │ ├── event/ +│ │ │ │ │ ├── repository/ +│ │ │ │ │ └── service/ +│ │ │ │ ├── comments/ +│ │ │ │ │ ├── controller/ +│ │ │ │ │ ├── dto/ +│ │ │ │ │ ├── entity/ +│ │ │ │ │ ├── event/ +│ │ │ │ │ ├── repository/ +│ │ │ │ │ └── service/ +│ │ │ │ ├── common/ +│ │ │ │ │ ├── config/ +│ │ │ │ │ ├── dto/ +│ │ │ │ │ ├── entity/ +│ │ │ │ │ ├── exception/ +│ │ │ │ │ └── interceptor/ +│ │ │ │ ├── interest/ +│ │ │ │ │ ├── controller/ +│ │ │ │ │ ├── dto/ +│ │ │ │ │ ├── entity/ +│ │ │ │ │ ├── event/ +│ │ │ │ │ ├── mapper/ +│ │ │ │ │ ├── repository/ +│ │ │ │ │ └── service/ +│ │ │ │ ├── notification/ +│ │ │ │ │ ├── controller/ +│ │ │ │ │ ├── dto/ +│ │ │ │ │ ├── entity/ +│ │ │ │ │ ├── enums/ +│ │ │ │ │ ├── eventlistener/ +│ │ │ │ │ ├── repository/ +│ │ │ │ │ └── service/ +│ │ │ │ ├── subscribe/ +│ │ │ │ │ ├── controller/ +│ │ │ │ │ ├── dto/ +│ │ │ │ │ ├── entity/ +│ │ │ │ │ ├── event/ +│ │ │ │ │ ├── mapper/ +│ │ │ │ │ ├── repository/ +│ │ │ │ │ └── service/ +│ │ │ │ ├── user/ +│ │ │ │ │ ├── controller/ +│ │ │ │ │ ├── dto/ +│ │ │ │ │ ├── repository/ +│ │ │ │ │ └── service/ +│ │ │ │ ├── useractivity/ +│ │ │ │ │ ├── controller/ +│ │ │ │ │ ├── document/ +│ │ │ │ │ ├── dto/ +│ │ │ │ │ ├── event/ +│ │ │ │ │ ├── json/ +│ │ │ │ │ ├── listener/ +│ │ │ │ │ ├── mapper/ +│ │ │ │ │ ├── repository/ +│ │ │ │ │ └── service/ +│ │ │ │ └── MonewApiApplication.java +│ │ │ └── resources/ +│ │ │ ├── static/ +│ │ │ │ ├── assets/ +│ │ │ │ └── fonts.pretendard/ +│ │ │ ├── application.yml +│ │ │ ├── application-dev.yml +│ │ │ ├── application-prod.yml +│ │ │ └── logback-spring.xml +│ │ └── test/ +│ └──── build.gradle +│ +├── monew-batch/ +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── java/com/monew/monew_batch/ +│ │ │ │ ├── article/ +│ │ │ │ │ ├── config/ +│ │ │ │ │ ├── dto/ +│ │ │ │ │ ├── enums/ +│ │ │ │ │ ├── job/ +│ │ │ │ │ ├── matric/ +│ │ │ │ │ ├── properties/ +│ │ │ │ │ ├── repository/ +│ │ │ │ │ ├── scheduler/ +│ │ │ │ │ └── service/ +│ │ │ │ ├── common.config/ +│ │ │ │ ├── notification/ +│ │ │ │ │ ├── config/ +│ │ │ │ │ ├── scheduler/ +│ │ │ │ │ └── service/ +│ │ │ │ ├── user/ +│ │ │ │ │ ├── config/ +│ │ │ │ │ ├── metrics/ +│ │ │ │ │ └── scheduler/ +│ │ │ │ └── MonewBatchApplication.java +│ │ │ └── resources/ +│ │ │ ├── application.yml +│ │ │ ├── application-dev.yml +│ │ │ ├── application-prod.yml +│ │ │ └── schema-batch.sql +│ │ └── test/ +│ └─── build.gradle +│ +├── monew-monitor/ +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── java/com/monew/monew_monitor/ +│ │ │ └── resources/ +│ │ │ ├── application.yml +│ │ │ └── prometheus.yml +│ │ └── test/ +│ └─── build.gradle +│ +├── monitoring/ +│ └── prometheus.yml +│ +├── .dockerignore +├── .env +├── .env.example +├── .gitignore +├── build.gradle +├── docker-compose.prod.yml +├── Dockerfile.api +├── Dockerfile.batch +├── Dockerfile.monitor +├── Dockerfile.multi +├── gradlew +├── gradlew.bat +├── [README.md](http://readme.md/) +└── settings.gradle + +``` From 9cdfdc0b5dfd78d29c853a22ba5dc339949dd5f7 Mon Sep 17 00:00:00 2001 From: Jaehyeok Lim <217686834+JaehyeokLim@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:33:21 +0900 Subject: [PATCH 177/178] Revise README with project and team information Updated project details, team structure, and technology stack in README. --- README.md | 270 +++++++++++++++++++++++++++--------------------------- 1 file changed, 136 insertions(+), 134 deletions(-) diff --git a/README.md b/README.md index 2565f04..4642fbd 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,151 @@ -# 🧑‍💻 MONEW +# 모뉴 (MONEW) - 마음대로 골라보는 모든 뉴스 -> 협업 문서: [Notion 팀 문서 바로가기](https://www.notion.so/2-29228815f6f280ca8005d12bc9670225?pvs=21) -> +monew_for_readme ---- +### 관련 자료 ---- +> 협업 문서: [Notion 팀 문서 바로가기](https://www.notion.so/2-29228815f6f280ca8005d12bc9670225?pvs=21) -## 📘 프로젝트 소개 +
-**여러 뉴스 API를 통합하여 사용자에게 맞춤형 뉴스를 제공하고, 의견을 나눌 수 있는 소셜 기능을 갖춘 서비스** +## 📘 프로젝트 소개 -- **프로젝트 기간:** 2025.10.20 ~ 2025.11.07 -- **목표:** 관심사를 등록하면 자동으로 관련 뉴스를 수집·갱신해주는 개인 맞춤형 뉴스 동기화 플랫폼 -- **핵심 기능:** - - 관심사 기반 뉴스 자동 수집 (OpenAPI 연동) - - 사용자 활동 (댓글, 좋아요, 알림 등) 통합 관리 - - 스케줄러 기반 기사 동기화 및 복구 기능 구현 +**MONEW**는 사용자의 관심사에 맞춰 뉴스를 자동으로 수집하고, 댓글·좋아요·알림 등 **소셜 기능을 통해 뉴스 경험을 확장하는 개인화 뉴스 플랫폼**입니다. -## 👥 팀원 구성 +* **프로젝트 기간:** 2025.10.17 ~ 2025.11.07 +* **기획 의도:** 넘쳐나는 뉴스 속에서 ‘나에게 맞는 뉴스’만 보고 싶은 사용자의 니즈 해결 +* **핵심 목표:** -| 이름 | 역할 | 개인 GitHub | -| --- | --- | --- | -| 강문영 | 알림 관련 API | [🔗 GitHub](https://github.com/truuuely) | -| 김찬혁 | 댓글 관련 API | [🔗 GitHub](https://github.com/chanhyeok0201) | -| 이예림 | 관심사관련 API | [🔗 GitHub](https://github.com/yeahlimm) | -| 임재혁 | 뉴스 기사 관련 API | [🔗 GitHub](https://github.com/JaehyeokLim) | -| 정영진 | 활동 내역 관련 API | [🔗 GitHub](https://github.com/userjin2123) | -| 최도한 | 사용자 관련 API | [🔗 GitHub](https://github.com/DoHanChoi) | + * 관심사 기반 뉴스 큐레이션 및 자동 수집 + * 사용자 활동(댓글, 좋아요, 알림) 통합 관리 + * 스케줄러 기반 기사 동기화 및 S3 백업 복구 기능 + * Prometheus + Grafana 기반 실시간 관찰성 확보 --- +### 🎥 시연 영상 +https://github.com/user-attachments/assets/e1d18b5b-4470-41db-b318-1a91ae83fef4 + +
+ ## 🛠 기술 스택 -| 구분 | 사용 기술 | -| --- | --- | -| Language & Core | java17 | -| Framework / Runtime | Spring Boot 3.x, Spring Batch | -| Database & ORM | PostgreSQL, Spring Data JPA, QueryDSL, MongoDB | -| Build & Dependency Management | Gradle | -| DevOps / Infra | AWS (ECS, S3, RDS), GitHub Actions, Docker | -| API & Documentation | Spring REST Docs, Swagger (OpenAPI 3) | -| Monitoring & Metrics | Spring Actuator, Prometheus , Grafana | -| Utilities & Others | Lombok, Validation (Jakarta Bean Validation), Logback (MDC Logging) | +| **구분** | **사용 기술** | +| :-------------------------------- | :-------------------------------------------------------------------------------------------------- | +| **Language & Core** | Java 17 | +| **Framework / Runtime** | Spring Boot 3.x, Spring Batch | +| **Database & ORM** | PostgreSQL, MongoDB, H2, Spring Data JPA, QueryDSL | +| **Build & Dependency Management** | Gradle | +| **Infra & DevOps** | AWS (ECS Fargate, ECR, RDS, S3), Docker, GitHub Actions | +| **Monitoring & Metrics** | Spring Actuator, Prometheus, Grafana | +| **API & Docs** | Spring REST Docs, Swagger (OpenAPI 3) | +| **Utilities & Others** | Lombok, MapStruct, Jakarta Validation, Logback (MDC Logging), BCryptPasswordEncoder, JUnit, Postman | +| **Collaboration Tools** | GitHub, Discord, Figma, Notion | ---- +
-## 🔍 팀원별 구현 기능 +## 💡 팀원 소개 및 주요 구현 기능 -### 🧩 강문영 +### 🧾 임재혁 -- **알림 관리** - - 작성한 댓글 좋아요 시 알림 - - 관심사가 포함된 기사 수집 시 알림 +* NAVER, 조선일보, 한경, 연합뉴스 API 기반 뉴스 자동 수집 +* Spring Batch 기반 수집 → 정제 → 저장 3단계 Job 구성 +* 기사 수집, 백업, 삭제 및 메트릭 수집 로직 구현 +* AWS S3 기반 JSON 백업/복구 기능 구축 +* Jacoco, Codecov 기반 CI 환경 구성 및 커버리지 측정 자동화 ---- +> GitHub: [🔗 JaehyeokLim](https://github.com/JaehyeokLim) + +
### 🔒 김찬혁 -- **댓글 관리** - - 기사별 댓글 작성 - - 본인 댓글 수정/삭제 - - 댓글 좋아요 추가/삭제 +* 댓글 CRUD 및 좋아요 기능 구현 +* Docker & AWS ECS 배포 환경 구성 +* GitHub Actions 통한 자동 배포(CI/CD) 파이프라인 구축 +* Prometheus + Grafana 기반 모니터링 환경 구성 +* DDL 설계 ---- +> GitHub: [🔗 chanhyeok0201](https://github.com/chanhyeok0201) -### 🎓 이예림 +
-- **관심사 관리** - - 관심사 추가 기능 구현 - - 관심사 구독 기능 구현 +### 🧩 강문영 ---- +* 알림 CRUD 및 이벤트 기반 알림 시스템 구현 +* 스케줄링 기반 알림 삭제 로직 구현 +* JobExecutionListener를 사용하여 구독 뉴스 기사 수집 알림 기능 구현 -### 🧾 임재혁 +> GitHub: [🔗 truuuely](https://github.com/truuuely) -- **뉴스 기사 관리** - - NAVER, HANKYUNG, CHOSUN, YEONHAP 출처의 기사 수집 - - 기사와 관심사 키워드를 연동 - - S3에 백업하여 복구 가능 +
---- +### 🎓 이예림 + +* 관심사 CRUD 및 구독 로직 구현 +* TDD 기반 테스트 코드 작성 및 검증 체계 구축 + +> GitHub: [🔗 yeahlimm](https://github.com/yeahlimm) + +
### ⏰ 정영진 -- **활동 내역 관리** - - 작성한 댓글 조회 - - 좋아요 누른 댓글 조회 - - 최근 조회한 뉴스 기사 확인 +* 사용자 활동 내역(댓글, 좋아요, 뉴스 열람) 통합 조회 기능 구현 +* PostgreSQL → MongoDB 캐시 구조로 전환하여 조회 성능 개선 +* Docker & AWS ECS 배포 환경 구성 +* GitHub Actions 통한 자동 배포(CI/CD) 파이프라인 구축 +* Prometheus + Grafana 기반 모니터링 환경 구성 ---- +> GitHub: [🔗 userjin2123](https://github.com/userjin2123) -### ⏰ 최도한 +
-- **사용자 관리** - - 로그인 및 회원가입 (email, 닉네임, 비밀번호 입력) - - 회원가입 시 유효성 검사 +### 👤 최도한 ---- +* 회원가입, 로그인, 탈퇴 기능 구현 +* BCrypt 기반 비밀번호 암호화 처리 +* 사용자 배치 및 메트릭 수집 로직 구성 -📁 파일 구조 +> GitHub: [🔗 DoHanChoi](https://github.com/DoHanChoi) -``` +
+ +## ⚙️ 시스템 아키텍처 + +**데이터 성격에 따라 저장소를 분리하고, 클라우드 기반 자동 배포 및 모니터링 환경 구축** + +* **PostgreSQL** → 핵심 서비스 데이터 (회원, 기사, 댓글, 관심사) +* **MongoDB** → 조회가 빈번한 사용자 활동 캐시 +* **H2** → 테스트 데이터 저장 +* **S3** → 뉴스 백업 및 복구 파일 저장 +* **Spring Batch** → 정기 뉴스 수집 및 데이터 정제 +* **Prometheus + Grafana + Actuator** → 메트릭 수집 및 서비스 상태 모니터링 (정영진, 김찬혁 담당) +* **GitHub Actions + ECS + ALB** → CI/CD 자동화 및 트래픽 분산 (김찬혁, 정영진 담당) + +
+ +## 🔍 트러블슈팅 요약 + +| **담당자** | **구분** | **문제 상황** | **원인** | **해결 방법** | +| :------ | :----- | :------------------------- | :---------------------------- | :------------------------------------------------- | +| 강문영 | 알림 | 알림 전체 확인 시 N+1 문제 | Dirty Checking으로 인한 다중 Update | JPQL Bulk Update로 개선 (92% 속도 향상) | +| 임재혁 | 키워드 | Keyword 중복 및 참조 불일치 | 비정규화된 ArticleKeywordLogs 구조 | InterestArticleKeywords 도입 및 JSON 기반 백업 구조로 통합 | +| 임재혁 | 배치 | 멀티스레드 환경 중복키 충돌 | JPA Save()의 SELECT→INSERT 구조 | PostgreSQL `ON CONFLICT DO NOTHING` + JDBC 전환으로 해결 | +| 정영진 | 활동 내역 | 여러 Repository 호출로 인한 쿼리 병목 | 다중 쿼리 구조로 인한 성능 저하 | CTE 기반 단일 쿼리 통합 (15% 성능 개선) | +| 정영진 | DB 부하 | PostgreSQL 단일 처리로 부하 집중 | 배치와 서비스 요청 혼재 | MongoDB를 캐시 DB로 분리 (P99 지연 27% 개선) | + +
+ +### ERD + +Monew (2) 1 + +
+ +### 📁 파일 구조 + +```plaintext monew-backend/ ├── .github/ │ └── workflows/ @@ -109,80 +153,43 @@ monew-backend/ │ ├── deploy-batch.yml │ └── deploy-monitor.yml │ +├── .gradle/ +├── .idea/ +├── build/ +├── gradle/ +├── logs/ +│ ├── monew-api/ │ ├── src/ │ │ ├── main/ │ │ │ ├── java/com/monew/monew_api/ │ │ │ │ ├── article/ -│ │ │ │ │ ├── controller/ -│ │ │ │ │ ├── dto/ -│ │ │ │ │ ├── entity/ -│ │ │ │ │ ├── event/ -│ │ │ │ │ ├── repository/ -│ │ │ │ │ └── service/ │ │ │ │ ├── comments/ -│ │ │ │ │ ├── controller/ -│ │ │ │ │ ├── dto/ -│ │ │ │ │ ├── entity/ -│ │ │ │ │ ├── event/ -│ │ │ │ │ ├── repository/ -│ │ │ │ │ └── service/ │ │ │ │ ├── common/ -│ │ │ │ │ ├── config/ -│ │ │ │ │ ├── dto/ -│ │ │ │ │ ├── entity/ -│ │ │ │ │ ├── exception/ -│ │ │ │ │ └── interceptor/ │ │ │ │ ├── interest/ -│ │ │ │ │ ├── controller/ -│ │ │ │ │ ├── dto/ -│ │ │ │ │ ├── entity/ -│ │ │ │ │ ├── event/ -│ │ │ │ │ ├── mapper/ -│ │ │ │ │ ├── repository/ -│ │ │ │ │ └── service/ │ │ │ │ ├── notification/ -│ │ │ │ │ ├── controller/ -│ │ │ │ │ ├── dto/ -│ │ │ │ │ ├── entity/ -│ │ │ │ │ ├── enums/ -│ │ │ │ │ ├── eventlistener/ -│ │ │ │ │ ├── repository/ -│ │ │ │ │ └── service/ │ │ │ │ ├── subscribe/ -│ │ │ │ │ ├── controller/ -│ │ │ │ │ ├── dto/ -│ │ │ │ │ ├── entity/ -│ │ │ │ │ ├── event/ -│ │ │ │ │ ├── mapper/ -│ │ │ │ │ ├── repository/ -│ │ │ │ │ └── service/ │ │ │ │ ├── user/ -│ │ │ │ │ ├── controller/ -│ │ │ │ │ ├── dto/ -│ │ │ │ │ ├── repository/ -│ │ │ │ │ └── service/ │ │ │ │ ├── useractivity/ -│ │ │ │ │ ├── controller/ -│ │ │ │ │ ├── document/ -│ │ │ │ │ ├── dto/ -│ │ │ │ │ ├── event/ -│ │ │ │ │ ├── json/ -│ │ │ │ │ ├── listener/ -│ │ │ │ │ ├── mapper/ -│ │ │ │ │ ├── repository/ -│ │ │ │ │ └── service/ │ │ │ │ └── MonewApiApplication.java │ │ │ └── resources/ +│ │ │ ├── db/ +│ │ │ │ ├── data/ +│ │ │ │ └── schema.sql │ │ │ ├── static/ +│ │ │ │ ├── api/ │ │ │ │ ├── assets/ -│ │ │ │ └── fonts.pretendard/ +│ │ │ │ └── index.html │ │ │ ├── application.yml │ │ │ ├── application-dev.yml │ │ │ ├── application-prod.yml │ │ │ └── logback-spring.xml │ │ └── test/ -│ └──── build.gradle +│ │ ├── java/com/monew/monew_api/ +│ │ │ ├── Comment/ +│ │ │ └── Notification/ +│ │ └── resources/application-test.yml +│ └── build.gradle │ ├── monew-batch/ │ ├── src/ @@ -198,15 +205,9 @@ monew-backend/ │ │ │ │ │ ├── repository/ │ │ │ │ │ ├── scheduler/ │ │ │ │ │ └── service/ -│ │ │ │ ├── common.config/ +│ │ │ │ ├── common/ │ │ │ │ ├── notification/ -│ │ │ │ │ ├── config/ -│ │ │ │ │ ├── scheduler/ -│ │ │ │ │ └── service/ │ │ │ │ ├── user/ -│ │ │ │ │ ├── config/ -│ │ │ │ │ ├── metrics/ -│ │ │ │ │ └── scheduler/ │ │ │ │ └── MonewBatchApplication.java │ │ │ └── resources/ │ │ │ ├── application.yml @@ -214,20 +215,21 @@ monew-backend/ │ │ │ ├── application-prod.yml │ │ │ └── schema-batch.sql │ │ └── test/ -│ └─── build.gradle +│ │ ├── java/com/monew/monew_batch/s3/AWS3Test.java +│ │ └── resources/application-test.yml +│ └── build.gradle │ ├── monew-monitor/ │ ├── src/ │ │ ├── main/ │ │ │ ├── java/com/monew/monew_monitor/ +│ │ │ │ └── MonewMonitorApplication.java │ │ │ └── resources/ │ │ │ ├── application.yml │ │ │ └── prometheus.yml │ │ └── test/ -│ └─── build.gradle -│ -├── monitoring/ -│ └── prometheus.yml +│ │ └── java/com/monew/monew_monitor/ +│ └── build.gradle │ ├── .dockerignore ├── .env @@ -241,7 +243,7 @@ monew-backend/ ├── Dockerfile.multi ├── gradlew ├── gradlew.bat -├── [README.md](http://readme.md/) +├── README.md └── settings.gradle - ``` + From 4792790d094bccd6f4c1d5673b31f677d0fee2dc Mon Sep 17 00:00:00 2001 From: Jaehyeok Lim <217686834+JaehyeokLim@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:37:31 +0900 Subject: [PATCH 178/178] Update README.md --- README.md | 30 +++++------------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 4642fbd..8ef75be 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,6 @@ https://github.com/user-attachments/assets/e1d18b5b-4470-41db-b318-1a91ae83fef4 * Docker & AWS ECS 배포 환경 구성 * GitHub Actions 통한 자동 배포(CI/CD) 파이프라인 구축 * Prometheus + Grafana 기반 모니터링 환경 구성 -* DDL 설계 > GitHub: [🔗 chanhyeok0201](https://github.com/chanhyeok0201) @@ -111,35 +110,16 @@ https://github.com/user-attachments/assets/e1d18b5b-4470-41db-b318-1a91ae83fef4
-## ⚙️ 시스템 아키텍처 - -**데이터 성격에 따라 저장소를 분리하고, 클라우드 기반 자동 배포 및 모니터링 환경 구축** - -* **PostgreSQL** → 핵심 서비스 데이터 (회원, 기사, 댓글, 관심사) -* **MongoDB** → 조회가 빈번한 사용자 활동 캐시 -* **H2** → 테스트 데이터 저장 -* **S3** → 뉴스 백업 및 복구 파일 저장 -* **Spring Batch** → 정기 뉴스 수집 및 데이터 정제 -* **Prometheus + Grafana + Actuator** → 메트릭 수집 및 서비스 상태 모니터링 (정영진, 김찬혁 담당) -* **GitHub Actions + ECS + ALB** → CI/CD 자동화 및 트래픽 분산 (김찬혁, 정영진 담당) - -
- -## 🔍 트러블슈팅 요약 +### ERD -| **담당자** | **구분** | **문제 상황** | **원인** | **해결 방법** | -| :------ | :----- | :------------------------- | :---------------------------- | :------------------------------------------------- | -| 강문영 | 알림 | 알림 전체 확인 시 N+1 문제 | Dirty Checking으로 인한 다중 Update | JPQL Bulk Update로 개선 (92% 속도 향상) | -| 임재혁 | 키워드 | Keyword 중복 및 참조 불일치 | 비정규화된 ArticleKeywordLogs 구조 | InterestArticleKeywords 도입 및 JSON 기반 백업 구조로 통합 | -| 임재혁 | 배치 | 멀티스레드 환경 중복키 충돌 | JPA Save()의 SELECT→INSERT 구조 | PostgreSQL `ON CONFLICT DO NOTHING` + JDBC 전환으로 해결 | -| 정영진 | 활동 내역 | 여러 Repository 호출로 인한 쿼리 병목 | 다중 쿼리 구조로 인한 성능 저하 | CTE 기반 단일 쿼리 통합 (15% 성능 개선) | -| 정영진 | DB 부하 | PostgreSQL 단일 처리로 부하 집중 | 배치와 서비스 요청 혼재 | MongoDB를 캐시 DB로 분리 (P99 지연 27% 개선) | +Monew (2) 1
-### ERD +## ⚙️ 시스템 아키텍처 -Monew (2) 1 +**데이터 성격에 따라 저장소를 분리하고, 클라우드 기반 자동 배포 및 모니터링 환경 구축** +스크린샷 2025-11-12 오후 3 35 30