diff --git a/.env b/.env index 9c1e6f17..26c1f02f 100644 --- a/.env +++ b/.env @@ -21,6 +21,9 @@ DOCKER_GID=999 AI_USERNAME=postgres_ai AI_DB_PASSWORD=dinhanst2832004 AI_DATABASE=ai_db +POST_USERNAME=postgres_post +POST_DB_PASSWORD=dinhanst2832004 +POST_DATABASE=post_db #Redis REDIS_PASSWORD=dinhanst2832004 #Neo4j diff --git a/.idea/compiler.xml b/.idea/compiler.xml index c9d19361..0adf030b 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -18,6 +18,15 @@ + + + + + + + + + @@ -31,6 +40,7 @@ + @@ -58,7 +68,9 @@ + + diff --git a/.idea/encodings.xml b/.idea/encodings.xml index a715e768..4895500e 100644 --- a/.idea/encodings.xml +++ b/.idea/encodings.xml @@ -10,6 +10,8 @@ + + diff --git a/.vs/VSWorkspaceState.json b/.vs/VSWorkspaceState.json new file mode 100644 index 00000000..1df61109 --- /dev/null +++ b/.vs/VSWorkspaceState.json @@ -0,0 +1,9 @@ +{ + "ExpandedNodes": [ + "", + "\\FileService", + "\\FileService\\FileService.Api" + ], + "SelectedNode": "\\FileService\\FileService.sln", + "PreviewInSolutionExplorer": false +} \ No newline at end of file diff --git a/.vs/backend/FileContentIndex/4a76a1dd-5482-471a-9378-46b89c11dd52.vsidx b/.vs/backend/FileContentIndex/4a76a1dd-5482-471a-9378-46b89c11dd52.vsidx new file mode 100644 index 00000000..06265279 Binary files /dev/null and b/.vs/backend/FileContentIndex/4a76a1dd-5482-471a-9378-46b89c11dd52.vsidx differ diff --git a/.vs/backend/FileContentIndex/b3582c71-e689-4300-a0e2-16d2db0ab231.vsidx b/.vs/backend/FileContentIndex/b3582c71-e689-4300-a0e2-16d2db0ab231.vsidx new file mode 100644 index 00000000..898b42aa Binary files /dev/null and b/.vs/backend/FileContentIndex/b3582c71-e689-4300-a0e2-16d2db0ab231.vsidx differ diff --git a/.vs/backend/v17/.wsuo b/.vs/backend/v17/.wsuo new file mode 100644 index 00000000..fba11942 Binary files /dev/null and b/.vs/backend/v17/.wsuo differ diff --git a/.vs/backend/v17/DocumentLayout.backup.json b/.vs/backend/v17/DocumentLayout.backup.json new file mode 100644 index 00000000..133085a6 --- /dev/null +++ b/.vs/backend/v17/DocumentLayout.backup.json @@ -0,0 +1,37 @@ +{ + "Version": 1, + "WorkspaceRootPath": "D:\\CNTT\\Capstone Project\\backend\\", + "Documents": [ + { + "AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|D:\\CNTT\\Capstone Project\\backend\\FileService\\FileService.Api\\Program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}", + "RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:FileService\\FileService.Api\\Program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}" + } + ], + "DocumentGroupContainers": [ + { + "Orientation": 0, + "VerticalTabListWidth": 256, + "DocumentGroups": [ + { + "DockedWidth": 200, + "SelectedChildIndex": 0, + "Children": [ + { + "$type": "Document", + "DocumentIndex": 0, + "Title": "Program.cs", + "DocumentMoniker": "D:\\CNTT\\Capstone Project\\backend\\FileService\\FileService.Api\\Program.cs", + "RelativeDocumentMoniker": "FileService\\FileService.Api\\Program.cs", + "ToolTip": "D:\\CNTT\\Capstone Project\\backend\\FileService\\FileService.Api\\Program.cs", + "RelativeToolTip": "FileService\\FileService.Api\\Program.cs", + "ViewState": "AgIAAAkAAAAAAAAAAAAAABMAAAAoAAAAAAAAAA==", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|", + "WhenOpened": "2025-08-12T20:18:19.009Z", + "EditorCaption": "" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.vs/backend/v17/DocumentLayout.json b/.vs/backend/v17/DocumentLayout.json new file mode 100644 index 00000000..133085a6 --- /dev/null +++ b/.vs/backend/v17/DocumentLayout.json @@ -0,0 +1,37 @@ +{ + "Version": 1, + "WorkspaceRootPath": "D:\\CNTT\\Capstone Project\\backend\\", + "Documents": [ + { + "AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|D:\\CNTT\\Capstone Project\\backend\\FileService\\FileService.Api\\Program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}", + "RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:FileService\\FileService.Api\\Program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}" + } + ], + "DocumentGroupContainers": [ + { + "Orientation": 0, + "VerticalTabListWidth": 256, + "DocumentGroups": [ + { + "DockedWidth": 200, + "SelectedChildIndex": 0, + "Children": [ + { + "$type": "Document", + "DocumentIndex": 0, + "Title": "Program.cs", + "DocumentMoniker": "D:\\CNTT\\Capstone Project\\backend\\FileService\\FileService.Api\\Program.cs", + "RelativeDocumentMoniker": "FileService\\FileService.Api\\Program.cs", + "ToolTip": "D:\\CNTT\\Capstone Project\\backend\\FileService\\FileService.Api\\Program.cs", + "RelativeToolTip": "FileService\\FileService.Api\\Program.cs", + "ViewState": "AgIAAAkAAAAAAAAAAAAAABMAAAAoAAAAAAAAAA==", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|", + "WhenOpened": "2025-08-12T20:18:19.009Z", + "EditorCaption": "" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.vs/backend/v17/workspaceFileList.bin b/.vs/backend/v17/workspaceFileList.bin new file mode 100644 index 00000000..4f8d79fb Binary files /dev/null and b/.vs/backend/v17/workspaceFileList.bin differ diff --git a/.vs/slnx.sqlite b/.vs/slnx.sqlite new file mode 100644 index 00000000..c2dedd8c Binary files /dev/null and b/.vs/slnx.sqlite differ diff --git a/coding-service/src/main/java/com/codecampus/coding/exception/ErrorCode.java b/coding-service/src/main/java/com/codecampus/coding/exception/ErrorCode.java index 85a31f5f..6f595a88 100644 --- a/coding-service/src/main/java/com/codecampus/coding/exception/ErrorCode.java +++ b/coding-service/src/main/java/com/codecampus/coding/exception/ErrorCode.java @@ -48,6 +48,8 @@ public enum ErrorCode { USER_ALREADY_EXISTS(4098101, CONFLICT_STATUS, "Người dùng đã tồn tại!", CONFLICT), + // 410 + ; private final int code; diff --git a/docker-compose.prod-infra.yml b/docker-compose.prod-infra.yml index fa406fad..2ccfd6e5 100644 --- a/docker-compose.prod-infra.yml +++ b/docker-compose.prod-infra.yml @@ -22,6 +22,26 @@ services: retries: 5 networks: [ backend ] + post-db: + image: bitnami/postgresql:latest + container_name: post-db + restart: unless-stopped + environment: + - POSTGRESQL_USERNAME=${POST_USERNAME} + - POSTGRESQL_PASSWORD=${POST_DB_PASSWORD} + - POSTGRESQL_DATABASE=${POST_DATABASE} + - POSTGRESQL_POSTGRES_PASSWORD=${POST_DB_PASSWORD} + ports: + - "5439:5432" +# volumes: +# - post_pg_data:/bitnami/postgresql + healthcheck: + test: [ "CMD-SHELL","pg_isready -U postgres" ] + interval: 10s + timeout: 5s + retries: 5 + networks: [ backend ] + quiz-db: image: bitnami/postgresql:latest container_name: quiz-db diff --git a/pom.xml b/pom.xml index 7a2818d6..e2480204 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,7 @@ search-service ai-service chat-service + post-service diff --git a/post-service/.gitattributes b/post-service/.gitattributes new file mode 100644 index 00000000..3b41682a --- /dev/null +++ b/post-service/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/post-service/.gitignore b/post-service/.gitignore new file mode 100644 index 00000000..667aaef0 --- /dev/null +++ b/post-service/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/post-service/.mvn/wrapper/maven-wrapper.properties b/post-service/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..12fbe1e9 --- /dev/null +++ b/post-service/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip diff --git a/post-service/mvnw b/post-service/mvnw new file mode 100644 index 00000000..19529ddf --- /dev/null +++ b/post-service/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/post-service/mvnw.cmd b/post-service/mvnw.cmd new file mode 100644 index 00000000..249bdf38 --- /dev/null +++ b/post-service/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/post-service/pom.xml b/post-service/pom.xml new file mode 100644 index 00000000..5300f25b --- /dev/null +++ b/post-service/pom.xml @@ -0,0 +1,337 @@ + + + 4.0.0 + + com.codecampus + codecampus + 0.0.1-SNAPSHOT + ../pom.xml + + + post-service + post + Post Service Repository + + + + + + + + + + + + + + + + 21 + UTF-8 + ${java.version} + ${java.version} + + + 1.72.0 + 4.30.2 + 2024.0.1 + + 1.18.38 + 1.6.3 + 0.2.0 + + 2.19.0 + 10.3 + 6.5.0 + 0.8.13 + 3.1.0.RELEASE + + + 0.6.1 + 1.7.1 + 3.12.1 + + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + io.github.openfeign.form + feign-form + 3.8.0 + + + io.github.openfeign.form + feign-form-spring + 3.8.0 + + + org.springframework.kafka + spring-kafka + + + + + net.devh + grpc-server-spring-boot-starter + ${grpc.server.starter.version} + + + io.grpc + grpc-services + ${grpc.version} + + + io.grpc + grpc-stub + ${grpc.version} + + + io.grpc + grpc-netty-shaded + ${grpc.version} + + + io.grpc + grpc-protobuf + ${grpc.version} + + + io.grpc + grpc-census + ${grpc.version} + + + com.google.protobuf + protobuf-java + ${protobuf.version} + + + com.codecampus + common-protos + ${project.version} + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + + + + + com.nimbusds + nimbus-jose-jwt + ${nimbus-jose.version} + + + org.springframework.security + spring-security-oauth2-jose + ${security-oauth2-jose.version} + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + com.h2database + h2 + runtime + + + org.postgresql + postgresql + runtime + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.grpc + spring-grpc-test + test + + + org.springframework.kafka + spring-kafka-test + test + + + org.springframework.security + spring-security-test + test + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.plugin.version} + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.projectlombok + lombok-mapstruct-binding + ${lombok.mapstruct.binding.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + --enable-preview + -Amapstruct.defaultComponentModel=spring + -Amapstruct.verbose=true + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + + prepare-agent + + + + report + prepare-package + + report + + + + + + com/codecampus/post/dto/** + com/codecampus/post/entity/** + com/codecampus/post/mapper/** + com/codecampus/post/configuration/** + + + + + + + kr.motd.maven + os-maven-plugin + ${os.maven.plugin.version} + + + detect + initialize + + detect + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + org.projectlombok + lombok + + + + + + + diff --git a/post-service/src/main/java/com/codecampus/post/Mapper/PostMapper.java b/post-service/src/main/java/com/codecampus/post/Mapper/PostMapper.java new file mode 100644 index 00000000..e05bdbc8 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/Mapper/PostMapper.java @@ -0,0 +1,23 @@ +package com.codecampus.post.Mapper; + + +import com.codecampus.post.dto.request.PostRequestDto; +import com.codecampus.post.entity.Post; +import org.mapstruct.BeanMapping; +import org.mapstruct.Mapper; +import org.mapstruct.MappingTarget; +import org.mapstruct.NullValuePropertyMappingStrategy; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface PostMapper { + PostRequestDto toDto(Post post); + List toDtoList(List posts); + + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + Post toEntity(PostRequestDto postRequestDto); + + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + void updateEntityFromDto(PostRequestDto postRequestDto, @MappingTarget Post post); +} diff --git a/post-service/src/main/java/com/codecampus/post/PostServiceApplication.java b/post-service/src/main/java/com/codecampus/post/PostServiceApplication.java new file mode 100644 index 00000000..33095ed9 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/PostServiceApplication.java @@ -0,0 +1,15 @@ +package com.codecampus.post; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; + +@SpringBootApplication +@EnableFeignClients(basePackages = "com.codecampus.post.service.FeignConfig") +public class PostServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(PostServiceApplication.class, args); + } + +} diff --git a/post-service/src/main/java/com/codecampus/post/config/AuthenticationRequestInterceptor.java b/post-service/src/main/java/com/codecampus/post/config/AuthenticationRequestInterceptor.java new file mode 100644 index 00000000..0b6b6536 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/config/AuthenticationRequestInterceptor.java @@ -0,0 +1,44 @@ +package com.codecampus.post.config; + +import feign.RequestInterceptor; +import feign.RequestTemplate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * Feign RequestInterceptor để tự động thêm header Authorization + * của HTTP request hiện tại vào mọi request gửi đi qua Feign client. + * + *

Khi có ServletRequestAttributes từ RequestContextHolder, + * lấy header "Authorization" từ HttpServletRequest và + * thêm vào RequestTemplate; nếu header rỗng, bỏ qua.

+ */ +@Slf4j +public class AuthenticationRequestInterceptor + implements RequestInterceptor { + /** + * Phương thức được gọi trước khi Feign gửi request. + * + *
    + *
  1. Lấy ServletRequestAttributes từ RequestContextHolder.
  2. + *
  3. Nếu tồn tại, lấy HttpServletRequest và trích header "Authorization".
  4. + *
  5. Nếu header có giá trị, thêm vào RequestTemplate của Feign.
  6. + *
+ * + * @param requestTemplate template của request chuẩn bị gửi đi + */ + @Override + public void apply(RequestTemplate requestTemplate) { + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + + var authHeader = attributes.getRequest().getHeader("Authorization"); + + log.info("Header: {}", authHeader); + if (StringUtils.hasText(authHeader)) { + requestTemplate.header("Authorization", authHeader); + } + } +} diff --git a/post-service/src/main/java/com/codecampus/post/config/CustomJwtDecoder.java b/post-service/src/main/java/com/codecampus/post/config/CustomJwtDecoder.java new file mode 100644 index 00000000..c29afefc --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/config/CustomJwtDecoder.java @@ -0,0 +1,50 @@ +package com.codecampus.post.config; + +import com.nimbusds.jwt.SignedJWT; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Component; + +import java.text.ParseException; + +/** + * JwtDecoder tùy chỉnh sử dụng thư viện Nimbus để phân tích và giải mã JWT. + * + *

Phương thức {@link #decode} sẽ: + *

    + *
  1. Parse chuỗi token thành đối tượng SignedJWT.
  2. + *
  3. Trích xuất thời gian phát hành (issueTime) và thời gian hết hạn (expirationTime)
  4. + * dưới dạng Instant. + *
  5. Tạo đối tượng {@link Jwt} của Spring Security với các thông tin: + * token gốc, lần phát hành, thời gian hết hạn, header và claims của JWT.
  6. + *
  7. Ném {@link JwtException} nếu không thể parse hoặc token không hợp lệ.
  8. + *
+ *

+ */ +@Component +public class CustomJwtDecoder implements JwtDecoder { + /** + * Giải mã chuỗi JWT và trả về đối tượng Jwt chứa thông tin claims. + * + * @param token chuỗi JWT đã được ký (không bao gồm tiền tố Bearer) + * @return đối tượng Jwt chứa token gốc, thời gian phát hành, hết hạn, header và claims + * @throws JwtException nếu token không hợp lệ hoặc không parse được + */ + @Override + public Jwt decode(String token) throws JwtException { + try { + SignedJWT signedJWT = SignedJWT.parse(token); + + return new Jwt( + token, + signedJWT.getJWTClaimsSet().getIssueTime().toInstant(), + signedJWT.getJWTClaimsSet().getExpirationTime().toInstant(), + signedJWT.getHeader().toJSONObject(), + signedJWT.getJWTClaimsSet().getClaims() + ); + } catch (ParseException e) { + throw new JwtException("Invalid token"); + } + } +} diff --git a/post-service/src/main/java/com/codecampus/post/config/DualPortConfig.java b/post-service/src/main/java/com/codecampus/post/config/DualPortConfig.java new file mode 100644 index 00000000..c1b786ce --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/config/DualPortConfig.java @@ -0,0 +1,29 @@ +//package com.codecampus.coding.config; +// +//import org.apache.catalina.connector.Connector; +//import org.springframework.beans.factory.annotation.Value; +//import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +//import org.springframework.boot.web.servlet.server.ServletWebServerFactory; +//import org.springframework.context.annotation.Bean; +//import org.springframework.context.annotation.Configuration; +// +//@Configuration +//public class DualPortConfig { +// @Value("${server.http.port}") +// private int httpPort; +// +// @Bean +// public ServletWebServerFactory servletContainer() { +// // Tomcat là default container của Spring Boot Starter Web +// TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(); +// +// Connector httpConnector = +// new Connector(TomcatServletWebServerFactory.DEFAULT_PROTOCOL); +// httpConnector.setScheme("http"); +// httpConnector.setPort(httpPort); +// httpConnector.setSecure(false); +// +// factory.addAdditionalTomcatConnectors(httpConnector); +// return factory; +// } +//} diff --git a/post-service/src/main/java/com/codecampus/post/config/FeignConfiguration.java b/post-service/src/main/java/com/codecampus/post/config/FeignConfiguration.java new file mode 100644 index 00000000..e2513323 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/config/FeignConfiguration.java @@ -0,0 +1,28 @@ +package com.codecampus.post.config; + +import feign.codec.Encoder; +import feign.form.spring.SpringFormEncoder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Cấu hình cho Feign client để hỗ trợ gửi dữ liệu dạng multipart/form-data. + * + *

Bean {@link Encoder} được đăng ký sử dụng + * {@link SpringFormEncoder} giúp Feign mã hóa các request + * có nội dung form hoặc file upload một cách tự động. + *

+ */ +@Configuration +public class FeignConfiguration { + /** + * Đăng ký {@link Encoder} tùy chỉnh cho Feign, + * sử dụng {@link SpringFormEncoder} để mã hóa multipart/form-data. + * + * @return đối tượng Encoder hỗ trợ form encoding + */ + @Bean + public Encoder multipartFormEncoder() { + return new SpringFormEncoder(); + } +} diff --git a/post-service/src/main/java/com/codecampus/post/config/JwtAuthenticationEntryPoint.java b/post-service/src/main/java/com/codecampus/post/config/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..64bfe13e --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/config/JwtAuthenticationEntryPoint.java @@ -0,0 +1,66 @@ +package com.codecampus.post.config; + +import com.codecampus.post.dto.common.ApiResponse; +import com.codecampus.post.exception.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; + +/** + * Entry point tùy chỉnh cho Spring Security khi phát hiện yêu cầu không được xác thực. + * + *

Khi quá trình xác thực thất bại (ví dụ thiếu token hoặc token không hợp lệ), + * phương thức {@link #commence} sẽ được gọi để trả về phản hồi HTTP 401 với + * payload JSON chứa mã lỗi và thông điệp tương ứng.

+ */ +public class JwtAuthenticationEntryPoint + implements AuthenticationEntryPoint { + /** + * Xử lý phản hồi khi yêu cầu không được xác thực. + * + *

Thiết lập: + *

    + *
  • Status HTTP = 401 (Unauthorized).
  • + *
  • Content-Type = application/json.
  • + *
  • Body JSON bao gồm: + *
      + *
    • code: mã lỗi từ {@link ErrorCode#UNAUTHENTICATED}.
    • + *
    • message: thông điệp lỗi tương ứng.
    • + *
    + *
  • + *
+ *

+ * + * @param request đối tượng {@link HttpServletRequest} của client + * @param response đối tượng {@link HttpServletResponse} để gửi phản hồi + * @param authException ngoại lệ xác thực đã xảy ra + * @throws IOException nếu ghi response gặp lỗi I/O + */ + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) + throws IOException { + ErrorCode errorCode = ErrorCode.UNAUTHENTICATED; + + response.setStatus(errorCode.getStatusCode().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + ApiResponse apiResponse = ApiResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + + ObjectMapper objectMapper = new ObjectMapper(); + + response.getWriter() + .write(objectMapper.writeValueAsString(apiResponse)); + response.flushBuffer(); + } +} diff --git a/post-service/src/main/java/com/codecampus/post/config/SecurityConfig.java b/post-service/src/main/java/com/codecampus/post/config/SecurityConfig.java new file mode 100644 index 00000000..bec1abc3 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/config/SecurityConfig.java @@ -0,0 +1,167 @@ +package com.codecampus.post.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +import static com.codecampus.post.constant.config.SecurityConfigConstant.ACCEPT_HEADER; +import static com.codecampus.post.constant.config.SecurityConfigConstant.AUTHORIZATION_HEADER; +import static com.codecampus.post.constant.config.SecurityConfigConstant.CONTENT_TYPE_HEADER; +import static com.codecampus.post.constant.config.SecurityConfigConstant.DELETE_METHOD; +//import static com.codecampus.post.constant.config.SecurityConfigConstant.FRONTEND_ENDPOINT; +//import static com.codecampus.post.constant.config.SecurityConfigConstant.FRONTEND_ENDPOINT2; +//import static com.codecampus.post.constant.config.SecurityConfigConstant.FRONTEND_ENDPOINT3; +import static com.codecampus.post.constant.config.SecurityConfigConstant.GET_METHOD; +import static com.codecampus.post.constant.config.SecurityConfigConstant.OPTIONS_METHOD; +import static com.codecampus.post.constant.config.SecurityConfigConstant.PATCH_METHOD; +import static com.codecampus.post.constant.config.SecurityConfigConstant.POST_METHOD; +import static com.codecampus.post.constant.config.SecurityConfigConstant.PUBLIC_ENDPOINTS; +import static com.codecampus.post.constant.config.SecurityConfigConstant.PUT_METHOD; +import static com.codecampus.post.constant.config.SecurityConfigConstant.URL_PATTERN_ALL; + +/** + * Cấu hình bảo mật cho Profile Service sử dụng Spring Security. + * + *

Bao gồm: + *

    + *
  • Định nghĩa các endpoint công khai (không yêu cầu xác thực).
  • + *
  • Cấu hình OAuth2 Resource Server sử dụng JWT với bộ giải mã tùy chỉnh và entry point xử lý lỗi.
  • + *
  • Tắt CSRF để phù hợp với API REST.
  • + *
  • Thiết lập CORS cho các origin, method và header được phép.
  • + *
  • Cấu hình converter để chuyển đổi JWT claims thành GrantedAuthority.
  • + *
+ *

+ */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + @Autowired + private CustomJwtDecoder customJwtDecoder; + + /** + * Cấu hình SecurityFilterChain cho HTTP security. + * + *

Thiết lập: + *

    + *
  • Cho phép truy cập PUBLIC_ENDPOINTS không cần xác thực.
  • + *
  • Các request khác phải xác thực JWT.
  • + *
  • Sử dụng customJwtDecoder và jwtAuthenticationConverter để xử lý JWT.
  • + *
  • Sử dụng JwtAuthenticationEntryPoint để trả về 401 khi xác thực thất bại.
  • + *
  • Tắt CSRF.
  • + *
+ *

+ * + * @param httpSecurity đối tượng HttpSecurity để cấu hình + * @return SecurityFilterChain đã cấu hình + * @throws Exception nếu cấu hình gặp lỗi + */ + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity httpSecurity) throws Exception { + httpSecurity.authorizeHttpRequests(request -> request + .requestMatchers(PUBLIC_ENDPOINTS) + .permitAll() + .anyRequest() + .authenticated()); + + httpSecurity.oauth2ResourceServer( + oauth2 -> oauth2.jwt(jwtConfigurer -> jwtConfigurer + .decoder(customJwtDecoder) + .jwtAuthenticationConverter( + jwtAuthenticationConverter())) + .authenticationEntryPoint( + new JwtAuthenticationEntryPoint())); + httpSecurity.csrf(AbstractHttpConfigurer::disable); + + return httpSecurity.build(); + } + + /** + * Cấu hình CORS cho ứng dụng. + *

+ * Phương thức này thiết lập: + *

    + *
  • Các origin được phép truy cập (danh sách FRONTEND_ENDPOINT, FRONTEND_ENDPOINT2, FRONTEND_ENDPOINT3).
  • + *
  • Các phương thức HTTP được phép (GET, POST, PUT, DELETE, PATCH, OPTIONS).
  • + *
  • Cho phép tất cả các header.
  • + *
  • Cho phép gửi credentials.
  • + *
  • Các header được expose (ví dụ: "Authorization").
  • + *
+ * Áp dụng cấu hình này cho tất cả các endpoint. + *

+ * + * @return CorsConfigurationSource chứa cấu hình CORS của ứng dụng + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + +// // Cho phép các origin truy cập định nghĩa sẵn +// configuration.setAllowedOrigins( +// List.of(FRONTEND_ENDPOINT, FRONTEND_ENDPOINT2, +// FRONTEND_ENDPOINT3) +// ); + + // Cho phép các phương thức HTTP được định nghĩa + configuration.setAllowedMethods( + List.of(GET_METHOD, POST_METHOD, PUT_METHOD, DELETE_METHOD, + PATCH_METHOD, OPTIONS_METHOD)); + + // Cho phép tất cả các header + configuration.setAllowedHeaders( + List.of(AUTHORIZATION_HEADER, CONTENT_TYPE_HEADER, + ACCEPT_HEADER)); + + // Cho phép gửi credentials (cookie, header, v.v.) + configuration.setAllowCredentials(true); + + // Expose header "Authorization" + configuration.setExposedHeaders(List.of(AUTHORIZATION_HEADER)); + + // Thời gian cache preflight request + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = + new UrlBasedCorsConfigurationSource(); + + // Áp dụng cấu hình cho tất cả các endpoint + source.registerCorsConfiguration(URL_PATTERN_ALL, configuration); + return source; + } + + /** + * Tạo {@link JwtAuthenticationConverter} để chuyển đổi JWT claims thành GrantedAuthority. + * Loại bỏ prefix mặc định (ví dụ "SCOPE_"). + * + * @return JwtAuthenticationConverter đã cấu hình + */ + @Bean + JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter authConverter = + new JwtGrantedAuthoritiesConverter(); + authConverter.setAuthorityPrefix(""); + + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter(authConverter); + + // Principal = claim "username" + converter.setPrincipalClaimName("username"); + + return converter; + } +} diff --git a/post-service/src/main/java/com/codecampus/post/config/audit/JpaAuditConfig.java b/post-service/src/main/java/com/codecampus/post/config/audit/JpaAuditConfig.java new file mode 100644 index 00000000..bfba3045 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/config/audit/JpaAuditConfig.java @@ -0,0 +1,31 @@ +package com.codecampus.post.config.audit; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.AuditorAware; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +@Configuration +@EnableJpaAuditing(auditorAwareRef = "auditorProvider") +public class JpaAuditConfig { + /** + * Trả về username hiện tại (hoặc “system” nếu chưa đăng nhập). + * Spring Data sẽ dùng giá trị này để gán vào @CreatedBy / @LastModifiedBy. + */ + @Bean + public AuditorAware auditorProvider() { + return () -> { + Authentication auth = SecurityContextHolder + .getContext() + .getAuthentication(); + + return Optional.ofNullable(auth) + .map(Authentication::getName) + .or(() -> Optional.of("system")); + }; + } +} diff --git a/post-service/src/main/java/com/codecampus/post/config/audit/SoftDeleteInterceptor.java b/post-service/src/main/java/com/codecampus/post/config/audit/SoftDeleteInterceptor.java new file mode 100644 index 00000000..760d06a1 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/config/audit/SoftDeleteInterceptor.java @@ -0,0 +1,38 @@ +package com.codecampus.post.config.audit; + +import com.codecampus.post.entity.audit.SoftDeletable; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.EmptyInterceptor; +import org.hibernate.type.Type; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.Instant; + +@Slf4j +public class SoftDeleteInterceptor extends EmptyInterceptor { + @Override + public void onDelete( + Object entity, + Object id, + Object[] state, + String[] propertyNames, + Type[] types) { + if (entity instanceof SoftDeletable soft) { + // Chặn DELETE -> UPDATE + soft.markDeleted(SecurityContextHolder + .getContext() + .getAuthentication() + .getName()); + + // Đẩy giá trị vào state[] để Hibernate cập nhật DB + for (int i = 0; i < propertyNames.length; i++) { + switch (propertyNames[i]) { + case "deletedBy" -> state[i] = soft.getDeletedBy(); + case "deletedAt" -> state[i] = Instant.now(); + } + } + } else { + super.onDelete(entity, id, state, propertyNames, types); + } + } +} diff --git a/post-service/src/main/java/com/codecampus/post/constant/PostAccessEnum.java b/post-service/src/main/java/com/codecampus/post/constant/PostAccessEnum.java new file mode 100644 index 00000000..b50fa057 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/constant/PostAccessEnum.java @@ -0,0 +1,6 @@ +package com.codecampus.post.constant; + +public enum PostAccessEnum { + PUBLIC, + PRIVATE, +} diff --git a/post-service/src/main/java/com/codecampus/post/constant/config/SecurityConfigConstant.java b/post-service/src/main/java/com/codecampus/post/constant/config/SecurityConfigConstant.java new file mode 100644 index 00000000..3a6bbfc6 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/constant/config/SecurityConfigConstant.java @@ -0,0 +1,35 @@ +package com.codecampus.post.constant.config; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor +public class SecurityConfigConstant { + // HEADER REQUEST + public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String CONTENT_TYPE_HEADER = "Content-Type"; + public static final String ACCEPT_HEADER = "Accept"; + + + // PATTERN REQUEST + public static final String URL_PATTERN_ALL = "/**"; + + // PUBLIC ENDPOINT + public static final String[] PUBLIC_ENDPOINTS = { + "/hello", + "/health", + "/actuator/**", + }; + +// // FRONTEND ENDPOINT +// public static final String FRONTEND_ENDPOINT = "http://192.168.1.30:4200"; +// public static final String FRONTEND_ENDPOINT2 = "http://localhost:4200"; +// public static final String FRONTEND_ENDPOINT3 = "http://127.0.0.1:5500"; + + // METHOD ALLOWED + public static String GET_METHOD = "GET"; + public static String POST_METHOD = "POST"; + public static String DELETE_METHOD = "DELETE"; + public static String PUT_METHOD = "PUT"; + public static String PATCH_METHOD = "PATCH"; + public static String OPTIONS_METHOD = "OPTIONS"; +} diff --git a/post-service/src/main/java/com/codecampus/post/constant/exception/ErrorCodeConstant.java b/post-service/src/main/java/com/codecampus/post/constant/exception/ErrorCodeConstant.java new file mode 100644 index 00000000..86a814e6 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/constant/exception/ErrorCodeConstant.java @@ -0,0 +1,20 @@ +package com.codecampus.post.constant.exception; + +import lombok.NoArgsConstructor; + +import static com.sun.source.doctree.DocTree.Kind.AUTHOR; +import static io.grpc.Status.NOT_FOUND; +import static org.springframework.http.HttpStatus.*; + +@NoArgsConstructor +public class ErrorCodeConstant { + public static final String INTERNAL_SERVER_STATUS = + INTERNAL_SERVER_ERROR.toString(); + public static final String BAD_REQUEST_STATUS = BAD_REQUEST.toString(); + public static final String UNAUTHORIZED_STATUS = + org.springframework.http.HttpStatus.UNAUTHORIZED.toString(); + public static final String FORBIDDEN_STATUS = FORBIDDEN.toString(); + public static final String NOT_FOUND_STATUS = NOT_FOUND.toString(); + public static final String CONFLICT_STATUS = CONFLICT.toString(); + public static final String AUTHOR_STATUS = AUTHOR.toString(); +} diff --git a/post-service/src/main/java/com/codecampus/post/controller/PostAccessController.java b/post-service/src/main/java/com/codecampus/post/controller/PostAccessController.java new file mode 100644 index 00000000..97e9ad17 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/controller/PostAccessController.java @@ -0,0 +1,44 @@ +package com.codecampus.post.controller; + +import com.codecampus.post.dto.common.ApiResponse; +import com.codecampus.post.dto.request.PostAccessRequestDto; +import com.codecampus.post.entity.PostAccess; +import com.codecampus.post.service.PostAccessService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/postAccess") +@RequiredArgsConstructor +public class PostAccessController { + + private final PostAccessService postAccessService; + + @PostMapping("/addAccess") + public ResponseEntity addOrUpdateAccess(@RequestBody PostAccessRequestDto dto) { + postAccessService.saveOrUpdateAccess(dto); + return ResponseEntity.ok(ApiResponse.builder() + .message("Access list updated successfully") + .build()); + } + + @DeleteMapping("/deleteAccess") + public ResponseEntity deleteAccess( + @RequestBody PostAccessRequestDto userIds) { + postAccessService.deleteAccess( userIds); + return ResponseEntity.ok(ApiResponse.builder() + .message("Access list deleted successfully") + .build()); + } + + @GetMapping("/getPostAccess/{postId}") + public ResponseEntity getAccessList(@PathVariable String postId) { + return ResponseEntity.ok(ApiResponse.builder() + .message("Access list retrieved successfully") + .result(postAccessService.getAccessByPostId(postId)) + .build()); + } +} diff --git a/post-service/src/main/java/com/codecampus/post/controller/PostCommentController.java b/post-service/src/main/java/com/codecampus/post/controller/PostCommentController.java new file mode 100644 index 00000000..7ed27667 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/controller/PostCommentController.java @@ -0,0 +1,80 @@ +package com.codecampus.post.controller; + +import com.codecampus.post.dto.common.ApiResponse; +import com.codecampus.post.dto.request.CommentRequestDto; +import com.codecampus.post.dto.request.UpdateCommentDto; +import com.codecampus.post.dto.response.CommentResponseDto; +import com.codecampus.post.entity.PostComment; +import com.codecampus.post.service.PostCommentService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/comments") +@RequiredArgsConstructor +public class PostCommentController { + + private final PostCommentService postCommentService; + + @PostMapping("/addComment") + public ResponseEntity addComment( + @RequestBody CommentRequestDto dto, + HttpServletRequest request + ) { + + PostComment saved = postCommentService.addComment(dto, request); + return ResponseEntity.ok( + ApiResponse.builder() + .message("Thêm bình luận thành công") + .build() + ); + } + + @GetMapping("/getCmtByPostId/{postId}") + public ResponseEntity getComments(@PathVariable String postId) { + List comments = postCommentService.getCommentsByPost(postId); + return ResponseEntity.ok( + ApiResponse.builder() + .message("Lấy bình luận thành công") + .result(comments) + .build() + ); + } + + @PutMapping("/updateComment") + public ResponseEntity updateComment( + @RequestBody UpdateCommentDto requestDto, + HttpServletRequest request + ) { + postCommentService.updateComment(requestDto, request); + return ResponseEntity.ok( + ApiResponse.builder() + .message("Bình luận chỉnh sửa thành công") + .build() + ); + } + + @DeleteMapping("/deleteComment/{commentId}") + public ResponseEntity deleteComment( + @PathVariable String commentId, + HttpServletRequest request + ) { + postCommentService.deleteComment(commentId, request); + return ResponseEntity.ok( + ApiResponse.builder() + .message("Xoá bình luận thành công") + .build() + ); + } + + private String extractUserIdFromToken(HttpServletRequest request) { + String token = request.getHeader("Authorization").substring(7); + // giả sử bạn có customJwtDecoder như ở PostService + return "decodedUserId"; // thay bằng code decode thực tế + } +} + diff --git a/post-service/src/main/java/com/codecampus/post/controller/PostController.java b/post-service/src/main/java/com/codecampus/post/controller/PostController.java new file mode 100644 index 00000000..145be9b1 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/controller/PostController.java @@ -0,0 +1,73 @@ +package com.codecampus.post.controller; + +import com.codecampus.post.dto.common.ApiResponse; +import com.codecampus.post.dto.common.PageRequestDto; +import com.codecampus.post.dto.request.AddFileDocumentDto; +import com.codecampus.post.dto.request.PostRequestDto; +import com.codecampus.post.service.PostService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController() +@RequestMapping("/posts") +public class PostController { + + @Autowired + private PostService postService; + + @GetMapping("/getAllAccessiblePosts") + public ResponseEntity getAllAccessiblePosts(HttpServletRequest request, @RequestBody PageRequestDto pageRequestDto) { + return ResponseEntity.ok(ApiResponse.builder() + .message("Success") + .result(postService.getAllAccessiblePosts(request, pageRequestDto)) + .build()); + } + + @GetMapping("/seachPosts/{searchText}") + public ResponseEntity SeachPosts(@PathVariable("searchText") String searchText, HttpServletRequest request, @RequestBody PageRequestDto pageRequestDto) { + return ResponseEntity.ok(ApiResponse.builder() + .message("Success") + .result(postService.SeachPosts(searchText, request, pageRequestDto)) + .build()); + } + +// @GetMapping("/getPostByIdIfAccessible/{postId}") +// public ResponseEntity getPostByIdIfAccessible(@PathVariable("postId") String postId, HttpServletRequest request) { +// return ResponseEntity.ok(ApiResponse.builder() +// .message("Success") +// .result(postService.getPostByIdIfAccessible(postId, request)) +// .build()); +// } + + @PostMapping("/createPost") + public ResponseEntity createPost( + @ModelAttribute PostRequestDto postRequestDto, + HttpServletRequest request + ) { + postService.createPost(postRequestDto, request); + return ResponseEntity.ok(ApiResponse.builder() + .message("Post created successfully") + .build()); + } + + @PutMapping("/updatePost") + public ResponseEntity updatePost( + @ModelAttribute PostRequestDto postRequestDto, + HttpServletRequest request + ) { + postService.updatePost(postRequestDto, request); + return ResponseEntity.ok(ApiResponse.builder() + .message("Post updated successfully") + .build()); + } + + @PutMapping("/deletePost/{postId}") + public ResponseEntity deletePost(@PathVariable("postId") String postId, HttpServletRequest request) { + postService.deletePost(postId, request); + return ResponseEntity.ok(ApiResponse.builder() + .message("Post deleted successfully") + .build()); + } +} diff --git a/post-service/src/main/java/com/codecampus/post/controller/PostReactionController.java b/post-service/src/main/java/com/codecampus/post/controller/PostReactionController.java new file mode 100644 index 00000000..4062bf56 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/controller/PostReactionController.java @@ -0,0 +1,50 @@ +package com.codecampus.post.controller; + +import com.codecampus.post.dto.common.ApiResponse; +import com.codecampus.post.dto.request.PostReactionRequestDto; +import com.codecampus.post.service.PostReactionService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/postReaction") +@RequiredArgsConstructor +public class PostReactionController { + + private final PostReactionService postReactionService; + + // Toggle reaction (upvote / downvote) + @PostMapping("/toggle") + public ResponseEntity toggleReaction(@RequestBody PostReactionRequestDto requestDto, + HttpServletRequest request) { + postReactionService.toggleReaction(requestDto, request); + return ResponseEntity.ok(ApiResponse.builder() + .message("Reaction toggled successfully") + .build()); + } + + // Get reaction count cho post + @GetMapping("/post/{postId}") + public ResponseEntity getPostReactions(@PathVariable String postId) { + Map counts = postReactionService.getReactionCount(postId, null); + return ResponseEntity.ok(ApiResponse.builder() + .message("Reaction counts retrieved successfully") + .result(counts) + .build()); + } + + // Get reaction count cho comment trong post + @GetMapping("/post/{postId}/comment/{commentId}") + public ResponseEntity getCommentReactions(@PathVariable String postId, + @PathVariable String commentId) { + Map counts = postReactionService.getReactionCount(postId, commentId); + return ResponseEntity.ok(ApiResponse.builder() + .message("Reaction counts retrieved successfully") + .result(counts) + .build()); + } +} diff --git a/post-service/src/main/java/com/codecampus/post/dto/common/ApiResponse.java b/post-service/src/main/java/com/codecampus/post/dto/common/ApiResponse.java new file mode 100644 index 00000000..e108de9e --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/dto/common/ApiResponse.java @@ -0,0 +1,25 @@ +package com.codecampus.post.dto.common; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.*; +import lombok.experimental.FieldDefaults; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiResponse { + // Mã phản hồi mặc định (20000) cho các phản hồi thành công + @Builder.Default + int code = 20000; + + String message; + + // Trạn thái phản hồi mặc định (mã 20000) cho các phản hồi thành công + @Builder.Default + String status = "Thành công!"; + + private T result; +} diff --git a/post-service/src/main/java/com/codecampus/post/dto/common/PageRequestDto.java b/post-service/src/main/java/com/codecampus/post/dto/common/PageRequestDto.java new file mode 100644 index 00000000..e11d3ee4 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/dto/common/PageRequestDto.java @@ -0,0 +1,11 @@ +package com.codecampus.post.dto.common; + +import lombok.Data; + +@Data +public class PageRequestDto { + private int page; + private int size; + private String sortBy; + private String sortDirection; +} diff --git a/post-service/src/main/java/com/codecampus/post/dto/common/PageResponse.java b/post-service/src/main/java/com/codecampus/post/dto/common/PageResponse.java new file mode 100644 index 00000000..3d0d191e --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/dto/common/PageResponse.java @@ -0,0 +1,25 @@ +package com.codecampus.post.dto.common; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Data; +import lombok.experimental.FieldDefaults; + +import java.util.Collections; +import java.util.List; + +@Data +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +// Mặc định cho phép response cả null khi Dev +// Khi build thì KHÔNG response null +//@JsonInclude(JsonInclude.Include.NON_NULL) +public class PageResponse { + int currentPage; + int totalPages; + int pageSize; + long totalElements; + + @Builder.Default + private List data = Collections.emptyList(); +} diff --git a/post-service/src/main/java/com/codecampus/post/dto/request/AddFileDocumentDto.java b/post-service/src/main/java/com/codecampus/post/dto/request/AddFileDocumentDto.java new file mode 100644 index 00000000..2afac42d --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/dto/request/AddFileDocumentDto.java @@ -0,0 +1,21 @@ +package com.codecampus.post.dto.request; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.UUID; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AddFileDocumentDto { + private MultipartFile file; + private String category; // enum truyền dạng string + private String description; + private List tags; + private boolean isLectureVideo = false; + private boolean isTextbook = false; + private UUID orgId; +} diff --git a/post-service/src/main/java/com/codecampus/post/dto/request/CommentRequestDto.java b/post-service/src/main/java/com/codecampus/post/dto/request/CommentRequestDto.java new file mode 100644 index 00000000..e2449c00 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/dto/request/CommentRequestDto.java @@ -0,0 +1,16 @@ +package com.codecampus.post.dto.request; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CommentRequestDto { + private String postId; + private String parentCommentId; // null nếu là comment gốc + private String content; +} diff --git a/post-service/src/main/java/com/codecampus/post/dto/request/PostAccessRequestDto.java b/post-service/src/main/java/com/codecampus/post/dto/request/PostAccessRequestDto.java new file mode 100644 index 00000000..b8fa8531 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/dto/request/PostAccessRequestDto.java @@ -0,0 +1,16 @@ +package com.codecampus.post.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class PostAccessRequestDto { + private String postId; + private List userIds; + private Boolean isExcluded; +} diff --git a/post-service/src/main/java/com/codecampus/post/dto/request/PostReactionRequestDto.java b/post-service/src/main/java/com/codecampus/post/dto/request/PostReactionRequestDto.java new file mode 100644 index 00000000..4d614630 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/dto/request/PostReactionRequestDto.java @@ -0,0 +1,18 @@ +package com.codecampus.post.dto.request; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PostReactionRequestDto { + private String postId; + private String userId; + private String commentId; // for comment reactions, can be null if reacting to the post itself + private String reactionType; // upvote || downvote +} + diff --git a/post-service/src/main/java/com/codecampus/post/dto/request/PostRequestDto.java b/post-service/src/main/java/com/codecampus/post/dto/request/PostRequestDto.java new file mode 100644 index 00000000..aa884929 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/dto/request/PostRequestDto.java @@ -0,0 +1,28 @@ +package com.codecampus.post.dto.request; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + + +@Getter +@Setter +@Builder +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PostRequestDto { + private String postId; // for update post + private String title; + private String orgId; + private String content; + private String oldImgesUrls; // for update post img + private boolean isPublic; + private boolean allowComment; + private String postType; + private String hashtag; + private String status; + private AddFileDocumentDto fileDocument; // for add file document +// private PostAccess postAccess; +} diff --git a/post-service/src/main/java/com/codecampus/post/dto/request/UpdateCommentDto.java b/post-service/src/main/java/com/codecampus/post/dto/request/UpdateCommentDto.java new file mode 100644 index 00000000..9a03c5d6 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/dto/request/UpdateCommentDto.java @@ -0,0 +1,13 @@ +package com.codecampus.post.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class UpdateCommentDto { + private String commentId; + private String content; +} diff --git a/post-service/src/main/java/com/codecampus/post/dto/response/AddFileResponseDto.java b/post-service/src/main/java/com/codecampus/post/dto/response/AddFileResponseDto.java new file mode 100644 index 00000000..16ff566d --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/dto/response/AddFileResponseDto.java @@ -0,0 +1,13 @@ +package com.codecampus.post.dto.response; + +import lombok.Data; + +import java.util.List; + +@Data +public class AddFileResponseDto { + private int code; + private String message; + private String status; + private String result; +} diff --git a/post-service/src/main/java/com/codecampus/post/dto/response/CommentResponseDto.java b/post-service/src/main/java/com/codecampus/post/dto/response/CommentResponseDto.java new file mode 100644 index 00000000..f40abf28 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/dto/response/CommentResponseDto.java @@ -0,0 +1,18 @@ +package com.codecampus.post.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CommentResponseDto { + private String commentId; + private String userId; + private String content; + private List replies; +} + diff --git a/post-service/src/main/java/com/codecampus/post/dto/response/PostAccessResponseDto.java b/post-service/src/main/java/com/codecampus/post/dto/response/PostAccessResponseDto.java new file mode 100644 index 00000000..eb057b5b --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/dto/response/PostAccessResponseDto.java @@ -0,0 +1,12 @@ +package com.codecampus.post.dto.response; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class PostAccessResponseDto { + private String postId; + private String userId; + private boolean isExcluded; +} diff --git a/post-service/src/main/java/com/codecampus/post/entity/Post.java b/post-service/src/main/java/com/codecampus/post/entity/Post.java new file mode 100644 index 00000000..2e87851b --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/entity/Post.java @@ -0,0 +1,41 @@ +package com.codecampus.post.entity; + +import com.codecampus.post.entity.audit.AuditMetadata; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Entity +@Getter +@Setter +@Table(name = "post") +public class Post extends AuditMetadata { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String postId; + + private String userId; + private String orgId; + private String postType; //global, organization, group, etc. + private String title; + private String content; + private Boolean isPublic; + private Boolean allowComment; + private String hashtag; + private String status; + private List imagesUrls; + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL) + @JsonIgnore + private List comments; + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL) + private List reactions; + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL) + private List accesses; +} + diff --git a/post-service/src/main/java/com/codecampus/post/entity/PostAccess.java b/post-service/src/main/java/com/codecampus/post/entity/PostAccess.java new file mode 100644 index 00000000..487d4d5e --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/entity/PostAccess.java @@ -0,0 +1,24 @@ +package com.codecampus.post.entity; + +import com.codecampus.post.entity.audit.AuditMetadata; +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.Fetch; +import org.springframework.context.annotation.Lazy; + +@Entity +@Data +@Table(name = "post_access") +public class PostAccess extends AuditMetadata { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String postAccessId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + private String userId; + private Boolean isExcluded; +} + diff --git a/post-service/src/main/java/com/codecampus/post/entity/PostComment.java b/post-service/src/main/java/com/codecampus/post/entity/PostComment.java new file mode 100644 index 00000000..2d4f12e6 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/entity/PostComment.java @@ -0,0 +1,32 @@ +package com.codecampus.post.entity; + +import com.codecampus.post.entity.audit.AuditMetadata; +import jakarta.persistence.*; +import lombok.Data; + +import java.util.List; + +@Entity +@Data +@Table(name = "post_comment") +public class PostComment extends AuditMetadata { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String commentId; + + private String userId; + private String content; + + + @ManyToOne + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne + @JoinColumn(name = "parent_comment_id") + private PostComment parentComment; // comment reply + + @OneToMany(mappedBy = "parentComment", cascade = CascadeType.ALL) + private List replies; +} + diff --git a/post-service/src/main/java/com/codecampus/post/entity/PostImage.java b/post-service/src/main/java/com/codecampus/post/entity/PostImage.java new file mode 100644 index 00000000..087c81bd --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/entity/PostImage.java @@ -0,0 +1,23 @@ +//package com.codecampus.post.entity; +// +//import com.codecampus.post.entity.audit.AuditMetadata; +//import jakarta.persistence.*; +// +//import java.time.LocalDateTime; +//import java.util.UUID; +// +//@Entity +//@Table(name = "post_image") +//public class PostImage extends AuditMetadata { +// @Id +// @GeneratedValue(strategy = GenerationType.UUID) +// private String postImageId; +// +// private String imageUrl; +// private String altText; +// +// @ManyToOne +// @JoinColumn(name = "post_id", nullable = false) +// private Post post; +//} +// diff --git a/post-service/src/main/java/com/codecampus/post/entity/PostReaction.java b/post-service/src/main/java/com/codecampus/post/entity/PostReaction.java new file mode 100644 index 00000000..30b583e4 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/entity/PostReaction.java @@ -0,0 +1,25 @@ +package com.codecampus.post.entity; + +import com.codecampus.post.entity.audit.AuditMetadata; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter +@Setter +@Table(name = "post_reaction") +public class PostReaction extends AuditMetadata { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String reactionId; + + private String userId; + private String commentId; // for comment reactions + private String emojiType; + + + @ManyToOne + @JoinColumn(name = "post_id", nullable = false) + private Post post; +} diff --git a/post-service/src/main/java/com/codecampus/post/entity/audit/AuditMetadata.java b/post-service/src/main/java/com/codecampus/post/entity/audit/AuditMetadata.java new file mode 100644 index 00000000..222709a1 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/entity/audit/AuditMetadata.java @@ -0,0 +1,57 @@ +package com.codecampus.post.entity.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.AccessLevel; +import lombok.Data; +import lombok.experimental.FieldDefaults; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; + +@Data +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@FieldDefaults(level = AccessLevel.PROTECTED) +public class AuditMetadata implements SoftDeletable { + /* ---- create ---- */ + @CreatedBy + @Column(name = "created_by") + String createdBy; + + @CreatedDate + @Column(name = "created_at") + Instant createdAt; + + /* ---- update ---- */ + @LastModifiedBy + @Column(name = "updated_by") + String updatedBy; + + @LastModifiedDate + @Column(name = "updated_at") + Instant updatedAt; + + /* ---- soft-delete ---- */ + @Column(name = "deleted_by") + String deletedBy; + + @Column(name = "deleted_at") + Instant deletedAt; + + @Override + public void markDeleted(String by) { + this.deletedBy = by; + this.deletedAt = Instant.now(); + } + + @Override + public boolean isDeleted() { + return deletedAt != null; + } +} diff --git a/post-service/src/main/java/com/codecampus/post/entity/audit/SoftDeletable.java b/post-service/src/main/java/com/codecampus/post/entity/audit/SoftDeletable.java new file mode 100644 index 00000000..991bc432 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/entity/audit/SoftDeletable.java @@ -0,0 +1,9 @@ +package com.codecampus.post.entity.audit; + +public interface SoftDeletable { + void markDeleted(String by); + + boolean isDeleted(); + + String getDeletedBy(); +} diff --git a/post-service/src/main/java/com/codecampus/post/exception/AppException.java b/post-service/src/main/java/com/codecampus/post/exception/AppException.java new file mode 100644 index 00000000..434d3af2 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/exception/AppException.java @@ -0,0 +1,15 @@ +package com.codecampus.post.exception; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class AppException extends RuntimeException { + private ErrorCode errorCode; + + public AppException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/post-service/src/main/java/com/codecampus/post/exception/ErrorCode.java b/post-service/src/main/java/com/codecampus/post/exception/ErrorCode.java new file mode 100644 index 00000000..08776a21 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/exception/ErrorCode.java @@ -0,0 +1,65 @@ +package com.codecampus.post.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; + +import static com.codecampus.post.constant.exception.ErrorCodeConstant.*; +import static org.springframework.http.HttpStatus.*; + +@Getter +public enum ErrorCode { + // Các lỗi không phân loại và lỗi chung + UNCATEGORIZED_EXCEPTION(99999, INTERNAL_SERVER_STATUS, + "Lỗi chưa phân loại", INTERNAL_SERVER_ERROR), + + // 400 - Bad Request + + + // 401 - Unauthorized + UNAUTHENTICATED(4019001, UNAUTHORIZED_STATUS, "Chưa xác thực!", + HttpStatus.UNAUTHORIZED), + AUTHOR_UNAUTHORIZED(4019002,AUTHOR_STATUS, "Không phải người đăng bài", + HttpStatus.UNAUTHORIZED), + + // 403 - Forbidden + UNAUTHORIZED(4039001, FORBIDDEN_STATUS, "Bạn không có quyền truy cập!", + FORBIDDEN), + + // 404 - Not Found + USER_NOT_FOUND(4049001, NOT_FOUND_STATUS, "Không tìm thấy người dùng!", + NOT_FOUND), + TARGET_USER_NOT_FOUND(4049003, NOT_FOUND_STATUS, + "Không tìm thấy người dùng mục tiêu!", NOT_FOUND), + POST_NOT_FOUND(4049004, NOT_FOUND_STATUS, "Không tìm thấy bài đăng!", + NOT_FOUND), + ORG_NOT_FOUND(4049004, NOT_FOUND_STATUS, "Không tìm thấy tổ chức!", + NOT_FOUND), + + + // 409 - Conflict + USER_ALREADY_EXISTS(4099001, CONFLICT_STATUS, "Người dùng đã tồn tại!", + CONFLICT), + + //410 - invalid + INVALID_FILE_TYPE(4109001, BAD_REQUEST_STATUS, "Định dạng tệp không hợp lệ", + HttpStatus.BAD_REQUEST) + + ; + + private final int code; + private final String status; + private final String message; + private final HttpStatusCode statusCode; + + ErrorCode( + int code, + String status, + String message, + HttpStatusCode statusCode) { + this.code = code; + this.status = status; + this.message = message; + this.statusCode = statusCode; + } +} diff --git a/post-service/src/main/java/com/codecampus/post/exception/GlobalExceptionHandler.java b/post-service/src/main/java/com/codecampus/post/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..349345cc --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/exception/GlobalExceptionHandler.java @@ -0,0 +1,102 @@ +package com.codecampus.post.exception; + +import com.codecampus.post.dto.common.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.FeignException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +@Slf4j +@RequiredArgsConstructor +public class GlobalExceptionHandler { + + private final ObjectMapper objectMapper; + + /** + * Xử lý các ngoại lệ RuntimeException không được bắt riêng. + * + * @param exception ngoại lệ RuntimeException được ném ra + * @return ResponseEntity chứa ApiResponse với mã lỗi, thông điệp và trạng thái lỗi tương ứng + */ + @ExceptionHandler(value = Exception.class) + ResponseEntity> handlingRuntimeException( + RuntimeException exception) { + log.error("Exception: " + exception.getMessage(), exception); + + ApiResponse apiResponse = new ApiResponse<>(); + apiResponse.setCode(ErrorCode.UNCATEGORIZED_EXCEPTION.getCode()); + apiResponse.setMessage(ErrorCode.UNCATEGORIZED_EXCEPTION.getMessage()); + apiResponse.setStatus(ErrorCode.UNCATEGORIZED_EXCEPTION.getStatus()); + + return ResponseEntity.badRequest().body(apiResponse); + } + + /** + * Xử lý ngoại lệ ứng dụng (AppException) và chuyển thành ApiResponse. + * + * @param exception ngoại lệ AppException được ném ra + * @return ResponseEntity chứa ApiResponse với thông tin lỗi tương ứng + */ + @ExceptionHandler(value = AppException.class) + ResponseEntity> handlingAppException( + AppException exception) { + log.error("Exception: " + exception.getMessage(), exception); + + ErrorCode errorCode = exception.getErrorCode(); + ApiResponse apiResponse = new ApiResponse<>(); + apiResponse.setCode(errorCode.getCode()); + apiResponse.setMessage(errorCode.getMessage()); + apiResponse.setStatus(errorCode.getStatus()); + + return ResponseEntity.status(errorCode.getStatusCode()) + .body(apiResponse); + } + + /** + * Xử lý ngoại lệ AccessDeniedException khi người dùng không có quyền truy cập. + * + * @param exception ngoại lệ AccessDeniedException + * @return ResponseEntity chứa ApiResponse với thông tin lỗi truy cập + */ + @ExceptionHandler(value = AccessDeniedException.class) + ResponseEntity> handlingAccessDeniedException( + AccessDeniedException exception) { + ErrorCode errorCode = ErrorCode.UNAUTHORIZED; + + ApiResponse apiResponse = ApiResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .status(errorCode.getStatus()) + .build(); + + return ResponseEntity.status(errorCode.getStatusCode()) + .body(apiResponse); + } + + @ExceptionHandler(value = FeignException.class) + ResponseEntity handlingFeignException(FeignException exception) { + log.error("FeignException: " + exception.getMessage(), exception); + + try { + // Lấy body lỗi từ service kia (UTF-8) + String body = exception.contentUTF8(); + + // Parse về ApiResponse nếu format giống của mình + ApiResponse apiResponse = objectMapper.readValue(body, ApiResponse.class); + + return ResponseEntity.status(exception.status()) + .body(apiResponse); + + } catch (Exception e) { + // Nếu parse thất bại thì trả body thô + return ResponseEntity.status(exception.status()) + .body(exception.contentUTF8()); + } + } +} diff --git a/post-service/src/main/java/com/codecampus/post/repository/PostAccessRepository.java b/post-service/src/main/java/com/codecampus/post/repository/PostAccessRepository.java new file mode 100644 index 00000000..35051b8e --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/repository/PostAccessRepository.java @@ -0,0 +1,16 @@ +package com.codecampus.post.repository; + +import com.codecampus.post.entity.PostAccess; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PostAccessRepository extends JpaRepository { + + List findByPost_PostId(String postId); + + void deleteByPost_PostIdAndUserIdIn(String postId, List userIds); +} + diff --git a/post-service/src/main/java/com/codecampus/post/repository/PostCommentRepository.java b/post-service/src/main/java/com/codecampus/post/repository/PostCommentRepository.java new file mode 100644 index 00000000..338d3508 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/repository/PostCommentRepository.java @@ -0,0 +1,16 @@ +package com.codecampus.post.repository; + +import com.codecampus.post.entity.PostComment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PostCommentRepository extends JpaRepository { + + List findByPost_PostIdAndParentCommentIsNullOrderByCommentIdDesc(String postId); + + List findByParentComment_CommentIdOrderByCommentIdAsc(String parentCommentId); +} + diff --git a/post-service/src/main/java/com/codecampus/post/repository/PostReactionRepository.java b/post-service/src/main/java/com/codecampus/post/repository/PostReactionRepository.java new file mode 100644 index 00000000..69725e22 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/repository/PostReactionRepository.java @@ -0,0 +1,16 @@ +package com.codecampus.post.repository; + +import com.codecampus.post.entity.PostAccess; +import com.codecampus.post.entity.PostReaction; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface PostReactionRepository extends JpaRepository { + Optional findByPost_PostIdAndUserId(String postId, String userId); + Optional findByPost_PostIdAndUserIdAndCommentId(String postId, String userId, String commentId); + long countByPost_PostIdAndEmojiType(String postId, String emojiType); + long countByPost_PostIdAndCommentIdAndEmojiType(String postId, String commentId, String emojiType); +} diff --git a/post-service/src/main/java/com/codecampus/post/repository/PostRepository.java b/post-service/src/main/java/com/codecampus/post/repository/PostRepository.java new file mode 100644 index 00000000..f421350b --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/repository/PostRepository.java @@ -0,0 +1,62 @@ +package com.codecampus.post.repository; + +import com.codecampus.post.entity.Post; +import feign.Param; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface PostRepository extends JpaRepository { + @Query(""" + SELECT DISTINCT p FROM Post p + LEFT JOIN p.accesses a + WHERE p.isPublic = true OR a.userId = :userId +""") + List findAccessiblePosts(String userId); + + @Query(""" + SELECT p FROM Post p + LEFT JOIN p.accesses a + WHERE p.postId = :postId AND (p.isPublic = true OR a.userId = :userId) +""") + Optional findAccessiblePostById(String postId, String userId); + + @Query(""" + SELECT DISTINCT p FROM Post p + LEFT JOIN p.accesses pa + WHERE p.deletedAt IS NULL + AND ( + p.postType = 'Global' + OR p.isPublic = true + OR (pa.userId = :userId AND (pa.isExcluded IS NULL OR pa.isExcluded = false)) + ) +""") + Page findAllVisiblePosts(String userId, Pageable pageable); + + @Query(value = """ + SELECT DISTINCT p.* + FROM post p + LEFT JOIN post_access pa ON p.post_id = pa.post_id + WHERE p.deleted_at IS NULL + AND p.search_vector @@ plainto_tsquery('simple', :searchText) + AND ( + p.post_type = 'Global' + OR p.is_public = true + OR (pa.user_id = :userId AND (pa.is_excluded IS NULL OR pa.is_excluded = false)) + ) + """, + nativeQuery = true) + Page searchVisiblePosts( + @Param("searchText") String searchText, + @Param("userId") String userId, + Pageable pageable + ); + + +} diff --git a/post-service/src/main/java/com/codecampus/post/service/FeignConfig/FeignMultipartSupportConfig.java b/post-service/src/main/java/com/codecampus/post/service/FeignConfig/FeignMultipartSupportConfig.java new file mode 100644 index 00000000..934b5d79 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/service/FeignConfig/FeignMultipartSupportConfig.java @@ -0,0 +1,24 @@ +package com.codecampus.post.service.FeignConfig; + +import feign.codec.Encoder; +import feign.form.spring.SpringFormEncoder; +import org.springframework.cloud.openfeign.support.SpringEncoder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; + +@Configuration +public class FeignMultipartSupportConfig { + + private final ObjectFactory messageConverters; + + public FeignMultipartSupportConfig(ObjectFactory messageConverters) { + this.messageConverters = messageConverters; + } + + @Bean + public Encoder feignFormEncoder() { + return new SpringFormEncoder(new SpringEncoder(messageConverters)); + } +} diff --git a/post-service/src/main/java/com/codecampus/post/service/FeignConfig/FileServiceClient.java b/post-service/src/main/java/com/codecampus/post/service/FeignConfig/FileServiceClient.java new file mode 100644 index 00000000..5ceab4cf --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/service/FeignConfig/FileServiceClient.java @@ -0,0 +1,20 @@ +package com.codecampus.post.service.FeignConfig; + +import com.codecampus.post.dto.request.AddFileDocumentDto; +import com.codecampus.post.dto.response.AddFileResponseDto; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; + +@FeignClient(name = "file-service", + url = "http://localhost:8082", + configuration = FeignMultipartSupportConfig.class) +public interface FileServiceClient { + + @PostMapping( + value = "/file/api/FileDocument/add", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) + AddFileResponseDto uploadFile(@ModelAttribute AddFileDocumentDto dto); +} diff --git a/post-service/src/main/java/com/codecampus/post/service/PostAccessService.java b/post-service/src/main/java/com/codecampus/post/service/PostAccessService.java new file mode 100644 index 00000000..e41aaad3 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/service/PostAccessService.java @@ -0,0 +1,74 @@ +package com.codecampus.post.service; + +import com.codecampus.post.dto.response.PostAccessResponseDto; +import com.codecampus.post.entity.PostAccess; +import com.codecampus.post.dto.request.PostAccessRequestDto; +import com.codecampus.post.entity.Post; +import com.codecampus.post.repository.PostAccessRepository; +import com.codecampus.post.repository.PostRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PostAccessService { + + private final PostAccessRepository postAccessRepository; + private final PostRepository postRepository; + + // Thêm hoặc cập nhật quyền truy cập cho nhiều user + @Transactional + public void saveOrUpdateAccess(PostAccessRequestDto dto) { + Post post = postRepository.findById(dto.getPostId()) + .orElseThrow(() -> new RuntimeException("Post not found")); + + // lấy danh sách userId đã có + Set existingUserIds = postAccessRepository.findByPost_PostId(dto.getPostId()) + .stream() + .map(PostAccess::getUserId) + .collect(Collectors.toSet()); + + // chỉ giữ lại userId chưa có + List newAccessList = dto.getUserIds().stream() + .filter(userId -> !existingUserIds.contains(userId)) + .map(userId -> { + PostAccess pa = new PostAccess(); + pa.setPost(post); + pa.setUserId(userId); + pa.setIsExcluded(dto.getIsExcluded()); + return pa; + }) + .toList(); + + if (!newAccessList.isEmpty()) { + postAccessRepository.saveAll(newAccessList); + } + } + + + // Xoá quyền của nhiều user + @Transactional + public void deleteAccess(PostAccessRequestDto dto) { + postAccessRepository.deleteByPost_PostIdAndUserIdIn(dto.getPostId(), dto.getUserIds()); + } + + // Lấy danh sách quyền của 1 bài post + @Transactional + public List getAccessByPostId(String postId) { + return postAccessRepository.findByPost_PostId(postId).stream() + .map(pa -> PostAccessResponseDto.builder() + .postId(pa.getPost().getPostId()) + .userId(pa.getUserId()) + .isExcluded(Boolean.TRUE.equals(pa.getIsExcluded())) + .build() + ) + .toList(); + } + +} + diff --git a/post-service/src/main/java/com/codecampus/post/service/PostCommentService.java b/post-service/src/main/java/com/codecampus/post/service/PostCommentService.java new file mode 100644 index 00000000..48da42f1 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/service/PostCommentService.java @@ -0,0 +1,166 @@ +package com.codecampus.post.service; + +import com.codecampus.post.config.CustomJwtDecoder; +import com.codecampus.post.dto.request.CommentRequestDto; +import com.codecampus.post.dto.request.UpdateCommentDto; +import com.codecampus.post.dto.response.CommentResponseDto; +import com.codecampus.post.entity.Post; +import com.codecampus.post.entity.PostComment; +import com.codecampus.post.repository.PostCommentRepository; +import com.codecampus.post.repository.PostRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +public class PostCommentService { + + private final PostCommentRepository postCommentRepository; + private final PostRepository postRepository; + private final CustomJwtDecoder customJwtDecoder; + + @Transactional + public PostComment addComment(CommentRequestDto dto, HttpServletRequest request) { + String token = request.getHeader("Authorization"); + if(token == null || !token.startsWith("Bearer ")) { + throw new IllegalArgumentException("Authorization token is missing or invalid"); + } + String userId = customJwtDecoder.decode(token.substring(7)).getClaims().get("userId").toString(); // Assuming user ID is stored in the principal + + Post post = postRepository.findById(dto.getPostId()) + .orElseThrow(() -> new RuntimeException("Post not found")); + + PostComment comment = new PostComment(); + comment.setPost(post); + comment.setUserId(userId); + comment.setContent(dto.getContent()); + + if (dto.getParentCommentId() != null) { + PostComment parent = postCommentRepository.findById(dto.getParentCommentId()) + .orElseThrow(() -> new RuntimeException("Parent comment not found")); + comment.setParentComment(parent); + } + + return postCommentRepository.save(comment); + } + + public List getCommentsByPost(String postId) { + List parents = postCommentRepository + .findByPost_PostIdAndParentCommentIsNullOrderByCommentIdDesc(postId); + + return parents.stream() + .filter(c -> !c.isDeleted()) // chỉ lấy comment chưa xóa + .map(c -> mapWithLimitDepth(c, 1)) // bắt đầu từ depth = 1 + .toList(); + } + + private CommentResponseDto mapWithLimitDepth(PostComment comment, int depth) { + List replyDtos; + + if (depth < 2) { + // cấp 1 -> cho phép lấy reply + replyDtos = comment.getReplies() != null + ? comment.getReplies().stream() + .filter(r -> !r.isDeleted()) + .flatMap(r -> { + if (depth + 1 < 2) { + // vẫn còn trong giới hạn, tiếp tục đệ quy + return Stream.of(mapWithLimitDepth(r, depth + 1)); + } else { + // cấp 2 rồi, gom hết con/cháu thành cấp 2 luôn + return flattenReplies(r).stream(); + } + }) + .toList() + : List.of(); + } else { + // depth >= 2 thì không bao giờ xảy ra vì ta dừng ở depth=1 rồi flatten + replyDtos = List.of(); + } + + return new CommentResponseDto( + comment.getCommentId(), + comment.getUserId(), + comment.getContent(), + replyDtos + ); + } + + /** + * Flatten toàn bộ reply từ comment con (cấp >= 2) thành danh sách cấp 2 + */ + private List flattenReplies(PostComment comment) { + List flatList = new ArrayList<>(); + + if (!comment.isDeleted()) { + flatList.add(new CommentResponseDto( + comment.getCommentId(), + comment.getUserId(), + comment.getContent(), + List.of() // ép thành cấp 2 -> không còn replies + )); + } + + if (comment.getReplies() != null) { + for (PostComment child : comment.getReplies()) { + flatList.addAll(flattenReplies(child)); // đệ quy gom vào 1 list + } + } + + return flatList; + } + + + + + + + @Transactional + public void updateComment(UpdateCommentDto requestDto, HttpServletRequest request) { + String token = request.getHeader("Authorization"); + if(token == null || !token.startsWith("Bearer ")) { + throw new IllegalArgumentException("Authorization token is missing or invalid"); + } + String userId = customJwtDecoder.decode(token.substring(7)).getClaims().get("userId").toString(); // Assuming user ID is stored in the principal + if (userId == null) { + throw new IllegalArgumentException("User ID is required for updating comment"); + } + + PostComment comment = postCommentRepository.findById(requestDto.getCommentId()) + .orElseThrow(() -> new RuntimeException("Comment not found")); + + if (!comment.getUserId().equals(userId)) { + throw new RuntimeException("Not allowed to edit this comment"); + } + + comment.setContent(requestDto.getContent()); + postCommentRepository.save(comment); + } + + @Transactional + public void deleteComment(String commentId, HttpServletRequest request) { + String token = request.getHeader("Authorization"); + if (token == null || !token.startsWith("Bearer ")) { + throw new IllegalArgumentException("Authorization token is missing or invalid"); + } + String userId = customJwtDecoder.decode(token.substring(7)).getClaims().get("userId").toString(); + if (userId == null) { + throw new IllegalArgumentException("User ID is required for deleting comment"); + } + + PostComment comment = postCommentRepository.findById(commentId) + .orElseThrow(() -> new RuntimeException("Comment not found")); + + if (!comment.isDeleted()) { + comment.markDeleted(userId); + postCommentRepository.save(comment); + } + } +} + diff --git a/post-service/src/main/java/com/codecampus/post/service/PostReactionService.java b/post-service/src/main/java/com/codecampus/post/service/PostReactionService.java new file mode 100644 index 00000000..60223be7 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/service/PostReactionService.java @@ -0,0 +1,97 @@ +package com.codecampus.post.service; + +import com.codecampus.post.config.CustomJwtDecoder; +import com.codecampus.post.dto.request.PostReactionRequestDto; +import com.codecampus.post.entity.Post; +import com.codecampus.post.entity.PostReaction; +import com.codecampus.post.repository.PostReactionRepository; +import com.codecampus.post.repository.PostRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class PostReactionService { + private final PostReactionRepository postReactionRepository; + private final PostRepository postRepository; + private final CustomJwtDecoder customJwtDecoder; + + @Transactional + public void toggleReaction(PostReactionRequestDto requestDto, HttpServletRequest request) { + String token = request.getHeader("Authorization"); + if (token == null || !token.startsWith("Bearer ")) { + throw new IllegalArgumentException("Authorization token is missing or invalid"); + } + + String userId = customJwtDecoder.decode(token.substring(7)) + .getClaims() + .get("userId") + .toString(); + + if (userId == null) { + throw new IllegalArgumentException("User ID is required for post reaction"); + } + + // Kiểm tra reaction đã tồn tại chưa + Optional existingReaction = (requestDto.getCommentId() != null) + ? postReactionRepository.findByPost_PostIdAndUserIdAndCommentId( + requestDto.getPostId(), userId, requestDto.getCommentId()) + : postReactionRepository.findByPost_PostIdAndUserId( + requestDto.getPostId(), userId); + + if (existingReaction.isPresent()) { + PostReaction reaction = existingReaction.get(); + + if (reaction.getEmojiType().equals(requestDto.getReactionType())) { + // Nếu user bấm lại cùng loại reaction -> xóa + postReactionRepository.delete(reaction); + return; + } else { + // Nếu user chọn reaction khác -> update + reaction.setEmojiType(requestDto.getReactionType()); + postReactionRepository.save(reaction); + return; + } + } + + //chưa có reaction nào -> tạo mới + Post post = postRepository.findById(requestDto.getPostId()) + .orElseThrow(() -> new RuntimeException("Post not found")); + + PostReaction newReaction = new PostReaction(); + newReaction.setPost(post); + newReaction.setUserId(userId); + newReaction.setCommentId(requestDto.getCommentId()); + newReaction.setEmojiType(requestDto.getReactionType()); + + postReactionRepository.save(newReaction); + } + + @Transactional + public Map getReactionCount(String postId, String commentId) { + long upvotes; + long downvotes; + + if (commentId == null) { + // Reaction cho post + upvotes = postReactionRepository.countByPost_PostIdAndEmojiType(postId, "upvote"); + downvotes = postReactionRepository.countByPost_PostIdAndEmojiType(postId, "downvote"); + } else { + // Reaction cho comment trong post + upvotes = postReactionRepository.countByPost_PostIdAndCommentIdAndEmojiType(postId, commentId, "upvote"); + downvotes = postReactionRepository.countByPost_PostIdAndCommentIdAndEmojiType(postId, commentId, "downvote"); + } + + Map result = new HashMap<>(); + result.put("upvote", upvotes); + result.put("downvote", downvotes); + return result; + } + +} diff --git a/post-service/src/main/java/com/codecampus/post/service/PostService.java b/post-service/src/main/java/com/codecampus/post/service/PostService.java new file mode 100644 index 00000000..430a4186 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/service/PostService.java @@ -0,0 +1,197 @@ +package com.codecampus.post.service; + + +import com.codecampus.post.Mapper.PostMapper; +import com.codecampus.post.config.CustomJwtDecoder; +import com.codecampus.post.dto.common.PageRequestDto; +import com.codecampus.post.dto.common.PageResponse; +import com.codecampus.post.dto.request.AddFileDocumentDto; +import com.codecampus.post.dto.request.PostRequestDto; +import com.codecampus.post.dto.response.AddFileResponseDto; +import com.codecampus.post.entity.Post; +import com.codecampus.post.exception.AppException; +import com.codecampus.post.exception.ErrorCode; +import com.codecampus.post.repository.PostRepository; +import com.codecampus.post.service.FeignConfig.FileServiceClient; +import jakarta.servlet.http.HttpServletRequest; +import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Lazy; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import java.io.IOException; +import java.io.InputStream; +import java.net.http.HttpHeaders; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PostService { + private final CustomJwtDecoder customJwtDecoder; + private final PostRepository postRepository; + private final PostMapper postMapper; + private final FileServiceClient fileServiceClient; + + public PageResponse getAllAccessiblePosts(HttpServletRequest request, PageRequestDto pageRequestDto) { + String token = request.getHeader("Authorization"); + String userId = customJwtDecoder.decode(token.substring(7)).getClaims().get("userId").toString(); + Pageable pageable = PageRequest.of(pageRequestDto.getPage(), pageRequestDto.getSize(), Sort.by("createdAt").descending()); + Page postPage = postRepository.findAllVisiblePosts(userId, pageable); + + return PageResponse.builder() + .currentPage(postPage.getNumber()) + .totalPages(postPage.getTotalPages()) + .pageSize(postPage.getSize()) + .totalElements(postPage.getTotalElements()) + .data(postPage.getContent()) + .build(); + } + + public PageResponse SeachPosts(String searchText, HttpServletRequest request, PageRequestDto pageRequestDto) { + String token = request.getHeader("Authorization"); + String userId = customJwtDecoder.decode(token.substring(7)).getClaims().get("userId").toString(); + Pageable pageable = PageRequest.of(pageRequestDto.getPage(), pageRequestDto.getSize(), Sort.by("created_at").descending()); + Page postPage = postRepository.searchVisiblePosts(searchText, userId, pageable); + + return PageResponse.builder() + .currentPage(postPage.getNumber()) + .totalPages(postPage.getTotalPages()) + .pageSize(postPage.getSize()) + .totalElements(postPage.getTotalElements()) + .data(postPage.getContent()) + .build(); + } + +// public Optional getPostByIdIfAccessible(String postId, HttpServletRequest request) { +// String token = request.getHeader("Authorization"); +// String userId = customJwtDecoder.decode(token.substring(7)).getClaims().get("userId").toString(); +// return postRepository.findAccessiblePostById(postId, userId) +// .filter(post -> !post.isDeleted()) +// .map(postMapper::toDto); +// } + + public void createPost(PostRequestDto postRequestDto, HttpServletRequest request) { + String token = request.getHeader("Authorization"); + String userId = customJwtDecoder.decode(token.substring(7)) + .getClaims() + .get("userId") + .toString(); + + List fileUrls = Collections.emptyList(); + + var fileDoc = postRequestDto.getFileDocument(); + if (fileDoc != null && fileDoc.getFile() != null && !fileDoc.getFile().isEmpty()) { + + MultipartFile file = fileDoc.getFile(); + + // Kiểm tra file được tải lên có phải ảnh thật hay không + if (!isRealImage(file)) { + throw new AppException(ErrorCode.INVALID_FILE_TYPE); + } + + // Upload file sau khi xác thực + AddFileResponseDto response = fileServiceClient.uploadFile(fileDoc); + + // Nếu API C# trả result là String URL + fileUrls = Optional.ofNullable(response) + .map(AddFileResponseDto::getResult) + .map(List::of) + .orElse(Collections.emptyList()); + } + + Post post = postMapper.toEntity(postRequestDto); + post.setImagesUrls(fileUrls); + post.setUserId(userId); + + postRepository.save(post); + } + + public void updatePost(PostRequestDto postRequestDto, HttpServletRequest request) { + String token = request.getHeader("Authorization"); + String userId = customJwtDecoder.decode(token.substring(7)) + .getClaims() + .get("userId").toString(); + String username = customJwtDecoder.decode(token.substring(7)) + .getClaims() + .get("username").toString(); + + Optional optionalPost = postRepository.findById(postRequestDto.getPostId()); + if (optionalPost.isEmpty()) throw new AppException(ErrorCode.POST_NOT_FOUND); + + Post existingPost = optionalPost.get(); + + // Chỉ cho phép người tạo hoặc admin chỉnh sửa + if (!existingPost.getUserId().equals(userId) && !"admin".equals(username)) { + throw new AppException(ErrorCode.UNAUTHORIZED); + } + + // Danh sách ảnh sau khi chỉnh sửa + List updatedImageUrls = new ArrayList<>(); + + // 1. Giữ lại ảnh cũ mà người dùng vẫn muốn giữ + if (postRequestDto.getOldImgesUrls() != null && !postRequestDto.getOldImgesUrls().isEmpty()) { + updatedImageUrls.addAll( + existingPost.getImagesUrls().stream() + .filter(url -> postRequestDto.getOldImgesUrls().contains(url)) + .toList() + ); + } + + // 2. Nếu có ảnh mới thì kiểm tra và upload + if (postRequestDto.getFileDocument() != null && + postRequestDto.getFileDocument().getFile() != null && + !postRequestDto.getFileDocument().getFile().isEmpty()) { + + MultipartFile file = postRequestDto.getFileDocument().getFile(); + if (!isRealImage(file)) { + throw new AppException(ErrorCode.INVALID_FILE_TYPE); + } + + // Upload ảnh mới + AddFileResponseDto response = fileServiceClient.uploadFile(postRequestDto.getFileDocument()); + List newFileUrls = Optional.ofNullable(response) + .map(AddFileResponseDto::getResult) + .map(List::of) + .orElse(Collections.emptyList()); + + updatedImageUrls.addAll(newFileUrls); + } + + // 3. Cập nhật các field khác + postMapper.updateEntityFromDto(postRequestDto, existingPost); + existingPost.setImagesUrls(updatedImageUrls); + + postRepository.save(existingPost); + } + + + public void deletePost(String postId, HttpServletRequest request) { + String token = request.getHeader("Authorization"); + String deletedBy = customJwtDecoder.decode(token.substring(7)).getClaims().get("username").toString(); + Post post = postRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("Post not found with id: " + postId)); + post.markDeleted(deletedBy); + postRepository.save(post); + } + + public boolean isRealImage(MultipartFile file) { + try (InputStream input = file.getInputStream()) { + return ImageIO.read(input) != null; + } catch (IOException e) { + return false; + } + } + + +} + diff --git a/post-service/src/main/java/com/codecampus/post/utils/ConvertUtils.java b/post-service/src/main/java/com/codecampus/post/utils/ConvertUtils.java new file mode 100644 index 00000000..d7b12b6a --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/utils/ConvertUtils.java @@ -0,0 +1,80 @@ +package com.codecampus.post.utils; + +import org.mapstruct.Named; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +/** + * Tiện ích chuyển đổi giữa định dạng chuỗi ngày "dd/MM/yyyy" + * và đối tượng {@link Instant}. + * + *

Cung cấp các phương thức: + *

    + *
  • {@link #parseDdMmYyyyToInstant(String)}: Chuyển chuỗi ngày sang {@code Instant} tại đầu ngày (00:00 UTC).
  • + *
  • {@link #formatInstantToDdMmYyyyUtc(Instant)}: Định dạng {@code Instant} sang chuỗi ngày theo UTC.
  • + *
  • {@link #formatInstantToDdMmYyyyLocal(Instant)}: Định dạng {@code Instant} sang chuỗi ngày theo múi giờ hệ thống.
  • + *
+ *

+ */ +public class ConvertUtils { + // Định dạng dd/MM/yyyy + private static final DateTimeFormatter DMY_FORMATTER = + DateTimeFormatter.ofPattern("dd/MM/yyyy"); + + /** + * Chuyển chuỗi ngày có định dạng dd/MM/yyyy sang Instant (đầu ngày). + * + * @param dateStr chuỗi ngày theo định dạng "dd/MM/yyyy", ví dụ "31/12/2024" + * @return Instant tương ứng (thời điểm bắt đầu ngày đó) theo múi giờ hệ thống + */ + @Named("DdMmYyyyToInstant") + public static Instant parseDdMmYyyyToInstant(String dateStr) { + if (dateStr == null || dateStr.isBlank()) { + return null; + } + + // Parse thành LocalDate + LocalDate localDate = LocalDate.parse(dateStr, DMY_FORMATTER); + // Chuyển sang Instant tại thời điểm 00:00 của ngày, theo múi giờ hệ thống + return localDate.atStartOfDay(ZoneOffset.UTC).toInstant(); + } + + /** + * Chuyển Instant về chuỗi ngày "dd/MM/yyyy" tính theo UTC. + * + * @param instant thời điểm cần format + * @return chuỗi ngày dạng "dd/MM/yyyy" + */ + @Named("instantToDdMmYyyyUTC") + public static String formatInstantToDdMmYyyyUtc(Instant instant) { + if (instant == null) { + return null; + } + + return instant + .atZone(ZoneOffset.UTC) + .toLocalDate() + .format(DMY_FORMATTER); + } + + /** + * Chuyển Instant về chuỗi ngày "dd/MM/yyyy" tính theo múi giờ hệ thống. + * + * @param instant thời điểm cần format + * @return chuỗi ngày dạng "dd/MM/yyyy" + */ + public static String formatInstantToDdMmYyyyLocal(Instant instant) { + if (instant == null) { + return null; + } + + return instant + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(DMY_FORMATTER); + } +} diff --git a/post-service/src/main/java/com/codecampus/post/utils/DateTimeFormatter.java b/post-service/src/main/java/com/codecampus/post/utils/DateTimeFormatter.java new file mode 100644 index 00000000..d78f18b3 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/utils/DateTimeFormatter.java @@ -0,0 +1,113 @@ +package com.codecampus.post.utils; + +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Function; + +/** + * Tiện ích định dạng ngày giờ (DateTime) cho các thông báo hiển thị tương đối. + * + *

Cung cấp phương thức format(Instant) để trả về chuỗi mô tả khoảng thời gian đã trôi qua: + *

    + *
  • Nếu dưới 60 giây, hiển thị "x second(s) ago".
  • + *
  • Nếu dưới 60 phút, hiển thị "x minute(s) ago".
  • + *
  • Nếu dưới 24 giờ, hiển thị "x hour(s) ago".
  • + *
  • Nếu lớn hơn hoặc bằng 24 giờ, hiển thị ngày theo định dạng ISO (YYYY-MM-DD).
  • + *
+ *

+ */ +@Component +public class DateTimeFormatter { + /** + * Bản đồ chiến lược format dựa trên ngưỡng thời gian (giây). + * Key: số giây tối đa, Value: hàm format tương ứng. + */ + Map> strategyMap = new LinkedHashMap<>(); + + /** + * Khởi tạo bản đồ chiến lược với các ngưỡng: + *
    + *
  • 60 giây → formatInSeconds()
  • + *
  • 3600 giây (60 phút) → formatInMinutes()
  • + *
  • 86400 giây (24 giờ) → formatInHours()
  • + *
  • Long.MAX_VALUE → formatInDate()
  • + *
+ */ + public DateTimeFormatter() { + strategyMap.put(60L, this::formatInSeconds); + strategyMap.put(3600L, this::formatInMinutes); + strategyMap.put(86400L, this::formatInHours); + strategyMap.put(Long.MAX_VALUE, this::formatInDate); + } + + /** + * Định dạng Instant thành chuỗi mô tả khoảng thời gian đã trôi qua. + * + * @param instant thời điểm cần định dạng + * @return chuỗi mô tả thời gian (ví dụ "5 minute(s) ago" hoặc "2025-06-14") + */ + public String format(Instant instant) { + long elapseSeconds = ChronoUnit.SECONDS.between(instant, Instant.now()); + + var strategy = strategyMap.entrySet() + .stream() + .filter(longFunctionEntry -> elapseSeconds < + longFunctionEntry.getKey()) + .findFirst().get(); + return strategy.getValue().apply(instant); + } + + /** + * Format khoảng thời gian tính theo giây. + * + * @param instant thời điểm cần so sánh + * @return "x second(s) ago" + */ + private String formatInSeconds(Instant instant) { + long elapseSeconds = ChronoUnit.SECONDS.between(instant, Instant.now()); + return String.format("%s second(s) ago", elapseSeconds); + } + + /** + * Format khoảng thời gian tính theo phút. + * + * @param instant thời điểm cần so sánh + * @return "x minute(s) ago" + */ + private String formatInMinutes(Instant instant) { + long elapseMinutes = ChronoUnit.MINUTES.between(instant, Instant.now()); + return String.format("%s minute(s) ago", elapseMinutes); + } + + /** + * Format khoảng thời gian tính theo giờ. + * + * @param instant thời điểm cần so sánh + * @return "x hour(s) ago" + */ + private String formatInHours(Instant instant) { + long elapseHours = ChronoUnit.HOURS.between(instant, Instant.now()); + return String.format("%s hour(s) ago", elapseHours); + } + + /** + * Khi khoảng thời gian đã quá 24 giờ, format về chuỗi ngày ISO (YYYY-MM-DD). + * + * @param instant thời điểm cần định dạng + * @return chuỗi ngày ISO + */ + private String formatInDate(Instant instant) { + LocalDateTime localDateTime = + instant.atZone(ZoneId.systemDefault()).toLocalDateTime(); + java.time.format.DateTimeFormatter dateTimeFormatter = + java.time.format.DateTimeFormatter.ISO_DATE; + + return localDateTime.format(dateTimeFormatter); + } +} diff --git a/post-service/src/main/java/com/codecampus/post/utils/PageResponseUtils.java b/post-service/src/main/java/com/codecampus/post/utils/PageResponseUtils.java new file mode 100644 index 00000000..009389f8 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/utils/PageResponseUtils.java @@ -0,0 +1,28 @@ +package com.codecampus.post.utils; + +import com.codecampus.post.dto.common.PageResponse; +import org.springframework.data.domain.Page; + +public class PageResponseUtils { + private PageResponseUtils() { + } + + /** + * Chuyển một {@link Page}<T> thành {@link PageResponse}<T>. + * + * @param pageData đối tượng Spring Data Page chứa dữ liệu và thông tin phân trang + * @param currentPage số trang hiện tại (bắt đầu từ 1) + * @param kiểu của phần tử trong trang + * @return {@link PageResponse}<T> tương ứng + */ + public static PageResponse toPageResponse(Page pageData, + int currentPage) { + return PageResponse.builder() + .currentPage(currentPage) + .pageSize(pageData.getSize()) + .totalPages(pageData.getTotalPages()) + .totalElements(pageData.getTotalElements()) + .data(pageData.getContent().stream().toList()) + .build(); + } +} diff --git a/post-service/src/main/java/com/codecampus/post/utils/SecurityUtils.java b/post-service/src/main/java/com/codecampus/post/utils/SecurityUtils.java new file mode 100644 index 00000000..7df2c1d4 --- /dev/null +++ b/post-service/src/main/java/com/codecampus/post/utils/SecurityUtils.java @@ -0,0 +1,20 @@ +package com.codecampus.post.utils; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * Tiện ích hỗ trợ lấy thông tin người dùng hiện đang đăng nhập + * từ ngữ cảnh bảo mật của Spring Security. + */ +@Slf4j +public class SecurityUtils { + /** + * Lấy ID của người dùng đã đăng nhập. + * + * @return chuỗi tên đăng nhập hoặc null nếu chưa xác thực + */ + public static String getMyUserId() { + return SecurityContextHolder.getContext().getAuthentication().getName(); + } +} diff --git a/post-service/src/main/resources/application.yml b/post-service/src/main/resources/application.yml new file mode 100644 index 00000000..724ae936 --- /dev/null +++ b/post-service/src/main/resources/application.yml @@ -0,0 +1,31 @@ +server: + port: 8090 + + servlet: + context-path: /post + +file: + service: + file-url: http://localhost:8082 + +spring: + application: + name: post-service + datasource: + url: jdbc:postgresql://localhost:5439/post_db + username: postgres_post + password: dinhanst2832004 + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + show-sql: true + + cloud: + openfeign: + multipart: + enabled: true diff --git a/post-service/src/test/java/com/codecampus/post/PostServiceApplicationTests.java b/post-service/src/test/java/com/codecampus/post/PostServiceApplicationTests.java new file mode 100644 index 00000000..11f7ce3f --- /dev/null +++ b/post-service/src/test/java/com/codecampus/post/PostServiceApplicationTests.java @@ -0,0 +1,13 @@ +package com.codecampus.post; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class PostServiceApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/profile-service/src/main/java/com/codecampus/profile/repository/OrgRepository.java b/profile-service/src/main/java/com/codecampus/profile/repository/OrgRepository.java index 13cde93c..7e9e2e97 100644 --- a/profile-service/src/main/java/com/codecampus/profile/repository/OrgRepository.java +++ b/profile-service/src/main/java/com/codecampus/profile/repository/OrgRepository.java @@ -15,6 +15,8 @@ public interface OrgRepository @Query(value = """ MATCH (o:Organization {orgId:$orgId})-[a:ASSIGNED_ORG_EXERCISE]->(e:Exercise) RETURN a, e ORDER BY e.title + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (o:Organization {orgId:$orgId})-[a:ASSIGNED_ORG_EXERCISE]->(:Exercise) diff --git a/profile-service/src/main/java/com/codecampus/profile/repository/UserProfileRepository.java b/profile-service/src/main/java/com/codecampus/profile/repository/UserProfileRepository.java index 63b881c7..aaf8fb5e 100644 --- a/profile-service/src/main/java/com/codecampus/profile/repository/UserProfileRepository.java +++ b/profile-service/src/main/java/com/codecampus/profile/repository/UserProfileRepository.java @@ -47,6 +47,8 @@ public interface UserProfileRepository MATCH (u)-[completed:COMPLETED_EXERCISE]->(e:Exercise) RETURN completed, e ORDER BY completed.completedAt DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (u:User {userId:$userId}) @@ -64,6 +66,8 @@ Page findCompletedExercises( MATCH (u)-[saved:SAVED_EXERCISE]->(e:Exercise) RETURN saved, e ORDER BY saved.saveAt DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (u:User {userId:$userId}) @@ -81,6 +85,8 @@ Page findSavedExercises( MATCH (u)-[created:CREATED_EXERCISE]->(e:Exercise) RETURN created, e ORDER BY created.id DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (u:User {userId:$userId}) @@ -100,6 +106,8 @@ Page findCreatedExercises( MATCH (u)-[cs:CONTEST_STATUS]->(c:Contest) RETURN cs, c ORDER BY cs.updatedAt DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (u:User {userId:$userId}) @@ -119,6 +127,8 @@ Page findContestStatuses( MATCH (u)-[sp:SAVED_POST]->(p:Post) RETURN sp, p ORDER BY sp.saveAt DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (u:User {userId:$userId}) @@ -136,6 +146,8 @@ Page findSavedPosts( MATCH (u)-[r:REACTION]->(p:Post) RETURN r, p ORDER BY r.at DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (u:User {userId:$userId}) @@ -153,6 +165,8 @@ Page findReactions( MATCH (u)-[rp:REPORTED_POST]->(p:Post) RETURN rp, p ORDER BY rp.reportedAt DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (u:User {userId:$userId}) @@ -172,6 +186,8 @@ Page findReportedPosts( MATCH (u)-[:HAS_ACTIVITY]->(a:ActivityWeek) RETURN a ORDER BY a.weekStart DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (u:User {userId:$userId}) @@ -192,6 +208,8 @@ Page findActivityWeek( WHERE target.deletedAt IS NULL RETURN f, target ORDER BY f.since DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (me:User {userId:$userId}) @@ -211,6 +229,8 @@ Page findFollowings( WHERE src.deletedAt IS NULL RETURN f, src ORDER BY f.since DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (me:User {userId:$userId}) @@ -230,6 +250,8 @@ Page findFollowers( WHERE target.deletedAt IS NULL RETURN b, target ORDER BY b.since DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (me:User {userId:$userId}) @@ -250,6 +272,8 @@ Page findBlocked( MATCH (u)-[m:MEMBER_ORG]->(o:Organization) RETURN m, o ORDER BY m.joinAt DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (u:User {userId:$userId}) @@ -268,6 +292,8 @@ Page findMemberOrgs( WHERE m.memberRole = $role RETURN m, o ORDER BY m.joinAt DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (u:User {userId:$userId}) @@ -287,6 +313,8 @@ Page findMemberOrgsByRole( MATCH (u)-[c:CREATED_ORG]->(o:Organization) RETURN c, o ORDER BY c.createdAt DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (u:User {userId:$userId}) @@ -306,6 +334,8 @@ Page findCreatedOrgs( MATCH (u)-[s:SUBSCRIBED_TO]->(p:Package) RETURN s, p ORDER BY s.start DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (u:User {userId:$userId}) @@ -326,6 +356,8 @@ Page findSubscriptions( WHERE f.type = $type RETURN sr, f ORDER BY sr.saveAt DESC + SKIP $skip + LIMIT $limit """, countQuery = """ MATCH (u:User {userId:$userId})