Compare commits

..
40 Commits
Author SHA1 Message Date
lun_admin df3f3739e1 ... 2026-05-06 16:16:19 +09:00
lun_admin 2215e97deb ... 2026-05-06 15:54:23 +09:00
lun_admin 86d8cfeda4 .... 2026-01-19 17:09:08 +09:00
lun_admin 9c3d2f8d1e .... 2026-01-14 15:26:11 +09:00
lun_admin df50c9a2da ... 2026-01-07 16:42:06 +09:00
lun_admin 3d62a51153 .... 2026-01-06 18:20:31 +09:00
lun_admin 526a7b598f .. 2025-12-31 15:44:15 +09:00
lun_admin 1fa47201a7 ... 2025-12-30 18:13:31 +09:00
lun_admin dd528c702f 첫화면 로드를 좀 줄이자... 2025-12-30 18:10:35 +09:00
lun_admin 84ba6a02aa ... 2025-12-30 17:35:29 +09:00
lun_admin b9fe935e98 ... 2025-12-29 18:06:51 +09:00
lun_admin efc03bac91 ... 2025-12-29 17:18:17 +09:00
lun_admin b10d3223fd ... 2025-12-29 16:36:43 +09:00
lun_admin 7397d403d4 ... 2025-12-26 18:00:05 +09:00
lun_admin e8355b3048 .. 2025-12-26 17:31:21 +09:00
lun_admin 9b29b623c2 ... 2025-12-09 17:50:06 +09:00
lun_admin f3b8dd43e1 ... 2025-11-11 17:45:17 +09:00
lun_admin 93ff0354dc ... 2025-11-11 14:38:30 +09:00
lun_admin 73072d1812 ... 2025-11-10 18:02:19 +09:00
lun_admin 5ac3b05660 .. 2025-10-17 18:14:12 +09:00
lun_admin 00bba0bc39 ... 2025-09-29 10:19:05 +09:00
lun_admin d2a1f37f39 no message 2025-09-26 14:56:56 +09:00
lun_admin 74e88d7d89 .... 2025-09-24 17:29:33 +09:00
lun_admin 7af46ac655 ... 2025-09-23 18:03:59 +09:00
lun_admin 46dda0e02a ... 2025-09-23 17:37:22 +09:00
lun_admin 0ce20e4bf1 .. 2025-09-23 15:37:51 +09:00
lun_admin d6043543a1 ... 2025-09-19 18:07:50 +09:00
lun_admin 4b652c4df5 .... 2025-09-19 16:32:24 +09:00
lun_admin 5e0db4ff03 ... 2025-09-18 17:55:32 +09:00
lun_admin 17aea8b43b ... 2025-09-16 18:42:55 +09:00
lun_admin 39c9624774 ... 2025-09-15 18:21:57 +09:00
lun_admin 1ab12cb6d9 ... 2025-09-15 17:18:44 +09:00
lun_admin cc43ea8e0a ... 2025-09-12 18:01:23 +09:00
lun_admin 19c5d5473f ... 2025-09-12 16:55:21 +09:00
lun_admin d62a4a3c15 ... 2025-09-08 18:21:57 +09:00
lun_admin 51b97e2422 ... 2025-09-08 16:35:09 +09:00
lun_admin 26a0f14e54 ... 2025-09-05 18:21:34 +09:00
lun_admin 4cb009d150 ... 2025-09-05 18:02:27 +09:00
lun_admin 2987825cb2 ... 2025-09-03 18:38:06 +09:00
lun_admin 903292b246 ... 2025-09-03 18:00:39 +09:00
244 changed files with 21737 additions and 15718 deletions
+6 -3
View File
@@ -1,4 +1,5 @@
FROM openjdk:17
FROM eclipse-temurin:17-jdk
ENV TG_TARGET_ID=default
ENV TG_MINE=default
ENV WEATHER_KEY=default
@@ -11,17 +12,19 @@ ENV MRA_PW=default
ENV RESOURCE_HANDLER=default
ENV RESOURCE_LOCATION=default
ENV IMAGE_UPLOAD_PATH=default
ENV PUZZLE_IMAGE_UPLOAD_PATH=default
ENV GAPI_KEY=default
ENV API_BASE_URL=default
WORKDIR /imgUpload
LABEL maintainer="lunaticbum <lunaticbum@gmail.com>"
LABEL version="0.0.7"
LABEL description="Spring Boot Jar Test"
ARG JAR_FILE=build/libs/lun-0.0.7-SNAPSHOT.jar
ARG JAR_FILE=build/libs/lun-0.0.7-SNAPSHOT-prod.jar
COPY ${JAR_FILE} app.jar
EXPOSE 443
#EXPOSE 27012
#EXPOSE 3307
#ENTRYPOINT ["java","-jar","app.jar","-Dspring-boot.run.arguments=--telegram.bot.key=${BOT_KEY}, --telegram.my.id=${TG_MINE}, --telegram.target.id=${TG_TARGET_ID}, --weather.api.key=${WEATHER_KEY}"]
ENTRYPOINT ["java","-Dtelegram.bot.key=${BOT_KEY}","-Dtelegram.my.id=${TG_MINE}","-Dtelegram.target.id=${TG_TARGET_ID}","-Dweather.api.key=${WEATHER_KEY}","-Dspring.datasource.url=${DATASOURCE_URL}" ,"-Dspring.data.mongodb.uri=${MONGODB_HOST}","-Dspring.data.mongodb.database=${MONGODB_NAME}","-Dspring.datasource.username=${MRA_ADMIN}","-Dspring.datasource.password=${MRA_PW}","-Dresource.handler=${RESOURCE_HANDLER}","-Dresource.location=${RESOURCE_LOCATION}","-Dimage.upload.path=${IMAGE_UPLOAD_PATH}","-Dapi.gg.place=${GAPI_KEY}","-jar","app.jar"]
ENTRYPOINT ["java","-Dtelegram.bot.key=${BOT_KEY}","-Dtelegram.my.id=${TG_MINE}","-Dtelegram.target.id=${TG_TARGET_ID}","-Dweather.api.key=${WEATHER_KEY}","-Dspring.datasource.url=${DATASOURCE_URL}" ,"-Dspring.data.mongodb.uri=${MONGODB_HOST}","-Dspring.data.mongodb.database=${MONGODB_NAME}","-Dspring.datasource.username=${MRA_ADMIN}","-Dspring.datasource.password=${MRA_PW}","-Dresource.handler=${RESOURCE_HANDLER}","-Dresource.location=${RESOURCE_LOCATION}","-Dimage.upload.path=${IMAGE_UPLOAD_PATH}","-Dpuzzle.image.path=${PUZZLE_IMAGE_UPLOAD_PATH}","-Dapi.gg.place=${GAPI_KEY}","-Dapi.base-url=${API_BASE_URL}","-jar","app.jar"]
#-Dtelegram.bot.key=bot7934509464:AAE_xUbICxMdywLGnxo7BkeIqA1nVza4P9w -Dtelegram.target.id=71476436 -Dtelegram.my.id=71476436 -Dweather.api.key=de574a260b1f474d99955729241909 -Dspring.datasource.url=jdbc:mariadb://mra.sbspace.synology.me -Dspring.data.mongodb.uri=mongodb://lun_admin:VioPup*383@mongo.sbspace.synology.me/?wtimeoutMS=300&connectTimeoutMS=500&socketTimeoutMS=200 -Dspring.data.mongodb.database=lun_db -Dspring.datasource.username=lun_admin -Dspring.datasource.password=VioPup*383 -Dresource.handler=/blog/post/image/** -Dresource.location=file:///imgUpload -Dimage.upload.path=imgUpload
+1
View File
@@ -0,0 +1 @@
google.com, pub-9504446465764716, DIRECT, f08c47fec0942fa0
+194 -32
View File
@@ -1,6 +1,27 @@
import com.github.jk1.license.render.*
import com.github.jk1.license.filter.ExcludeTransitiveDependenciesFilter
import com.github.jk1.license.filter.LicenseBundleNormalizer
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import com.github.jk1.license.render.InventoryMarkdownReportRenderer
import org.jsoup.Jsoup
import org.springframework.boot.gradle.tasks.bundling.BootJar
//import org.gradle.internal.impldep.org.jsoup.Jsoup
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath ("org.jsoup:jsoup:1.18.1")
// 빌드 스크립트에서 commonmark 라이브러리를 사용할 수 있도록 추가합니다.
classpath("org.commonmark:commonmark:0.18.0")
}
}
plugins {
kotlin("jvm") version "1.9.25"
@@ -8,6 +29,7 @@ plugins {
id("org.springframework.boot") version "3.3.4"
id("io.spring.dependency-management") version "1.1.6"
id("com.github.jk1.dependency-license-report") version "2.0"
}
group = "kr.lunaticbum.back"
@@ -33,55 +55,46 @@ repositories {
}
dependencies {
// implementation ("jakarta.servlet:jakarta.servlet-api") //스프링부트 3.0 이상
// implementation ("jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api") //스프링부트 3.0 이상
// implementation ("org.glassfish.web:jakarta.servlet.jsp.jstl") //스프링부트 3.0 이상
implementation ("org.slf4j:jcl-over-slf4j")
// implementation ("org.springframework.boot:spring-boot-starter-batch")
implementation ("org.springframework.boot:spring-boot-starter-quartz")
// [추가] Kotlin BOM(Bill of Materials)을 사용하여 모든 코틀린 라이브러리 버전을 정렬합니다.
implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.9.25"))
implementation ("com.google.code.gson:gson:2.11.0")
// --- 기존 의존성 (정리됨) ---
implementation ("org.slf4j:jcl-over-slf4j")
implementation ("org.springframework.boot:spring-boot-starter-quartz")
implementation ("org.apache.tomcat.embed:tomcat-embed-jasper")
implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
implementation("nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect")
implementation ("org.jsoup:jsoup:1.18.1")
implementation ("org.seleniumhq.selenium:selenium-java:4.10.0")
implementation ("org.commonmark:commonmark:0.18.0")
implementation ("net.coobird:thumbnailator:0.4.14")
implementation("org.sejda.imageio:webp-imageio:0.1.6")
implementation ("com.drewnoakes:metadata-extractor:2.19.0")
implementation("org.springframework.boot:spring-boot-starter-security")
compileOnly("org.projectlombok:lombok")
// implementation(platform("com.google.cloud:libraries-bom: 26.55.0"))
// implementation("com.google.cloud:google-cloud-apikeys")
implementation ("com.google.maps:google-maps-services:2.2.0")
// implementation ("org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0-SNAPSHOT")
// implementation ("org.springframework.ai:spring-ai-vertex-ai-gemini-spring-boot-starter:1.0.0-SNAPSHOT")
// implementation("org.springframework.ai:spring-ai-ollama-spring-boot-starter:1.0.0-SNAPSHOT")
implementation(platform("org.springframework.ai:spring-ai-bom:1.0.0-M6"))
implementation("org.springframework.ai:spring-ai-ollama-spring-boot-starter:1.0.0-M6")
implementation ("org.springframework.ai:spring-ai-qdrant-store-spring-boot-starter")
// implementation ("io.qdrant:client:1.13.0")
implementation ("org.slf4j:slf4j-simple:1.7.25")
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
implementation("io.jsonwebtoken:jjwt-impl:0.11.5")
implementation("io.jsonwebtoken:jjwt-jackson:0.11.5")
// [수정] 버전 번호를 제거합니다. (BOM이 버전을 관리해 줍니다)
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// [수정] Gson 라이브러리 중복 제거 (2.11.0 버전만 남김)
implementation ("com.google.code.gson:gson:2.11.0")
annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.projectreactor:reactor-test")
@@ -135,20 +148,169 @@ tasks.jar {
})
}
// ✅ licenseReport는 이전과 동일하게 Markdown을 생성하도록 둡니다.
licenseReport {
// 라이센스 고지 파일을 반환할 경로 default는 $projectDir/reports/dependency-license
outputDir = "$projectDir/build/licenses"
renderers = arrayOf(InventoryMarkdownReportRenderer())
// filters = arrayOf(com.github.jk1.license.filter.LicenseBundleNormalizer(), com.github.jk1.license.filter.ExcludeTransitiveDependenciesFilter())
}
// markdown 생성
// renderers = listOf(InventoryMarkdownReportRenderer()).toTypedArray()
tasks.register("updateLicensePage") {
dependsOn("generateLicenseReport")
// html 생성
renderers = listOf(InventoryHtmlReportRenderer()).toTypedArray()
doLast {
// file("$projectDir/build/licenses").listFiles().forEach {
// println("${it.absolutePath}: ${it.name}")
// }
// ... 로그 출력 로직은 그대로 유지 ...
println("🚀 'updateLicensePage' 태스크를 시작합니다.")
// xml 생성
// renderers = [new XmlReportRenderer()]
val licenseMarkdownFile = file("$projectDir/build/licenses/licenses.md")
val targetHtmlFile = file("src/main/resources/templates/content/licenses.html")
// 보고서에 첫 번째 수준 종속성만 표기
filters = listOf(LicenseBundleNormalizer(), ExcludeTransitiveDependenciesFilter()).toTypedArray()
}
println(" - 원본 마크다운 파일: ${licenseMarkdownFile.path}")
println(" - 대상 HTML 파일: ${targetHtmlFile.path}")
if (!licenseMarkdownFile.exists()) {
throw GradleException("❌ 라이선스 마크다운 파일이 생성되지 않았습니다. '${licenseMarkdownFile.path}'")
}
val licenseMarkdown = licenseMarkdownFile.readText()
println(" - 마크다운 파일을 성공적으로 읽었습니다. (내용 길이: ${licenseMarkdown.length})")
val parser = Parser.builder().build()
val renderer = HtmlRenderer.builder().build()
val licenseHtml = renderer.render(parser.parse(licenseMarkdown))
println(" - 마크다운을 HTML로 변환했습니다. (HTML 길이: ${licenseHtml.length})")
// ✅ Jsoup으로 HTML 파일을 파싱합니다.
val doc = Jsoup.parse(targetHtmlFile, "UTF-8")
// ✅ CSS 선택자를 이용해 ID가 'license-content-container'인 태그를 선택하고
// 그 내부 HTML을 생성된 라이선스 내용으로 교체합니다.
doc.selectFirst("#license-content-container")?.html(licenseHtml)
println(" - HTML 파일 내 placeholder div의 내용을 교체했습니다.")
// ✅ 변경된 HTML 내용을 파일에 다시 씁니다.
targetHtmlFile.writeText(doc.outerHtml())
println("✅ 라이선스 정보(HTML)가 '${targetHtmlFile.name}' 파일에 성공적으로 업데이트되었습니다.")
}
}
// 'build' 태스크 실행 시 이 작업이 자동으로 수행되도록 연결
// [수정 전] tasks.build { dependsOn(tasks.getByName("updateLicensePage")) }
tasks.named("build") { // [수정 후] 'build' 태스크를 더 안전하게 참조합니다.
dependsOn(tasks.named("updateLicensePage"))
}
tasks.named("bootJar") { // [수정 후] 'build' 태스크를 더 안전하게 참조합니다.
dependsOn(tasks.named("updateLicensePage"))
}
// 기본 bootJar 태스크의 설정을 가져오기 위한 참조
val bootJar by tasks.getting(BootJar::class)
//
//// 'prod' 프로필이 내장된 JAR를 빌드하는 최종 태스크 정의
//tasks.register<BootJar>("bootJarProd") {
// group = "build"
// description = "Builds a production JAR that defaults to the 'prod' profile."
// archiveClassifier.set("prod")
//
// // --- 필수 설정 복사 ---
// // 1. Main 클래스 설정 복사
// mainClass.set(bootJar.mainClass)
// // 2. Classpath 설정 복사
// classpath = bootJar.classpath
// // 3. Target Java Version 설정 복사 (이번 오류 해결)
// targetJavaVersion.set(bootJar.targetJavaVersion)
//
// manifest {
// attributes["Spring-Profiles-Active"] = "prod"
// }
//}
// "local" 프로파일용 JAR를 빌드하는 작업
tasks.register<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJarLocal") {
group = "build"
description = "로컬 환경용 JAR 파일을 빌드합니다 ('local' 프로파일 적용)."
archiveClassifier.set("local") // 파일 이름에 local 접미사 추가 (e.g., app-local.jar)
// 메인 클래스와 클래스패스는 기본 bootJar 설정을 따라갑니다.
mainClass.set(tasks.bootJar.get().mainClass)
classpath = tasks.bootJar.get().classpath
targetJavaVersion.set(bootJar.targetJavaVersion)
// 'resources' 폴더의 모든 파일을 복사하되...
from("src/main/resources") {
include("**/*")
// prod 설정 파일은 제외합니다.
exclude("application-prod.properties")
// local 설정 파일의 이름을 application.properties로 변경합니다.
rename("application-local.properties", "application.properties")
}
}
// "prod" 프로파일용 JAR를 빌드하는 작업
tasks.register<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJarProd") {
group = "build"
description = "운영 환경용 JAR 파일을 빌드합니다 ('prod' 프로파일 적용)."
archiveClassifier.set("prod") // 파일 이름에 prod 접미사 추가 (e.g., app-prod.jar)
// 메인 클래스와 클래스패스는 기본 bootJar 설정을 따라갑니다.
mainClass.set(tasks.bootJar.get().mainClass)
classpath = tasks.bootJar.get().classpath
targetJavaVersion.set(bootJar.targetJavaVersion)
// 'resources' 폴더의 모든 파일을 복사하되...
from("src/main/resources") {
include("**/*")
// local 설정 파일은 제외합니다.
exclude("application-local.properties")
// prod 설정 파일의 이름을 application.properties로 변경합니다.
rename("application-prod.properties", "application.properties")
}
}
// 🚀 1. 명령어를 실행할 새로운 Exec 태스크 정의
tasks.register<Exec>("runCommandAfterProdJar") {
group = "build"
description = "prod JAR 빌드 후 실행할 명령어를 정의합니다."
// 이 태스크는 bootJarProd가 성공해야만 의미가 있으므로, 의존성을 명시해주는 것이 좋습니다.
dependsOn(tasks.named("bootJarProd"))
// 실행할 OS 명령어와 인자를 설정합니다.
// 예시 1: Docker 이미지 빌드
commandLine("docker", "buildx","buildx","--platform","linux/amd64", "-t", "lunaticbum/testjar:0.025", ".")
// 예시 2: 빌드된 JAR 파일을 특정 서버로 복사
// commandLine("scp", "build/libs/your-app-name-prod.jar", "user@server:/path/to/deploy")
// 예시 3: 간단한 셸 스크립트 실행
// commandLine("./deploy.sh")
// 필요하다면 작업 디렉토리를 설정할 수 있습니다.
// workingDir = rootDir
// doLast {
// println("prod JAR 빌드가 완료되었습니다. 추가 명령어를 실행합니다.")
// exec {
// commandLine("docker", "push", "lunaticbum/testjar:0.025")
// // commandLine("echo", "Hello from doLast!")
// }
// }
}
// 🚀 2. bootJarProd 태스크가 끝나면 위에서 정의한 태스크를 실행하도록 연결
//tasks.named("bootJarProd") {
// finalizedBy(tasks.named("runCommandAfterProdJar"))
//}
//
//// 'build' 태스크 실행 시 이 작업이 자동으로 수행되도록 연결
//tasks.build {
// dependsOn(tasks.getByName("updateLicensePage"))
//}
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
-124
View File
@@ -1,124 +0,0 @@
//package kr.lunaticbum.back.lun.configs
//
//import io.netty.channel.ChannelOption
//import io.netty.handler.timeout.ReadTimeoutHandler
//import io.netty.handler.timeout.WriteTimeoutHandler
//import jakarta.servlet.ServletContext
//import jakarta.servlet.ServletException
//import kr.lunaticbum.back.lun.utils.LogService
//import lombok.RequiredArgsConstructor
//import org.springframework.beans.factory.annotation.Autowired
//import org.springframework.beans.factory.annotation.Qualifier
//import org.springframework.context.annotation.Bean
//import org.springframework.http.client.reactive.ReactorClientHttpConnector
//import org.springframework.web.WebApplicationInitializer
//import org.springframework.web.context.ContextLoaderListener
//import org.springframework.web.context.support.AnnotationConfigWebApplicationContext
//import org.springframework.web.reactive.function.client.ClientRequest
//import org.springframework.web.reactive.function.client.ClientResponse
//import org.springframework.web.reactive.function.client.ExchangeFilterFunction
//import org.springframework.web.reactive.function.client.WebClient
//import org.springframework.web.servlet.DispatcherServlet
//import org.springframework.web.servlet.HandlerInterceptor
//import org.springframework.web.servlet.config.annotation.InterceptorRegistry
//import org.springframework.web.util.DefaultUriBuilderFactory
//import reactor.core.publisher.Mono
//import reactor.netty.Connection
//import reactor.netty.http.client.HttpClient
//import java.time.Duration
//import java.util.concurrent.TimeUnit
//import java.util.function.Consumer
//
//
//@RequiredArgsConstructor
//class WebConfig : WebApplicationInitializer {
//
// lateinit var logService : LogService
//
// var factory: DefaultUriBuilderFactory = DefaultUriBuilderFactory()
//
// var httpClient: HttpClient = HttpClient.create()
// .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) // 10초
//
//
// @Throws(ServletException::class)
// override fun onStartup(servletContext: ServletContext) {
// // Spring MVC 프로젝트 설정을 위해 작성하는 클래스의 객체를 생성한다.
// val servletAppContext = AnnotationConfigWebApplicationContext()
//// servletAppContext.register(ServletAppContext::class.java)
//
// // 요청 발생 시 요청을 처리하는 서블릿을 DispatcherServlet으로 설정해준다.
//// val dispatcherServlet = DispatcherServlet(servletAppContext)
//// val servlet = servletContext.addServlet("dispatcher", dispatcherServlet)
////
//// // 부가 설정
//// servlet.setLoadOnStartup(1)
//// servlet.addMapping("/")
//
// // Bean을 정의하는 클래스를 지정한다.
// val rootAppContext = AnnotationConfigWebApplicationContext()
// rootAppContext.register(RootAppContext::class.java)
//
// val listener = ContextLoaderListener(rootAppContext)
// servletContext.addListener(listener)
// }
//
//
//
//
// @Bean
// fun webClient(): WebClient {
// /**
// * 통신시 timeout 세팅
// * - connect, read, write 를 모두 5000ms
// */
//
// val httpClient = HttpClient.create()
// .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
// .responseTimeout(Duration.ofMillis(5000))
// .doOnConnected { conn: Connection ->
// conn.addHandlerLast(ReadTimeoutHandler(5000, TimeUnit.MILLISECONDS))
// .addHandlerLast(WriteTimeoutHandler(5000, TimeUnit.MILLISECONDS))
// }
//
// val webClient = WebClient.builder()
// .baseUrl("https://api.telegram.org/bot7934509464:AAE_xUbICxMdywLGnxo7BkeIqA1nVza4P9w")
// .clientConnector(ReactorClientHttpConnector(httpClient)) //생성한 HttpClient 연결
// //Request Header 로깅 필터
// .filter(
// ExchangeFilterFunction.ofRequestProcessor { clientRequest: ClientRequest ->
// logService.log(">>>>>>>>> REQUEST <<<<<<<<<<")
// logService.log("Request: ${clientRequest.method()} ${clientRequest.url()}")
// clientRequest.headers()
// .forEach { (name: String?, values: MutableList<String?>?) ->
// values.forEach(
// Consumer<String> { value: String? ->
// logService.log(
// "${name} : ${value}"
// )
// })
// }
// Mono.just<ClientRequest>(clientRequest)
// }
// ) //Response Header 로깅 필터
// .filter(
// ExchangeFilterFunction.ofResponseProcessor { clientResponse: ClientResponse ->
// logService.log(">>>>>>>>>> RESPONSE <<<<<<<<<<")
// clientResponse.headers().asHttpHeaders()
// .forEach { (name: String?, values: MutableList<String?>?) ->
// values.forEach(
// Consumer<String> { value: String? ->
// logService.log(
// "${name} ${value}"
// )
// })
// }
// Mono.just<ClientResponse>(clientResponse)
// }
// )
// .defaultHeader("Content-type", "application/x-www-form-urlencoded;charset=utf-8") //기본 헤더설정
// .build()
//
// return webClient
// }
//}
@@ -0,0 +1,117 @@
//import com.google.gson.Gson
//import okhttp3.*
//import okhttp3.MediaType.Companion.toMediaType
//import okhttp3.RequestBody.Companion.asRequestBody
//import okhttp3.RequestBody.Companion.toRequestBody
//import java.io.File
//import java.io.IOException
//
//// Gson 파싱을 위한 데이터 클래스
//data class LoginRequest(val userId: String, val userPw: String)
//data class LoginResponse(val token: String?)
//
///**
// * API 통합 테스트를 실행하는 메인 함수입니다.
// * IDE에서 직접 실행(▶)할 수 있습니다.
// */
//fun main() {
// val tester = ApiIntegrationTest()
// tester.runBookmarkTest()
//}
//
//class ApiIntegrationTest {
//
// private val client = OkHttpClient()
// private val gson = Gson()
// private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
//
// // --- 테스트 환경 설정 ---
// private val baseUrl = "http://localhost:443"
// private val testUserId = "lunaticbum"
// private val testUserPw = "VioPup*383"
// private val imageToUpload = File("test_image.jpg") // 프로젝트 루트에 있는 이미지 파일
//
// /**
// * 로그인 API를 호출하여 JWT 토큰을 반환합니다.
// */
// private fun loginAndGetToken(): String? {
// println("1. 로그인을 시도합니다...")
//
// val loginRequest = LoginRequest(userId = testUserId, userPw = testUserPw)
// val requestBody = gson.toJson(loginRequest).toRequestBody(jsonMediaType)
//
// val request = Request.Builder()
// .url("$baseUrl/api/auth/login")
// .post(requestBody)
// .build()
//
// try {
// client.newCall(request).execute().use { response ->
// if (!response.isSuccessful) {
// println("❌ 로그인 실패: ${response.code} - ${response.body?.string()}")
// return null
// }
// val responseBody = response.body?.string()
// val loginResponse = gson.fromJson(responseBody, LoginResponse::class.java)
// println("✅ 로그인 성공!")
// return loginResponse.token
// }
// } catch (e: IOException) {
// println("❌ 로그인 중 오류 발생: ${e.message}")
// return null
// }
// }
//
// /**
// * 발급받은 토큰을 사용하여 북마크 저장 API를 호출합니다.
// */
// private fun saveBookmarkWithToken(token: String) {
// println("\n2. 발급받은 토큰으로 북마크 저장을 시도합니다... ${imageToUpload.absolutePath}")
//
// if (!imageToUpload.exists()) {
// println("❌ 파일 없음: '${imageToUpload.path}' 경로에 테스트 이미지가 존재하지 않습니다.")
// return
// }
//
// // Multipart 요청 본문 생성
// val requestBody = MultipartBody.Builder()
// .setType(MultipartBody.FORM)
// .addFormDataPart(
// "bookmarkData",
// """{"url":"https://m.cafe.daum.net/dotax/Elgq/4636033","userComment":"Kotlin 테스트 코멘트","visibility":"PUBLIC"}"""
// )
// .addFormDataPart(
// "imageFile",
// imageToUpload.name,
// imageToUpload.asRequestBody("image/jpeg".toMediaType())
// )
// .build()
//
// val request = Request.Builder()
// .url("$baseUrl/api/bookmarks/with-image")
// .header("Authorization", "Bearer $token") // 헤더에 JWT 토큰 추가
// .post(requestBody)
// .build()
//
// try {
// client.newCall(request).execute().use { response ->
// println("✅ 북마크 저장 요청 완료! 응답 코드: ${response.code}")
// println("응답 내용: ${response.body?.string()}")
// }
// } catch (e: IOException) {
// println("❌ 북마크 저장 중 오류 발생: ${e.message}")
// }
// }
//
// /**
// * 전체 테스트 시나리오를 실행합니다.
// */
// fun runBookmarkTest() {
// val token = loginAndGetToken()
// if (token != null) {
// saveBookmarkWithToken(token)
// } else {
// println("\n테스트 중단: 로그인에 실패하여 북마크 저장을 진행할 수 없습니다.")
// }
// }
//}
@@ -16,3 +16,26 @@
//import org.springframework.stereotype.Component
//import java.time.LocalDateTime
//
import kr.lunaticbum.back.lun.service.FeedService
import kr.lunaticbum.back.lun.service.PostManager
import kr.lunaticbum.back.lun.service.StockMonitorService // 추가
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
@Component
@EnableScheduling
class BatchScheduler(
private val postManager: PostManager,
private val feedService: FeedService,
private val stockMonitorService: StockMonitorService // [추가] 주입
) {
// ... 기존 메서드들 ...
// [추가] 자동매매 모니터링 (예: 10초마다 실행)
@Scheduled(fixedDelay = 10000)
fun runAutoTrading() {
stockMonitorService.checkAndExecuteAutoSell()
}
}
@@ -1,93 +0,0 @@
package kr.lunaticbum.back.lun.configs
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.CacheControl
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import java.time.Duration
@Configuration
class AppConfig : WebMvcConfigurer {
@Value("\${resource.handler}")
private val resourceHandler: String? = null
@Value("\${resource.location}")
private val resourceLocation: String? = null
val cacheControl: CacheControl = CacheControl.maxAge(Duration.ofHours(1))
@Bean
fun authInterceptor(): BumsInterceptor {
return BumsInterceptor()
}
override fun addResourceHandlers(registry: org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry) {
registry.addResourceHandler(resourceHandler).addResourceLocations(resourceLocation).setCacheControl(cacheControl)
}
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(authInterceptor())
.addPathPatterns(
"/home.bs",
"/bums/where.bs" ,
"/tlg/repotToMe.bjx",
"/user/login.bs", "/user/signup.bs","/user/login.bjx",
"/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx"
)
// super.addInterceptors(registry)
}
// @Bean
// fun qdrantClient(): QdrantClient {
// return QdrantClient("https://ollama.lunaticbum.kr:6334")
// }
// @Bean
// fun chatClient(): OllamaApi {
// return OllamaApi("https://lama.lunaticbum.kr")
//
//// .withDefaultOptions(
//// OllamaOptions.create()
//// .withModel("phi4:14b")
//// .withNumThread(5)
//// .withSeed(5)
//// .withTemperature(0.9f))
// }
// @Bean
// fun getProperty() : Map<String,String>{
// println("telegramBotKey >>>> $telegramBotKey")
// println("telegramMyId >>>> $telegramMyId")
// println("weatherApiKey >>>> $weatherApiKey")
//
// return hashMapOf(Pair("telegramMyId",telegramMyId))
// }
// @Bean
// fun memberRepository(): MemberRepository {
// return MemoryMemberRepository()
// }
//
// @Bean
// fun discountPolicy(): DiscountPolicy {
// return RateDiscountPolicy()
// }
//
// @Bean
// fun memberService(): MemberService {
// return MemberServiceImpl(memberRepository())
// }
//
// @Bean
// fun orderService(): OrderService {
// return OrderServiceImpl(memberRepository(), discountPolicy())
// }
}
@@ -1,62 +0,0 @@
//package kr.lunaticbum.back.lun.configs
//
//import lombok.RequiredArgsConstructor
//import lombok.extern.slf4j.Slf4j
//import org.springframework.batch.core.Job
//import org.springframework.batch.core.Step
//import org.springframework.batch.core.StepContribution
//import org.springframework.batch.core.configuration.DuplicateJobException
//import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing
//import org.springframework.batch.core.configuration.support.DefaultBatchConfiguration
//import org.springframework.batch.core.job.builder.JobBuilder
//import org.springframework.batch.core.repository.JobRepository
//import org.springframework.batch.core.scope.context.ChunkContext
//import org.springframework.batch.core.step.builder.StepBuilder
//import org.springframework.batch.core.step.tasklet.Tasklet
//import org.springframework.batch.repeat.RepeatStatus
//import org.springframework.context.annotation.Bean
//import org.springframework.context.annotation.Configuration
//import org.springframework.transaction.PlatformTransactionManager
//import org.springframework.web.reactive.function.client.WebClient
//
//
//@Configuration
//@RequiredArgsConstructor
//class BatchConfig : DefaultBatchConfiguration() {
//
// @Bean
// @Throws(DuplicateJobException::class)
// fun testJob(jobRepository: JobRepository, transactionManager: PlatformTransactionManager?): Job {
// val job: Job = JobBuilder("testJob", jobRepository!!)
// .start(testStep(jobRepository, transactionManager))
// .build()
// return job
// }
//
// fun testStep(jobRepository: JobRepository?, transactionManager: PlatformTransactionManager?): Step {
// val step: Step = StepBuilder("testStep", jobRepository!!)
// .tasklet(testTasklet(), transactionManager!!)
// .build()
// return step
// }
//
// fun testTasklet(): Tasklet {
// return (Tasklet { contribution: StepContribution?, chunkContext: ChunkContext? ->
// println("***** hello batch! *****")
// val client0 = WebClient.create()
// val result = client0.get()
// .uri("http://api.weatherapi.com/v1/current.json?key=de574a260b1f474d99955729241909&q=seoul&aqi=no")
// .retrieve()
// .bodyToMono(String::class.java).block() ?: "FAIL"
//
//
// val client = WebClient.create()
// client.get()
// .uri("https://api.telegram.org/bot7934509464:AAE_xUbICxMdywLGnxo7BkeIqA1nVza4P9w/sendMessage?chat_id=71476436&text=${result}")
// .retrieve()
// .bodyToMono(String::class.java).block() ?: "FAIL"
//
// RepeatStatus.FINISHED
// })
// }
//}
@@ -1,71 +0,0 @@
package kr.lunaticbum.back.lun.configs
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import kr.lunaticbum.back.lun.model.User
import lombok.Getter
import lombok.RequiredArgsConstructor
import org.springframework.stereotype.Component
import java.security.Key
import java.util.*
import kotlin.collections.HashMap
@Component
class JwtGenerator {
fun generateAccessToken(ACCESS_SECRET: Key?, ACCESS_EXPIRATION: Long, user: User): String {
val now = System.currentTimeMillis()
return Jwts.builder()
.setHeader(createHeader())
.setClaims(createClaims(user))
.setSubject(user.userId)
.setExpiration(Date(now + ACCESS_EXPIRATION))
.signWith(ACCESS_SECRET, SignatureAlgorithm.HS256)
.compact()
}
fun generateRefreshToken(REFRESH_SECRET: Key?, REFRESH_EXPIRATION: Long, user: User): String {
val now = System.currentTimeMillis()
return Jwts.builder()
.setHeader(createHeader())
.setClaims(createClaims(user))
.setSubject(user.getIdentifier())
.setExpiration(Date(now + REFRESH_EXPIRATION))
.signWith(REFRESH_SECRET, SignatureAlgorithm.HS256)
.compact()
}
private fun createHeader(): Map<String, Any> {
val header: MutableMap<String, Any> = HashMap()
header["typ"] = "JWT"
header["alg"] = "HS256"
return header
}
private fun createClaims(user: User): Map<String, Any?> {
val claims: MutableMap<String, Any?> = HashMap()
claims["Identifier"] = user.getIdentifier()
claims["Role"] = user.getRole()
return claims
}
}
@RequiredArgsConstructor
@Getter
enum class TokenStatus {
AUTHENTICATED,
EXPIRED,
INVALID
}
@RequiredArgsConstructor
@Getter
enum class JwtRule(val value: String) {
JWT_ISSUE_HEADER("Set-Cookie"),
JWT_RESOLVE_HEADER("Cookie"),
ACCESS_PREFIX("access"),
REFRESH_PREFIX("refresh");
}
@@ -1,29 +0,0 @@
package kr.lunaticbum.back.lun.configs
import org.springframework.context.annotation.Configuration
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories
import org.springframework.scheduling.annotation.EnableAsync
@Configuration
@EnableMongoRepositories( basePackages = arrayOf("kr.lunaticbum.back.lun"))
@EnableAsync
class RootAppContext {
// @Bean
// fun mongoClient(): MongoClient {
// return MongoClient("localhost")
// }
// fun mongoDbFactory(): MongoDbFactory {
// return SimpleMongoDbFactory(mongoClient(), "test")
// }
// @Bean
// fun mongoTemplate(): MongoTemplate {
// return MongoTemplate(mongoDbFactory())
// }
// fun mongoTemplate() :MongoTemplate {
// return MongoTemplate()
// }
}
@@ -1,148 +0,0 @@
package kr.lunaticbum.back.lun.configs
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kr.lunaticbum.back.lun.model.MongoPersistentTokenRepository
import kr.lunaticbum.back.lun.model.UserManager
import kr.lunaticbum.back.lun.utils.LogService
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
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.configuration.WebSecurityCustomizer
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.AuthenticationException
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.security.web.authentication.RememberMeServices
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl
import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository
import org.springframework.web.ErrorResponse
import javax.sql.DataSource
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val userManager: UserManager,
private val bCryptPasswordEncoder: BCryptPasswordEncoder,
private val tokenRepository: MongoPersistentTokenRepository
) {
@Autowired
lateinit var logService: LogService
@Bean
fun webSecurityCustomizer(): WebSecurityCustomizer {
return WebSecurityCustomizer { web ->
web.ignoring().requestMatchers("/blog/post/images/**")
}
}
// RememberMeServices를 Bean으로 생성하고 필드에 할당하거나, 생성자 주입을 할 수 있음
@Bean
fun rememberMeServices(): RememberMeServices {
val key = "your-remember-me-key"
return PersistentTokenBasedRememberMeServices(key, userManager,
tokenRepository as PersistentTokenRepository?
).apply {
setParameter("remember-me") // 기본 파라미터명
setTokenValiditySeconds(86400) // 토큰 유효시간 설정
// 필요시 setAlwaysRemember(true) 등 추가 설정 가능
println("CALLED rememberMeServices")
}
}
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http.csrf { csrf ->
csrf.ignoringRequestMatchers(
"/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx",
"/blog/post/imageUpload.bjx", "/blog/post.bjx",
"/blog/post/images/**","/puzzle/**","/puzzle/play/**",
"/rank/**",
"/sudoku/**",
) // 여기 예외 추가
}.authorizeHttpRequests { auth ->
auth
.requestMatchers(
"/",
"/home.bs",
"/bums/where.bs" ,
"/tlg/repotToMe.bjx",
"/user/login.bs", "/user/signup.bs","/user/login.bjx",
"/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx",
// "/blog/post/imageUpload.bjx",
"/blog/post/images/**",
"/rank/**","/sudoku/**",
"/puzzle/play","/puzzle/2048","/puzzle/play/**","/puzzle/sudoku",
"/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll()
.anyRequest().authenticated()
}.formLogin { form ->
form.loginPage("/user/login.bs")
.defaultSuccessUrl("/", true)
.permitAll()
}.rememberMe { rememberMe ->
rememberMe.rememberMeServices(rememberMeServices())
.key("remember-BsTs*!12@") // 보통 안전한 키 지정
.tokenRepository(tokenRepository)
.tokenValiditySeconds(60 * 60 * 24 * 7) // 7일간 유효
.userDetailsService(userManager) // 사용자 정보 서비스 지정
}.logout { logout ->
logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll()
}
return http.build()
}
@Bean
fun authenticationManager(http: HttpSecurity): AuthenticationManager {
val authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder::class.java)
authenticationManagerBuilder
.userDetailsService(userManager)
.passwordEncoder(bCryptPasswordEncoder)
return authenticationManagerBuilder.build() // .and() 없이 직접 build() 호출
}
private val unauthorizedEntryPoint =
AuthenticationEntryPoint { request: HttpServletRequest?, response: HttpServletResponse, authException: AuthenticationException? ->
val fail: ErrorResponse = ErrorResponse.create( Throwable("아직 못들어와"),
HttpStatus.UNAUTHORIZED, "Spring security unauthorized..."
)
response.status = HttpStatus.UNAUTHORIZED.value()
val json = ObjectMapper().writeValueAsString(fail)
response.contentType = MediaType.APPLICATION_JSON_VALUE
val writer = response.writer
writer.write(json)
writer.flush()
}
private val accessDeniedHandler =
AccessDeniedHandler { request: HttpServletRequest?, response: HttpServletResponse, accessDeniedException: AccessDeniedException? ->
val fail: ErrorResponse = ErrorResponse.create( Throwable("아직 못들어와"),
HttpStatus.FORBIDDEN, "Spring security forbidden..."
)
response.status = HttpStatus.FORBIDDEN.value()
val json = ObjectMapper().writeValueAsString(fail)
response.contentType = MediaType.APPLICATION_JSON_VALUE
val writer = response.writer
writer.write(json)
writer.flush()
}
@Bean
fun bCryptPasswordEncoder(): BCryptPasswordEncoder {
return BCryptPasswordEncoder()
}
}
@@ -1,37 +0,0 @@
package kr.lunaticbum.back.lun.configs
//// Spring MVC 프로젝트에 관련된 설정을 하는 클래스
//@Configuration // Controller 어노테이션이 셋팅되어 있는 클래스를 Controller로 등록한다.
////@ComponentScan("kr.lunaticbum.back.lun.controllers")
//internal class ServletAppContext : WebMvcConfigurer {
// // // Controller의 메서드가 반환하는 jsp의 이름 앞뒤에 경로와 확장자를 붙혀주도록 설정한다.
//// override fun configureViewResolvers(registry: ViewResolverRegistry) {
//// // TODO Auto-generated method stub
//// super.configureViewResolvers(registry)
////// registry.viewResolver { viewName, locale -> }
////// registry.jsp("/WEB-INF/views/", ".jsp")
//// }
////
//// // 정적 파일의 경로를 매핑한다.
// override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
// // TODO Auto-generated method stub
//// super.addResourceHandlers(registry)
// registry
// .addResourceHandler("/")
//// .addResourceHandler("/**")
// .addResourceLocations("classpath:/META-INF/resources/")
// .addResourceLocations("classpath:/static/")
// .addResourceLocations("classpath:/templates/")
// .addResourceLocations("classpath:/templates/user/")
// .setCacheControl(CacheControl.maxAge(10,TimeUnit.SECONDS))
// super.addResourceHandlers(registry)
// }
//// @Autowired
//// @Qualifier(value = "authInterceptor")
//// private val authInterceptor: HandlerInterceptor? = null
////
//// override fun addInterceptors(registry: InterceptorRegistry) {
//// registry.addInterceptor(authInterceptor).addPathPatterns("/**")
//// }
//}
@@ -0,0 +1,53 @@
package kr.lunaticbum.back.lun.configs.core
import kr.lunaticbum.back.lun.configs.web.BumsInterceptor
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.CacheControl
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import java.time.Duration
@Configuration
class AppConfig : WebMvcConfigurer {
@Value("\${resource.handler}")
private val resourceHandler: String? = null
@Value("\${resource.location}")
private val resourceLocation: String? = null
val cacheControl: CacheControl = CacheControl.maxAge(Duration.ofHours(1))
@Bean
fun authInterceptor(): BumsInterceptor {
return BumsInterceptor()
}
override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
registry.addResourceHandler(resourceHandler).addResourceLocations(resourceLocation).setCacheControl(cacheControl)
}
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(authInterceptor())
.addPathPatterns(
"/home.bs",
"/bums/where.bs",
"/user/info", // "내 정보" 페이지도 추가하면 좋습니다.
"/tlg/repotToMe.bjx",
"/tlg/sendToMe.bjx",
"/user/login.bs", "/user/signup.bs", "/user/login.bjx","/bookmarks/**",
"/blog/viewer/**", "/blog/posts", "/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx"
)
}
}
@@ -1,4 +1,4 @@
package kr.lunaticbum.back.lun.configs
package kr.lunaticbum.back.lun.configs.core
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@@ -1,4 +1,4 @@
package kr.lunaticbum.back.lun.configs
package kr.lunaticbum.back.lun.configs.core
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Configuration
@@ -1,4 +1,4 @@
package kr.lunaticbum.back.lun.configs
package kr.lunaticbum.back.lun.configs.core
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.EnvironmentAware
@@ -14,7 +14,7 @@ class GlobalEnvironment : EnvironmentAware {
val EncType10 = "T2"
val EncType01 = "T1"
val ApiKeyWordKey = "keyword"
private val pad = "%7C%2A-%2A%7C"
private val pad = "|*-*|"
fun padding(key : String) = pad.plus(key).plus(pad)
}
@Value("\${telegram.bot.key}")
@@ -1,4 +1,4 @@
package kr.lunaticbum.back.lun.configs
package kr.lunaticbum.back.lun.configs.core
import jakarta.servlet.ServletContext
import org.springframework.web.WebApplicationInitializer
@@ -0,0 +1,71 @@
//package kr.lunaticbum.back.lun.configs
//
//import io.jsonwebtoken.Jwts
//import io.jsonwebtoken.SignatureAlgorithm
//import kr.lunaticbum.back.lun.model.User
//import lombok.Getter
//import lombok.RequiredArgsConstructor
//import org.springframework.stereotype.Component
//import java.security.Key
//import java.util.*
//import kotlin.collections.HashMap
//
//
//@Component
//class JwtGenerator {
// fun generateAccessToken(ACCESS_SECRET: Key?, ACCESS_EXPIRATION: Long, user: User): String {
// val now = System.currentTimeMillis()
//
// return Jwts.builder()
// .setHeader(createHeader())
// .setClaims(createClaims(user))
// .setSubject(user.userId)
// .setExpiration(Date(now + ACCESS_EXPIRATION))
// .signWith(ACCESS_SECRET, SignatureAlgorithm.HS256)
// .compact()
// }
//
// fun generateRefreshToken(REFRESH_SECRET: Key?, REFRESH_EXPIRATION: Long, user: User): String {
// val now = System.currentTimeMillis()
//
// return Jwts.builder()
// .setHeader(createHeader())
// .setClaims(createClaims(user))
// .setSubject(user.getIdentifier())
// .setExpiration(Date(now + REFRESH_EXPIRATION))
// .signWith(REFRESH_SECRET, SignatureAlgorithm.HS256)
// .compact()
// }
//
//
// private fun createHeader(): Map<String, Any> {
// val header: MutableMap<String, Any> = HashMap()
// header["typ"] = "JWT"
// header["alg"] = "HS256"
// return header
// }
//
// private fun createClaims(user: User): Map<String, Any?> {
// val claims: MutableMap<String, Any?> = HashMap()
// claims["Identifier"] = user.getIdentifier()
// claims["Role"] = user.getRole()
// return claims
// }
//}
//
//@RequiredArgsConstructor
//@Getter
//enum class TokenStatus {
// AUTHENTICATED,
// EXPIRED,
// INVALID
//}
//
//@RequiredArgsConstructor
//@Getter
//enum class JwtRule(val value: String) {
// JWT_ISSUE_HEADER("Set-Cookie"),
// JWT_RESOLVE_HEADER("Cookie"),
// ACCESS_PREFIX("access"),
// REFRESH_PREFIX("refresh");
//}
@@ -0,0 +1,449 @@
package kr.lunaticbum.back.lun.configs.security
import com.fasterxml.jackson.databind.ObjectMapper
import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.MalformedJwtException
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kr.lunaticbum.back.lun.model.MongoPersistentTokenRepository
import kr.lunaticbum.back.lun.model.UserManager
import kr.lunaticbum.back.lun.utils.LogService
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
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.configuration.WebSecurityCustomizer
import org.springframework.security.core.AuthenticationException
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.security.web.authentication.RememberMeServices
import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository
import org.springframework.web.ErrorResponse
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
import jakarta.servlet.FilterChain
import kr.lunaticbum.back.lun.utils.JwtUtil
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.context.SecurityContext
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.security.web.context.HttpRequestResponseHolder
import org.springframework.security.web.context.HttpSessionSecurityContextRepository
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository
import org.springframework.security.web.context.SecurityContextRepository
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
import org.springframework.security.web.util.matcher.NegatedRequestMatcher
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import java.security.SignatureException
import java.util.Date
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // @PreAuthorize 어노테이션을 사용하기 위해 추가
class SecurityConfig(
private val jwtUtil: JwtUtil,
private val userManager: UserManager,
private val bCryptPasswordEncoder: BCryptPasswordEncoder,
private val tokenRepository: MongoPersistentTokenRepository,
private val customAccessDeniedHandler: CustomAccessDeniedHandler
) {
@Autowired
lateinit var logService: LogService
@Bean
fun securityContextRepository(): SecurityContextRepository {
return ApiAndWebSecurityContextRepository()
}
@Bean
fun webSecurityCustomizer(): WebSecurityCustomizer {
// 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다.
return WebSecurityCustomizer { web ->
web.ignoring().requestMatchers( "/images/**")
}
}
val key = "your-remember-me-key"
@Bean
fun rememberMeServices(): RememberMeServices {
return PersistentTokenBasedRememberMeServices(key, userManager, tokenRepository).apply {
setParameter("rememberMe") // [핵심] JS에서 보내는 이름과 일치시킴 ('rememberMe')
setTokenValiditySeconds(86400 * 14) // 2주 (14일) 유지
setAlwaysRemember(false) // 사용자가 체크했을 때만 기억
}
}
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val configuration = CorsConfiguration()
configuration.allowedOrigins = listOf("*")
configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
configuration.allowedHeaders = listOf("*")
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", configuration)
return source
}
@Bean
@Order(1) // API 보안 설정을 먼저 적용
fun apiFilterChain(http: HttpSecurity): SecurityFilterChain {
http.securityContext { context ->
context.securityContextRepository(securityContextRepository())
}
.securityMatcher { request ->
val path = request.servletPath
path.startsWith("/api/") && !path.startsWith("/api/stock/")
}
.csrf { it.disable() }
.cors { it.configurationSource(corsConfigurationSource()) }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음
.authorizeHttpRequests { auth ->
auth
.requestMatchers("/api/synology/**").permitAll()
.requestMatchers(HttpMethod.GET,"/api/feed").permitAll()
.requestMatchers("/api/stock/**").permitAll()
.requestMatchers("/api/ranks/**").permitAll()
.requestMatchers("/api/stats/visitors").permitAll()
.requestMatchers(HttpMethod.GET,"/api/stock/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/images/**").permitAll()
.requestMatchers("/api/auth/login").permitAll() // 로그인 API는 모두 허용
.anyRequest().authenticated() // 나머지 API는 인증 필요
}
.exceptionHandling { handling ->
// handling.authenticationEntryPoint(jwtAuthenticationEntryPoint())
handling.accessDeniedHandler(accessDeniedHandler2)
}
// [수정 포인트] 필터 추가 순서 변경
// 1. JWT 필터 인스턴스 생성
val jwtFilter = JwtAuthenticationFilter(jwtUtil, userManager)
// 2. 디버깅 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
// (체인 상태: ... -> DebugFilter -> UsernamePasswordAuthenticationFilter)
http.addFilterBefore(object : OncePerRequestFilter() {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
if (request.requestURI.startsWith("/api/synology")) {
val auth = SecurityContextHolder.getContext().authentication
println(">>> SECURITY DEBUG: [${request.method}] ${request.requestURI}")
println(" User: ${auth?.name ?: "Anonymous"}")
println(" Authorities: ${auth?.authorities}")
}
filterChain.doFilter(request, response)
}
}, UsernamePasswordAuthenticationFilter::class.java)
// 3. JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
// addFilterBefore는 타겟 바로 앞에 끼워넣으므로,
// 결과 체인 순서는: DebugFilter -> JwtAuthenticationFilter -> UsernamePasswordAuthenticationFilter 가 됩니다.
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build()
}
@Bean
fun jwtAuthenticationEntryPoint(): AuthenticationEntryPoint {
return AuthenticationEntryPoint { request, response, authException ->
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.contentType = MediaType.APPLICATION_JSON_VALUE
val body = mapOf(
"status" to HttpServletResponse.SC_UNAUTHORIZED,
"error" to "Unauthorized",
"message" to (authException.message ?: "JWT Authentication Failed"),
"path" to request.servletPath
)
ObjectMapper().writeValue(response.outputStream, body)
}
}
@Bean
@Order(2) // 웹 페이지 보안 설정
fun webFilterChain(http: HttpSecurity): SecurityFilterChain {
// http.securityMatcher(NegatedRequestMatcher(AntPathRequestMatcher("/api/**")))
http.securityMatcher(NegatedRequestMatcher { request ->
val path = request.servletPath
path.startsWith("/api/") && !path.startsWith("/api/stock/")
})
http.cors { }
.csrf { csrf ->
csrf.ignoringRequestMatchers(
"/api/**", // <-- 이 줄을 추가하세요!
"/user/login.bjx",
"/user/joinUser.bjx",
"/tlg/repotToMe.bjx",
"/tlg/sendToMe.bjx",
"/tlg/webhook",
"/api/ranks/submit",
"/bums/save/loc.api",
"/puzzle/**",
)
}.authorizeHttpRequests { auth ->
auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// 1. 정적 리소스 = permitAll
.requestMatchers(
"/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**"
).permitAll()
.requestMatchers("/api/synology/**").permitAll()
.requestMatchers(HttpMethod.GET,"/stock/**").permitAll()
// 2. 공개 GET API 및 페이지 = permitAll
.requestMatchers(HttpMethod.GET,
"/api/images/**",
"/stock/**",
"/", "/home.bs", "/bums/where.bs",
"/user/login.bs", "/user/join.bs",
"/blog/viewer/**", "/blog/posts",
"/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx",
"/blog/posts/{postId}/comments.bjx", "/blog/comments/{commentId}/replies.bjx",
"/blog/categories.bjx", "/blog/hashtags.bjx",
"/puzzle/**", "/api/ranks/list", "/licenses",
"/puzzle/images/**",
"/bums/face.bs", // [추가] 사이트 소개 페이지
"/bookmarks/**", // [추가] 북마크 목록 페이지
"/ads.txt",
"/tlg/webhook",
"/slideshow"
).permitAll()
// 3. 공개 POST API = permitAll
.requestMatchers(HttpMethod.POST,
"/user/login.bjx",
"/user/joinUser.bjx",
"/api/ranks/submit",
"/bums/save/loc.api",
"/puzzle/**",
"/tlg/repotToMe.bjx",
"/tlg/webhook",
"/tlg/sendToMe.bjx",
"/blog/post/*/like.bjx",
"/blog/post/*/unlike.bjx",
"/bookmarks/*/like", // [추가] 북마크 좋아요
"/bookmarks/*/unlike",
"/ads.txt"
// [추가] 북마크 싫어요
).permitAll()
// 4. 'WRITE' 또는 'ADMIN' 권한이 필요한 요청
.requestMatchers(
"/blog/edit/**",
"/blog/post.bjx",
"/blog/post/imageUpload.bjx"
).hasAnyRole("WRITE", "ADMIN")
// 5. 'ADMIN' 권한이 필요한 요청 (my_info.html의 관리자 기능)
.requestMatchers(
"/user/approve-writer/**", "/user/reject-writer/**",
"/blog/post/*/block", "/blog/post/*/unblock",
"/api/images/*/approve-banner",
"/api/images/*/revoke-banner"
).hasRole("ADMIN")
// 6. 나머지 모든 요청 = authenticated (인증 필요)
.anyRequest().authenticated()
}
.formLogin { form ->
form
.loginPage("/home.bs?action=login") // 로그인 페이지 (GET)
.loginProcessingUrl("/login.bjx") // [핵심] 로그인 폼이 제출되는 주소 (POST)
.defaultSuccessUrl("/") // 성공 시 이동할 주소
.failureUrl("/home.bs?action=login&error=true") // 실패 시 이동할 주소
}
.rememberMe { rememberMe ->
rememberMe.rememberMeServices(rememberMeServices())
.key(key)
.tokenRepository(tokenRepository)
.tokenValiditySeconds(60 * 60 * 24 * 7)
.userDetailsService(userManager)
}.logout { logout ->
logout.logoutUrl("/user/logout.bs").logoutSuccessUrl("/").permitAll()
}.exceptionHandling { handling ->
handling.accessDeniedHandler(customAccessDeniedHandler)
// .authenticationEntryPoint(unauthorizedEntryPoint) // 인증되지 않은 사용자가 접근 시
// .accessDeniedHandler(accessDeniedHandler) // 인증은 되었으나 권한이 없는 사용자가 접근 시
}
return http.build()
}
private val accessDeniedHandler2 =
AccessDeniedHandler { request: HttpServletRequest?, response: HttpServletResponse, accessDeniedException: AccessDeniedException? ->
println("${accessDeniedException?.message }\nSpring security forbidden...")
val fail: ErrorResponse = ErrorResponse.create( Throwable("권한이 없습니다."),
HttpStatus.FORBIDDEN, "${accessDeniedException?.message }\nSpring security forbidden..."
)
response.status = HttpStatus.FORBIDDEN.value()
val json = ObjectMapper().writeValueAsString(fail)
response.contentType = MediaType.APPLICATION_JSON_VALUE
val writer = response.writer
writer.write(json)
writer.flush()
}
@Bean
fun authenticationManager(http: HttpSecurity): AuthenticationManager {
val authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder::class.java)
authenticationManagerBuilder
.userDetailsService(userManager)
.passwordEncoder(bCryptPasswordEncoder)
return authenticationManagerBuilder.build()
}
private val unauthorizedEntryPoint =
AuthenticationEntryPoint { request: HttpServletRequest?, response: HttpServletResponse, authException: AuthenticationException? ->
val fail: ErrorResponse = ErrorResponse.create( Throwable("아직 못들어와"),
HttpStatus.UNAUTHORIZED, "Spring security unauthorized..."
)
response.status = HttpStatus.UNAUTHORIZED.value()
val json = ObjectMapper().writeValueAsString(fail)
response.contentType = MediaType.APPLICATION_JSON_VALUE
val writer = response.writer
writer.write(json)
writer.flush()
}
private val accessDeniedHandler =
AccessDeniedHandler { request: HttpServletRequest?, response: HttpServletResponse, accessDeniedException: AccessDeniedException? ->
val fail: ErrorResponse = ErrorResponse.create( Throwable("아직 못들어와"),
HttpStatus.FORBIDDEN, "Spring security forbidden..."
)
response.status = HttpStatus.FORBIDDEN.value()
val json = ObjectMapper().writeValueAsString(fail)
response.contentType = MediaType.APPLICATION_JSON_VALUE
val writer = response.writer
writer.write(json)
writer.flush()
}
@Bean
fun bCryptPasswordEncoder(): BCryptPasswordEncoder {
return BCryptPasswordEncoder()
}
}
class JwtAuthenticationFilter(
private val jwtUtil: JwtUtil,
private val userManager: UserManager
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val authHeader = request.getHeader("Authorization")
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response)
return
}
try {
val jwt = authHeader.substring(7)
val username = jwtUtil.extractUsername(jwt)
if (SecurityContextHolder.getContext().authentication == null) {
val userDetails = this.userManager.loadUserByUsername(username)
if (jwtUtil.isTokenValid(jwt, userDetails)) {
println("jwtUtil.isTokenValid($jwt, $userDetails)")
val authToken = UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.authorities
)
authToken.details = WebAuthenticationDetailsSource().buildDetails(request)
println("authToken.details >>> ${authToken.details}")
SecurityContextHolder.getContext().authentication = authToken
}
}
} catch (e: ExpiredJwtException) {
println("JWT Error: Token has expired - ${e.message}")
} catch (e: SignatureException) {
println("JWT Error: Signature validation failed - ${e.message}")
} catch (e: MalformedJwtException) {
println("JWT Error: Malformed token - ${e.message}")
} catch (e: Exception) {
println("JWT Error: Could not set user authentication in security context - ${e.message}")
}
filterChain.doFilter(request, response)
println("JWT Token validated")
}
}
@Component // 이 클래스를 Spring Bean으로 등록
class CustomAccessDeniedHandler : AccessDeniedHandler {
override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
accessDeniedException: AccessDeniedException
) {
// 1. 요청(Request) 객체에 오류 정보를 속성(Attribute)으로 담습니다.
request.setAttribute("timestamp", Date())
request.setAttribute("exception", accessDeniedException)
request.setAttribute("path", request.requestURI)
// 2. 응답 상태 코드를 403 (Forbidden)으로 설정합니다.
response.status = HttpServletResponse.SC_FORBIDDEN
// 3. /access-denied 경로로 요청을 전달(Forward)합니다.
// Redirect가 아닌 Forward를 사용해야 request에 담은 정보가 유지됩니다.
val dispatcher = request.getRequestDispatcher("/access-denied")
dispatcher.forward(request, response)
}
}
class ApiAndWebSecurityContextRepository : SecurityContextRepository {
// API 요청은 /api/** 패턴에 매칭됩니다.
// private val apiRequestMatcher = AntPathRequestMatcher("/api/**")
private val apiRequestMatcher = RequestMatcher { request ->
val path = request.servletPath
path.startsWith("/api/") && !path.startsWith("/api/stock/")
}
// API 요청에 대해서는 세션을 전혀 사용하지 않고, 오직 요청 기간 동안만 SecurityContext를 저장합니다. (완벽한 STATELESS)
private val apiContextRepository = RequestAttributeSecurityContextRepository()
// 그 외 모든 웹 요청에 대해서는 기본 HttpSession 리포지토리를 사용합니다 (STATEFUL).
private val webContextRepository = HttpSessionSecurityContextRepository()
override fun loadContext(requestResponseHolder: HttpRequestResponseHolder): SecurityContext {
val request = requestResponseHolder.request
return if (apiRequestMatcher.matches(request)) {
apiContextRepository.loadContext(requestResponseHolder)
} else {
webContextRepository.loadContext(requestResponseHolder)
}
}
override fun saveContext(context: SecurityContext, request: HttpServletRequest, response: HttpServletResponse) {
if (apiRequestMatcher.matches(request)) {
apiContextRepository.saveContext(context, request, response)
} else {
webContextRepository.saveContext(context, request, response)
}
}
override fun containsContext(request: HttpServletRequest): Boolean {
return if (apiRequestMatcher.matches(request)) {
apiContextRepository.containsContext(request)
} else {
webContextRepository.containsContext(request)
}
}
}
@@ -1,28 +1,29 @@
package kr.lunaticbum.back.lun.configs
package kr.lunaticbum.back.lun.configs.web
import com.google.gson.Gson
import jakarta.servlet.http.Cookie
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncTypeKey
import kr.lunaticbum.back.lun.model.UserManager
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment.Companion.ApiKeyWordKey
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment.Companion.EncType11
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment.Companion.EncTypeKey
import kr.lunaticbum.back.lun.utils.JwtUtil
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.lang.Nullable
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.RememberMeServices
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Component
import org.springframework.stereotype.Service
import org.springframework.web.servlet.HandlerInterceptor
import org.springframework.web.servlet.ModelAndView
@Component
class BumsInterceptor : HandlerInterceptor {
class BumsInterceptor(
) : HandlerInterceptor {
@Autowired
lateinit var globalEvv : GlobalEnvironment
@Autowired
lateinit var jwtUtil: JwtUtil
val WRITE_PERMISSION_KEY = "PERMISSION"
@Throws(Exception::class)
@@ -31,7 +32,7 @@ class BumsInterceptor : HandlerInterceptor {
// if (!skippResourcesExtension) {
// println("===============================================")
// println("==================== BEGIN ====================")
// println("Request URL ===> " + request.requestURL)
println("Request URL ===> " + request.requestURL)
// }
@@ -48,28 +49,27 @@ class BumsInterceptor : HandlerInterceptor {
handler: Any,
@Nullable modelAndView: ModelAndView?
) {
modelAndView?.modelMap?.put(EncTypeKey, EncType11)
modelAndView?.modelMap?.put(ApiKeyWordKey,"Def")
if (modelAndView != null) {
println("modelAndView modelMap size >>> ${modelAndView?.modelMap?.keys?.size}")
// [수정] modelAndView가 null이 아닐 경우에만 로직을 실행하도록 변경합니다.
if (modelAndView != null && modelAndView.hasView()) {
modelAndView.modelMap.put(EncTypeKey, EncType11)
modelAndView.modelMap.put(ApiKeyWordKey, "Def")
println("modelMap 내용 추가 완료: ${modelAndView.modelMap}")
} else {
val authentication = SecurityContextHolder.getContext().authentication
val principal = authentication?.principal
var jwtToken: String? = null
if (principal is UserDetails) {
jwtToken = jwtUtil.generateToken(principal)
}
modelAndView.modelMap.put("jwtToken", jwtToken)
}else {
println("modelAndView가 null이라 모델에 값 추가 불가")
}
super.postHandle(request, response, handler, modelAndView)
}
fun cookieUpdate(cookie: Cookie?) : Cookie? {
cookie?.maxAge = (globalEvv.ACCESS_EXPIRATION / 1000).toInt()
cookie?.domain = "lunaticbum.kr"
cookie?.secure = true
cookie?.path = "/"
return cookie
}
}
@@ -0,0 +1,42 @@
package kr.lunaticbum.back.lun.configs
import kr.lunaticbum.back.lun.model.User
import kr.lunaticbum.back.lun.model.UserManager // UserManager가 있는 패키지 import (확인 필요)
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ModelAttribute
import reactor.core.publisher.Mono
@ControllerAdvice
class GlobalControllerAdvice(
private val userManager: UserManager // [추가] 유저 정보를 조회하기 위해 주입
) {
@Value("\${api.base-url}")
private lateinit var apiBaseUrl: String
@ModelAttribute("apiBaseUrl")
fun addApiBaseUrlToModel(): String {
return apiBaseUrl
}
// [추가] 로그인한 경우, User 엔티티(테마 정보 포함)를 모델에 "user"라는 이름으로 추가
// @ModelAttribute("user")
// fun addUserToModel(@AuthenticationPrincipal userDetails: UserDetails?): Mono<User> {
// return if (userDetails != null) {
// userManager.findById(userDetails.username)
// } else {
// Mono.empty()
// }
// }
@ModelAttribute("user")
fun currentUser(@AuthenticationPrincipal userDetails: UserDetails?): User? {
if (userDetails == null) return null
// [수정] Mono 객체를 그대로 반환하지 말고 .block()을 통해 실제 객체를 반환해야 합니다.
return userManager.findById(userDetails.username).block()
}
}
@@ -0,0 +1,55 @@
package kr.lunaticbum.back.lun.configs.web
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.LoggerFactory
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // 👈 1. 모든 필터 중 가장 먼저 실행되도록 설정
class RequestLoggingFilter : OncePerRequestFilter() {
// 2. Slf4j 로거 생성 (SecurityConfig의 LogService 대신 표준 로거 사용)
private val log = LoggerFactory.getLogger(RequestLoggingFilter::class.java)
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val requestUri = request.requestURI
// 3. (선택적) 'puzzle' 관련 요청만 로깅하여 로그 양 조절
val shouldLog = requestUri.contains("puzzle") || requestUri.contains("api")
if (shouldLog) {
val method = request.method
val queryString = if (request.queryString != null) "?${request.queryString}" else ""
log.info(">>> REQUEST: [$method] $requestUri$queryString")
}
try {
// 4. 실제 요청 처리 (다음 필터 또는 컨트롤러로 전달)
filterChain.doFilter(request, response)
} finally {
// 5. 응답이 나갈 때 상태 코드 로깅
val status = response.status
// ⚠️ 301 리디렉션이 발생하면 경고(WARN) 레벨로 상세히 로깅
if (status == HttpServletResponse.SC_MOVED_PERMANENTLY) { // 301
val location = response.getHeader("Location") // 리디렉션 대상 URL
log.warn("<<< RESPONSE 301 (Moved Permanently):")
log.warn("<<< FROM: [${request.method}] ${request.requestURI}")
log.warn("<<< TO: $location") // 👈 이 주소를 확인하세요!
}
// (선택적) 그 외 'puzzle' 요청의 응답 로깅
else if (shouldLog) {
log.info("<<< RESPONSE: $status FOR [${request.method}] $requestUri")
}
}
}
}
@@ -0,0 +1,30 @@
package kr.lunaticbum.back.lun.configs.web
import io.netty.channel.ChannelOption
import io.netty.handler.timeout.ReadTimeoutHandler
import io.netty.handler.timeout.WriteTimeoutHandler
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.web.reactive.function.client.WebClient
import reactor.netty.http.client.HttpClient
import java.util.concurrent.TimeUnit
@Configuration
class WebClientConfig {
@Bean
fun webClient(): WebClient {
// Netty HttpClient에 타임아웃 설정
val httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) // 연결 타임아웃 5초
.doOnConnected { conn ->
conn.addHandlerLast(ReadTimeoutHandler(5, TimeUnit.SECONDS)) // 읽기 타임아웃 5초
.addHandlerLast(WriteTimeoutHandler(5, TimeUnit.SECONDS)) // 쓰기 타임아웃 5초
}
return WebClient.builder()
.clientConnector(ReactorClientHttpConnector(httpClient))
.build()
}
}
@@ -1,428 +0,0 @@
package kr.lunaticbum.back.lun.controllers
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.Metadata
import com.google.gson.Gson
import com.google.maps.GeoApiContext
import com.google.maps.GeocodingApi
import com.google.maps.model.LatLng
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncTypeKey
import kr.lunaticbum.back.lun.model.*
import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.getFileExtension
import net.coobird.thumbnailator.Thumbnails
import org.commonmark.node.Node
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.io.Resource
import org.springframework.core.io.UrlResource
import org.springframework.data.domain.Pageable
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono
import java.io.*
import java.net.URLDecoder
import java.text.SimpleDateFormat
import java.util.*
@RestController
@RequestMapping("/blog")
class BlogController() {
companion object {
val TEMPTOKEN = "TEMP_TOKEN_VIBUM"
}
@Autowired
lateinit var globalEvv : GlobalEnvironment
@Autowired
private lateinit var locationLogService: LocationLogService
@Autowired
private lateinit var postManager: PostManager
@Autowired
lateinit var logService: LogService
val WRITE_PERMISSION_KEY = "PERMISSION"
@GetMapping("write/{token}","write.bs")
fun writ(@PathVariable token : String? ) : ResultMV{
val vm = ResultMV("content/blog/write")
if (token.equals(TEMPTOKEN)) {
vm.modelMap.put(WRITE_PERMISSION_KEY,"OK")
vm.modelMap.put(EncTypeKey, EncType11)
vm.modelMap.put(ApiKeyWordKey,"WRITE")
vm.modelMap.put("title","회원이 들어는 구나~!!")
vm.modelMap.put("defaultTitle","무제(無題) (Untitled, ${SimpleDateFormat("yyyy-MM-dd HH:mm").format(Date())})")
} else {
vm.modelMap.put(WRITE_PERMISSION_KEY,"NO")
}
return vm
}
@PostMapping("post.bjx")
fun post(httpServletRequest: HttpServletRequest, @RequestBody jsonString: String) : ResponseEntity<ResponceResult> {
logService.log(httpServletRequest.requestURI)
logService.log(jsonString)
var lResultCode = 0
var lResultMsg = "Suscces"
val decodedBytes: ByteArray = Base64.getDecoder().decode(jsonString)
String(decodedBytes).let {
Gson().fromJson<RequestModel>(it, RequestModel::class.java)?.let { model ->
logService.log(Gson().toJson(model))
model.data?.let { jsonString ->
try {
val reqString = jsonString.split(GlobalEnvironment.padding(model.getKeyword()))
val nb = arrayListOf<String>()
val na = arrayListOf<String>()
reqString[0].replace(GlobalEnvironment.padding(model.getKeyword()),"").split("").toList().let { na.addAll(it) }
reqString[1].replace(GlobalEnvironment.padding(model.getKeyword()),"").split("").toList().let { nb.addAll(it) }
var max = nb.size + na.size
var fullData = arrayListOf<String>()
for (idx in 0..max) { if (idx % 2 == 0) { if (nb.size > 0) { fullData.add(nb.removeLast()) } } else { if (na.size > 0) { fullData.add(na.removeLast()) } } }
logService.log(fullData.joinToString(""))
var target = Gson().fromJson(fullData.joinToString(""), Post::class.java) ?: Post()
if (target.writeTime < 1L) {
target.id = null
target.writeTime = System.currentTimeMillis()
} else {
logService.log("target.writeTime >>> ${target.writeTime}")
target.modifyTime = System.currentTimeMillis()
postManager.save(target)
target = Gson().fromJson(fullData.joinToString(""), Post::class.java) ?: Post()
target.originId = target.id
target.id = null
}
var postMono = postManager.save(target)
if (postMono != null) {
lResultMsg = "save post"
lResultCode = 0
} else {
lResultMsg = "not founding user[can't find same id,email.. ]"
lResultCode = 7100
}
} catch (e: Exception) {
e.printStackTrace()
lResultMsg = "unknown exception"
lResultCode = 7999
}
}
}
}
val responce = ResponseEntity.ok().headers {
}.contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply {
this.resultCode = lResultCode
this.resultMsg = lResultMsg
})
return responce
}
@GetMapping("rankOfViews.bjx")
fun rankOfViews(httpServletRequest: HttpServletRequest): Mono<ResponseEntity<PostsResult>> {
logService.log(httpServletRequest.requestURI)
val resultCode = 0
val resultMsg = "Success"
return postManager.getTop10Posts()
.collectList() // Flux<Post> -> Mono<List<Post>>
.map { postsList ->
val postsResult = PostsResult().apply {
this.resultCode = resultCode
this.resultMsg = resultMsg
this.posts = postsList // List<Post> 할당 가능
}
ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(postsResult)
}
}
@GetMapping("recentOfPost.bjx")
fun recentOfPost(httpServletRequest: HttpServletRequest): Mono<ResponseEntity<PostsResult>> {
logService.log(httpServletRequest.requestURI)
val resultCode = 0
val resultMsg = "Success"
return postManager.getRecent10Posts()
.collectList() // Flux<Post> -> Mono<List<Post>>
.map { postsList ->
val postsResult = PostsResult().apply {
this.resultCode = resultCode
this.resultMsg = resultMsg
this.posts = postsList // List<Post> 할당 가능
}
ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(postsResult)
}
}
@GetMapping("viewer/{postId}")
fun viewer(@PathVariable postId : String) : ResultMV{
val vm = ResultMV("content/blog/viewer")
postManager.getPost(postId).block().apply {
this?.title = URLDecoder.decode(this?.title)
println("this?.content >>> ${this?.content}")
if (this?.content is String){
this?.content = URLDecoder.decode(this?.content)
} else {
this?.content = Gson().toJson(this?.content)
}
globalEvv.gapiKey?.let {
if (this?.firstAddress?.length ?: 0 < 4){
try {
var addrs = GeocodingApi.reverseGeocode(GeoApiContext.Builder().apiKey(it).build(), LatLng(this?.firstPostLat!!,this?.firstPostLon!!)).await()
this.firstAddress = addrs.first().formattedAddress
postManager.save(this)
} catch (e: Exception) {}
}
if (this?.modifyAddress?.length ?: 0 < 4){
try {
var addrs = GeocodingApi.reverseGeocode(GeoApiContext.Builder().apiKey(it).build(), LatLng(this?.modifyLat!!,this?.modifyLon!!)).await()
this.modifyAddress = addrs.first().formattedAddress
postManager.save(this)
} catch (e: Exception) {}
}
}
vm.modelMap.put("srcPost",this)
}
return vm
}
@GetMapping("modify.bs")
fun modify(httpServletRequest: HttpServletRequest, @RequestParam("token") token : String?) : ResultMV{
logService.log("incoming modify")
val vm = ResultMV("content/blog/modify")
val authentication = SecurityContextHolder.getContext().authentication
val principal = authentication.principal
if (principal is UserDetails) {
val username = principal.username
// 추가 정보 사용 가능
postManager.find20()?.apply {
forEach {
it.title = URLDecoder.decode(it.title)
val content = URLDecoder.decode(it.content)
it.content = if (content.length > 50) content.substring(0,150) else content
}
vm.modelMap.put("chunkedPosts", this.chunked(3))
}
vm.modelMap.put(WRITE_PERMISSION_KEY,"OK")
vm.modelMap.put("path","editor/")
vm.modelMap.put("SK",token)
}
vm.modelMap.put("rowKey","chunkedPosts_")
return vm
}
@GetMapping("editor/{postId}")
fun editor(@PathVariable postId : String) : ResultMV{
val vm = ResultMV("content/blog/editor")
postManager.getPost(postId).block().apply {
this?.title = URLDecoder.decode(this?.title)
this?.content = URLDecoder.decode(this?.content)
vm.modelMap.put("srcPost",this)
}
return vm
}
@GetMapping("posts")
fun posts(pageable: Pageable) : ResultMV{
val vm = ResultMV("content/blog/posts")
try {
vm.modelMap.put("Posts", postManager.find20(pageable).apply {
this.forEach {
println("it.id ==> ${it.id}")
it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content)
val parser: Parser = Parser.builder().build()
val document: Node = parser.parse(it.content)
val renderer = HtmlRenderer.builder().build()
Jsoup.parse(renderer.render(document))?.let { doc ->
val firstImg: Element? = doc.select("img")?.first()
val imgSrc: String = firstImg?.attr("src") ?: ""
it.image = imgSrc
it.thumb = imgSrc.replace(imgSrc.split("/").last(), imgSrc.split("/").last().replace(".","_thumbnail."))
generateThumbnail(imgSrc.split("/").last(), 200)
it.html = doc.text()
}
it.title = if ((it.title?.length ?: 0) >= 1) it.title else ""
}
})
}catch (ex: Exception){ex.printStackTrace()}
return vm
}
fun generateThumbnail(originalPath: String, targetWidth: Int) {
try {
val originalFile = File("$uploadPath${File.separator}$originalPath")
println("origin ${originalPath}")
println("thumb ${originalPath
.replace(".", "_thumbnail.")}")
// 썸네일 경로 생성 (예: /upload/uuid.jpg → /upload/uuid_thumbnail.jpg)
val thumbnailPath = originalPath
.replace(".", "_thumbnail.")
val thumbnailFile = File("$uploadPath${File.separator}$thumbnailPath")
// 썸네일 이미 존재하면 종료
if (thumbnailFile.exists()) {
println("썸네일 이미 존재: $thumbnailPath")
return
}
// 원본 파일 존재 확인
if (!originalFile.exists()) {
println("원본 파일 없음: $originalPath")
return
}
// 썸네일 생성 (가로 기준 비율 유지)
Thumbnails.of(originalFile)
.width(targetWidth)
.keepAspectRatio(true)
.toFile(thumbnailFile)
println("썸네일 생성 완료: $thumbnailPath")
} catch (e: IOException) {
println("썸네일 생성 실패: ${e.message}")
}
}
@GetMapping("recent")
fun recent() : ResultMV{
val vm = ResultMV("content/blog/viewer")
locationLogService.find10().forEach {
logService.log(Gson().toJson(it))
}
locationLogService.getLocationLog()?.let {
try {
val client0 = WebClient.create()
val result = client0.get()
.uri("http://api.weatherapi.com/v1/current.json?key=${globalEvv.weatherApiKey}&q=${it.mLatitude},${it.mLongitude}&aqi=no")
.retrieve()
.bodyToMono(String::class.java)
.block() ?: "FAIL"
Gson().fromJson(result, CurrentWeather::class.java)?.let { sss ->
logService.log("지역:${sss.location?.name}\n날씨:${sss.current?.condition?.text}\n온도:${sss.current?.temp_c}\n습도:${sss.current?.humidity}\n" +
"체감온도:${sss.current?.feelslike_c}\nhttps://www.accuweather.com/ko/search-locations?query=${it.mLatitude},${it.mLongitude}")
}
}
catch (e : Exception) {
}
}
return vm
}
@Value("\${image.upload.path}")
private val uploadPath: String? = null
@Value("\${resource.handler}")
private val resourceHandler: String? = null
@ResponseBody
@GetMapping("post/images/{fileName}")
fun getImage(@PathVariable fileName : String) : Resource {
val imgUploadPath = ("file:" +uploadPath + File.separator + fileName)
return UrlResource.from(imgUploadPath)
}
@PostMapping("post/imageUpload.bjx")
fun postImage(@RequestPart("file") upload: MultipartFile, res: HttpServletResponse, req: HttpServletRequest): ResponseEntity<FileSaveResult> {
var lResultCode = 0
var lResultMsg = "Success"
var out: FileOutputStream? = null
var targetFile: File? = null
val uuid = UUID.randomUUID()
val extension: String = getFileExtension(upload.originalFilename) ?: ""
try {
val bytes = upload.bytes
val f = File(uploadPath)
if (!f.exists()) f.mkdirs()
// 원본 이미지 저장 경로
val originalImagePath = "$uploadPath${File.separator}$uuid.$extension"
logService.log("Original image path: $originalImagePath")
// 썸네일 저장 경로
val thumbnailPath = "$uploadPath${File.separator}${uuid}_thumbnail.$extension"
logService.log("Thumbnail path: $thumbnailPath")
targetFile = File(originalImagePath)
if (!targetFile.parentFile.exists()) targetFile.parentFile.mkdirs()
// 원본 이미지 저장
out = FileOutputStream(originalImagePath)
out.write(bytes)
out.flush()
// 썸네일 생성 및 저장
Thumbnails.of(originalImagePath)
.width(200) // 가로 크기를 설정
.keepAspectRatio(true)
.toFile(thumbnailPath)
logService.log("Original image saved: ${File(originalImagePath).exists()}")
logService.log("Thumbnail saved: ${File(thumbnailPath).exists()}")
// 메타데이터 읽기 (원본 이미지에서)
val metadata: Metadata? = ImageMetadataReader.readMetadata(File(originalImagePath))
metadata?.let {
it.directories?.forEach { directory ->
logService.log(directory.name)
logService.log(directory.tags.map { tag ->
logService.log("tag.tagName >>> ${tag.tagName} || tag.description ${tag.description}")
}.joinToString(" \n"))
}
}
} catch (e: IOException) {
e.printStackTrace()
lResultCode = 1
lResultMsg = "Error: ${e.message}"
} finally {
try {
out?.close()
} catch (e: IOException) {
e.printStackTrace()
}
}
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(FileSaveResult().apply {
this.resultCode = lResultCode
this.resultMsg = lResultMsg
this.fileName = "$uuid.$extension"
this.thumbnailName = "${uuid}_thumbnail.$extension"
})
}
}
@@ -1,73 +0,0 @@
package kr.lunaticbum.back.lun.controllers
import com.google.gson.Gson
import jakarta.servlet.http.HttpServletRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.model.*
import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.plainText
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.servlet.ModelAndView
import java.util.Base64
@RestController
@RequestMapping("/bums")
class BumsPrivate {
@Autowired
lateinit var globalEvv : GlobalEnvironment
@Autowired
lateinit var logService: LogService
@Autowired
lateinit var locationService: LocationLogService
@GetMapping("where.bs")
fun where() : ResultMV {
val m = ResultMV("content/private/where")
locationService.find10().apply {
m.modelMap.put("locations",this)
}
m.setTitle("돼지 여기있다요~!!")
return m
}
@ResponseBody
@PostMapping("save/loc.api")
fun login(httpServletRequest: HttpServletRequest, @RequestBody jsonString: String) : ResponseEntity<ResponceResult> {
logService.log("${httpServletRequest.requestURI}")
logService.log(jsonString)
var location : LocationLog? = null
jsonString.plainText().let {
Gson().fromJson<LocationLog>(it, LocationLog::class.java)?.let { model ->
location = model
logService.log(model.toString())
locationService.save(model)
}
}
val responce = ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply {
})
// CoroutineScope(Dispatchers.IO).launch {
// location?.let {
// val client = WebClient.create()
// client.get()
// .uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${it.mAddressLines.first()} 저장")
// .retrieve()
// .bodyToMono(String::class.java).block() ?: "FAIL"
// }
// }
return responce
}
}
@@ -1,231 +0,0 @@
package kr.lunaticbum.back.lun.controllers
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import jakarta.servlet.http.HttpServletResponse
import kotlinx.coroutines.reactor.awaitSingle
import kr.lunaticbum.back.lun.model.PostManager
import kr.lunaticbum.back.lun.model.ResultMV
import kr.lunaticbum.back.lun.utils.LogService
import net.coobird.thumbnailator.Thumbnails
import org.commonmark.node.Node
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.io.File
import java.io.IOException
import java.net.URLDecoder
@RestController
@RequestMapping()
class Home {
@Autowired
lateinit var logService: LogService
@Autowired
private lateinit var postManager: PostManager
data class PostView(
val id: Long,
val title: String,
val thumb: String?,
val writeTime: Long,
val textOnly: String,
val firstImage: String?
)
data class DeltaOp(val insert: Any)
data class Delta(val ops: List<DeltaOp>)
fun extractFromDelta(deltaJson: String): Pair<String, String?> {
val delta: Delta = Gson().fromJson(deltaJson, Delta::class.java)
var textOnly = StringBuilder()
var firstImage: String? = null
delta.ops.forEach { op ->
if (op.insert is String) {
textOnly.append(op.insert)
} else if (op.insert is Map<*, *>) {
val obj = op.insert as Map<*, *>
if (obj["image"] != null && firstImage == null) {
firstImage = obj["image"].toString()
}
}
}
return textOnly.toString() to firstImage
}
@GetMapping("/","/home.bs")
suspend fun home() : ResultMV {
val vm = ResultMV("content/home")
try {
vm.modelMap.put("Posts", postManager.find8().apply {
this.forEach {
it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content)
val parser: Parser = Parser.builder().build()
val document: Node = parser.parse(it.content)
val renderer = HtmlRenderer.builder().build()
println("content >>> ${it.content}")
try {
JsonParser.parseString(it.content)
it.content?.let { content ->
var delta = extractFromDelta(content)
val firstImg = delta.second
it.image = firstImg ?: "images/pic01.jpg"
it.thumb = firstImg ?: "images/pic01.jpg"
it.html = delta.first
}
} catch (e: Exception) {
Jsoup.parse(renderer.render(document))?.let { doc ->
val firstImg: Element? = doc.select("img")?.first()
val imgSrc: String = firstImg?.attr("src") ?: ""
it.image = imgSrc
it.thumb = imgSrc.replace(imgSrc.split("/").last(), imgSrc.split("/").last().replace(".","_thumbnail."))
generateThumbnail(imgSrc.split("/").last(), 200)
it.html = doc.text()
}
}
it.title = if ((it.title?.length ?: 0) >= 1) it.title else ""
}
}.chunked(2))
}catch (ex: Exception){ex.printStackTrace()}
vm.modelMap.put("path","/blog/viewer/")
return vm
}
@Value("\${image.upload.path}")
private val uploadPath: String? = null
@Value("\${resource.handler}")
private val resourceHandler: String? = null
fun generateThumbnail(originalPath: String, targetWidth: Int) {
try {
val originalFile = File("$uploadPath${File.separator}$originalPath")
println("origin ${originalPath}")
println("thumb ${originalPath
.replace(".", "_thumbnail.")}")
// 썸네일 경로 생성 (예: /upload/uuid.jpg → /upload/uuid_thumbnail.jpg)
val thumbnailPath = originalPath
.replace(".", "_thumbnail.")
val thumbnailFile = File("$uploadPath${File.separator}$thumbnailPath")
// 썸네일 이미 존재하면 종료
if (thumbnailFile.exists()) {
println("썸네일 이미 존재: $thumbnailPath")
return
}
// 원본 파일 존재 확인
if (!originalFile.exists()) {
println("원본 파일 없음: $originalPath")
return
}
// 썸네일 생성 (가로 기준 비율 유지)
Thumbnails.of(originalFile)
.width(targetWidth)
.keepAspectRatio(true)
.toFile(thumbnailFile)
println("썸네일 생성 완료: $thumbnailFile")
} catch (e: IOException) {
println("썸네일 생성 실패: ${e.message}")
}
}
@GetMapping("/h2")
fun home2() : ResultMV {
val vm = ResultMV("content/index_ex")
vm.modelMap.put("Posts", postManager.find20(Pageable.ofSize(20)).apply {
this.forEach {
it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content)
logService.log(Gson().toJson(it))
}
})
return vm
}
@GetMapping("/left-sidebar")
fun lside() : ResultMV {
val vm = ResultMV("content/left-sidebar")
vm.modelMap.put("Posts", postManager.find20(Pageable.ofSize(20)).apply {
this.forEach {
it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content)
logService.log(Gson().toJson(it))
}
})
return vm
}
@GetMapping("/no-sidebar")
fun nside() : ResultMV {
val vm = ResultMV("content/no-sidebar")
vm.modelMap.put("Posts", postManager.find20(Pageable.ofSize(20)).apply {
this.forEach {
it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content)
logService.log(Gson().toJson(it))
}
})
return vm
}
@GetMapping("/right-sidebar")
fun rside() : ResultMV {
val vm = ResultMV("content/right-sidebar")
vm.modelMap.put("Posts", postManager.find20(Pageable.ofSize(20)).apply {
this.forEach {
it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content)
logService.log(Gson().toJson(it))
}
})
return vm
}
@GetMapping("/two-sidebar")
fun bside() : ResultMV {
val vm = ResultMV("content/two-sidebar")
vm.modelMap.put("Posts", postManager.find20(Pageable.ofSize(20)).apply {
this.forEach {
it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content)
logService.log(Gson().toJson(it))
}
})
return vm
}
@GetMapping("/login")
fun login(response: HttpServletResponse) {
response.sendRedirect("/user/login")
}
@GetMapping("/licenses")
fun licenses() : ResultMV {
val vm = ResultMV("content/licenses")
return vm
}
}
@@ -0,0 +1,72 @@
package kr.lunaticbum.back.lun.controllers
import kr.lunaticbum.back.lun.model.MessageService
import kr.lunaticbum.back.lun.model.ResultMV
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Mono
// 쪽지 전송 시 Body 데이터를 받기 위한 DTO
data class MessageRequest(val receiverId: String, val title: String, val content: String)
@RestController
@RequestMapping("/messages")
@PreAuthorize("isAuthenticated()") // 모든 메시지 기능은 로그인한 사용자만 가능
class MessageController(private val messageService: MessageService) {
/**
* 안 읽은 쪽지 개수를 확인하는 API (헤더 아이콘 표시용)
*/
@GetMapping("/unread-count")
fun getUnreadCount(@AuthenticationPrincipal userDetails: UserDetails): Mono<Map<String, Long>> {
return messageService.getUnreadMessageCount(userDetails.username)
.map { count -> mapOf("count" to count) }
}
/**
* 쪽지함 페이지를 보여주는 핸들러
*/
@GetMapping
fun getInboxPage(@AuthenticationPrincipal userDetails: UserDetails): Mono<ResultMV> {
val vm = ResultMV("content/messages/inbox")
return messageService.getMessagesForUser(userDetails.username)
.collectList()
.map { messages ->
vm.modelMap["messages"] = messages
vm.modelMap["pageTitle"] = "내 쪽지함"
vm
}
}
/**
* 쪽지를 보내는 API
*/
@PostMapping("/send")
fun sendMessage(
@AuthenticationPrincipal userDetails: UserDetails,
@RequestBody request: MessageRequest
): Mono<ResponseEntity<String>> {
return messageService.sendMessage(
userDetails.username,
request.receiverId,
request.title,
request.content
).map { ResponseEntity.ok("메시지를 보냈습니다.") }
}
/**
* 특정 쪽지를 읽음 처리하는 API
*/
@PostMapping("/{messageId}/read")
fun markAsRead(
@PathVariable messageId: String,
@AuthenticationPrincipal userDetails: UserDetails
): Mono<ResponseEntity<Void>> {
return messageService.markMessageAsRead(messageId, userDetails.username)
.map { ResponseEntity.ok().build<Void>() }
.defaultIfEmpty(ResponseEntity.notFound().build())
}
}
@@ -1,5 +0,0 @@
package kr.lunaticbum.back.lun.controllers
class Owner {
}
@@ -1,87 +1,221 @@
package kr.lunaticbum.back.lun.controllers
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.PuzzleService
import kr.lunaticbum.back.lun.model.ResultMV
import kr.lunaticbum.back.lun.model.* // 필요한 모든 모델 클래스를 import
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.io.UrlResource
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.nio.file.Paths
/**
* [통합 게임 API 허브 컨트롤러]
* 1. 모든 게임의 HTML 페이지 서빙
* 2. 모든 게임의 플레이 로직 API (게임 시작, 검증, 상태 업데이트 등) 제공
* (기존 SudokuController, SpiderController의 기능을 모두 통합)
*/
@RestController
@RequestMapping("/puzzle")
class PuzzleController(private val puzzleService: PuzzleService) { // 생성자 주입
@RequestMapping("/puzzle") // 모든 게임 API는 /puzzle 공통 경로 하위에 배치
class PuzzleController(
// 모든 게임 로직이 통합된 PuzzleService 하나만 주입받음
private val puzzleService: PuzzleService,
@Value("\${puzzle.image.path}") private val puzzleImagePath: String
) {
// [신규 추가] 저장된 퍼즐 이미지를 제공하는 API
@GetMapping("/images/{filename}")
fun getPuzzleImage(@PathVariable filename: String): ResponseEntity<Any> {
return try {
val path = Paths.get(puzzleImagePath).resolve(filename)
val resource = UrlResource(path.toUri())
if (resource.exists() || resource.isReadable) {
ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG) // 이미지는 PNG로 저장했으므로
.body(resource)
} else {
ResponseEntity.notFound().build()
}
} catch (e: Exception) {
ResponseEntity.internalServerError().build()
}
}
// ======================================================
// 1. NONOGRAM API (기존 엔드포인트 유지)
// ======================================================
/**
* 노노그램: 이미지 업로드 및 퍼즐 생성
*/
@PostMapping("upload.bjx")
suspend fun createPuzzleFromImage(@RequestParam("imageFile") imageFile: MultipartFile): ResponseEntity<Any> {
return try {
val savedPuzzle = puzzleService.generateAndSavePuzzle(imageFile)
ResponseEntity.ok(savedPuzzle) // 성공 시 200 OK와 함께 결과 반환
ResponseEntity.ok(savedPuzzle)
} catch (e: Exception) {
e.printStackTrace()
// 실패 시 500 에러와 메시지 반환
ResponseEntity.internalServerError().body("이미지 처리 중 오류 발생: ${e.message}")
}
}
/**
* 특정 ID의 퍼즐을 삭제하는 엔드포인트
* @param id URL 경로에서 추출한 퍼즐의 고유 ID
* 노노그램: 퍼즐 ID로 삭제
*/
@DeleteMapping("/{id}.bjx")
suspend fun deletePuzzle(@PathVariable id: String): ResponseEntity<Void> {
return try {
puzzleService.deletePuzzle(id)
// 성공적으로 삭제되면 204 No Content 응답을 보냅니다.
ResponseEntity.noContent().build()
} catch (e: Exception) {
// 실패 시 500 에러 응답
ResponseEntity.internalServerError().build()
}
}
// ======================================================
// 2. SUDOKU API (★ SudokuController에서 마이그레이션됨)
// ======================================================
/**
* ID가 지정된 경우 특정 퍼즐을 로드합니다.
* 스도쿠: 새 게임 시작 (난이도별 문제 반환)
*/
@GetMapping("/sudoku/start")
suspend fun sudokuStartGame(
// 🔽 [수정] 'level' -> 'difficulty', 기본값 '4'
@RequestParam(defaultValue = "4") difficulty: String
// ❌ 'blockSizeStr' 파라미터 제거
): PuzzleService.SudokuGameDto {
// 🔽 [수정] 파라미터 1개만 전달
return puzzleService.sudoku_startGame(difficulty)
}
/**
* 스도쿠: 사용자가 제출한 답안 검증
*/
@PostMapping("/sudoku/validate")
suspend fun sudokuValidate(@RequestBody validateDto: PuzzleService.SudokuValidateDto): Map<String, Boolean> {
val isCorrect = puzzleService.sudoku_validateSolution(validateDto)
return mapOf("correct" to isCorrect)
}
/**
* 스도쿠: (관리용) 새 퍼즐 문제 생성 및 DB 저장
*/
// @GetMapping("/sudoku/sudoku_gen")
// suspend fun sudokuGenerateSinglePuzzle(): SudokuPuzzle {
// puzzleService.sudoku_generateAndSavePuzzle()
// return SudokuPuzzle()
// }
@GetMapping("/admin/generate-puzzles")
suspend fun generateAdminPuzzles(
@RequestParam(defaultValue = "4") blockSize: Int,
@RequestParam(defaultValue = "5") count: Int
): String {
// 예: /admin/generate-puzzles?blockSize=4&count=10
// 16x16 퍼즐 10개 생성
repeat(count) {
try {
puzzleService.sudoku_generateAndSavePuzzle(blockSize)
} catch (e: Exception) {
// (퍼즐 문자열이 unique=true이므로 중복되면 예외 발생 가능)
}
}
return "$count 개의 $blockSize x $blockSize 퍼즐 생성 완료."
}
// ======================================================
// 3. SPIDER API (★ SpiderController에서 마이그레이션 및 Coroutine 변환됨)
// ======================================================
/**
* 스파이더: 새 게임 시작 (무늬 수, 카드 장 수 기반)
*/
@GetMapping("/spider/new")
suspend fun spiderNewGame(@RequestParam numSuits: Int, @RequestParam numCards: String): SpiderGame {
return puzzleService.spider_newGame(numSuits, numCards)
}
/**
* 스파이더: ID로 기존 게임 불러오기
*/
@GetMapping("/spider/{id}")
suspend fun spiderGetGame(@PathVariable id: String): ResponseEntity<SpiderGame> {
val game = puzzleService.spider_getGame(id)
return if (game != null) ResponseEntity.ok(game) else ResponseEntity.notFound().build()
}
/**
* 스파이더: 게임 상태 업데이트 (카드 이동 시)
*/
@PostMapping("/spider/update")
suspend fun spiderUpdateGame(@RequestBody game: SpiderGame): SpiderGame {
return puzzleService.spider_updateGame(game)
}
/**
* 스파이더: 스톡에서 새 카드 분배
*/
@PostMapping("/spider/deal")
suspend fun spiderDealCards(@RequestBody request: Map<String, String>): SpiderGame {
val gameId = request["gameId"] ?: throw IllegalArgumentException("Game ID is required.")
return puzzleService.spider_dealCardsFromStock(gameId)
}
/**
* 스파이더: 실행 취소 (Undo)
*/
@PostMapping("/spider/undo")
suspend fun spiderUndo(@RequestBody request: Map<String, String>): SpiderGame {
val gameId = request["gameId"] ?: throw IllegalArgumentException("Game ID is required.")
return puzzleService.spider_undoGame(gameId)
}
// ======================================================
// 4. 페이지 서빙 엔드포인트 (기존 로직 유지)
// ======================================================
/**
* 노노그램: 특정 ID의 퍼즐 플레이 페이지
*/
@GetMapping("/play/{id}")
suspend fun playPuzzlePage(@PathVariable id: String, model: Model): ResultMV {
val puzzle = puzzleService.findById(id).awaitSingleOrNull()
val vm = ResultMV("content/puzzle/play")
val vm = ResultMV("content/puzzle/nonogram")
return if (puzzle != null) {
vm.model.put("puzzle", puzzle)
vm
} else {
// DB에 퍼즐이 하나도 없을 경우 홈으로 리다이렉트
vm.viewName = "redirect:/"
vm
}
}
/**
* (★추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다.
* 노노그램: 랜덤 퍼즐 플레이 페이지
*/
@GetMapping("/play")
suspend fun playRandomPuzzlePage(): ResultMV {
val puzzle = puzzleService.findRandomPuzzle()
val vm = ResultMV("content/puzzle/play")
val vm = ResultMV("content/puzzle/nonogram")
return if (puzzle != null) {
vm.model.put("puzzle", puzzle)
vm
} else {
// DB에 퍼즐이 하나도 없을 경우 홈으로 리다이렉트
vm.viewName = "redirect:/"
vm
}
}
/**
* (★추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다.
* 2048: 게임 페이지 서빙
*/
@GetMapping("/2048")
suspend fun play2048(): ResultMV {
@@ -90,27 +224,78 @@ class PuzzleController(private val puzzleService: PuzzleService) { // 생성자
}
/**
* (★추가된 메서드) ID가 지정되지 않은 경우 랜덤 퍼즐을 로드합니다.
* 스도쿠: 게임 페이지 서빙
*/
@GetMapping("/sudoku")
suspend fun sudoku(): ResultMV {
val vm = ResultMV("content/puzzle/sudoku")
return vm
}
/**
* 스도쿠: 게임 페이지 서빙
*/
@GetMapping("/sudoku_gen.bs")
suspend fun sudoku_gen(): ResultMV {
val vm = ResultMV("content/puzzle/sudoku_gen")
return vm
}
/**
* 스파이더: 게임 페이지 서빙
*/
@GetMapping("/spider")
suspend fun spider(): ResultMV {
val vm = ResultMV("content/puzzle/spider")
return vm
}
@GetMapping("/","/upload.bs")
/**
* 메인 페이지 (노노그램 업로드)
*/
@GetMapping("/", "/upload.bs")
suspend fun uploadPuzzle() : ResultMV {
val vm = ResultMV("content/puzzle/upload")
return vm
}
}
@RestController
@RequestMapping("/api/ranks") // 모든 랭킹 API는 이 공통 경로를 사용
class GameRankController(private val gameRankService: GameRankService) {
/**
* [전체 수정]
* 서비스가 반환하는 Mono<RankSubmissionResult>를 그대로 받아 Ok(200)로 반환합니다.
*/
@PostMapping("/submit") // 👈 [중요] /api/ranks/submit이 아닌 /submit
fun submitRank(@RequestBody rankDto: UnifiedRankDto): Mono<ResponseEntity<Any>> {
return gameRankService.submitRank(rankDto) // 1. 반환 타입: Mono<RankSubmissionResult>
.map { rankResult -> // 2. 🔽 .collectList() 제거
// 3. 성공 시 RankSubmissionResult 객체를 body에 담아 OK(200) 응답
ResponseEntity.ok<Any>(rankResult)
}
.onErrorResume { e -> // 👈 이름 중복 등 서비스 레벨의 예외 처리
// 4. 실패 시 예외 메시지를 400 Bad Request로 반환
Mono.just(ResponseEntity.badRequest().body(e.message ?: "랭킹 등록 중 오류 발생"))
}
}
/**
* 모든 게임을 위한 통합 랭킹 조회 엔드포인트
* 예: /api/ranks/list?gameType=SUDOKU&contextId=123
* 예: /api/ranks/list?gameType=GAME_2048
*/
@GetMapping("/list")
fun getUnifiedRanks(
@RequestParam gameType: GameType,
@RequestParam contextId: String? = null
): Flux<GameRank> {
// contextId가 "null" 문자열로 오는 경우를 방지하여 실제 null로 처리
val effectiveContextId = if (contextId == "null") null else contextId
return gameRankService.getRanks(gameType, effectiveContextId)
}
}
@@ -1,39 +0,0 @@
package kr.lunaticbum.back.lun.controllers
import kr.lunaticbum.back.lun.model.Rank
import kr.lunaticbum.back.lun.model.RankRepository
import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@RestController
@RequestMapping("/rank")
class RankController(val rankRepository: RankRepository) {
// private val rankRepository: RankRepository
//
// init {
// this.rankRepository = rankRepository
// }
/**
* 새로운 랭킹을 저장합니다.
* 요청 Body에 gameId가 포함되어야 합니다.
* @param rank 저장할 랭크 정보 (gameId, name, score)
* @return Mono<Rank>
</Rank> */
@PostMapping("/ranks")
fun saveRank(@RequestBody rank: Rank): Mono<Rank?> { // 👈 요청 Body는 Rank 모델을 그대로 사용
return rankRepository.save(rank)
}
/**
* 특정 게임의 상위 10개 랭킹 리스트를 조회합니다.
* @param gameId 경로 변수(Path Variable)로 게임 ID를 받습니다.
* @return Flux<Rank>
</Rank> */
@GetMapping("/ranks/{gameId}") // 👈 엔드포인트에 Path Variable 추가
fun getRankingsByGameId(@PathVariable gameId: String): Flux<Rank?> {
return rankRepository.findTop10ByGameIdOrderByScoreDesc(gameId)
}
}
@@ -0,0 +1,543 @@
package kr.lunaticbum.back.lun.controllers
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpSession
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.withContext
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
import kr.lunaticbum.back.lun.model.KisAuthSession
import kr.lunaticbum.back.lun.model.KisConfigRequest
import kr.lunaticbum.back.lun.model.ResponceResult
import kr.lunaticbum.back.lun.model.ResultMV
import kr.lunaticbum.back.lun.model.UserManager
import kr.lunaticbum.back.lun.repository.TradeHistoryRepository
import kr.lunaticbum.back.lun.service.DirectLoginService
import kr.lunaticbum.back.lun.service.KisApiService
import kr.lunaticbum.back.lun.service.KisMarketService
import kr.lunaticbum.back.lun.service.StockMonitorService
import kr.lunaticbum.back.lun.services.TelegramBotService
import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.MarketTimeManager
import org.springframework.http.ResponseEntity
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.*
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Flux // [추가]
import reactor.core.publisher.Mono
import reactor.kotlin.core.util.function.component1
import reactor.kotlin.core.util.function.component2
import java.time.Duration // [추가]
data class StockOrderRequest(
val code: String,
val type: String,
val qty: Int,
val price: Int,
val isAutoTrade: Boolean = false,
val targetProfitRate: Double = 0.0,
val stopLossRate: Double = 0.0,
val isSellAll: Boolean = false
)
@Controller
@RequestMapping("/stock")
class StockViewController(
private val kisApiService: KisApiService,
private val kisMarketService: KisMarketService,
private val directLoginService: DirectLoginService,
private val userDetailsService: UserDetailsService
) {
@GetMapping("/auto-trade") // [추가] 자동매매 리스트 화면
fun autoTradePage(): ResultMV = ResultMV("content/stock/auto_trade").apply { setTitle("자동매매 관리") }
@GetMapping("/history") // [추가] 거래내역 화면
fun historyPage(): ResultMV = ResultMV("content/stock/history").apply { setTitle("거래 내역") }
@GetMapping("/detail")
fun detailPage(): ResultMV = ResultMV("content/stock/detail").apply { setTitle("종목 상세 분석") }
@GetMapping("/dashboard", "/dashboard.bs")
fun dashboardPage(): ResultMV = ResultMV("content/stock/dashboard").apply { setTitle("나의 투자 대시보드") }
@GetMapping("/config")
fun configPage(): ResultMV = ResultMV("content/stock/config").apply { setTitle("Stock API 설정") }
@GetMapping("/market")
fun margketPage(): ResultMV = ResultMV("content/stock/market").apply { setTitle("Stock API 설정") }
@GetMapping("/direct-login")
suspend fun directLogin(
@RequestParam token: String,
session: HttpSession,
request: HttpServletRequest
): String {
return try {
val ip = request.getHeader("X-Forwarded-For") ?: request.remoteAddr
val ua = request.getHeader("User-Agent") ?: ""
val deviceId = request.cookies?.find { it.name == "LUN_DEVICE_ID" }?.value
// 1. 토큰 검증 (비동기)
val info = directLoginService.validateAndGet(token, ip, ua, deviceId)
// 2. 앱 로그인 처리 (블로킹 구간 격리)
// UserDetailsService는 블로킹 DB 호출을 포함할 수 있으므로 IO 스레드에서 실행
if (request.userPrincipal == null || request.userPrincipal.name != info.username) {
withContext(Dispatchers.IO) {
val userDetails = userDetailsService.loadUserByUsername(info.username)
val auth = UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
SecurityContextHolder.getContext().authentication = auth
session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext())
}
}
// 3. 주식 API 연결 (비동기)
val config = KisConfigRequest(info.appKey, info.appSecret, info.accountNo)
val accessToken = kisApiService.verifyAndGetToken(config).awaitSingle()
session.setAttribute("KIS_AUTH", KisAuthSession(info.appKey, info.appSecret, info.accountNo, accessToken))
"redirect:/stock/dashboard"
} catch (e: Exception) {
e.printStackTrace()
// 에러 메시지 한글 깨짐 방지 처리
val errorMsg = if (e.message?.contains("Timeout") == true) {
"로그인 시간 초과 (DB 응답 지연)"
} else {
e.message ?: "접속 실패"
}
val encodedMsg = java.net.URLEncoder.encode(errorMsg, "UTF-8")
"redirect:/stock/config?error=$encodedMsg"
}
}
// IP 추출 헬퍼 함수
private fun getClientIp(request: HttpServletRequest): String {
var ip = request.getHeader("X-Forwarded-For")
if (ip.isNullOrEmpty() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.remoteAddr
}
return ip ?: ""
}
}
@RestController
@RequestMapping("/api/stock")
class StockApiController(
private val webClient: WebClient,
private val userManager: UserManager,
private val kisApiService: KisApiService,
private val kisMarketService: KisMarketService,
private val logService: LogService,
private val stockMonitorService: StockMonitorService,
private val tradeHistoryRepository: TradeHistoryRepository,
private val telegramBotService: TelegramBotService, // [추가]
private val directLoginService: DirectLoginService,
private val globalEvv: GlobalEnvironment // [추가]
) {
@GetMapping("/status")
fun getMarketStatus(): ResponseEntity<Map<String, Any>> {
val status = MarketTimeManager.getCurrentStatus()
return ResponseEntity.ok(mapOf(
"resultCode" to 0,
"status_code" to status.code,
"status_name" to status.label,
"is_tradeable" to MarketTimeManager.isTradeable() // 정규장 거래 가능 여부
))
}
@PostMapping("/auth")
suspend fun authenticate(
@RequestBody body: Map<String, String>,
session: HttpSession
): ResponseEntity<Map<String, Any>> {
val appKey = body["appKey"] ?: ""
val appSecret = body["appSecret"] ?: ""
val accountNo = body["accountNo"] ?: ""
if (appKey.isBlank() || appSecret.isBlank() || accountNo.isBlank()) {
return ResponseEntity.ok(mapOf("resultCode" to 400, "resultMsg" to "모든 정보를 입력해주세요."))
}
return try {
// 1. 토큰 발급 시도 (유효성 검증)
val config = KisConfigRequest(appKey, appSecret, accountNo)
val token = kisApiService.verifyAndGetToken(config).awaitSingle()
// 2. 세션에 저장 (이게 있어야 대시보드 접근 가능)
val authInfo = KisAuthSession(appKey, appSecret, accountNo, token)
session.setAttribute("KIS_AUTH", authInfo)
ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "인증되었습니다."))
} catch (e: Exception) {
e.printStackTrace()
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "인증 실패: ${e.message}"))
}
}
@PostMapping("/order")
suspend fun placeOrder(
@RequestBody req: StockOrderRequest,
session: HttpSession
): ResponseEntity<Map<String, Any>> {
val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession
?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다."))
// 종목명 조회 (기존 로직 유지)
var stockName = req.code
try {
val priceRes = kisMarketService.getCurrentPrice(req.code, auth).awaitSingle()
val output = priceRes["output"] as? Map<String, String>
stockName = output?.get("rprs_mrkt_kor_name") ?: req.code
} catch (e: Exception) {}
return try {
// [핵심 변경] 전량 매도(isSellAll)일 경우 서버에서 잔고 조회 후 수량 결정
var finalQty = req.qty
var finalPrice = req.price.toString()
if (req.isSellAll) {
println(">>> [SellAll Debug] 잔고 조회 시작 (Code: ${req.code})")
// 1. 내 잔고 조회
val balanceRes = kisApiService.getAccountBalance(auth).awaitSingle()
val stocks = balanceRes["output1"] as? List<Map<String, Any>> ?: emptyList()
// 2. 해당 종목 보유 수량 찾기
// API마다 필드명이 다를 수 있으므로 확인 필요 (pdno: 상품번호)
val targetStock = stocks.find { it["pdno"] == req.code }
finalQty = targetStock?.get("hldg_qty")?.toString()?.toIntOrNull() ?: 0
println(">>> [SellAll Debug] 조회된 보유수량: $finalQty (Raw: $targetStock)")
// 3. 가격은 시장가("0")로 강제 설정
finalPrice = "0"
if (finalQty <= 0) {
println(">>> [SellAll Debug] 보유 수량 0이라 중단")
return ResponseEntity.ok(mapOf("resultCode" to 400, "resultMsg" to "보유 수량이 없습니다."))
}
delay(800)
}
// 4. 결정된 수량과 가격으로 주문 실행
val response = kisApiService.orderStock(auth, req.type, req.code, finalQty.toString(), finalPrice).awaitSingle()
val rtCd = response["rt_cd"] as? String ?: ""
if (rtCd == "0") {
val output = response["output"] as? Map<String, Any> ?: emptyMap()
val orderNo = output["ODNO"] as? String ?: "번호없음"
// 히스토리 저장 등 후처리 (기존 코드 유지)
val msgType = if(req.isSellAll) "🔥시장가 전량매도" else (if(req.type == "BUY") "매수" else "매도")
stockMonitorService.saveHistory(req.code, stockName, req.type, 0.0, finalQty, orderNo, false, "주문완료")
telegramBotService.sendTelegramMessage(
globalEvv.telegramMyId,
"[$msgType] 주문 성공\n종목: $stockName\n수량: ${finalQty}"
)
ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "주문 전송 완료 (주문번호: $orderNo)"))
} else {
val msg = response["msg1"] as? String ?: "주문 실패"
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to msg))
}
} catch (e: Exception) {
e.printStackTrace()
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "오류: ${e.message}"))
}
}
@PostMapping("/auto-trade/update-stoploss")
suspend fun updateStopLoss(@RequestBody body: Map<String, Any>): ResponseEntity<Map<String, Any>> {
val id = body["id"] as? String ?: return ResponseEntity.ok(mapOf("resultCode" to 400))
val rate = body["stopLossRate"].toString().toDoubleOrNull() ?: return ResponseEntity.ok(mapOf("resultCode" to 400))
val success = stockMonitorService.updateStopLossRate(id, rate)
return if(success) ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "수정되었습니다."))
else ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "실패"))
}
// [신규] 자동매매 리스트 조회 API
@GetMapping("/auto-trade/list")
fun getAutoTradeList(): ResponseEntity<Map<String, Any>> {
val list = stockMonitorService.getAllTasks()
return ResponseEntity.ok(mapOf("resultCode" to 0, "data" to list))
}
// [신규] 자동매매 취소 API
@PostMapping("/auto-trade/cancel")
suspend fun cancelAutoTrade(@RequestBody body: Map<String, String>): ResponseEntity<Map<String, Any>> {
val id = body["id"] ?: return ResponseEntity.ok(mapOf("resultCode" to 400, "resultMsg" to "ID 없음"))
val success = stockMonitorService.cancelMonitoring(id)
return if(success) ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "취소되었습니다."))
else ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "찾을 수 없습니다."))
}
// [신규] 목표 수익률 수정 API
@PostMapping("/auto-trade/update")
suspend fun updateAutoTrade(@RequestBody body: Map<String, Any>): ResponseEntity<Map<String, Any>> {
val id = body["id"] as? String ?: return ResponseEntity.ok(mapOf("resultCode" to 400))
val rate = body["targetRate"].toString().toDoubleOrNull() ?: return ResponseEntity.ok(mapOf("resultCode" to 400))
val success = stockMonitorService.updateTargetRate(id, rate)
return if(success) ResponseEntity.ok(mapOf("resultCode" to 0, "resultMsg" to "수정되었습니다."))
else ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "실패"))
}
// [신규] 거래 내역 조회 API
@GetMapping("/history/list")
suspend fun getHistoryList(): ResponseEntity<Map<String, Any>> {
val list = tradeHistoryRepository.findAllByOrderByTimeDesc().collectList().awaitSingle()
return ResponseEntity.ok(mapOf("resultCode" to 0, "data" to list))
}
@PostMapping("/config")
suspend fun saveConfig(
@RequestBody config: KisConfigRequest,
session: HttpSession
): ResponseEntity<ResponceResult> {
return try {
val token = kisApiService.verifyAndGetToken(config).awaitSingle()
val authInfo = KisAuthSession(
appKey = config.appKey,
appSecret = config.appSecret,
accountNo = config.accountNo,
accessToken = token
)
session.setAttribute("KIS_AUTH", authInfo)
ResponseEntity.ok(ResponceResult().apply { resultCode = 0; resultMsg = "연결 성공" })
} catch (e: Exception) {
ResponseEntity.ok(ResponceResult().apply { resultCode = 7001; resultMsg = "연결 실패: ${e.message}" })
}
}
@GetMapping("/ws-key")
suspend fun getWebSocketKey(session: HttpSession): ResponseEntity<Map<String, Any>> {
val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession
?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다."))
return try {
// 접속키 발급 요청 (Config 정보 재구성 필요)
val config = KisConfigRequest(auth.appKey, auth.appSecret, auth.accountNo)
val approvalKey = kisApiService.getWebSocketApprovalKey(config).awaitSingle()
ResponseEntity.ok(mapOf(
"resultCode" to 0,
"approval_key" to approvalKey
))
} catch (e: Exception) {
e.printStackTrace()
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "접속키 발급 실패: ${e.message}"))
}
}
@GetMapping("/rank/{type}")
suspend fun getRank(
@PathVariable type: String,
session: HttpSession
): ResponseEntity<Map<String, Any>> {
val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession
?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다."))
return try {
val responseMono = when (type) {
"volume", "recommend", "amount" -> kisMarketService.getVolumeRank(auth)
"rising" -> kisMarketService.getFluctuationRank(auth, "0")
"falling" -> kisMarketService.getFluctuationRank(auth, "1")
else -> throw IllegalArgumentException("잘못된 요청입니다.")
}
val response = responseMono.awaitSingle()
var output = response["output"] as? List<Map<String, String>> ?: emptyList()
if (type == "recommend") {
output = output.filter {
val rate = it["prdy_ctrt"]?.toDoubleOrNull() ?: 0.0
val price = it["stck_prpr"]?.toIntOrNull() ?: 0
rate > 0.0 && rate < 25.0 && price >= 1000
}
} else if (type == "amount") {
output = output.sortedByDescending {
val price = it["stck_prpr"]?.toLongOrNull() ?: 0L
val vol = it["acml_vol"]?.toLongOrNull() ?: 0L
price * vol
}
}
val list = output.take(15).mapIndexed { index, item ->
val code = item["mksc_shrn_iscd"] ?: item["stck_shrn_iscd"] ?: item["iscd_stat_cls_code"] ?: ""
val price = item["stck_prpr"]?.toLongOrNull() ?: 0L
val vol = item["acml_vol"]?.toLongOrNull() ?: 0L
val amount = (price * vol) / 100000000
mapOf(
"rank" to (index + 1),
"code" to code,
"name" to (item["hts_kor_isnm"] ?: item["prdt_name"] ?: ""),
"price" to (item["stck_prpr"] ?: "0"),
"change_rate" to (item["prdy_ctrt"] ?: "0.0"),
"volume" to (item["acml_vol"] ?: "0"),
"amount" to amount
)
}
ResponseEntity.ok(mapOf("resultCode" to 0, "data" to list))
} catch (e: Exception) {
e.printStackTrace()
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "조회 실패: ${e.message}"))
}
}
val delayTime = 600L
@GetMapping("/details")
suspend fun getStockDetails(
@RequestParam codes: String,
session: HttpSession
): ResponseEntity<Map<String, Any>> {
val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession
?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다."))
val codeList = codes.split(",").map { it.trim() }.filter { it.isNotEmpty() }.take(3)
if (codeList.isEmpty()) return ResponseEntity.ok(mapOf("resultCode" to 400))
return try {
// 1. [병렬 호출] 내 잔고 조회 & 자동매매 목록 조회
val balanceMono = kisApiService.getAccountBalance(auth)
val tasks = stockMonitorService.getAllTasks() // 현재 진행중인 자동매매 목록
// 2. 종목별 시세/차트 조회 (기존 로직)
val detailsFlux = Flux.fromIterable(codeList)
.concatMap { code ->
kisMarketService.getCurrentPrice(code, auth)
.delayElement(Duration.ofMillis(delayTime))
.flatMap { priceData ->
kisMarketService.getMinuteChart(code, auth)
.map { chartData -> Triple(code, priceData, chartData) }
}
.delayElement(Duration.ofMillis(delayTime))
}
.collectList()
// 3. 데이터 합치기 (잔고 + 상세정보)
val (balanceRes, details) = Mono.zip(balanceMono, detailsFlux).awaitSingle()
// 잔고 데이터 파싱 (보유 종목 찾기용)
val myStocks = (balanceRes["output1"] as? List<Map<String, Any>>) ?: emptyList()
val dataList = details.map { item ->
val (code, priceRes, chartRes) = item
val output = priceRes["output"] as? Map<String, String> ?: emptyMap()
val chartOutput = chartRes["output2"] as? List<Map<String, String>> ?: emptyList()
// 차트 데이터 가공
val chartList = chartOutput.take(60).reversed().map { tick ->
mapOf(
"time" to (tick["stck_cntg_hour"]?.substring(0, 4) ?: ""),
"price" to (tick["stck_prpr"] ?: "0"),
"volume" to (tick["cntg_vol"] ?: "0")
)
}
// [추가] 내 보유 정보 찾기
// API마다 종목코드 필드명이 다를 수 있으므로 pdno(잔고)와 stck_shrn_iscd(현재가) 비교
val myStock = myStocks.find { it["pdno"] == code }
val myQty = myStock?.get("hldg_qty")?.toString()?.toIntOrNull() ?: 0
val myAvgPrice = myStock?.get("pchs_avg_pric")?.toString()?.toDoubleOrNull() ?: 0.0
val myProfitRate = myStock?.get("evlu_pfls_rt")?.toString()?.toDoubleOrNull() ?: 0.0
// [추가] 자동매매 진행 여부 확인
val autoTradeTask = tasks.find { it.stockCode == code }
val isAutoActive = autoTradeTask != null
val targetRate = autoTradeTask?.targetProfitRate ?: 0.0
mapOf(
"code" to code,
"name" to (output["rprs_mrkt_kor_name"] ?: ""),
"price" to (output["stck_prpr"] ?: "0"),
"change_rate" to (output["prdy_ctrt"] ?: "0.0"),
"volume" to (output["acml_vol"] ?: "0"),
"chart" to chartList,
// 내 보유 정보
"my_qty" to myQty,
"my_price" to myAvgPrice,
"my_profit_rate" to myProfitRate,
// 자동매매 정보
"is_auto_active" to isAutoActive,
"auto_target_rate" to targetRate
)
}
ResponseEntity.ok(mapOf("resultCode" to 0, "data" to dataList))
} catch (e: Exception) {
e.printStackTrace()
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "조회 실패: ${e.message}"))
}
}
@GetMapping("/balance")
suspend fun getBalance(session: HttpSession): ResponseEntity<Map<String, Any>> {
val auth = session.getAttribute("KIS_AUTH") as? KisAuthSession
?: return ResponseEntity.ok(mapOf("resultCode" to 401, "resultMsg" to "인증 정보가 없습니다."))
return try {
val response = kisApiService.getAccountBalance(auth).awaitSingle()
val rtCd = response["rt_cd"] as? String ?: ""
if (rtCd != "0") {
val msg = response["msg1"] as? String ?: "알 수 없는 오류"
return ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "KIS 오류: $msg ($rtCd)"))
}
val output1 = response["output1"] as? List<Map<String, Any>> ?: emptyList()
val output2 = response["output2"] as? List<Map<String, Any>> ?: emptyList()
val summary = if (output2.isNotEmpty()) output2[0] else emptyMap()
val taxFeeRate = 1.0025
val stocks = output1.map { stock ->
val buyPrice = stock["pchs_avg_pric"]?.toString()?.toDoubleOrNull() ?: 0.0
val currentPrice = stock["prpr"]?.toString()?.toDoubleOrNull() ?: 0.0
val qty = stock["hldg_qty"]?.toString()?.toIntOrNull() ?: 0
val profitRate = stock["evlu_pfls_rt"]?.toString()?.toDoubleOrNull() ?: 0.0
val evalAmount = stock["evlu_amt"]?.toString()?.toLongOrNull() ?: (currentPrice * qty).toLong()
mapOf(
"code" to (stock["pdno"]?.toString() ?: ""),
"name" to (stock["prdt_name"]?.toString() ?: ""),
"qty" to qty,
"buy_price" to buyPrice,
"current_price" to currentPrice,
"eval_amount" to evalAmount,
"profit_rate" to profitRate,
"break_even_price" to buyPrice * taxFeeRate
)
}
val resultData = mapOf(
"total_asset" to (summary["tot_evlu_amt"]?.toString() ?: "0"),
"total_profit_rate" to (summary["evlu_pfls_rt"]?.toString() ?: "0.0"),
"stocks" to stocks
)
ResponseEntity.ok(mapOf("resultCode" to 0, "data" to resultData))
} catch (e: Exception) {
e.printStackTrace()
ResponseEntity.ok(mapOf("resultCode" to 500, "resultMsg" to "처리 실패: ${e.message}"))
}
}
@GetMapping("/market")
suspend fun getMarketIndicators(): ResponseEntity<Map<String, Any>> {
return ResponseEntity.ok(mapOf())
}
}
@@ -0,0 +1,68 @@
package kr.lunaticbum.back.lun.controllers
import jakarta.servlet.http.Cookie
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kr.lunaticbum.back.lun.service.DirectLoginService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/stock")
class StockLinkController(
private val directLoginService: DirectLoginService
) {
@PostMapping("/generate-link")
suspend fun generateLink(
@RequestBody body: Map<String, String>,
request: HttpServletRequest,
response: HttpServletResponse, // [추가] 쿠키 설정을 위해 필요
principal: java.security.Principal?
): ResponseEntity<Map<String, String>> {
return try {
if (principal == null) return ResponseEntity.ok(mapOf("resultCode" to "401", "resultMsg" to "로그인 필요"))
val ip = request.getHeader("X-Forwarded-For") ?: request.remoteAddr
val ua = request.getHeader("User-Agent") ?: ""
// 1. 기존 쿠키 확인 또는 새 Device ID 생성
var deviceId = request.cookies?.find { it.name == "LUN_DEVICE_ID" }?.value
if (deviceId.isNullOrBlank()) {
deviceId = UUID.randomUUID().toString()
// 2. 쿠키 설정 (30일 유지, HttpOnly 아님-JS접근가능해야 편함 or HttpOnly 권장)
val cookie = Cookie("LUN_DEVICE_ID", deviceId)
cookie.path = "/"
cookie.maxAge = 60 * 60 * 24 * 30 // 30일
cookie.isHttpOnly = true // 보안 강화 (JS 탈취 방지)
response.addCookie(cookie)
}
// 3. DB에 토큰 + DeviceID 저장
val token = directLoginService.createToken(
key = body["key"] ?: "",
secret = body["secret"] ?: "",
acc = body["acc"] ?: "",
username = principal.name,
ip = ip,
ua = ua,
deviceId = deviceId
)
// ... URL 생성 로직 ...
val scheme = request.scheme
val serverName = request.serverName
val serverPort = request.serverPort
val portPart = if ((scheme == "http" && serverPort == 80) || (scheme == "https" && serverPort == 443)) "" else ":$serverPort"
val fullUrl = "$scheme://$serverName$portPart/stock/direct-login?token=$token"
ResponseEntity.ok(mapOf("resultCode" to "0", "url" to fullUrl))
} catch (e: Exception) {
ResponseEntity.ok(mapOf("resultCode" to "500", "resultMsg" to e.message.toString()))
}
}
}
@@ -1,36 +0,0 @@
package kr.lunaticbum.back.lun.controllers
import kr.lunaticbum.back.lun.model.GameRecord
import kr.lunaticbum.back.lun.model.SudokuPuzzle
import kr.lunaticbum.back.lun.model.SudokuService
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/sudoku")
class SudokuController(private val sudokuService: SudokuService) {
@GetMapping("/start")
suspend fun startGame(@RequestParam(defaultValue = "easy") difficulty: String): SudokuService.GameDto {
return sudokuService.startGame(difficulty)
}
@PostMapping("/complete")
suspend fun completeGame(@RequestBody recordDto: SudokuService.RecordDto) {
sudokuService.saveRecord(recordDto)
}
@GetMapping("/ranking/{puzzleId}")
suspend fun getRankings(@PathVariable puzzleId: Long): List<GameRecord> {
return sudokuService.getRankings(puzzleId)
}
@PostMapping("/generate")
suspend fun generateSinglePuzzle(): SudokuPuzzle {
return sudokuService.generateAndSavePuzzle()
}
@PostMapping("/validate")
suspend fun validate(@RequestBody validateDto: SudokuService.ValidateDto): Map<String, Boolean> {
val isCorrect = sudokuService.validateSolution(validateDto)
return mapOf("correct" to isCorrect)
}
}
@@ -0,0 +1,276 @@
package kr.lunaticbum.back.lun.controllers
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.PhotoMetadata
import kr.lunaticbum.back.lun.repository.PhotoMetadataRepository
import org.slf4j.LoggerFactory
import org.springframework.core.io.buffer.DataBuffer
import org.springframework.core.io.buffer.DataBufferUtils
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
data class SynologyListRequest(
val nasAddress: String,
val sid: String,
val offset: Int,
val limit: Int
)
data class MetadataRequest(
val id: String,
val memo: String?,
val tags: List<String>
)
@RestController
@RequestMapping("/api/synology")
class SynologyProxyController(
private val photoMetadataRepository: PhotoMetadataRepository
) {
private val logger = LoggerFactory.getLogger(SynologyProxyController::class.java)
private val mapper = ObjectMapper()
private val webClient = WebClient.builder()
.codecs { configurer ->
configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024)
}
.build()
private fun buildBaseUrl(nasAddress: String): String {
return if (nasAddress.startsWith("http")) nasAddress else "https://$nasAddress"
}
// [핵심 로직] 라이브 포토의 짝꿍 비디오 ID 찾기 (Plan A -> Plan B)
private fun fetchLivePhotoUnitId(
baseUrl: String,
sid: String,
folderId: String,
uuid: String?,
originalFilename: String?
): String? {
try {
// ========================================================================
// PLAN A: MediaGroupUUID로 검색 (가장 정확함)
// ========================================================================
if (!uuid.isNullOrEmpty()) {
val searchUri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
.path("/webapi/entry.cgi")
.queryParam("api", "SYNO.Foto.Browse.Item")
.queryParam("version", "1")
.queryParam("method", "list")
.queryParam("folder_id", folderId)
.queryParam("type", "video")
.queryParam("filter_media_group_uuid", uuid)
.queryParam("limit", 1)
.queryParam("_sid", sid)
.build().toUri()
val response = webClient.get().uri(searchUri).retrieve().bodyToMono(String::class.java).block()
logger.info(">>> [Smart Lookup] Plan A response: $response")
val list = mapper.readTree(response).path("data").path("list")
if (list.isArray && list.size() > 0) {
val foundId = list.get(0).path("id").asText()
logger.info(">>> [Smart Lookup] Plan A Success! Found ID via UUID: $foundId")
return foundId
}
}
// ========================================================================
// PLAN B: 파일명 매칭 (UUID 실패 시 시도)
// 예: IMG_1234.HEIC -> 같은 폴더의 IMG_1234.MOV 찾기
// ========================================================================
if (!originalFilename.isNullOrEmpty()) {
val baseName = originalFilename.substringBeforeLast(".")
// 해당 폴더의 비디오 목록 조회 (최대 1000개)
val listUri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
.path("/webapi/entry.cgi")
.queryParam("api", "SYNO.Foto.Browse.Item")
.queryParam("version", "1")
.queryParam("method", "list")
.queryParam("folder_id", folderId)
.queryParam("offset", 0)
.queryParam("type", "video")
.queryParam("limit", 1000)
.queryParam("_sid", sid)
.build().toUri()
val response = webClient.get().uri(listUri).retrieve().bodyToMono(String::class.java).block()
logger.info(">>> [Smart Lookup] Plan B response: $response")
val list = mapper.readTree(response).path("data").path("list")
if (list.isArray) {
for (node in list) {
val videoName = node.path("filename").asText("")
val videoBase = videoName.substringBeforeLast(".")
// 확장자 제외한 이름이 같으면 빙고!
if (videoBase.equals(baseName, ignoreCase = true)) {
val foundId = node.path("id").asText()
logger.info(">>> [Smart Lookup] Plan B Success! Found matching file: $videoName (ID: $foundId)")
return foundId
}
}
}
}
logger.warn(">>> [Smart Lookup] Failed. Both Plan A and B failed for file: $originalFilename")
return null
} catch (e: Exception) {
logger.error(">>> [Smart Lookup] Error", e)
return null
}
}
@PostMapping("/login")
suspend fun login(@RequestBody request: Map<String, String>): ResponseEntity<Any> {
val nasAddress = request["nasAddress"] ?: return ResponseEntity.badRequest().body("No nasAddress")
val username = request["username"] ?: return ResponseEntity.badRequest().body("No username")
val password = request["password"] ?: return ResponseEntity.badRequest().body("No password")
val baseUrl = buildBaseUrl(nasAddress)
val uri = "$baseUrl/webapi/auth.cgi?api=SYNO.API.Auth&version=3&method=login&account=$username&passwd=$password&session=Foto&format=cookie"
return try {
val response = webClient.get().uri(uri).retrieve().bodyToMono(String::class.java).awaitSingle()
ResponseEntity.ok(response)
} catch (e: Exception) {
ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message))
}
}
@PostMapping("/list")
suspend fun getList(@RequestBody request: SynologyListRequest): ResponseEntity<Any> {
val baseUrl = buildBaseUrl(request.nasAddress)
val uri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
.path("/webapi/entry.cgi")
.queryParam("api", "SYNO.Foto.Browse.Item")
.queryParam("version", "1")
.queryParam("method", "list")
.queryParam("offset", request.offset)
.queryParam("limit", request.limit)
// [중요] 정보를 미리 확보 (thumbnail, exif)
.queryParam("additional", "[\"thumbnail\",\"exif\"]")
.queryParam("_sid", request.sid)
.build().toUri()
return try {
val response = webClient.get().uri(uri).retrieve().bodyToMono(String::class.java).awaitSingle()
ResponseEntity.ok(response)
} catch (e: Exception) {
ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message))
}
}
@GetMapping("/geocode")
suspend fun reverseGeocode(
@RequestParam id: String, @RequestParam lat: Double, @RequestParam lon: Double
): ResponseEntity<Any> {
val existingData = photoMetadataRepository.findById(id).awaitSingleOrNull()
if (existingData != null) {
return ResponseEntity.ok(mapOf(
"address" to (existingData.address ?: ""),
"memo" to (existingData.memo ?: ""),
"tags" to existingData.tags
))
}
val uri = "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=10&accept-language=ko"
return try {
val responseString = webClient.get().uri(uri).header("User-Agent", "SynoPhotoSlideshow/1.0").retrieve().bodyToMono(String::class.java).awaitSingle()
val node = mapper.readTree(responseString)
val addressName = node.path("display_name").asText(null)
if (!addressName.isNullOrEmpty()) {
val metadata = PhotoMetadata(id = id, address = addressName, latitude = lat, longitude = lon)
photoMetadataRepository.save(metadata).awaitSingle()
return ResponseEntity.ok(mapOf("address" to addressName, "memo" to "", "tags" to emptyList<String>()))
}
ResponseEntity.ok(mapOf("address" to "Unknown"))
} catch (e: Exception) {
ResponseEntity.ok(mapOf("address" to "Unknown"))
}
}
@PostMapping("/metadata")
suspend fun saveMetadata(@RequestBody req: MetadataRequest): ResponseEntity<Any> {
val existingMetadata = photoMetadataRepository.findById(req.id).awaitSingleOrNull()
val metadata = existingMetadata ?: PhotoMetadata(id = req.id)
metadata.memo = req.memo
metadata.tags = req.tags.toMutableList()
photoMetadataRepository.save(metadata).awaitSingle()
return ResponseEntity.ok(mapOf("success" to true))
}
@GetMapping("/image")
fun getImage(
@RequestParam nasAddress: String,
@RequestParam sid: String,
@RequestParam id: String,
@RequestParam(defaultValue = "download") mode: String,
@RequestParam(required = false) cacheKey: String?,
@RequestParam(required = false) folderId: String?,
@RequestParam(required = false) uuid: String?,
@RequestParam(required = false) filename: String?
): ResponseEntity<StreamingResponseBody> {
val baseUrl = buildBaseUrl(nasAddress)
var targetId = id
// [Smart Lookup 발동 조건] video 모드 + 폴더ID 존재
if (mode == "video" && !folderId.isNullOrEmpty()) {
val unitId = fetchLivePhotoUnitId(baseUrl, sid, folderId, uuid, filename)
if (unitId != null) targetId = unitId
}
val rawUrl = when (mode) {
"thumbnail" -> {
val cacheKeyParam = if (!cacheKey.isNullOrEmpty()) "&cache_key=$cacheKey" else ""
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Thumbnail&version=1&method=get&type=unit&size=xl&id=$id$cacheKeyParam&_sid=$sid"
}
"video" -> {
// 다운로드 API로 비디오 스트리밍 (MP4/MOV 원본)
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Download&version=1&method=download&id=$targetId&cache_key=$cacheKey&_sid=$sid"
}
else -> {
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Download&version=1&method=download&force_download=true&item_id=[$id]&_sid=$sid"
}
}
val uri = java.net.URI.create(rawUrl)
try {
val responseEntity = webClient.get()
.uri(uri)
.accept(MediaType.ALL)
.retrieve()
.toEntityFlux(DataBuffer::class.java)
.block() ?: return ResponseEntity.notFound().build()
val contentType = responseEntity.headers.contentType ?: MediaType.APPLICATION_OCTET_STREAM
val contentLength = responseEntity.headers.contentLength
if (contentType.includes(MediaType.APPLICATION_JSON)) {
return ResponseEntity.notFound().build()
}
val streamingBody = StreamingResponseBody { outputStream ->
val flux = responseEntity.body ?: Flux.empty()
DataBufferUtils.write(flux, outputStream).blockLast()
}
val builder = ResponseEntity.ok().contentType(contentType)
if (contentLength > 0) builder.contentLength(contentLength)
return builder.body(streamingBody)
} catch (e: Exception) {
e.printStackTrace()
return ResponseEntity.notFound().build()
}
}
}
@@ -1,618 +1,65 @@
package kr.lunaticbum.back.lun.controllers
import bums.lunatic.launcher.utils.CompressStringUtil
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import com.google.maps.GeoApiContext
import com.google.maps.PlacesApi
import com.google.maps.model.LatLng
import com.google.maps.model.PlaceType
import com.google.maps.model.PlacesSearchResult
import com.google.maps.model.RankBy
import jakarta.servlet.http.HttpServletRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.model.*
import kr.lunaticbum.back.lun.service.Lama
import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.model.Result
// [중요] 서비스 클래스 import 추가
import kr.lunaticbum.back.lun.services.TelegramBotService
import kr.lunaticbum.back.lun.utils.extractModelData
import org.springframework.ai.chat.messages.UserMessage
import org.springframework.ai.chat.prompt.Prompt
import org.springframework.ai.ollama.api.OllamaApi
import org.springframework.ai.ollama.api.OllamaOptions
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.context.annotation.Bean
import org.springframework.http.MediaType
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.ui.ModelMap
import org.springframework.web.bind.annotation.*
import org.springframework.web.reactive.function.BodyInserters
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.SimpleDateFormat
import java.time.Duration
import java.util.*
import java.util.prefs.Preferences
@RestController
@RequestMapping("/tlg")
class Telegram {
class Telegram(
private val telegramBotService: TelegramBotService
) {
// @ResponseBody
// @GetMapping("hello")
// fun hello(): String {
// return "hello1212"
// }
@Autowired
lateinit var globalEvv : GlobalEnvironment
@Autowired
lateinit var telegramService: TelegramMsgService
@Autowired
lateinit var logService: LogService
@Autowired
lateinit var locationLogService: LocationLogService
// [참고] 기존 코드에 있던 다른 엔드포인트들(repotToMe, kesy 등)이 필요하다면 여기에 유지하세요.
// 리팩토링의 핵심인 webhook 부분만 아래와 같이 정리합니다.
@ResponseBody
@GetMapping("hello")
fun hello(): String {
return "hello1212"
@PostMapping("webhook")
suspend fun webhook(@RequestBody update: Result?): String {
// 서비스로 로직 위임
telegramBotService.processWebhookUpdate(update)
return "Success"
}
@Autowired
lateinit var rssDataService: RssDataService
val keyworkd = arrayListOf("I0Z","dcBEW", "TGyG", "U=Qu", "Bm=s")
val keyworkd2 = arrayListOf("x-n", "Y_D", "u", "uoo", "dfhZ", "gSKY")
@ResponseBody
@PostMapping("repotToMe.bjx")
fun repotToMe(@RequestBody jsonString: String) {
jsonString.extractModelData { exception, originDataString ->
println("jsonString $jsonString $originDataString")
if (exception == null) {
Gson().fromJson<ReportModel>(originDataString, ReportModel::class.java)?.let { msg ->
WebClient.create().get()
.uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${msg.name}님이 전송\n${msg.message}\n회신가능 메일${msg.email}")
.retrieve()
.bodyToMono(String::class.java).block() ?: "FAIL"
telegramBotService.sendTelegramMessage(null,"${msg.name}:${msg.email}\n${msg.message}")
}
}
}
}
@ResponseBody
@GetMapping("kesy/{path}")
fun getEncode(@PathVariable path: String): ModelMap {
var returnModelMap = ModelMap()
var comp = decodeCompressedString(path)
returnModelMap.put("C",comp)
returnModelMap.put("D", trimWithDecompString(comp))
return returnModelMap
}
fun decodeCompressedString(value : String) : String {
var comp = CompressStringUtil.compressString(value)
println("comp >>> $comp")
var chunked = Math.abs(Random().nextInt() % 3) + 1
chunked = if (chunked % 2 == 1) chunked + 1 else chunked
comp = comp?.chunked(chunked) {
return@chunked it.padStart(chunked,'=')
}?.joinToString("")?.reversed().plus("$chunked").plus(Char(Math.abs(Random().nextInt() % 57) + 65))
var word = if (System.currentTimeMillis() % 2L == 0L) {
keyworkd.get(chunked)
} else {
comp = comp.plus(Char(Math.abs(Random().nextInt() % 57) + 65))
keyworkd2.get(chunked)
}
comp = (word).plus(comp)
return comp
}
fun trimWithDecompString(comp : String) : String {
var doubleIpmt = false
var compressed : String? = comp
keyworkd2.forEach { if(compressed?.startsWith(it) == true) {
doubleIpmt = true
} }
var charChunked = compressed?.lastOrNull()
println("comp?.removeSuffix(charChunked!!.toString()) ${compressed?.removeSuffix(charChunked!!.toString())}")
compressed = compressed?.removeSuffix(charChunked!!.toString())
if (doubleIpmt) {
charChunked = compressed?.lastOrNull()
compressed = compressed?.removeSuffix(charChunked!!.toString())
}
charChunked = compressed?.lastOrNull()
println("charChunked >> $charChunked")
var chunked = charChunked?.toString()?.toInt() ?: 0
println("chunked >> $chunked")
println("comp?.removeSuffix(charChunked!!.toString()) ${compressed?.removeSuffix(charChunked!!.toString())}")
compressed = (compressed?.substring(0,compressed.length -1))
println("comp $compressed")
compressed = compressed?.removePrefix(keyworkd.get(chunked))?.removePrefix(keyworkd2.get(chunked))?.reversed()
println("comp $compressed")
compressed = compressed?.chunked(chunked){
return@chunked it.toString().replace("=","")
}?.joinToString("")
println("comp $compressed")
var decomp = CompressStringUtil.decompressString(compressed)
println("decomp $decomp")
return decomp
}
@ResponseBody
@PostMapping("webhook")
suspend fun test(httpServletRequest: HttpServletRequest, @RequestBody update : kr.lunaticbum.back.lun.model.Result?, @RequestBody updates : kr.lunaticbum.back.lun.model.TelegramUpdate? ) : String {
try {
println("test strat ${Gson().toJson(updates)}")
println("test strat ${Gson().toJson(update)}")
// println("test strat ${httpServletRequest.requestURI}")
update?.message?.let { msg ->
if(msg?.location != null && msg?.location?.latitude != 0.0 && msg?.location?.latitude != 0.0 ) {
CoroutineScope(Dispatchers.IO).launch {
try {
var pref = Preferences.userNodeForPackage(Telegram::class.java)
var prefKey = pref.get("GAPI_KEY".plus("_").plus(msg.from!!.id.toString()),"")
if (prefKey?.length ?: 0 < 4) {
prefKey = globalEvv.gapiKey
}
println("prefKey >> ${prefKey}")
if (prefKey != null && prefKey.length > 0) {
println("test strat ${msg.location}")
println("test prefKey ${prefKey}")
val lat = BigDecimal(msg?.location?.latitude!!).setScale(6, RoundingMode.HALF_UP)
val long = BigDecimal(msg?.location?.longitude!!).setScale(6, RoundingMode.HALF_UP)
WebClient.create().get()
.uri("http://api.weatherapi.com/v1/current.json?key=${globalEvv.weatherApiKey}&q=${lat},${long}&aqi=no")
.retrieve()
.bodyToMono(CurrentWeather::class.java)
.timeout(Duration.ofSeconds(30L))
.block()?.let { sss ->
println("test strat ${sss}")
CoroutineScope(Dispatchers.IO).launch {
try {
val msg = TelegramSendMsg(
"${msg.from!!.id!!}",
sss.getSummaryInfo(lat.toString(), long.toString())
)
val fullUrl =
"https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage"
val result = WebClient.create(fullUrl)
.post()
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(Gson().toJson(msg)))
.retrieve()
.bodyToMono(String::class.java).block() ?: "FAIL"
println("fullUrl ${fullUrl} : result $result")
} catch (e: Exception) {
e.printStackTrace()
}
}
}
val context = GeoApiContext.Builder()
.apiKey(prefKey.trim())
.build()
var types =
arrayOf(PlaceType.RESTAURANT, PlaceType.CAFE, PlaceType.BAR, PlaceType.BAKERY)
types.forEach { type ->
PlacesApi.nearbySearchQuery(context, LatLng(lat.toDouble(), long.toDouble()))
.type(type).rankby(RankBy.DISTANCE).language("ko").await()?.let { respoce ->
respoce.results.filter {
return@filter it.rating > 4 && it.userRatingsTotal > 1
}.sortedBy { it.userRatingsTotal }.forEach {
try {
val msg = TelegramSendMsg(
"${msg.from!!.id!!}",
"${type.name} :: " + it.summary(lat.toDouble(), long.toDouble())
)
println("msg >>> ${Gson().toJson(msg)}")
val fullUrl =
"https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage"
val result = WebClient.create(fullUrl)
.post()
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(Gson().toJson(msg)))
.retrieve()
.bodyToMono(String::class.java).block() ?: "FAIL"
println("fullUrl ${fullUrl} : result $result")
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
}
else {
sendSimpleMsg(globalEvv.telegramBotKey!!,msg.from!!.id.toString(),"서비스 키를 등록하셈.\n/setGaipKeys {key}")
}
}
catch(e : Exception) {
e.printStackTrace()
}
}
} else if(msg.text?.startsWith("/") == true) {
// msg.text?.split(" ")?.let { cmds ->
// cmds[0].let { cmd ->
// when(cmd.trim()) {
// "/reqGapiKeys" -> {
// sendSimpleMsg(globalEvv.telegramBotKey!!,globalEvv.telegramMyId!!,"${msg.from!!.id.toString()}님이 서비스 키를 요첨항./setGaipKeys {key}")
// }
// "/setGaipKeys" -> {
// var pref = Preferences.userNodeForPackage(Telegram::class.java)
// pref.put("GAPI_KEY".plus("_").plus(msg.from!!.id.toString()), cmds[1])
// pref.sync()
// println("test prefKey ${"GAPI_KEY".plus("_").plus(msg.from!!.id.toString())}")
// println("test prefKey ${cmds[1]}")
// println("test prefKey ${pref.get("GAPI_KEY".plus("_").plus(msg.from!!.id.toString()),"")}")
//
// }
// "/get" ->{}
// "/jf" ->{
//// CoroutineScope(Dispatchers.IO).launch {
//// logService.log("${cmd} Start ${cmds[1]}")
//// String.format(String(Base64.getMimeDecoder().decode("aHR0cHM6Ly9qYXZtb3N0LnRvL3NlYXJjaC9tb3ZpZS8lcw==".toByteArray())),cmds[1]).getJ().let { doc -> FeedParseManager.parse(doc,rssDataService) }
//// logService.log("${cmd} END ${cmds[1]}")
//// }
//// CoroutineScope(Dispatchers.IO).launch {
//// logService.log("on Cmd JF with SO")
//// logService.log("${cmd} Start ${cmds[1]}")
//// String.format(String(Base64.getMimeDecoder().decode("aHR0cHM6Ly9rcjcwLnNvZ2lybC5zby8/cz0lcw==".toByteArray())),cmds[1]).getJ().let { doc -> FeedParseManager.parse(doc,rssDataService)}
//// logService.log("${cmd} END ${cmds[1]}")
//// }
// }
// "/lama" -> {
// val req = BumlamaReq(msg.text!!.replace(cmd,""))
// CoroutineScope(Dispatchers.IO).launch {
//
// val fullUrl =
// "https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=lama 에게 전송 ${req.reqMsg}"
// logService.log("fullUrl >>> ${fullUrl}")
// WebClient.create().get()
// .uri(fullUrl)
// .retrieve()
// .bodyToMono(String::class.java).block()
// }
// CoroutineScope(Dispatchers.IO).launch {
// logService.log("${cmd} Start ${cmds[1]}")
//// msg.chat?.id
// try {
// val client = WebClient.create()
// client.post()
// .uri(lamaGenerated)
// .body(BodyInserters.fromValue(Gson().toJson(req)))
// .retrieve()
// .bodyToMono(String::class.java).timeout(Duration.ofSeconds(6000L)).block()?.let { result ->
// Gson().fromJson(result, BumlamaResp::class.java)?.let { sss ->
// println(Gson().toJson(sss))
// val fullUrl = "https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${sss.response}"
// logService.log("fullUrl >>> ${fullUrl}")
// WebClient.create().get()
// .uri(fullUrl)
// .retrieve()
// .bodyToMono(String::class.java).block() ?: "FAIL"
// }
// }
// } catch (e: Exception) {
// e.printStackTrace()
// }
//
// logService.log("${cmd[0]} END ${cmd[1]}")
// }
// }
// else -> {}
// }
// }
// }
} else if (msg.text?.contains("어디") == true) {
msg.from?.id?.let { sendMsg(it.toString()) }
} else {
println(msg.text)
val req = BumlamaReq(msg.text)
CoroutineScope(Dispatchers.IO).launch {
val fullUrl =
"https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=blama 일시키겠=> '${req.reqMsg}'"
logService.log("fullUrl >>> ${fullUrl}")
WebClient.create().get()
.uri(fullUrl)
.retrieve()
.bodyToMono(String::class.java).block()
}
CoroutineScope(Dispatchers.IO).launch {
var originalQuery = msg.text ?: ""
lama.generateResponse(originalQuery.replace("오늘","오늘(${SimpleDateFormat("yyyy-MM-dd").format(Date())})"),msg.from?.id.toString())
}
}
}
logService.log("test $httpServletRequest.requestURI")
} catch (e : Exception) {
}
return "Success"
}
// fun chatClient(): ChatClient {
// return OllamaChatClient(OllamaApi("https://lama.lunaticbum.kr"))
// .withDefaultOptions(
// OllamaOptions.create()
// .withModel("phi4:14b")
// .withNumThread(5)
// .withSeed(5)
// .withTemperature(0.9f)
// )
// }
@Autowired
lateinit var lama : Lama
@ResponseBody
@GetMapping("query/{path}")
fun googleQueryTest(@PathVariable path: String): String {
var originalQuery = path
// POST /collections
//
// Content-Type: application/json
//
// {
// "name": "movies",
// "vector_size": 3072,
// "distance": "Cosine"
// }
@PostMapping("sendToMe.bjx")
fun sendToMe(@RequestBody jsonString: String) {
println("jsonString $jsonString")
Gson().fromJson<SendToMeModel>(jsonString, SendToMeModel::class.java)?.let { msg ->
telegramBotService.sendTelegramMessage(msg.id,msg.message)
// println(lama.makeCollection())
// val gSearch = "https://psn.lunaticbum.kr/search?q=${originalQuery?.replace("오늘", SimpleDateFormat("yyyMMdd").format(Date()))}&language=auto&time_range=month&safesearch=0&categories=general&format=json"
// println("gSearch >>> ${gSearch}")
// var additionalInfo = StringBuffer()
// additionalInfo.append("참고자료")
// var idx = 0
// WebClient.create().get()
// .uri(gSearch)
// .retrieve()
// .bodyToMono(SearXng::class.java).timeout(Duration.ofMinutes(20L)).block()?.let { gsResult ->
// gsResult.results?.filter { it.score > 0.5}?.forEach {
// additionalInfo.append(idx).append(":").append(Gson().toJson(it))
// idx += 1
// }
// }
CoroutineScope(Dispatchers.IO).async {
lama.generateResponse(originalQuery.replace("오늘","오늘(${SimpleDateFormat("yyyy-MM-dd").format(Date())})"))
}
return "TEST"
}
enum class LamaQueryType(val keywords : ArrayList<String>) {
None(arrayListOf()),
Search(arrayListOf("검색")),
Weather(arrayListOf("날씨")),
NearBy(arrayListOf("주변에","근처에")),
Post(arrayListOf("POST","저장")),
}
class LamaQuery {
var userQuery : String? = null
var now = SimpleDateFormat("yyyy년MM월dd일 HH:mm:ss").format(Date())
var userId : String? = null
var queryType : LamaQueryType = LamaQueryType.None
var req : BumlamaReq? = null
var telegramBotKey : String? = null
fun start() {
req = BumlamaReq(userQuery)
LamaQueryType.values().reversed().forEach { type ->
type.keywords.forEach {
if (queryType.equals(LamaQueryType.None)) {
if(userQuery?.contains(it) == true) {
queryType = type
}
}
}
}
when (queryType) {
// LamaQueryType.None -> {
//
// }
LamaQueryType.Search -> {
}
LamaQueryType.Weather -> {
}
LamaQueryType.Post -> {
}
else -> {
askToLama()
}
}
}
fun searchInfo() {
askToLama()
}
fun searchWeather() {
askToLama()
}
fun searchNearBy() {
askToLama()
}
fun askToLama() {
CoroutineScope(Dispatchers.IO).launch {
req?.let { req ->
val client = WebClient.create()
client.post()
.uri(lamaGenerated)
.body(BodyInserters.fromValue(Gson().toJson(req)))
.retrieve()
.bodyToMono(String::class.java).timeout(Duration.ofMinutes(20L)).block()?.let { result ->
Gson().fromJson(result, BumlamaResp::class.java)?.let { sss ->
CoroutineScope(Dispatchers.IO).launch {
var toalmsg = "${userQuery}의 대답이 도착했어요.\n" + "${sss.response}"
val fullUrl = "https://api.telegram.org/${telegramBotKey}/sendMessage"
toalmsg.chunked(2048).forEach { chunkedMsg ->
println("fullUrl >>> ${fullUrl}")
var tlgSend = TelegramSendMsg(userId!!, chunkedMsg)
WebClient
.create()
.post()
.uri(fullUrl)
.body(BodyInserters.fromValue(Gson().toJson(tlgSend)))
.retrieve().bodyToMono(String::class.java).timeout(Duration.ofMinutes(20L))
.block()?.let { result ->
}
}
}
}
}
}
}.start()
}
}
fun sendSimpleMsg(telegramBotKey : String , userId : String, msg :String) {
val fullUrl = "https://api.telegram.org/${telegramBotKey}/sendMessage"
var tlgSend = TelegramSendMsg(userId, msg)
WebClient
.create()
.post()
.uri(fullUrl)
.body(BodyInserters.fromValue(Gson().toJson(tlgSend)))
.retrieve().bodyToMono(String::class.java).timeout(Duration.ofMinutes(20L))
.block()?.let { result ->
}
}
@Bean
@Scheduled(cron = "0 0 0/1 * * *") //
fun runJob() {
try {
logService.log("telegramBotKey >>>> ${globalEvv.telegramBotKey}")
logService.log("telegramMyId >>>> ${globalEvv.telegramMyId}")
logService.log("weatherApiKey >>>> ${globalEvv.weatherApiKey}")
if (
((globalEvv.weatherApiKey?.length ?: 0) > 3) &&
((globalEvv.telegramBotKey?.length ?: 0) > 3) &&
((globalEvv.telegramMyId?.length ?: 0) > 3)
) {
locationLogService.getLocationLog()?.let {
try {
WebClient.create().get()
.uri("http://api.weatherapi.com/v1/current.json?key=${globalEvv.weatherApiKey}&q=${it.mLatitude},${it.mLongitude}&aqi=no")
.retrieve()
.bodyToMono(String::class.java)
.timeout(Duration.ofSeconds(30L))
.block()?.let { result ->
Gson().fromJson(result, CurrentWeather::class.java)?.let { sss ->
val fullUrl = "https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${sss.getSummaryInfo(BigDecimal(it.mLatitude).setScale(3, RoundingMode.HALF_UP).toString(),BigDecimal(it.mLongitude).setScale(3, RoundingMode.HALF_UP).toString())}"
logService.log("fullUrl >>> ${fullUrl}")
WebClient.create().get()
.uri(fullUrl)
.retrieve()
.bodyToMono(String::class.java).block() ?: "FAIL"
}
}
}
catch (e : Exception) {
}
}
}
}catch (e : Exception) {
e.printStackTrace()
}
}
fun sendMsg(target : String) {
val client = WebClient.create()
locationLogService.getLocationLog()?.let {
client.get()
.uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${target}&text=${it.timeString}\n${it.mAddressLines.first()}\nhttps://www.google.com/maps/search/?api=1&query=${it.mLatitude},${it.mLongitude}")
.retrieve()
.bodyToMono(String::class.java).block() ?: "FAIL"
}
}
}
data class SendToMeModel(var id : String, var message : String)
fun before5Min(): Long {
val cal: Calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT"))
cal.setTime(Date(System.currentTimeMillis()))
cal.timeZone = TimeZone.getDefault()
cal.add(Calendar.MINUTE, -10)
return cal.timeInMillis
}
class BumlamaReq {
private constructor()
constructor(reqMsg: String?) {
this.reqMsg = reqMsg
}
@SerializedName("prompt")
var reqMsg : String? = ""
var model : String = "phi4:14b"
// var format : String = "json"
var stream = false
}
class BumlamaResp {
var model : String? = ""//"phi4:14b",
var created_at : String? = ""// "": "2025-02-13T06:38:53.619359Z",
var response : String? = ""// "{ \n \"response\": \"Hello! How can I assist you today?\" \n}",
var done : Boolean? = true
var done_reason : String? = "stop"
var context : ArrayList<Long>? = arrayListOf()
var total_duration : Long = 0L//: 1600246875,
var load_duration : Long = 0L//: 27544792,
var prompt_eval_count : Long = 0L//: 11,
var prompt_eval_duration : Long = 0L//: 279000000,
var eval_count : Long = 0L//: 19,
var eval_duration : Long = 0L//: 1292000000
}
val lamaGenerated : String = "https://lama.lunaticbum.kr/api/generate"
data class TelegramSendMsg(
@SerializedName("chat_id")
val userId: String, // null을 허용하지 않음
@SerializedName("text")
val msg: String // null을 허용하지 않음
)
fun PlacesSearchResult.summary(currentLat : Double,currentLng: Double) : String {
return "${name}\n총 리뷰수: ${userRatingsTotal}\n평점 : ${rating}\n거리 : \n${calculateDistance(currentLat, currentLng, geometry!!.location!!.lat, geometry!!.location!!.lng)}km\n링크:\n https://www.google.com/maps/search/?api=1&query=${geometry!!.location!!.lat}%2C${geometry!!.location!!.lng}&query_place_id=${placeId}"
}
data class ReportModel(
var name : String? = null,
var email : String? = null,
var message : String? = null,
)
@@ -1,39 +1,61 @@
package kr.lunaticbum.back.lun.controllers
import com.google.gson.Gson
import com.google.protobuf.LazyStringArrayList.emptyList
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.ApiKeyWordKey
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncType11
import kr.lunaticbum.back.lun.configs.GlobalEnvironment.Companion.EncTypeKey
import kr.lunaticbum.back.lun.configs.JwtRule
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment.Companion.ApiKeyWordKey
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment.Companion.EncType11
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment.Companion.EncTypeKey
import kr.lunaticbum.back.lun.model.*
import kr.lunaticbum.back.lun.utils.JwtUtil
import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.extractModelData
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.domain.PageRequest
import org.springframework.http.MediaType
import org.springframework.http.ResponseCookie
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.bind.annotation.*
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.RememberMeServices
import org.springframework.security.web.context.HttpSessionSecurityContextRepository
import reactor.core.publisher.Mono
import java.io.File
import java.util.*
import javax.naming.AuthenticationException
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kr.lunaticbum.back.lun.model.Message
import kr.lunaticbum.back.lun.service.CommentService
import kr.lunaticbum.back.lun.service.PostHistoryManager
import kr.lunaticbum.back.lun.service.PostManager
import kr.lunaticbum.back.lun.service.WebBookmarkService
import org.springframework.stereotype.Controller
@RestController
@Controller
@RequestMapping("/user")
class UserController(
private val rememberMeServices: RememberMeServices
private val rememberMeServices: RememberMeServices,
private val userManager: UserManager,
private val postManager: PostManager,
private val commentService: CommentService,
private val gameRankService: GameRankService, // [신규 추가] GameRankService 의존성 주입
private val messageService: MessageService,
private val webBookmarkService: WebBookmarkService,
private val imageMetaService: ImageMetaService,
private val jwtUtil: JwtUtil,
private val migrationService: MigrationService,
private val postHistoryManager: PostHistoryManager
) {
@@ -43,9 +65,27 @@ class UserController(
@Autowired
lateinit var logService: LogService
@Autowired
lateinit var userManager: UserManager
// [추가] 게시물 히스토리 조회 페이지 (관리자 전용)
@GetMapping("/admin/posts/{postId}/history")
@PreAuthorize("hasRole('ADMIN')")
suspend fun postHistoryPage(@PathVariable postId: String): ResultMV {
val vm = ResultMV("content/user/post_history") // 새 템플릿 파일을 렌더링
// 1. 현재 버전의 게시물 조회
val currentPost = postManager.findById(postId).awaitSingleOrNull()
if (currentPost == null) {
// 게시물이 없으면 리디렉션 또는 에러 처리
return ResultMV("redirect:/user/info")
}
// 2. PostHistoryManager를 통해 히스토리 목록 조회
val historyList = postHistoryManager.findByPostId(postId).collectList().awaitSingle()
vm.modelMap["currentPost"] = currentPost
vm.modelMap["historyList"] = historyList
vm.setTitle("'${currentPost.title}' 수정 히스토리")
return vm
}
@GetMapping("join.bs")
fun hello(httpServletRequest: HttpServletRequest): ResultMV {
logService.log("onJoin")
@@ -117,6 +157,10 @@ class UserController(
val principal = authResult?.principal
if (principal is UserDetails) {
val token = jwtUtil.generateToken(principal)
loginResult.token = token // 2. 응답 객체에 토큰 추가
println("target.remeberMe >>> ${target.rememberMe}")
loginResult.rememberMe = target.rememberMe
if (target.rememberMe == true) {
@@ -160,6 +204,23 @@ class UserController(
}
}
@PostMapping("/api/user/theme")
@ResponseBody
fun updateTheme(
@RequestBody request: Map<String, String>,
@AuthenticationPrincipal user: UserDetails?
): Mono<ResponseEntity<String>> {
if (user == null) return Mono.just(ResponseEntity.ok("Guest theme saved locally"))
val newTheme = request["theme"] ?: "default"
return userManager.findById(user.username).flatMap { dbUser ->
dbUser.theme = newTheme
userManager.save(dbUser)
}.map {
ResponseEntity.ok("Theme updated to $newTheme")
}
}
private fun setTokenToCookie(tokenPrefix: String, token: String, maxAgeSeconds: Long): ResponseCookie {
return ResponseCookie.from(tokenPrefix, token)
@@ -223,4 +284,212 @@ class UserController(
.retrieve()
.bodyToMono(String::class.java).block() ?: "FAIL"
}
@PostMapping("/admin/migrate-posts")
@PreAuthorize("hasRole('ADMIN')")
fun runPostMigration(): Mono<ResponseEntity<MigrationReport>> {
return migrationService.migratePosts()
.map { report -> ResponseEntity.ok(report) }
.onErrorResume { e -> // 에러 발생 시
val errorReport = MigrationReport(0, 0, 0, listOf(e.message ?: "Unknown error"))
Mono.just(ResponseEntity.status(500).body(errorReport))
}
}
/**
* [수정] '내 정보' 페이지를 위한 핸들러 (게임 랭킹 조회 추가)
*/
@GetMapping("/info")
suspend fun myInfoPage(@AuthenticationPrincipal userDetails: UserDetails?): ResultMV {
if (userDetails == null) {
return ResultMV("redirect:/home.bs?action=login")
}
val vm = ResultMV("content/user/my_info")
val username = userDetails.username
// 1. 기본 유저 정보 조회
val user = userManager.findById(username)?.block()
if (user != null) {
// 가입일을 보기 좋은 형식으로 변환하여 모델에 추가
val joinDate = Instant.ofEpochMilli(user.user_join)
.atZone(ZoneId.systemDefault())
.toLocalDate()
vm.modelMap["user"] = user
vm.modelMap["joinDate"] = joinDate.format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일"))
}
// 2. 내가 쓴 글 목록 조회 (최신 10개)
val myPosts = postManager.findPostsByWriter(username, PageRequest.of(0, 10)).collectList().block()
vm.modelMap["myPosts"] = myPosts ?: emptyList()
// 사용자가 저장한 모든 북마크 목록을 가져옵니다.
val myBookmarks = webBookmarkService.getBookmarksForUser(username) // 1. 모든 북마크를 Flux로 가져옴
.collectList() // 2. Flux 스트림을 Mono<List<WebBookmark>>으로 변환
.block() // 3. 최종적으로 List<WebBookmark>으로 변환
// 모델에 "myBookmarks" 라는 키로 저장된 북마크 리스트를 추가합니다.
vm.modelMap["myBookmarks"] = myBookmarks ?: emptyList()
// 3. 내가 쓴 댓글 목록 조회 (최신 10개)
val myComments = commentService.findCommentsByWriter(username, PageRequest.of(0, 10)).collectList().block()
vm.modelMap["myComments"] = myComments ?: emptyList()
// [신규] 받은 쪽지와 보낸 쪽지를 모두 조회하고, 시간순으로 합쳐서 모델에 추가합니다.
val receivedMessages : List<Message> = (messageService.getMessagesForUser(username).collectList().block() ?: emptyList()) as List<Message>
val sentMessages : List<Message> = (messageService.getSentMessagesByUser(username).collectList().block() ?: emptyList()) as List<Message>
// 두 리스트를 합친 후, 최신순으로 정렬합니다.
val allMessages = (receivedMessages + sentMessages).sortedByDescending { it.timestamp }
vm.modelMap["myMessages"] = allMessages
// 4. [신규 추가] 내가 남긴 게임 랭킹 조회 (최신 20개)
val myRanks = gameRankService.getRanksByPlayer(username).take(20).collectList().block()
vm.modelMap["myRanks"] = myRanks ?: emptyList()
vm.modelMap["pageTitle"] = "내 정보" // 동적 페이지 제목 설정
val isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true
vm.modelMap["isAdmin"] = isAdmin
if (isAdmin) {
// 관리자일 경우, 추가 정보 조회
vm.modelMap["allUsers"] = userManager.findAllUsers().collectList().block()
vm.modelMap["permissionRequests"] = userManager.findUsersRequestingWritePermission().collectList().block()
vm.modelMap["allRecentPosts"] = postManager.findAllVersionsPaginated(PageRequest.of(0, 20)).block() // 모든 글 조회
vm.modelMap["allImages"] = imageMetaService.getAllImages().collectList().block()
// [신규 추가] 사이트 소개글 히스토리를 모델에 추가
vm.modelMap["aboutPostHistory"] = postManager.findAboutPostHistory().collectList().block()
}
return vm
}
// [신규] 글쓰기 권한 요청 API
@PostMapping("/request-write")
@ResponseBody
fun requestWrite(@AuthenticationPrincipal userDetails: UserDetails?): Mono<ResponseEntity<String>> {
if (userDetails == null) {
return Mono.just(ResponseEntity.status(401).build())
}
return userManager.requestWritePermission(userDetails.username)
.map { savedUser -> // DB에 저장된 유저 정보를 받음
// --- 텔레그램 알림 전송 로직 ---
try {
val message = "[권한 요청] 사용자 '${savedUser.user_id}'님이 글쓰기 권한을 요청했습니다."
val client = WebClient.create()
client.get()
.uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${message}")
.retrieve()
.bodyToMono(String::class.java)
.subscribe( // non-blocking (Fire-and-Forget) 방식으로 호출
{ response -> logService.log("Telegram notification sent successfully for user ${savedUser.user_id}. Response: $response") },
{ error -> logService.log("Error sending Telegram notification for user ${savedUser.user_id}: ${error.message}") }
)
} catch (e: Exception) {
// WebClient 생성 또는 설정 중 발생할 수 있는 동기적 예외 처리
logService.log("Exception while preparing Telegram notification for user ${savedUser.user_id}: ${e.message}")
}
// --- 알림 로직 끝 ---
// 기존과 동일하게 클라이언트에게 성공 응답을 반환
ResponseEntity.ok("요청이 완료되었습니다.")
}
.defaultIfEmpty(ResponseEntity.status(404).body("사용자를 찾을 수 없습니다."))
}
// [신규] 글쓰기 권한 승인 API (관리자 전용)
@PostMapping("/approve-writer/{userId}")
@PreAuthorize("hasRole('ADMIN')")
@ResponseBody
fun approveWriter(@PathVariable userId: String): Mono<ResponseEntity<User>> {
return userManager.approveWritePermission(userId)
.map { ResponseEntity.ok(it) }
.defaultIfEmpty(ResponseEntity.notFound().build())
}
// [신규] 글쓰기 권한 거절 API (관리자 전용)
@PostMapping("/reject-writer/{userId}")
@PreAuthorize("hasRole('ADMIN')")
@ResponseBody
fun rejectWriter(@PathVariable userId: String): Mono<ResponseEntity<User>> {
return userManager.rejectWritePermission(userId)
.map { ResponseEntity.ok(it) }
.defaultIfEmpty(ResponseEntity.notFound().build())
}
/**
* 북마크 저장을 위해 클라이언트로부터 받는 데이터를 담는 DTO
*/
data class BookmarkSaveRequest(
val url: String,
val title: String?,
val description: String?,
val thumbnailUrl: String?,
val userComment: String?,
val visibility: String?
)
@PostMapping("/bookmarks/save")
@ResponseBody
fun saveBookmark(
// [수정] DTO 대신 URL과 코멘트만 간단히 받도록 변경
@RequestBody request: Map<String, String>,
@AuthenticationPrincipal user: UserDetails?
): Mono<ResponseEntity<WebBookmark>> {
if (user == null) {
return Mono.just(ResponseEntity.status(401).build())
}
val url = request["url"] ?: return Mono.just(ResponseEntity.badRequest().build())
// [수정] URL과 사용자 정보, PENDING 상태만으로 북마크 객체를 생성하여 저장
val newBookmark = WebBookmark(
userId = user.username,
url = url,
userComment = request["userComment"],
visibility = request["visibility"] ?: Visibility.PRIVATE.name,
userSelectedImageUrl = request["userSelectedImageUrl"],
metadataStatus = MetadataStatus.PENDING.name // 초기 상태는 PENDING
)
// DB에 저장하고 즉시 사용자에게 성공 응답을 반환
return webBookmarkService.saveBookmark(newBookmark)
.map { savedBookmark -> ResponseEntity.ok(savedBookmark) }
}
}
// --- API 요청/응답을 위한 DTO ---
data class LoginRequest(val userId: String, val userPw: String)
data class LoginResponse(val token: String)
@RestController
@RequestMapping("/api/auth")
class AuthController(
private val authenticationManager: AuthenticationManager,
private val userManager: UserManager,
private val jwtUtil: JwtUtil,
private val logService: LogService
) {
@PostMapping("/login")
fun createAuthenticationToken(@RequestBody loginRequest: LoginRequest): ResponseEntity<*> {
// 1. 사용자 인증
authenticationManager.authenticate(
UsernamePasswordAuthenticationToken(loginRequest.userId, loginRequest.userPw)
)
logService.log("loginRequest.userId >>> ${loginRequest.userId}")
// 2. 인증 성공 시 UserDetails 객체 로드
val userDetails = userManager.loadUserByUsername(loginRequest.userId)
logService.log("userDetails.username >>> ${userDetails.username}")
// 3. JWT 토큰 생성
val token = jwtUtil.generateToken(userDetails)
// 4. 토큰을 응답으로 반환
return ResponseEntity.ok(LoginResponse(token))
}
}
@@ -0,0 +1,365 @@
package kr.lunaticbum.back.lun.controllers.api
import com.fasterxml.jackson.databind.ObjectMapper
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.BookmarkDataDto
import kr.lunaticbum.back.lun.model.BookmarkImage
import kr.lunaticbum.back.lun.model.BookmarkType
import kr.lunaticbum.back.lun.model.BookmarkUpdateRequest
import kr.lunaticbum.back.lun.model.ImageMetaService
import kr.lunaticbum.back.lun.model.ImageUrlRequest
import kr.lunaticbum.back.lun.model.ImageVisibilityRequest
import kr.lunaticbum.back.lun.model.Visibility
import kr.lunaticbum.back.lun.model.WebBookmark
import kr.lunaticbum.back.lun.service.CommentService
import kr.lunaticbum.back.lun.service.WebBookmarkService
import kr.lunaticbum.back.lun.utils.LogService
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import reactor.core.publisher.Mono
import java.nio.file.Files
import java.nio.file.Paths
import java.util.UUID
@RestController
@RequestMapping("/api/bookmarks")
class BookmarkApiController(
private val bookmarkService: WebBookmarkService,
private val imageMetaService: ImageMetaService,
private val commentService: CommentService,
private val objectMapper: ObjectMapper,
private val logService: LogService,
) {
@Value("\${image.upload.path}")
private val uploadPath: String? = null
@GetMapping("/categories")
fun getBookmarkCategories(): Mono<List<String>> {
return bookmarkService.findAllDistinctCategories().collectList()
}
@GetMapping("/tags")
fun getBookmarkTags(): Mono<List<String>> {
return bookmarkService.findAllDistinctTags().collectList()
}
@GetMapping("/list")
suspend fun getBookmarkList(
@AuthenticationPrincipal userDetails: UserDetails?,
pageable: Pageable
): ResponseEntity<Page<WebBookmark>> {
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable,null,null).awaitSingle()
val processedBookmarksPage = bookmarksPage.map { bookmark ->
if (bookmark.images.isEmpty() && bookmark.contentUrls.isNotEmpty()) {
bookmark.copy(
images = bookmark.contentUrls.map { url -> BookmarkImage(url = url, isVisible = true) }
)
} else {
bookmark
}
}
return ResponseEntity.ok(processedBookmarksPage)
}
@PostMapping("/with-image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun saveBookmarkWithImage(
@RequestPart("imageFile") imageFile: MultipartFile,
@RequestPart("bookmarkData") bookmarkDataJson: String,
@AuthenticationPrincipal user: UserDetails?
): Mono<ResponseEntity<WebBookmark>> {
if (user == null || uploadPath.isNullOrBlank()) {
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())
}
val uniqueFilename = "${UUID.randomUUID()}_${imageFile.originalFilename}"
val targetPath = Paths.get(uploadPath, uniqueFilename)
try {
imageFile.transferTo(targetPath.toFile())
} catch (e: Exception) {
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build())
}
val bookmarkData: BookmarkDataDto = objectMapper.readValue(bookmarkDataJson, BookmarkDataDto::class.java)
val newBookmark = WebBookmark(
userId = user.username,
url = bookmarkData.url,
userComment = bookmarkData.userComment,
visibility = bookmarkData.visibility ?: "PRIVATE",
metadataStatus = "PENDING",
userSelectedImageUrl = "/api/images/$uniqueFilename"
)
return bookmarkService.saveBookmark(newBookmark)
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }
}
@PostMapping("/with-content", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun saveBookmarkWithContent(
@RequestPart("files") files: List<MultipartFile>,
@RequestPart("bookmarkData") bookmarkDataJson: String,
@AuthenticationPrincipal user: UserDetails?
): Mono<ResponseEntity<WebBookmark>> {
logService.log("uploadPath >>> ${uploadPath}")
if (user == null || uploadPath.isNullOrBlank()) {
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())
}
val savedFilePaths = files.mapNotNull { file ->
val uniqueFilename = "${UUID.randomUUID()}_${file.originalFilename}"
val targetPath = Paths.get(uploadPath, uniqueFilename)
try {
file.transferTo(targetPath.toFile())
"/api/images/$uniqueFilename"
} catch (e: Exception) {
e.printStackTrace()
null
}
}
if (savedFilePaths.isEmpty()) {
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build())
}
val bookmarkData: BookmarkDataDto = objectMapper.readValue(bookmarkDataJson, BookmarkDataDto::class.java)
val newBookmark = WebBookmark(
userId = user.username,
url = bookmarkData.url,
bookmarkType = bookmarkData.bookmarkType ?: BookmarkType.IMAGE.name,
contentUrls = savedFilePaths,
userComment = bookmarkData.userComment,
visibility = bookmarkData.visibility ?: "PRIVATE",
metadataStatus = "COMPLETED",
thumbnailUrl = savedFilePaths.first()
)
return bookmarkService.saveBookmark(newBookmark)
.map { savedBookmark -> ResponseEntity.status(HttpStatus.CREATED).body(savedBookmark) }
}
@GetMapping("/{id}")
suspend fun getBookmarkById(
@PathVariable id: String,
@AuthenticationPrincipal userDetails: UserDetails?
): ResponseEntity<WebBookmark> {
val bookmark = bookmarkService.findById(id).awaitSingleOrNull()
?: return ResponseEntity.notFound().build()
val isOwner = userDetails?.username == bookmark.userId
val canView = when (bookmark.visibility) {
Visibility.PUBLIC.name -> true
Visibility.MEMBERS.name -> userDetails != null
Visibility.PRIVATE.name -> isOwner
else -> false
}
return if (canView) {
ResponseEntity.ok(bookmark)
} else {
ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
}
@DeleteMapping("/{id}")
suspend fun deleteBookmark(
@PathVariable id: String,
@AuthenticationPrincipal userDetails: UserDetails?
): ResponseEntity<Map<String, Any>> {
logService.log("북마크 삭제 요청: ID=$id, 사용자=${userDetails?.username}")
if (userDetails == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(mapOf("message" to "인증이 필요합니다."))
}
val bookmark = bookmarkService.findById(id).awaitSingleOrNull()
if (bookmark == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("message" to "삭제할 북마크를 찾을 수 없습니다: ID=$id"))
}
if (userDetails.username != bookmark.userId) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(mapOf("message" to "이 북마크를 삭제할 권한이 없습니다."))
}
return try {
bookmarkService.deleteBookmark(id).awaitSingleOrNull()
logService.log("DB 삭제 성공: ID=$id")
ResponseEntity.ok(mapOf("message" to "북마크가 성공적으로 삭제되었습니다.", "id" to id))
} catch (e: Exception) {
logService.log("DB 삭제 중 예외 발생: ID=$id, 오류=${e.message}")
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(mapOf("message" to "북마크 삭제 중 서버 오류가 발생했습니다."))
}
}
@PutMapping("/{id}")
suspend fun updateBookmark(
@PathVariable id: String,
@RequestBody request: BookmarkUpdateRequest,
@AuthenticationPrincipal userDetails: UserDetails?
): ResponseEntity<*> {
logService.log("북마크 업데이트 요청: ID=$id, 사용자=${userDetails?.username}")
if (userDetails == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(mapOf("message" to "인증이 필요합니다."))
}
val existingBookmark = bookmarkService.findById(id).awaitSingleOrNull()
if (existingBookmark == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("message" to "수정할 북마크를 찾을 수 없습니다: ID=$id"))
}
if (userDetails.username != existingBookmark.userId) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(mapOf("message" to "이 북마크를 수정할 권한이 없습니다."))
}
val updatedBookmark = existingBookmark.copy(
title = request.title ?: existingBookmark.title,
userComment = request.userComment ?: existingBookmark.userComment,
visibility = request.visibility ?: existingBookmark.visibility,
category = request.category ?: existingBookmark.category,
tags = request.tags ?: existingBookmark.tags
)
return try {
val savedBookmark = bookmarkService.saveBookmark(updatedBookmark).awaitSingle()
logService.log("DB 업데이트 성공: ID=$id")
ResponseEntity.ok(savedBookmark)
} catch (e: Exception) {
logService.log("DB 업데이트 중 예외 발생: ID=$id, 오류=${e.message}")
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(mapOf("message" to "북마크 업데이트 중 서버 오류가 발생했습니다."))
}
}
@PostMapping("/{id}/images", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
suspend fun addImagesToBookmark(
@PathVariable id: String,
@RequestPart("files") files: List<MultipartFile>,
@AuthenticationPrincipal userDetails: UserDetails?
): ResponseEntity<*> {
if (userDetails == null || uploadPath.isNullOrBlank()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build<Unit>()
}
var bookmark = bookmarkService.findById(id).awaitSingleOrNull()
?: return ResponseEntity.notFound().build<Unit>()
if (bookmark.userId != userDetails.username) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build<Unit>()
}
if (bookmark.images.isEmpty() && bookmark.contentUrls.isNotEmpty()) {
bookmark = bookmark.copy(
images = bookmark.contentUrls.map { BookmarkImage(url = it, isVisible = true) },
contentUrls = emptyList()
)
}
val newImages = files.mapNotNull { file ->
val uniqueFilename = "${UUID.randomUUID()}_${file.originalFilename}"
val targetPath = Paths.get(uploadPath, uniqueFilename)
try {
file.transferTo(targetPath.toFile())
BookmarkImage(url = "/api/images/$uniqueFilename", isVisible = true)
} catch (e: Exception) {
null
}
}
val updatedBookmark = bookmark.copy(
images = bookmark.images + newImages
)
val saved = bookmarkService.saveBookmark(updatedBookmark).awaitSingle()
return ResponseEntity.ok(saved)
}
@PutMapping("/{id}/images/visibility")
suspend fun updateImageVisibility(
@PathVariable id: String,
@RequestBody request: ImageVisibilityRequest,
@AuthenticationPrincipal userDetails: UserDetails?
): ResponseEntity<*> {
if (userDetails == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build<Unit>()
}
var bookmark = bookmarkService.findById(id).awaitSingleOrNull()
?: return ResponseEntity.notFound().build<Unit>()
if (bookmark.userId != userDetails.username) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build<Unit>()
}
if (bookmark.images.isEmpty() && bookmark.contentUrls.isNotEmpty()) {
bookmark = bookmark.copy(
images = bookmark.contentUrls.map { BookmarkImage(url = it, isVisible = true) },
contentUrls = emptyList()
)
}
val updatedImages = bookmark.images.map {
if (it.url == request.imageUrl) {
it.copy(isVisible = !it.isVisible)
} else {
it
}
}
val updatedBookmark = bookmark.copy(images = updatedImages)
val saved = bookmarkService.saveBookmark(updatedBookmark).awaitSingle()
return ResponseEntity.ok(saved)
}
@DeleteMapping("/{id}/images")
suspend fun removeImageFromBookmark(
@PathVariable id: String,
@RequestBody request: ImageUrlRequest,
@AuthenticationPrincipal userDetails: UserDetails?
): ResponseEntity<Any> {
if (userDetails == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
}
val bookmark = bookmarkService.findById(id).awaitSingleOrNull()
?: return ResponseEntity.notFound().build()
if (bookmark.userId != userDetails.username) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
try {
val filename = request.imageUrl.substringAfterLast("/")
val filePath = Paths.get(uploadPath, filename)
if (filePath.toFile().exists()) {
Files.delete(filePath)
}
} catch (e: Exception) {
logService.log("Failed to delete image file: ${request.imageUrl}, Error: ${e.message}")
}
val updatedBookmark = bookmark.copy(
contentUrls = bookmark.contentUrls.filter { it != request.imageUrl },
images = bookmark.images.filter { it.url != request.imageUrl }
)
val saved = bookmarkService.saveBookmark(updatedBookmark).awaitSingle()
return ResponseEntity.ok(saved)
}
}
@@ -0,0 +1,50 @@
package kr.lunaticbum.back.lun.controllers.api
import kr.lunaticbum.back.lun.model.ImageMeta
import kr.lunaticbum.back.lun.model.ImageMetaService
import kr.lunaticbum.back.lun.model.ImageUploadResponse
import kr.lunaticbum.back.lun.services.ImageService // [Import 추가]
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import reactor.core.publisher.Mono
@RestController
@RequestMapping("/api/images")
class ImageApiController(
private val imageService: ImageService, // [주입]
private val imageMetaService: ImageMetaService
) {
@GetMapping("/{filename:.+}")
suspend fun getImage(
@PathVariable filename: String,
@RequestParam(required = false) type: String?
): ResponseEntity<ByteArray> {
// 모든 로직을 서비스로 위임
return imageService.loadImage(filename, type)
}
@PostMapping("/upload")
suspend fun uploadImage(@RequestParam("file") file: MultipartFile): Mono<ImageUploadResponse> {
return imageService.saveImage(file)
}
// (배너 승인/해제 메서드는 그대로 유지)
@PostMapping("/{imageId}/approve-banner")
@PreAuthorize("hasRole('ADMIN')")
fun approveBannerImage(@PathVariable imageId: String): Mono<ResponseEntity<ImageMeta>> {
return imageMetaService.approveForBanner(imageId)
.map { ResponseEntity.ok(it) }
.defaultIfEmpty(ResponseEntity.notFound().build())
}
@PostMapping("/{imageId}/revoke-banner")
@PreAuthorize("hasRole('ADMIN')")
fun revokeBannerImage(@PathVariable imageId: String): Mono<ResponseEntity<ImageMeta>> {
return imageMetaService.revokeBannerApproval(imageId)
.map { ResponseEntity.ok(it) }
.defaultIfEmpty(ResponseEntity.notFound().build())
}
}
@@ -0,0 +1,42 @@
package kr.lunaticbum.back.lun.controllers.api
import kr.lunaticbum.back.lun.utils.LogService
import org.jsoup.Jsoup
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import java.net.SocketTimeoutException
@RestController
@RequestMapping("/api/og")
class OpenGraphController(private val logService: LogService) {
@GetMapping("/parse")
fun fetchOpenGraphData(@RequestParam url: String): Mono<ResponseEntity<Map<String, String>>> {
return Mono.fromCallable {
try {
val doc = Jsoup.connect(url).get()
val title = doc.select("meta[property=og:title]").attr("content")
val description = doc.select("meta[property=og:description]").attr("content")
val imageUrl = doc.select("meta[property=og:image]").attr("content")
val data = mapOf(
"title" to (title.ifEmpty { doc.title() }),
"description" to description,
"thumbnailUrl" to imageUrl
)
ResponseEntity.ok(data)
} catch (e: SocketTimeoutException) {
logService.log("OG data parsing timed out for URL: $url")
ResponseEntity.status(408).body(mapOf("error" to "요청 시간이 초과되었습니다."))
} catch (e: Exception) {
logService.log("OG data parsing failed for URL: $url, Error: ${e.message}")
ResponseEntity.badRequest().body(mapOf("error" to "URL 정보를 가져올 수 없습니다."))
}
}.subscribeOn(Schedulers.boundedElastic())
}
}
@@ -0,0 +1,318 @@
package kr.lunaticbum.back.lun.controllers.api
import com.fasterxml.jackson.databind.ObjectMapper
import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.*
import kr.lunaticbum.back.lun.model.ContentType
import kr.lunaticbum.back.lun.model.FeedResponse
import kr.lunaticbum.back.lun.service.CommentService
import kr.lunaticbum.back.lun.service.FeedService
import kr.lunaticbum.back.lun.service.PostHistoryManager
import kr.lunaticbum.back.lun.service.PostManager
import kr.lunaticbum.back.lun.utils.LogService
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.authentication.AnonymousAuthenticationToken
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Mono
import java.net.URLEncoder
// Gibberish 요청 DTO
data class GibberishRequest(
val id: String? = null,
val content: String
)
// 댓글 요청 DTO
data class CommentRequest(
val content: String,
val targetType: ContentType
)
@RestController
@RequestMapping("/blog")
class PostApiController(
private val postManager: PostManager,
private val postHistoryManager: PostHistoryManager,
private val commentService: CommentService,
private val feedService: FeedService, // [신규] 통합 피드 서비스
private val objectMapper: ObjectMapper,
private val logService: LogService
) {
// --- 1. 통합 피드 API (Infinite Scroll용) ---
// script fetch url: /blog/feed?cursor=...&q=...
@GetMapping("/feed")
suspend fun getFeed(
@RequestParam(required = false) cursor: Long?,
@RequestParam(required = false) q: String?,
@AuthenticationPrincipal user: UserDetails?
): FeedResponse {
// 커서 기반 페이징 (기본 10개)
return feedService.getGlobalFeed(cursor, 10, q, user?.username).awaitSingle()
}
// --- 2. 통합 댓글 API (Post, Gibberish, Bookmark 공용) ---
@GetMapping("/comments/{targetId}")
fun getComments(
@PathVariable targetId: String,
@RequestParam type: ContentType
): Mono<CommentResponse> {
return commentService.getComments(targetId, type)
.collectList()
.map { comments -> CommentResponse(0, "Success", comments) }
}
@PostMapping("/comments/{targetId}")
fun addComment(
@PathVariable targetId: String,
@RequestBody request: CommentRequest,
@AuthenticationPrincipal user: UserDetails?
): Mono<CommentResponse> {
val writer = user?.username ?: "Anonymous"
// 간단한 유효성 검사
if (request.content.isBlank()) return Mono.just(CommentResponse(400, "Content is empty"))
return commentService.addComment(targetId, request.targetType, writer, request.content)
.map { CommentResponse(0, "Success") }
}
// 대댓글 조회 (기존 유지)
@GetMapping("/comments/{commentId}/replies.bjx")
fun getReplies(@PathVariable commentId: String): Mono<CommentResponse> {
// CommentService에 대댓글 조회 메서드가 구현되어 있다고 가정 (또는 기존 로직 유지)
// 여기서는 예시로 빈 리스트 반환 혹은 기존 서비스 호출
return Mono.just(CommentResponse(0, "Not implemented yet", emptyList()))
}
// --- 3. 게시글(Post) CRUD ---
@PostMapping("/post") // .bjx 접미사 제거 (RESTful 권장)
@Transactional
suspend fun savePost(
@RequestBody rawPost: Post, // JSON 그대로 매핑
@AuthenticationPrincipal user: UserDetails?
): PostSaveResponse {
if (user == null) {
return PostSaveResponse(401, "Authentication required", null)
}
// [핵심] 저장 전 인코딩 수행 (Editor는 Raw JSON을 보내고, 여기서 인코딩해서 저장)
// View에서 safeDecode로 풀어서 보여주게 됨.
val encodedTitle = URLEncoder.encode(rawPost.title ?: "", "UTF-8")
val encodedContent = URLEncoder.encode(rawPost.content ?: "", "UTF-8")
val encodedCategory = URLEncoder.encode(rawPost.category ?: "none", "UTF-8")
val encodedTags = URLEncoder.encode(rawPost.tags ?: "", "UTF-8")
val encodedFirstAddress = URLEncoder.encode(rawPost.firstAddress ?: "", "UTF-8")
val encodedModifyAddress = URLEncoder.encode(rawPost.modifyAddress ?: "", "UTF-8")
val incomingPost = rawPost.copy(
title = encodedTitle,
content = encodedContent,
category = encodedCategory,
tags = encodedTags,
firstAddress = encodedFirstAddress,
modifyAddress = encodedModifyAddress
)
return if (incomingPost.id.isNullOrBlank()) {
// --- Create ---
val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" }
val canWrite = user.authorities.any { it.authority == "ROLE_WRITE" }
if (!isAdmin && !canWrite) {
return PostSaveResponse(403, "Permission denied", null)
}
incomingPost.writer = user.username
incomingPost.writeTime = System.currentTimeMillis()
incomingPost.modifyTime = incomingPost.writeTime
val savedPost = postManager.save(incomingPost).awaitSingle()
PostSaveResponse(0, "Success", PostIdData(savedPost.id!!))
} else {
// --- Update ---
val originalPost = postManager.findById(incomingPost.id!!).awaitSingleOrNull()
?: return PostSaveResponse(404, "Original post not found", null)
val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" }
val isWriter = user.username == originalPost.writer
if (!isAdmin && !isWriter) {
return PostSaveResponse(403, "Permission denied", null)
}
// 히스토리 저장
val history = PostHistory(
postId = originalPost.id!!,
content = originalPost.content,
category = originalPost.category,
tags = originalPost.tags,
writer = originalPost.writer,
writeTime = originalPost.writeTime,
posting = originalPost.posting,
firstPostLat = originalPost.firstPostLat,
firstPostLon = originalPost.firstPostLon,
firstAddress = originalPost.firstAddress,
modifyAddress = originalPost.modifyAddress,
modifyTime = originalPost.modifyTime,
modifyLat = originalPost.modifyLat,
modifyLon = originalPost.modifyLon,
readCount = originalPost.readCount,
voteCount = originalPost.voteCount,
unlikeCount = originalPost.unlikeCount,
isBlocked = originalPost.isBlocked,
postType = originalPost.postType,
)
postHistoryManager.save(history).awaitSingle()
// 업데이트 객체 생성 (작성자는 원본 유지 또는 갱신)
val updatedPost = originalPost.copy(
title = incomingPost.title,
content = incomingPost.content,
posting = incomingPost.posting,
category = incomingPost.category,
tags = incomingPost.tags,
modifyTime = System.currentTimeMillis(),
modifyAddress = incomingPost.modifyAddress,
modifyLat = incomingPost.modifyLat,
modifyLon = incomingPost.modifyLon,
// writer는 변경하지 않음 (필요시 incomingPost.writer 사용)
)
val savedPost = postManager.save(updatedPost).awaitSingle()
PostSaveResponse(0, "Success", PostIdData(savedPost.id!!))
}
}
@DeleteMapping("/post/{postId}")
suspend fun deletePost(
@PathVariable postId: String,
@AuthenticationPrincipal user: UserDetails?
): ResponseEntity<Map<String, String>> {
if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
val post = postManager.findById(postId).awaitSingleOrNull()
?: return ResponseEntity.notFound().build()
val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" }
val isWriter = user.username == post.writer
if (!isAdmin && !isWriter) return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
postManager.deletePost(postId).awaitFirstOrNull()
return ResponseEntity.ok(mapOf("message" to "Deleted"))
}
// --- 4. Gibberish (짧은 글) CRUD ---
@PostMapping("/gibberish")
suspend fun saveGibberish(
@RequestBody request: GibberishRequest,
@AuthenticationPrincipal user: UserDetails?
): ResponseEntity<Any> {
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(mapOf("message" to "Login required"))
}
if (request.content.isBlank() || request.content.length > 100) {
return ResponseEntity.badRequest().body(mapOf("message" to "Content length must be 1-100"))
}
// 인코딩 처리
val encodedContent = URLEncoder.encode(request.content, "UTF-8")
return if (!request.id.isNullOrBlank()) {
// --- Update ---
val post = postManager.findById(request.id).awaitSingleOrNull()
?: return ResponseEntity.notFound().build()
if (post.writer != user.username) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(mapOf("message" to "Not authorized"))
}
post.content = encodedContent
post.modifyTime = System.currentTimeMillis()
postManager.save(post).awaitSingle()
ResponseEntity.ok().build()
} else {
// --- Create ---
val newPost = Post(
title = URLEncoder.encode(request.content.take(20), "UTF-8"), // 제목은 내용 앞부분
content = encodedContent,
writer = user.username,
writeTime = System.currentTimeMillis(),
modifyTime = System.currentTimeMillis(),
posting = true,
postType = PostType.GIBBERISH.name
)
val saved = postManager.save(newPost).awaitSingle()
ResponseEntity.status(HttpStatus.CREATED).body(saved)
}
}
// --- 5. 기타 조회 API (랭킹, 태그 등) ---
@GetMapping("/rankOfViews.bjx")
fun getRankOfViews(): Mono<ResponseEntity<PostListResponse>> {
val auth = SecurityContextHolder.getContext().authentication
val isAnon = auth == null || auth is AnonymousAuthenticationToken
val flux = if (isAnon) postManager.getTop5UniquePublishedByViews() else postManager.getTop5AllVersionsByViews()
return flux.collectList().map { ResponseEntity.ok(PostListResponse(it)) }
}
@GetMapping("/recentOfPost.bjx")
fun getRecentOfPost(): Mono<ResponseEntity<PostListResponse>> {
val auth = SecurityContextHolder.getContext().authentication
val isAnon = auth == null || auth is AnonymousAuthenticationToken
val flux = if (isAnon) postManager.getRecent5UniquePublished() else postManager.getRecent5AllVersions()
return flux.collectList().map { ResponseEntity.ok(PostListResponse(it)) }
}
@GetMapping("/categories.bjx")
fun getCategories(): Mono<TagResponse> {
return postManager.findAllDistinctCategories()
.collectList()
.map { TagResponse(tags = it) }
}
@GetMapping("/hashtags.bjx")
fun getHashtags(): Mono<TagResponse> {
return postManager.findAllDistinctTags()
.collectList()
.map { TagResponse(tags = it) }
}
// --- 6. 좋아요/싫어요 및 관리자 기능 ---
@PostMapping("/post/{postId}/like.bjx")
fun likePost(@PathVariable postId: String): Mono<VoteResponse> {
return postManager.incrementVote(postId).map { VoteResponse(it.voteCount, it.unlikeCount) }
}
@PostMapping("/post/{postId}/unlike.bjx")
fun unlikePost(@PathVariable postId: String): Mono<VoteResponse> {
return postManager.incrementUnlike(postId).map { VoteResponse(it.voteCount, it.unlikeCount) }
}
@PostMapping("/post/{postId}/block")
@PreAuthorize("hasRole('ADMIN')")
fun blockPost(@PathVariable postId: String): Mono<ResponseEntity<Post>> {
return postManager.blockPost(postId).map { ResponseEntity.ok(it) }.defaultIfEmpty(ResponseEntity.notFound().build())
}
@PostMapping("/post/{postId}/unblock")
@PreAuthorize("hasRole('ADMIN')")
fun unblockPost(@PathVariable postId: String): Mono<ResponseEntity<Post>> {
return postManager.unblockPost(postId).map { ResponseEntity.ok(it) }.defaultIfEmpty(ResponseEntity.notFound().build())
}
}
@@ -0,0 +1,18 @@
package kr.lunaticbum.back.lun.controllers.api
import kr.lunaticbum.back.lun.model.VisitorLogService
import kr.lunaticbum.back.lun.model.VisitorStatsDto
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Mono
@RestController
@RequestMapping("/api/stats")
class VisitorStatsController(private val visitorLogService: VisitorLogService) {
@GetMapping("/visitors")
fun getVisitorStatistics(): Mono<VisitorStatsDto> {
return visitorLogService.getVisitorStats()
}
}
@@ -0,0 +1,115 @@
package kr.lunaticbum.back.lun.controllers.view
import com.fasterxml.jackson.databind.ObjectMapper
import kotlinx.coroutines.reactive.awaitSingle
import kr.lunaticbum.back.lun.model.BookmarkImage
import kr.lunaticbum.back.lun.model.Comment
import kr.lunaticbum.back.lun.model.CommentResponse
import kr.lunaticbum.back.lun.model.ContentType
import kr.lunaticbum.back.lun.model.ImageMetaService
import kr.lunaticbum.back.lun.model.ResultMV
import kr.lunaticbum.back.lun.model.VoteResponse
import kr.lunaticbum.back.lun.service.CommentService
import kr.lunaticbum.back.lun.service.WebBookmarkService
import kr.lunaticbum.back.lun.utils.PayloadDecoder
import org.springframework.data.domain.PageRequest
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseBody
import reactor.core.publisher.Mono
@Controller
@RequestMapping("/bookmarks")
class BookmarkController(
private val bookmarkService: WebBookmarkService,
private val imageMetaService: ImageMetaService,
private val commentService: CommentService,
private val objectMapper: ObjectMapper
) {
@GetMapping
suspend fun bookmarkListPage(
@RequestParam(value = "page", defaultValue = "0") page: Int,
@RequestParam(required = false) category: String?,
@RequestParam(required = false) tag: String?,
@AuthenticationPrincipal userDetails: UserDetails?
): ResultMV {
val vm = ResultMV("content/bookmarks")
val pageable = PageRequest.of(page, 9)
val bookmarksPage = bookmarkService.getVisibleBookmarks(userDetails, pageable, category, tag).awaitSingle()
val processedBookmarksPage = bookmarksPage.map { bookmark ->
if (bookmark.images.isEmpty() && bookmark.contentUrls.isNotEmpty()) {
bookmark.copy(
images = bookmark.contentUrls.map { url -> BookmarkImage(url = url, isVisible = true) }
)
} else {
bookmark
}
}
vm.modelMap["bookmarksPage"] = processedBookmarksPage
vm.modelMap["allCategories"] = bookmarkService.findAllDistinctCategories().collectList().awaitSingle()
vm.modelMap["allTags"] = bookmarkService.findAllDistinctTags().collectList().awaitSingle()
vm.modelMap["currentCategory"] = category
vm.modelMap["currentTag"] = tag
vm.setTitle("저장된 페이지 목록")
return vm
}
@PostMapping("/{bookmarkId}/like")
@ResponseBody
fun likeBookmark(@PathVariable bookmarkId: String): Mono<VoteResponse> {
return bookmarkService.incrementVote(bookmarkId).map {
VoteResponse(it.voteCount, it.unlikeCount)
}
}
@PostMapping("/{bookmarkId}/unlike")
@ResponseBody
fun unlikeBookmark(@PathVariable bookmarkId: String): Mono<VoteResponse> {
return bookmarkService.incrementUnlike(bookmarkId).map {
VoteResponse(it.voteCount, it.unlikeCount)
}
}
@GetMapping("/{bookmarkId}/comments")
@ResponseBody
fun getComments(@PathVariable bookmarkId: String): Mono<CommentResponse> {
// [수정] getComments(targetId, type) 호출
return commentService.getComments(bookmarkId, ContentType.BOOKMARK)
.collectList()
.map { comments -> CommentResponse(0, "Success", comments) }
}
@PostMapping("/{bookmarkId}/comments")
@ResponseBody
fun addComment(
@PathVariable bookmarkId: String,
@RequestBody rawPayload: String,
@AuthenticationPrincipal user: UserDetails?
): Mono<CommentResponse> {
// [수정] Comment 객체 생성 시 변경된 필드(targetId, targetType) 사용
val comment = PayloadDecoder.decode(rawPayload, Comment::class.java, objectMapper)
// 기존 postId 필드가 삭제되었으므로 targetId/targetType 설정
// PayloadDecoder가 targetId 등을 채워주지 못할 경우를 대비해 수동 설정
val newComment = comment.copy(
targetId = bookmarkId,
targetType = ContentType.BOOKMARK,
writer = user?.username ?: "Anonymous",
writeTime = System.currentTimeMillis()
)
return commentService.addComment(newComment)
.map { CommentResponse(0, "Success") }
}
}
@@ -0,0 +1,92 @@
package kr.lunaticbum.back.lun.controllers.view
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.gson.Gson
import jakarta.servlet.http.HttpServletRequest
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
import kr.lunaticbum.back.lun.model.LocationLog
import kr.lunaticbum.back.lun.model.Post
import kr.lunaticbum.back.lun.model.ResponceResult
import kr.lunaticbum.back.lun.model.ResultMV
import kr.lunaticbum.back.lun.service.PostManager
import kr.lunaticbum.back.lun.services.LocationLogService
import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.plainText
import org.springframework.beans.factory.annotation.Autowired
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.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/bums")
class BumsPrivate {
@Autowired
lateinit var globalEvv : GlobalEnvironment
@Autowired
lateinit var logService: LogService
@Autowired
lateinit var postManager : PostManager
@Autowired
lateinit var locationService: LocationLogService
@GetMapping("face.bs")
suspend fun aboutMePage(): ResultMV {
val vm = ResultMV("content/about_view")
val aboutPost = postManager.findLatestAboutPost().awaitSingleOrNull()
if (aboutPost != null) {
vm.modelMap["srcPost"] = aboutPost
vm.modelMap["srcPostJson"] = ObjectMapper().writeValueAsString(aboutPost)
vm.setTitle("BUM'sPace 소개")
} else {
vm.modelMap["srcPost"] = Post(title = "소개글이 아직 작성되지 않았습니다.", content = "")
vm.modelMap["srcPostJson"] = "{}"
vm.setTitle("소개글 없음")
}
return vm
}
@GetMapping("where.bs")
suspend fun where(@RequestParam(value = "page", defaultValue = "0") page: Int) : ResultMV {
val m = ResultMV("content/private/where")
val pageable: Pageable = PageRequest.of(page, 30, Sort.by("time").descending())
val locationPage = locationService.findAll(pageable).awaitSingle()
m.modelMap.put("locationPage", locationPage)
m.setTitle("돼지 여기있다요~!!")
return m
}
@ResponseBody
@PostMapping("save/loc.api")
suspend fun login(httpServletRequest: HttpServletRequest, @RequestBody jsonString: String) : ResponseEntity<ResponceResult> {
logService.log("${httpServletRequest.requestURI}")
logService.log(jsonString)
jsonString.plainText().let {
Gson().fromJson<LocationLog>(it, LocationLog::class.java)?.let { model ->
logService.log(model.toString())
locationService.save(model).awaitSingle()
}
}
val responce = ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply {
})
return responce
}
}
@@ -0,0 +1,16 @@
package kr.lunaticbum.back.lun.controllers.view
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
@Controller
class CustomErrorController {
@GetMapping("/access-denied")
fun accessDeniedPage(model: Model): String {
model.addAttribute("statusCode", "403")
model.addAttribute("errorMessage", "이 페이지에 접근할 권한이 없습니다.")
model.addAttribute("errorDescription", "요청하신 리소스에 대한 접근 권한이 부족합니다. 관리자에게 문의하거나 다른 계정으로 로그인해 주세요.")
return "content/error_page"
}
}
@@ -0,0 +1,435 @@
package kr.lunaticbum.back.lun.controllers.view
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.gson.Gson
import com.google.gson.JsonParser
import jakarta.servlet.http.HttpServletResponse
import kotlinx.coroutines.reactive.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.*
import kr.lunaticbum.back.lun.model.ContentType
import kr.lunaticbum.back.lun.model.FeedResponse
import kr.lunaticbum.back.lun.repository.WebBookmarkRepository
import kr.lunaticbum.back.lun.service.FeedService
import kr.lunaticbum.back.lun.service.PostManager
import kr.lunaticbum.back.lun.services.ImageService
import kr.lunaticbum.back.lun.utils.LogService
import net.coobird.thumbnailator.Thumbnails
import org.jsoup.Jsoup
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.*
import java.io.File
import java.io.IOException
import java.net.URLDecoder
import java.text.SimpleDateFormat
import java.util.*
@Controller
class PostViewController(
private val postManager: PostManager,
private val imageMetaService: ImageMetaService,
private val visitorLogService: VisitorLogService,
private val objectMapper: ObjectMapper,
private val logService: LogService,
private val feedService: FeedService,
private val bookmarkRepository: WebBookmarkRepository, // [추가] 북마크 조회를 위해 필요
private val imageService: ImageService // [주입 추가]
) {
@Value("\${image.upload.path}")
private val uploadPath: String? = null
private fun safeDecode(value: String?): String {
if (value.isNullOrBlank()) return ""
return try {
// URL 인코딩된 문자열이면 디코딩, 아니거나 에러나면 원본 반환
URLDecoder.decode(value, "UTF-8")
} catch (e: Exception) {
value
}
}
// --- Helper Methods (View 전용) ---
private fun processPostForView(post: Post): Post {
// [수정] 모든 텍스트 필드에 safeDecode 적용
post.title = safeDecode(post.title)
// Gibberish는 내용도 인코딩되어 있으므로 필수 적용
post.content = safeDecode(post.content)
post.tags = safeDecode(post.tags)
post.category = safeDecode(post.category).ifBlank { "none" }
post.firstAddress = safeDecode(post.firstAddress)
post.modifyAddress = safeDecode(post.modifyAddress)
if (post.title!!.isBlank()) {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm")
post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]"
}
if (post.title!!.isBlank()) {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm")
post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]"
}
var firstImgSrc: String? // [수정] 초기화 null 제거 (나중에 할당되므로)
val defaultThumb = "/images/pic01.jpg"
try {
JsonParser.parseString(post.content)
val (text, firstImg) = extractFromDelta(post.content!!)
post.html = text
firstImgSrc = firstImg
} catch (e: Exception) {
val doc = Jsoup.parse(post.content)
post.html = doc.text()
firstImgSrc = doc.select("img").first()?.attr("src")
}
if (!firstImgSrc.isNullOrBlank()) {
val filename = firstImgSrc.substringAfterLast("/")
post.image = "/api/images/$filename"
// [변경] 서비스 메서드 호출
imageService.generateThumbnailFile(filename, 200)
val thumbFilename = filename.substringBeforeLast(".") + "_thumbnail." + filename.substringAfterLast(".")
post.thumb = "/api/images/$thumbFilename?type=thumbnail"
} else {
post.image = null
post.thumb = defaultThumb
}
return post
}
private data class DeltaOp(val insert: Any)
private data class Delta(val ops: List<DeltaOp>)
private fun extractFromDelta(deltaJson: String): Pair<String, String?> {
val delta: Delta = Gson().fromJson(deltaJson, Delta::class.java)
val textOnly = StringBuilder()
var firstImage: String? = null
delta.ops.forEach { op ->
if (op.insert is String) {
textOnly.append(op.insert)
} else if (op.insert is Map<*, *> && firstImage == null) {
val obj = op.insert as Map<*, *>
if (obj["image"] != null) {
firstImage = obj["image"].toString()
}
}
}
return textOnly.toString() to firstImage
}
private fun generateThumbnail(originalFilename: String, targetWidth: Int) {
if (uploadPath.isNullOrBlank() || originalFilename.isBlank()) return
try {
val originalFile = File(uploadPath, originalFilename)
val thumbnailFilename = originalFilename.substringBeforeLast(".") + "_thumbnail." + originalFilename.substringAfterLast(".")
val thumbnailFile = File(uploadPath, thumbnailFilename)
if (thumbnailFile.exists() || !originalFile.exists()) return
Thumbnails.of(originalFile)
.width(targetWidth)
.keepAspectRatio(true)
.toFile(thumbnailFile)
} catch (e: IOException) {
logService.log("Thumbnail generation failed for $originalFilename: ${e.message}")
}
}
@GetMapping("/api/feed")
@ResponseBody
suspend fun getFeedMore(
@RequestParam cursor: Long,
@RequestParam(required = false) q: String? // 더보기 할 때도 검색어 유지 필요
): FeedResponse {
return feedService.getGlobalFeed(cursor, 6, q).awaitSingle()
}
// --- View Endpoints ---
@GetMapping("/", "/home.bs")
suspend fun home(
request: jakarta.servlet.http.HttpServletRequest,
@RequestParam(required = false) q: String?, // 검색어
@AuthenticationPrincipal userDetails: UserDetails?
): ResultMV {
visitorLogService.recordVisit(request).subscribe()
val vm = ResultMV("content/home")
// 배너 로직 (기존 유지)
val defaultBannerImage = "/api/images/0e2bf8b1-1848-4650-b084-5b52d0815be9.jpg?type=banner"
val randomImage = imageMetaService.getRandomBannerImage().awaitSingleOrNull()
val bannerPath = randomImage?.path?.let {
if (it.contains("/blog/post/images/")) it.replace("/blog/post/images/", "/api/images/") + "?type=banner"
else it + "?type=banner"
} ?: defaultBannerImage
vm.modelMap["randomBannerImage"] = bannerPath
// [변경] FeedService를 통해 통합 피드 데이터 조회
val username = userDetails?.username
// 파라미터: (cursor=null, size=10, keyword=q, username)
val feedData = feedService.getGlobalFeed(null, 6, q, username).awaitSingle()
vm.modelMap["feedItems"] = feedData.items
vm.modelMap["nextCursor"] = feedData.nextCursor
vm.modelMap["searchQuery"] = q
// Gibberish 작성 폼용 (랜덤 문구는 이제 필요 없으면 제거 가능)
val randomGibberish = postManager.findRandomGibberish().awaitSingleOrNull()
if (randomGibberish != null) {
vm.modelMap["gibberish"] = URLDecoder.decode(randomGibberish.content, "UTF-8")
vm.modelMap["gibberishId"] = randomGibberish.id
}
return vm
}
@GetMapping("/blog/posts")
suspend fun postsList(
@RequestParam(value = "page", defaultValue = "0") page: Int,
@RequestParam(required = false) category: String?,
@RequestParam(required = false) tag: String?,
@AuthenticationPrincipal userDetails: UserDetails?
): ResultMV {
val vm = ResultMV("content/posts")
val pageable = PageRequest.of(page, 8)
vm.modelMap["currentCategory"] = category
vm.modelMap["currentTag"] = tag
val posts: List<Post>
val total: Long
when {
!category.isNullOrBlank() -> {
posts = postManager.findPostsByCategory(category, pageable).awaitSingle()
total = postManager.countPostsByCategory(category).awaitSingle()
vm.modelMap["filterTitle"] = "'${category}' 카테고리의 글"
}
!tag.isNullOrBlank() -> {
posts = postManager.findPostsByTag(tag, pageable).awaitSingle()
total = postManager.countPostsByTag(tag).awaitSingle()
vm.modelMap["filterTitle"] = "'#${tag}' 태그가 포함된 글"
}
else -> {
val roles = userDetails?.authorities?.map { it.authority } ?: emptyList()
val username = userDetails?.username
when {
roles.contains("ROLE_ADMIN") -> {
posts = postManager.findAllVersionsPaginated(pageable).awaitSingle()
total = postManager.countAllVersions().awaitSingle()
}
roles.contains("ROLE_WRITE") && username != null -> {
posts = postManager.findLatestUniqueForWriter(username, pageable).awaitSingle()
total = postManager.countLatestUniqueForWriter(username).awaitSingle()
}
else -> {
posts = postManager.findLatestUniquePaginated(pageable).awaitSingle()
total = postManager.countLatestUnique().awaitSingle()
}
}
}
}
val processedPosts = posts.map { processPostForView(it) }
vm.modelMap["postsPage"] = PageImpl(processedPosts, pageable, total)
return vm
}
@GetMapping("/slideshow")
fun slideshowPage(): ResultMV {
return ResultMV("content/slideshow") // templates/content/slideshow.html
}
// --- [핵심 수정] 뷰어 (북마크 포함) ---
// [핵심 수정] 뷰어 메서드
@GetMapping("/blog/viewer/{id}")
suspend fun postViewer(
@PathVariable("id") id: String,
@AuthenticationPrincipal userDetails: UserDetails?
): ResultMV {
val vm = ResultMV("content/viewer")
var viewerDto: PostViewerDto? = null
// 1. Post 조회
try {
val post = postManager.getPost(id).awaitSingleOrNull()
if (post != null) {
val processed = processPostForView(post)
val isWriter = userDetails?.username == post.writer
val isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true
if (!post.posting && !isWriter && !isAdmin) return ResultMV("redirect:/")
viewerDto = post.writer?.let {
PostViewerDto(
id = post.id!!,
type = if(post.postType == "GIBBERISH") ContentType.GIBBERISH else ContentType.POST,
title = processed.title ?: "",
content = processed.content ?: "" ,
writer = it,
writeTime = post.writeTime,
modifyTime = post.modifyTime,
tags = post.tags?.split(",")?.map{it.trim()}?.filter{it.isNotBlank()} ?: emptyList(),
category = processed.category,
originId = null, // 필요시 post.originId 매핑
// --- 템플릿 필드명 매핑 (Dto = Post) ---
posting = post.posting,
readCount = post.readCount, // views -> readCount
voteCount = post.voteCount, // likes -> voteCount (없으면 0)
unlikeCount = post.unlikeCount,
// 위치 정보 이름 변환 (Dto = Post)
firstPostLat = post.firstPostLat,
firstPostLon = post.firstPostLon, // Lng -> Lon
firstAddress = post.firstAddress,
modifyLat = post.modifyLat, // modifyPostLat -> modifyLat
modifyLon = post.modifyLon, // modifyPostLng -> modifyLon
modifyAddress = post.modifyAddress,
images = emptyList(),
thumb = post.thumb,
isOwner = isWriter,
isAdmin = isAdmin
)
}
}
} catch (e: Exception) { }
// 2. Bookmark 조회
if (viewerDto == null) {
val bookmark = bookmarkRepository.findById(id).awaitSingleOrNull()
if (bookmark != null) {
val isWriter = userDetails?.username == bookmark.userId
if (bookmark.visibility != "PUBLIC" && !isWriter) return ResultMV("redirect:/")
val allImages = (listOfNotNull(bookmark.displayImageUrl) + (bookmark.images.map { it.url } ?: emptyList())).distinct()
viewerDto = (bookmark.title ?: bookmark.url)?.let {
PostViewerDto(
id = bookmark.id!!,
type = ContentType.BOOKMARK,
title = it,
content = bookmark.userComment ?: bookmark.description ?: "",
writer = bookmark.userId,
writeTime = bookmark.savedAt,
modifyTime = bookmark.savedAt,
tags = bookmark.tags ?: emptyList(),
category = "Bookmark",
originId = null,
// --- 템플릿 필드명 매핑 (북마크 기본값) ---
posting = (bookmark.visibility == "PUBLIC"),
readCount = 0,
voteCount = bookmark.voteCount,
unlikeCount = bookmark.unlikeCount,
firstPostLat = 0.0,
firstPostLon = 0.0,
firstAddress = null,
modifyLat = 0.0,
modifyLon = 0.0,
modifyAddress = null,
images = allImages,
originalUrl = bookmark.url,
thumb = bookmark.displayImageUrl,
isOwner = isWriter,
isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true
)
}
}
}
if (viewerDto == null) return ResultMV("redirect:/")
vm.modelMap["srcPost"] = viewerDto
vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(viewerDto)
vm.modelMap["targetType"] = viewerDto.type
vm.modelMap["isOwner"] = viewerDto.isOwner
vm.modelMap["isAdmin"] = viewerDto.isAdmin
vm.modelMap["originalUrl"] = viewerDto.originalUrl
// vm.modelMap["apiBaseUrl"] = apiBaseUrl
return vm
}
@GetMapping(value = ["/blog/edit", "/blog/edit/{postId}"])
suspend fun editPost(
@PathVariable(required = false) postId: String?,
@RequestParam(required = false) type: String?,
@AuthenticationPrincipal userDetails: UserDetails?
): ResultMV {
if (userDetails == null) {
return ResultMV("redirect:/home.bs?action=login")
}
val isAdmin = userDetails.authorities.any { it.authority == "ROLE_ADMIN" }
val canWrite = userDetails.authorities.any { it.authority == "ROLE_WRITE" }
val vm = ResultMV("content/editor")
try {
if (postId == null) {
if (!canWrite && !isAdmin) {
return ResultMV("redirect:/blog/posts")
}
vm.modelMap["pageTitle"] = "새 글 작성"
val newPost = Post().apply {
title = "무제(無題) (${SimpleDateFormat("yyyy-MM-dd HH:mm").format(Date())})"
content = ""
if (type == PostType.ABOUT_SITE.name) {
this.postType = PostType.ABOUT_SITE.name
vm.modelMap["pageTitle"] = "사이트 소개글 작성"
}
}
vm.modelMap["srcPost"] = newPost
vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(newPost)
} else {
vm.modelMap["pageTitle"] = "글 수정"
val rawPost = postManager.findById(postId).awaitSingleOrNull()
?: return ResultMV("redirect:/blog/posts")
val isWriter = userDetails.username == rawPost.writer
if (!isAdmin && !isWriter) {
return ResultMV("redirect:/blog/posts")
}
var processedContent: String
try {
processedContent = URLDecoder.decode(rawPost.content, "UTF-8")
} catch (e: Exception) {
processedContent = rawPost.content ?: ""
}
rawPost.content = processedContent
val processedPost = processPostForView(rawPost)
vm.modelMap["srcPost"] = processedPost
vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(processedPost)
}
} catch (e: Exception) {
logService.log("Error processing edit page for postId: $postId. Error: ${e.message}")
return ResultMV("redirect:/blog/posts")
}
return vm
}
@GetMapping("/login")
fun login(response: HttpServletResponse) {
response.sendRedirect("/user/login")
}
@GetMapping("/licenses")
fun licenses() = ResultMV("content/licenses")
}
@@ -1,2 +0,0 @@
package kr.lunaticbum.back.lun.model
@@ -0,0 +1,23 @@
package kr.lunaticbum.back.lun.model
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
@Document(collection = "auto_trade_tasks")
data class AutoTradeEntity(
@Id
val id: String? = null,
val stockCode: String,
val stockName: String,
val buyPrice: Double,
val quantity: Int,
var targetProfitRate: Double, // 익절 수익률 (예: 5.0)
var stopLossRate: Double, // [추가] 손절 수익률 (예: -3.0)
val appKey: String,
val appSecret: String,
val accountNo: String,
var accessToken: String
)
@@ -0,0 +1,12 @@
package kr.lunaticbum.back.lun.model
// --- API 응답을 위한 DTO 클래스들 ---
data class PostListResponse(val posts: List<Post>)
data class CommentResponse(val resultCode: Int, val resultMsg: String, val comments: List<Comment>? = null)
data class PostSaveResponse(val resultCode: Int, val resultMsg: String, val data: PostIdData? = null)
data class PostIdData(val postId: String)
data class VoteResponse(val voteCount: Long, val unlikeCount: Long)
data class ImageUploadResponse(val resultCode: Int, val resultMsg: String, val fileName: String? = null)
data class TagResponse(val resultCode: Int = 0, val resultMsg: String = "OK", val tags: List<String>)
data class GibberishRequest(val id: String? = null,val content: String)
@@ -0,0 +1,19 @@
package kr.lunaticbum.back.lun.model
data class BookmarkDataDto(
val url: String,
val bookmarkType : String?,
val userComment: String?,
val visibility: String?
)
data class BookmarkUpdateRequest(
val title: String?,
val userComment: String?,
val visibility: String?,
val category: String?,
val tags: List<String>?
)
data class ImageUrlRequest(val imageUrl: String)
data class ImageVisibilityRequest(val imageUrl: String)
@@ -1,412 +0,0 @@
package kr.lunaticbum.back.lun.model
import com.google.gson.Gson
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.utils.LogService
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
import org.bson.codecs.pojo.annotations.BsonIgnore
import org.jsoup.Jsoup
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.annotation.Id
import org.springframework.data.domain.Sort
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.text.SimpleDateFormat
import java.time.Duration
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.*
class BumsPrivate {
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "LocationLog")
class LocationLog {
var mFeatureName: String? = null
var mAddressLines: ArrayList<String> = arrayListOf()
var mAdminArea: String? = null
var mSubAdminArea: String? = null
var mLocality: String? = null
var mSubLocality: String? = null
var mThoroughfare: String? = null
var mSubThoroughfare: String? = null
var mPremises: String? = null
var mPostalCode: String? = null
var mCountryCode: String? = null
var mCountryName: String? = null
var mLatitude = 0.0
var mLongitude = 0.0
var mPhone: String? = null
var timeString : String? = null
var mUrl: String? = null
var time : Long = 0L
var userId : String? = null
var bettween : String? = null
override fun toString(): String {
val buffer = StringBuffer()
buffer.append(mFeatureName).append("|").append("\n")
buffer.append(mAddressLines.joinToString(" , ")).append("|").append("\n")
buffer.append(mAdminArea).append("|").append("\n")
buffer.append(mSubAdminArea).append("|").append("\n")
buffer.append(mLocality).append("|").append("\n")
buffer.append(mSubLocality).append("|").append("\n")
buffer.append(mThoroughfare).append("|").append("\n")
buffer.append(mSubThoroughfare).append("|").append("\n")
buffer.append(mPremises).append("|").append("\n")
buffer.append(mPostalCode).append("|").append("\n")
buffer.append(mCountryCode).append("|").append("\n")
buffer.append(mCountryName).append("|").append("\n")
buffer.append(mLatitude).append("|").append("\n")
buffer.append(mLongitude).append("|").append("\n")
buffer.append(mPhone).append("|").append("\n")
buffer.append(mUrl).append("|").append("\n")
return buffer.toString()
}
}
@Repository
interface LocationLogRepository : ReactiveMongoRepository<LocationLog, String> {
@Query("{ 'time' : { \$gte: ?0 } }")
fun findRecent(since: Long, sort: Sort): Flux<LocationLog>
// @Query("SELECT l FROM LocationLog l WHERE l.timeString >= :since ORDER BY l.timeString DESC")
// fun findRecent(@Param("since") since: String): Flux<LocationLog>
fun findTop30ByOrderByTimeDesc(): Flux<LocationLog>
fun findAllBy() : Mono<LocationLog>
fun findFirstByOrderByTimeDesc() : Mono<LocationLog>
fun findFirstByUserIdOrderByTimeDesc(userId: String) : Mono<LocationLog>
fun save(log: LocationLog): Mono<LocationLog>
}
interface LocationService {
}
@Service
class LocationLogService : LocationService {
@Autowired
private lateinit var logService: LogService
@Autowired
private lateinit var logRepository: LocationLogRepository
fun find10() : List<LocationLog> {
val sinceMills = System.currentTimeMillis() - ((24 * 60 * 60 * 1000) * 7)
println("sinceMills >> $sinceMills")
val sort = Sort.by(Sort.Direction.DESC, "time") // 오름차순 정렬
// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
// val since = LocalDateTime.now().minusHours(24).format(formatter)
// println("since >> $since")
val flux = filterByDistanceReactive(logRepository.findRecent(sinceMills,sort), 10.0)
return flux.collectList().block(Duration.ofSeconds(30)) ?: listOf()
}
fun getLocationLog() : LocationLog? {
return logRepository.findFirstByOrderByTimeDesc().block()
}
fun getLocationLogBy(userId : String) : LocationLog? {
return logRepository.findFirstByOrderByTimeDesc().block()
}
fun filterByDistanceReactive(flux: Flux<LocationLog>, minDistanceMeter: Double): Flux<LocationLog> {
return flux
.buffer(2, 1)
.filter { pair ->
if (pair.size < 2) true
else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude) >= minDistanceMeter
}
.map { pair ->
val distance = if (pair.size < 2) 0.0 else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude)
val base = pair[0]
println("base >>> ${base.time} ${base.timeString}")
base.bettween = String.format("%.2f m", distance) // 소수점 두자리까지 거리 표시
base
}
}
// Haversine 거리계산 함수 (단위:m)
fun haversine(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val R = 6371000.0 // 지구 반지름(m)
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
fun save(log: LocationLog) {
println("saved msg before ${log}")
logRepository.save(log).subscribe( { println("saved msg after ${it}") },{e -> e.printStackTrace()},{
println("saved msg comp")
})
}
}
interface RssDataInterface {
fun title() : String
fun thumbnailUrl() : String
fun originPage() : String
fun description() : String
fun pubDate() : Long
fun category() : RssDataType
fun getCho() : String?
}
enum class RssDataType {
NO_DATA,
YOUTUBE,
NewsFeed,
GURU,
Most,
TAGS,
REDDIT,
REDDIT_nsfw,
Dotax,
FmKorae,
DcInside,
RuliWeb,
Clien,
TheQoo,
Arca;
// fun getResId() = when (this) {
// YOUTUBE -> R.drawable.youtube
// REDDIT, REDDIT_nsfw -> R.drawable.reddit
// Dotax -> R.drawable.daum
// FmKorae -> R.drawable.fmk
// DcInside -> R.drawable.dcinside
// Arca -> R.drawable.arca
// else -> {
// 0
// }
// }
fun defaultImgSize() = when (this) {
YOUTUBE -> 200
REDDIT_nsfw,GURU,Most -> 360
else -> { 120 }
}
// fun getDefaultVisibiliy() = when (this) {
// REDDIT_nsfw,GURU,Most,NewsFeed -> View.GONE
// else -> { View.VISIBLE }
// }
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "RssData")
class RssData : RssDataInterface {
@Id
var originPage : String? = null
var title : String? = null
var description : String? = null
var thumbnail : String? = null
var pubDate : Long = 0L
var category : String? = null
var chosung : String? = null
@BsonIgnore
var mRssDataType : RssDataType? = null
override fun title(): String {
return when(category()){
RssDataType.NewsFeed -> {
if(title?.length ?: 0 > 30) title?.substring(0,30).plus("...") else title ?: ""
}
else -> title ?: ""
}.apply {
// chosung = JamoUtils.split(this).joinToString("")
}
}
override fun thumbnailUrl(): String {
return thumbnail ?: ""
}
override fun originPage(): String {
return originPage ?: ""
}
override fun description(): String {
return when(category()){
RssDataType.YOUTUBE -> {
if(description?.contains("게시자") == true) description!!.split("게시자")[0] else description ?: ""
}
RssDataType.NewsFeed -> {
category().name
}
else -> description.plus(" / ").plus(category().name)
}
}
override fun pubDate(): Long {
return pubDate
}
override fun category(): RssDataType {
if (mRssDataType == null)
mRssDataType = RssDataType.valueOf(category!!)
return mRssDataType!!
}
override fun getCho(): String? {
return chosung
}
}
val USAGT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15"
fun String.getJ() = Jsoup.connect(this).userAgent(USAGT).get()
object FeedParseManager {
val parsers = listOf<SoInterface>(QVZTb2dpcmw,SkFWTW9zdA)
fun parse(doc : org.jsoup.nodes.Document, service: RssDataService) {
try {
parsers.filter { doc.title().contains(it.getName()) }.first()?.let {
it.parse(doc,service)
}
} catch (e : Exception) {
e.printStackTrace()
}
}
}
interface SoInterface{
fun getName() : String
fun parse(doc : org.jsoup.nodes.Document,service: RssDataService)
}
object QVZTb2dpcmw : SoInterface {
override fun getName(): String {
return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray()))
}
override fun parse(doc : org.jsoup.nodes.Document, service : RssDataService) {
var lists = arrayListOf<RssData>()
doc.getElementsByTag("article").forEach { article ->
val title = article.getElementsByTag("a").get(0).attr("title")
val href = article.getElementsByTag("a").get(0).attr("href")
val img = article.getElementsByTag("img").get(0).attr("data-src")
service.save(RssData().apply {
this.originPage = href
this.title = title
this.description = "Sogirl"
this.thumbnail = img
this.pubDate = Date().time
this.category = RssDataType.GURU.name
}) {
// CoroutineScope(Dispatchers.IO).launch {
// service.sendMsg("${title}\n${img}\n${href}")
// }
}
}
// lists.map {
// service.sendMsg("${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}")
// }
}
}
object SkFWTW9zdA : SoInterface {
var dmy = SimpleDateFormat("dd-MM-yyyy")
override fun getName(): String {
return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray()))
}
override fun parse(doc: org.jsoup.nodes.Document, service: RssDataService) {
var lists = arrayListOf<RssData>()
doc.getElementsByClass("card").forEach { card ->
var thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("src") else ""
if (thumb.contains("No+Poster")) thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("data-src") else thumb
var model = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("alt") else ""
if(card.getElementsByClass("card-block").size > 0) if(card.getElementsByClass("card-block").size > 0) {
val link = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("href")
val title = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("title")
val date = card.getElementsByTag("span").get(0).text()
service.save(RssData().apply {
lists.add(this)
description = model
thumbnail = thumb
originPage = link
this.title = title
category = RssDataType.Most.name
try {
pubDate = dmy.parse(date).time
}catch (e : Exception) {e.printStackTrace()}
}){
// CoroutineScope(Dispatchers.IO).launch {
// service.sendMsg("${title}\n${thumb}\n${link}")
// }
}
}
}
// service.sendMsg(lists.map {
// "${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}\n"
// }.joinToString(" \n "))
}
}
@Repository
interface RssDataRepository : ReactiveMongoRepository<RssData, String> {
fun findFirstByOriginPageEquals(originPage : String): Mono<RssData>
fun findAllByOrderByPubDate() : Mono<List<RssData>>
fun save(log: RssData): Mono<RssData>
}
@Service
class RssDataService {
@Autowired
private lateinit var logService: LogService
@Autowired
private lateinit var rssDataRepository: RssDataRepository
fun hasItem(originPage : String) {
}
fun getLocationLog() : List<RssData>? {
return rssDataRepository.findAllByOrderByPubDate().block()
}
fun save(log: RssData, callback : (Boolean)->Unit) {
println("saved msg before ${Gson().toJson(log)}")
log.originPage?.let {
if(rssDataRepository.findFirstByOriginPageEquals(it).block() == null) {
rssDataRepository.save(log)
.subscribe({ println("saved msg after ${it}") }, { e -> e.printStackTrace() }, {
println("saved msg comp")
callback(true)
})
} else {
println("있어???")
}
}
}
@Autowired
lateinit var globalEvv : GlobalEnvironment
suspend fun sendMsg(data : String) {
val client = WebClient.create()
client.get()
.uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${data}")
.retrieve()
.bodyToMono(String::class.java).block() ?: "FAIL"
}
}
@@ -0,0 +1,23 @@
package kr.lunaticbum.back.lun.model
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
@Document(collection = "comments")
data class Comment(
@Id val id: String? = null,
// [핵심 변경] postId -> targetId, targetType 추가
val targetId: String, // 댓글이 달린 원본 글/북마크의 ID
val targetType: ContentType, // POST, GIBBERISH, BOOKMARK
val writer: String, // 작성자
val content: String, // 내용
val writeTime: Long = System.currentTimeMillis(),
// 대댓글 기능을 위한 부모 댓글 ID (옵션)
val parentId: String? = null,
// 삭제 여부 (Soft Delete)
val isDeleted: Boolean = false
)
@@ -0,0 +1,25 @@
// src/main/kotlin/kr/lunaticbum/back/lun/model/DirectLoginToken.kt
package kr.lunaticbum.back.lun.model
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import java.time.LocalDateTime
@Document(collection = "direct_login_tokens")
data class DirectLoginToken(
@Id
val token: String,
val appKey: String,
val appSecret: String,
val accountNo: String,
val username: String,
// [보안 강화]
val deviceId: String, // 브라우저에 심어둔 쿠키 값
val clientIp: String, // 생성 당시 IP
val userAgent: String, // 생성 당시 기기 정보
val createdAt: LocalDateTime = LocalDateTime.now(),
val expiresAt: LocalDateTime = LocalDateTime.now().plusDays(30) // 30일 유효
)
@@ -0,0 +1,77 @@
package kr.lunaticbum.back.lun.model
enum class ContentType { POST, GIBBERISH, BOOKMARK }
//interface FeedItem {
// val id: String?
// val type: ContentType
// val title: String?
// val content: String? // 요약 내용 또는 본문
// val thumbnail: String? // 대표 이미지
// val createdAt: Long // 정렬 기준 시간
// val writer: String?
// val url: String // 클릭 시 이동할 경로
//}
// 피드 아이템 통합 DTO
data class FeedItemDto(
val id: String?, // 원본 게시물/북마크 ID
val type: ContentType, // 콘텐츠 타입 (POST, GIBBERISH, BOOKMARK)
val title: String?, // 제목 (Gibberish는 없을 수 있음)
val content: String?, // 내용 요약 또는 전체 (HTML 제거된 텍스트 권장)
val thumbnail: String?, // 썸네일 이미지 URL
val createdAt: Long, // 작성일/저장일 (정렬 기준)
val writer: String?, // 작성자
val url: String, // 클릭 시 이동할 주소 (내부 글보기 또는 외부 링크)
// 필요하다면 추가할 필드들
val voteCount: Long = 0, // 좋아요 수
val commentCount: Int = 0, // 댓글 수 (나중에 추가 가능)
val category: String? = null,
val tags: List<String> = emptyList(),
val isOwner: Boolean = false
)
// [최종 수정] 템플릿 오류 방지를 위한 '만능' DTO
// [최종] 템플릿(includes.html) 호환성 100% 보장 DTO
data class PostViewerDto(
val id: String,
val type: ContentType, // POST, BOOKMARK, GIBBERISH
val title: String,
val content: String,
val writer: String,
val writeTime: Long,
val modifyTime: Long = 0, // 템플릿 요구
val tags: List<String>,
val category: String? = null,
val originId: String? = null,
// --- 템플릿이 요구하는 통계/상태 필드명 ---
val posting: Boolean = true, // 공개 여부
val readCount: Long = 0, // views -> readCount
val voteCount: Long = 0, // likes -> voteCount
val unlikeCount: Long = 0, // 싫어요
// --- 템플릿이 요구하는 위치/주소 필드명 (정확히 일치해야 함) ---
val firstPostLat: Double? = 0.0,
val firstPostLon: Double? = 0.0, // Lng -> Lon
val firstAddress: String? = null,
val modifyLat: Double? = 0.0, // modifyPostLat -> modifyLat
val modifyLon: Double? = 0.0, // modifyPostLng -> modifyLon
val modifyAddress: String? = null,
// --- 뷰어 전용 추가 필드 ---
val images: List<String> = emptyList(),
val originalUrl: String? = null,
val thumb: String? = null,
val isOwner: Boolean = false,
val isAdmin: Boolean = false
)
data class FeedResponse(
val items: List<FeedItemDto>,
val nextCursor: Long? // 다음 요청 시 이 시간을 보내면 그 이전 글들을 줍니다.
)
@@ -0,0 +1,226 @@
package kr.lunaticbum.back.lun.model
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.utils.LogService
import org.bson.BsonType
import org.bson.codecs.pojo.annotations.BsonId
import org.bson.codecs.pojo.annotations.BsonRepresentation
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.annotation.Profile
import org.springframework.context.event.EventListener
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.Aggregation
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.io.File
import java.nio.file.Files
import java.util.concurrent.atomic.AtomicBoolean
import javax.imageio.ImageIO
@Document(collection = "ImageMeta") // 이미지 메타데이터를 저장할 컬렉션
data class ImageMeta(
@BsonId
@BsonRepresentation(BsonType.OBJECT_ID)
var id: String? = null,
var fileName: String, // 저장된 파일명 (예: uuid.jpg)
var originalFileName: String?, // 원본 파일명
var fileType: String?, // MIME 타입 (예: image/jpeg)
var fileSize: Long, // 파일 크기 (bytes)
var width: Int, // 이미지 가로 픽셀
var height: Int, // 이미지 세로 픽셀
var uploadTime: Long, // 등록일시 (Timestamp)
var path: String, // 이미지 접근 가능 URL 경로
var isBannerCandidate: Boolean = false
)
/**
* ImageMeta 컬렉션을 위한 Spring Data Repository
*/
interface ImageMetaRepository : ReactiveMongoRepository<ImageMeta, String> {
@Aggregation(pipeline = [ "{ \$sample: { size: 1 } }" ])
fun findRandomImage(): Mono<ImageMeta>
// [신규 추가] isBannerCandidate가 true인 이미지 중에서만 랜덤으로 1개를 선택
@Aggregation(pipeline = [
"{ \$match: { isBannerCandidate: true } }",
"{ \$sample: { size: 1 } }"
])
fun findRandomBannerCandidate(): Mono<ImageMeta>
fun findByFileName(fileName: String): Mono<ImageMeta>
// [신규 추가] 파일 이름 리스트를 기반으로 문서를 전부 삭제하는 기능
fun deleteAllByFileNameIn(fileNames: List<String>): Mono<Void>
}
/**
* 이미지 메타데이터 로직을 처리할 서비스
*/
@Service
class ImageMetaService(
private val repository: ImageMetaRepository,
private val logService: LogService, // LogService 주입
@Value("\${image.upload.path}") private val uploadPath: String, // application.properties의 업로드 경로 주입
@Value("\${build.config.run}") private val build_config_run: String
) {
// [신규 추가] 백그라운드 작업용 Coroutine Scope 정의
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// [신규 추가] 동기화 작업이 중복 실행되는 것을 방지하기 위한 Atomic Lock
private val isSyncRunning = AtomicBoolean(false)
/**
* 공개 메소드: 메타데이터 저장 (BlogController에서 사용)
*/
fun save(imageMeta: ImageMeta): Mono<ImageMeta> {
return repository.save(imageMeta)
}
/**
* 공개 메소드: 랜덤 이미지 가져오기 (Home 컨트롤러에서 사용)
*/
fun getRandomImage(): Mono<ImageMeta> {
return repository.findRandomImage()
}
// application.properties의 업로드 경로 주입
/**
* Spring Boot가 준비되었을 때(부팅 완료) 실행되는 리스너
*/
@Profile("!local")
@EventListener(ApplicationReadyEvent::class)
fun onApplicationReady() {
logService.log("Application ${build_config_run} ready. Launching initial image DB sync task...")
if (build_config_run.contains("prd")) {
launchSyncTask()
}
}
/**
* 동기화 작업을 비동기(Coroutine)로 실행하는 런처 (잠금 처리)
* BlogController에서도 이 함수를 호출할 수 있습니다.
*/
fun launchSyncTask() {
// AtomicBoolean을 사용해 현재 동기화 작업이 실행 중이 아닐 때만 true로 설정하고 새 작업을 시작
if (isSyncRunning.compareAndSet(false, true)) {
logService.log("Starting background image sync...")
serviceScope.launch {
try {
// 실제 동기화 로직 실행
runFileSystemSync()
} catch (e: Exception) {
logService.log("Unhandled error in sync launcher: ${e.message}")
} finally {
// 작업이 성공하든, 실패(중단)하든 항상 잠금을 해제하여 다음 작업을 허용
isSyncRunning.set(false)
logService.log("Background image sync finished. Lock released.")
}
}
} else {
logService.log("Skipping sync launch: Task is already running.")
}
}
/**
* 실제 파일 시스템과 DB를 동기화하는 핵심 로직
*/
// ImageMetaService.kt의 runFileSystemSync 함수를 아래 내용으로 교체하세요.
private suspend fun runFileSystemSync() {
// 1. [변경] 실제 업로드 폴더에서 파일 '이름' 목록을 Set으로 가져옵니다.
val physicalFilenames = File(uploadPath).listFiles { _, name ->
!name.contains("_thumbnail.") && (name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png") || name.endsWith(".gif"))
}?.map { it.name }?.toSet() ?: emptySet()
// 2. [변경] DB에서 모든 이미지 '파일 이름'을 Set으로 가져옵니다.
val dbFilenames = repository.findAll().map { it.fileName }.collectList().awaitSingle().toSet()
// 3. 파일 시스템과 DB의 파일 목록이 완전히 일치하면 동기화가 필요 없습니다.
if (physicalFilenames == dbFilenames) {
logService.log("Image sync check: File lists are identical. No sync needed.")
return
}
logService.log("Image sync: Mismatch detected. Syncing...")
try {
// 4. [추가] DB에 추가해야 할 파일 목록을 계산합니다. (실제 파일 O, DB X)
val filesToAdd = physicalFilenames - dbFilenames
if (filesToAdd.isNotEmpty()) {
logService.log("Sync: Found ${filesToAdd.size} files to add to DB...")
for (fileName in filesToAdd) {
val file = File(uploadPath, fileName)
if (file.exists()) {
val bufferedImage = ImageIO.read(file)
val metadata = ImageMeta(
fileName = file.name,
originalFileName = "Scanned from disk",
fileType = Files.probeContentType(file.toPath()),
fileSize = file.length(),
width = bufferedImage.width,
height = bufferedImage.height,
uploadTime = file.lastModified(),
path = "/blog/post/images/${file.name}"
)
repository.save(metadata).awaitSingle()
}
}
}
// 5. [추가] DB에서 삭제해야 할 '고아 데이터' 목록을 계산합니다. (실제 파일 X, DB O)
val filesToDelete = dbFilenames - physicalFilenames
if (filesToDelete.isNotEmpty()) {
logService.log("Sync: Found ${filesToDelete.size} orphan DB entries to delete...")
repository.deleteAllByFileNameIn(filesToDelete.toList()).awaitSingleOrNull()
}
logService.log("Image sync finished successfully.")
} catch (e: Exception) {
logService.log("CRITICAL SYNC FAILED: ${e.message}. Halting sync task.")
e.printStackTrace()
}
}
/**
* [이름 변경 및 로직 수정] 기존 getRandomImage -> getRandomBannerImage
* 배너 후보로 지정된 이미지 중에서 랜덤으로 하나를 가져옵니다.
*/
fun getRandomBannerImage(): Mono<ImageMeta> {
return repository.findRandomBannerCandidate()
}
// [신규 추가] 관리자 페이지에서 모든 이미지를 조회하기 위한 메서드
fun getAllImages(): Flux<ImageMeta> {
return repository.findAll()
}
// [신규 추가] 특정 이미지를 배너 후보로 승인하는 메서드
fun approveForBanner(imageId: String): Mono<ImageMeta> {
return repository.findById(imageId).flatMap { image ->
image.isBannerCandidate = true
repository.save(image)
}
}
// [신규 추가] 특정 이미지의 배너 후보 자격을 해제하는 메서드
fun revokeBannerApproval(imageId: String): Mono<ImageMeta> {
return repository.findById(imageId).flatMap { image ->
image.isBannerCandidate = false
repository.save(image)
}
}
}
@@ -0,0 +1,18 @@
package kr.lunaticbum.back.lun.model
// src/main/kotlin/kr/lunaticbum/back/lun/model/KisDtos.kt
// 클라이언트로부터 설정을 받을 때 사용
data class KisConfigRequest(
val appKey: String,
val appSecret: String,
val accountNo: String
)
// 세션에 저장하거나 API 응답에 사용할 토큰 정보
data class KisAuthSession(
val appKey: String,
val appSecret: String,
val accountNo: String,
var accessToken: String? = null,
var tokenExpiredAt: Long = 0L
)
@@ -0,0 +1,67 @@
package kr.lunaticbum.back.lun.model
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
import org.springframework.data.domain.Sort
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.Aggregation
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.text.SimpleDateFormat
import java.util.*
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "LocationLog")
class LocationLog {
var id: String? = null // MongoDB ID 필드 명시 권장
var mFeatureName: String? = null
var mAddressLines: ArrayList<String> = arrayListOf()
var mAdminArea: String? = null
var mSubAdminArea: String? = null
var mLocality: String? = null
var mSubLocality: String? = null
var mThoroughfare: String? = null
var mSubThoroughfare: String? = null
var mPremises: String? = null
var mPostalCode: String? = null
var mCountryCode: String? = null
var mCountryName: String? = null
var mLatitude = 0.0
var mLongitude = 0.0
var mPhone: String? = null
var timeString: String? = null
var mUrl: String? = null
var time: Long = 0L
var userId: String? = null
var bettween: String? = null // 거리 계산 결과 임시 저장용
val displayTime: String
get() {
if (!this.timeString.isNullOrBlank()) {
return this.timeString!!
}
if (this.time != 0L) {
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return formatter.format(Date(this.time))
}
return "[시간 정보 없음]"
}
override fun toString(): String {
return "$mAddressLines ($timeString)"
}
}
interface LocationLogRepository : ReactiveMongoRepository<LocationLog, String> {
@Aggregation(pipeline = ["{ \$match: { 'time' : { \$gte: ?0 } } }"])
fun findRecent(since: Long, sort: Sort): Flux<LocationLog>
fun findTop30ByOrderByTimeDesc(): Flux<LocationLog>
fun findFirstByOrderByTimeDesc(): Mono<LocationLog>
fun findFirstByUserIdOrderByTimeDesc(userId: String): Mono<LocationLog>
}
@@ -0,0 +1,70 @@
package kr.lunaticbum.back.lun.model
import org.bson.BsonType
import org.bson.codecs.pojo.annotations.BsonId
import org.bson.codecs.pojo.annotations.BsonRepresentation
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@Document(collection = "Messages")
data class Message(
@Id
@BsonId
@BsonRepresentation(BsonType.OBJECT_ID)
var id: String? = null,
var senderId: String,
var receiverId: String,
var title: String,
var content: String,
var timestamp: Long = System.currentTimeMillis(),
var isRead: Boolean = false
)
@Repository
interface MessageRepository : ReactiveMongoRepository<Message, String> {
fun findByReceiverIdOrderByTimestampDesc(receiverId: String): Flux<Message>
fun countByReceiverIdAndIsRead(receiverId: String, isRead: Boolean): Mono<Long>
fun findBySenderIdOrderByTimestampDesc(senderId: String): Flux<Message>
}
@Service
class MessageService(private val messageRepository: MessageRepository) {
fun getMessagesForUser(userId: String): Flux<Message> {
return messageRepository.findByReceiverIdOrderByTimestampDesc(userId)
}
fun getUnreadMessageCount(userId: String): Mono<Long> {
return messageRepository.countByReceiverIdAndIsRead(userId, false)
}
fun sendMessage(senderId: String, receiverId: String, title: String, content: String): Mono<Message> {
val message = Message(senderId = senderId, receiverId = receiverId, title = title, content = content)
return messageRepository.save(message)
}
fun markMessageAsRead(messageId: String, userId: String): Mono<Message> {
return messageRepository.findById(messageId)
.filter { it.receiverId == userId } // 본인 쪽지만 읽음 처리하도록 보안 강화
.flatMap { message ->
if (!message.isRead) {
message.isRead = true
messageRepository.save(message)
} else {
Mono.just(message)
}
}
}
// [신규] 사용자가 보낸 쪽지를 가져오는 서비스를 추가합니다.
fun getSentMessagesByUser(userId: String): Flux<Message> {
return messageRepository.findBySenderIdOrderByTimestampDesc(userId)
}
}
@@ -0,0 +1,112 @@
package kr.lunaticbum.back.lun.model
import kr.lunaticbum.back.lun.repository.PostHistoryRepository
import kr.lunaticbum.back.lun.repository.PostRepository
import org.springframework.data.domain.Sort
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.aggregation.Aggregation
import org.springframework.data.mongodb.core.query.Query
import org.springframework.data.mongodb.core.aggregation.Aggregation.*
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
// 마이그레이션 결과 리포트를 위한 데이터 클래스
data class MigrationReport(
val processedGroups: Int,
val latestPostsMigrated: Int,
val historyPostsMigrated: Int,
val errors: List<String>
)
@Service
class MigrationService(
private val mongoTemplate: ReactiveMongoTemplate,
private val postRepository: PostRepository, // 새 Post Repository
private val postHistoryRepository: PostHistoryRepository // 새 PostHistory Repository
) {
// Mono<MigrationReport>를 반환하여 비동기 작업의 결과를 컨트롤러에 전달
fun migratePosts(): Mono<MigrationReport> {
// 1. originId가 null이 아닌 문서만 대상으로 그룹화
val aggregation = newAggregation(
Post::class.java,
sort(Sort.Direction.DESC, "modifyTime"),
group("originId")
.push("$$ROOT").`as`("versions")
)
return mongoTemplate.aggregate(aggregation, "Post", Map::class.java)
.flatMap { versionGroup ->
@Suppress("UNCHECKED_CAST")
val versions = versionGroup["versions"] as? List<Map<String, Any>> ?: return@flatMap Mono.empty<Void>()
if (versions.isEmpty()) {
return@flatMap Mono.empty<Pair<Int, Int>>()
}
// 3. 첫 번째 항목이 최신 버전, 나머지가 과거 버전
// FIX 1: Changed .first to .first()
val latestVersionMap = versions.first()
val oldVersionsMaps = versions.drop(1)
// 4. 최신 버전을 새 Post 객체로 변환하고 저장
val newPost = mapToPost(latestVersionMap).apply {
originId = null
}
postRepository.save(newPost)
.flatMap { savedPost ->
// 5. 과거 버전들을 PostHistory 객체로 변환하여 저장
val historyFlux = Flux.fromIterable(oldVersionsMaps)
.map { oldVersionMap ->
mapToHistory(oldVersionMap, savedPost.id!!)
}
.flatMap { history ->
postHistoryRepository.save(history)
}
historyFlux.then(Mono.just(Pair(1, oldVersionsMaps.size)))
}
}
.reduce(Pair(0, 0)) { acc, pair ->
Pair(acc.first + (pair as Pair<Int,Int>).first, acc.second + pair.second)
}
.map { (latestCount, historyCount) ->
MigrationReport(
processedGroups = latestCount,
latestPostsMigrated = latestCount,
historyPostsMigrated = historyCount,
errors = emptyList()
)
}
.defaultIfEmpty(MigrationReport(0, 0, 0, listOf("No data to migrate.")))
}
// Map을 Post 객체로 변환하는 헬퍼 함수
private fun mapToPost(map: Map<String, Any>): Post = Post(
id = map["_id"]?.toString(),
originId = map["originId"] as? String, // 마이그레이션 중에는 originId가 필요
title = map["title"] as? String,
content = map["content"] as? String,
category = map["category"] as? String,
tags = map["tags"] as? String,
writer = map["writer"] as? String,
writeTime = (map["writeTime"] as? Number)?.toLong() ?: 0L,
posting = map["posting"] as? Boolean ?: false,
modifyTime = (map["modifyTime"] as? Number)?.toLong() ?: 0L
// ... Post의 모든 필드를 안전하게 변환 ...
)
// Map을 PostHistory 객체로 변환하는 헬퍼 함수
private fun mapToHistory(map: Map<String, Any>, newPostId: String): PostHistory = PostHistory(
postId = newPostId,
title = map["title"] as? String,
content = map["content"] as? String,
category = map["category"] as? String,
tags = map["tags"] as? String,
writer = map["writer"] as? String,
writeTime = (map["writeTime"] as? Number)?.toLong() ?: 0L,
posting = map["posting"] as? Boolean ?: false,
modifyTime = (map["modifyTime"] as? Number)?.toLong() ?: 0L
// ... PostHistory의 모든 필드를 안전하게 변환 ...
)
}
@@ -0,0 +1,19 @@
package kr.lunaticbum.back.lun.model
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
@Document(collection = "photo_metadata")
data class PhotoMetadata(
@Id
val id: String,
var address: String? = null,
var latitude: Double? = null,
var longitude: Double? = null,
var memo: String? = null,
// [추가] 태그 리스트
var tags: MutableList<String> = mutableListOf()
)
@@ -1,20 +1,29 @@
package kr.lunaticbum.back.lun.model
import com.fasterxml.jackson.databind.ObjectMapper
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
import kr.lunaticbum.back.lun.repository.PostHistoryRepository
import kr.lunaticbum.back.lun.utils.LogService
import lombok.AllArgsConstructor
import lombok.Data
import lombok.Getter
import lombok.NoArgsConstructor
import okio.Timeout
import org.bson.BsonType
import org.bson.codecs.pojo.annotations.BsonId
import org.bson.codecs.pojo.annotations.BsonIgnore
import org.bson.codecs.pojo.annotations.BsonRepresentation
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.annotation.Id
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.mongodb.core.FindAndModifyOptions // [추가됨]
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.data.mongodb.core.query.Query
import org.springframework.data.mongodb.core.query.Update
import org.springframework.data.mongodb.repository.Aggregation
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
@@ -26,167 +35,299 @@ import reactor.core.publisher.Mono
import java.net.URLDecoder
import java.time.Duration
import org.springframework.data.mongodb.core.index.CompoundIndex // [신규 추가]
import org.springframework.data.mongodb.core.index.IndexDirection // [신규 추가]
import org.springframework.data.mongodb.core.index.Indexed // [신규 추가]
import org.springframework.data.mongodb.core.query.Query
import org.springframework.security.core.userdetails.UserDetails
import java.text.SimpleDateFormat
import java.util.ArrayList
import java.util.Base64
import java.util.Date
@Document(collection = "PostHistory")
data class PostHistory(
@Id
var id: String? = null,
var postId: String, // 원본 Post의 ID
// --- Post의 모든 필드를 그대로 가져옵니다 ---
var title : String? = null,
var content : String? = null,
var category : String? = null,
var tags : String? = null,
var writer : String? = null,
var writeTime : Long = 0,
var posting : Boolean = false,
var firstPostLat : Double = 0.0,
var firstPostLon : Double = 0.0,
var firstAddress : String = "",
var modifyAddress : String = "",
var modifyTime : Long = 0,
var modifyLat : Double = 0.0,
var modifyLon : Double = 0.0,
var readCount : Long = 0,
var voteCount : Long = 0,
var unlikeCount : Long = 0,
var isBlocked: Boolean = false,
var postType: String = PostType.STANDARD.name,
// --- 히스토리 전용 필드 ---
var archivedAt: Long = System.currentTimeMillis() // 보관된 시간
)
enum class PostType {
STANDARD, // 일반 블로그 글
ABOUT_SITE, // 사이트 소개 글
GIBBERISH
}
@Document(collection = "Post")
@CompoundIndex(name = "origin_time_desc_idx", def = "{'originId': 1, 'modifyTime': -1}")
data class Post(
@BsonId
@BsonRepresentation(BsonType.OBJECT_ID)
var id: String? = null,
var originId: String? = null,
var title : String? = null,
var content : String? = null,
var category : String? = null,
var tags : String? = null,
var html : String? = null,
var image : String? = null,
var thumb : String? = null,
var writer : String? = null,
var writeTime : Long = 0,
var posting : Boolean = false,
var firstPostLat : Double = 0.0,
var firstPostLon : Double = 0.0,
var firstAddress : String = "",
var modifyAddress : String = "",
@Indexed(direction = IndexDirection.DESCENDING)
var modifyTime : Long = 0,
var modifyLat : Double = 0.0,
var modifyLon : Double = 0.0,
@Indexed(direction = IndexDirection.DESCENDING)
var readCount : Long = 0,
var voteCount : Long = 0,
var unlikeCount : Long = 0,
var isBlocked: Boolean = false,
// [추가] 게시물 타입을 구분하는 필드. 기본값은 'STANDARD'
var postType: String = PostType.STANDARD.name
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "Post")
class Post {
class TagResult {
var resultCode: Int = 0
var resultMsg: String = ""
var tags: List<String>? = null
}
@Data
@NoArgsConstructor
@AllArgsConstructor
class CommentsResult {
var resultCode: Int = 0
var resultMsg: String = ""
var comments: List<Comment>? = null
}
/**
* @Aggregation의 $count 단계에서 결과를 매핑하기 위한 헬퍼 데이터 클래스입니다.
*/
data class AggregationCount(val totalCount: Long)
/**
* common.js에서 Base64 인코딩 후 전송하는 데이터의 구조와 일치하는 DTO입니다.
* @param data 난독화된 실제 데이터 문자열
* @param key 데이터를 재조합하는 데 사용되는 키
* @param type 난독화 방식을 결정하는 타입
*/
data class EncryptedPayload(
val data: String = "",
val key: String = "",
val type: String = ""
)
@Getter
class RequestModel {
var type : String? = null
var key : String? = null
var data : String? = null
fun getKeyword() = key ?: ""
fun extractData() : String {
data?.let {
val reqString = data?.split(GlobalEnvironment.padding(getKeyword()))
val nb = arrayListOf<String>()
val na = arrayListOf<String>()
reqString?.get(0)?.replace(GlobalEnvironment.padding(getKeyword()),"")?.split("")?.toList()?.let { na.addAll(it) }
reqString?.get(1)?.replace(GlobalEnvironment.padding(getKeyword()),"")?.split("")?.toList()?.let { nb.addAll(it) }
val max = nb.size + na.size
val fullData = arrayListOf<String>()
for (idx in 0..max) { if (idx % 2 == 0) { if (nb.size > 0) { fullData.add(nb.removeLast()) } } else { if (na.size > 0) { fullData.add(na.removeLast()) } } }
return fullData.joinToString("")
}
return ""
}
}
@Getter
private class ReportModel {
var name : String? = null
var email : String? = null
var message : String? = null
}
object PayloadDecoder {
/**
* common.js의 unformat() 함수와 대칭되는 복호화 로직입니다.
* @param data 난독화된 데이터
* @param key 분리 기준이 되는 키
* @param type 복호화 방식을 결정하는 타입
* @return 원본 데이터 문자열
*/
private fun format(data: String, key: String, type: String): String {
val divider = "|*-*|$key|*-*|"
var (odd, even) = data.split(divider).let { it[0] to it[1] }
// 타입에 따라 unformat에서 적용된 reverse()를 다시 reverse()하여 원상복구
when (type) {
"T1" -> odd = odd.reversed()
"T2" -> even = even.reversed()
"T3" -> {
odd = odd.reversed()
even = even.reversed()
}
// [추가] 예외적인 type에 대한 기본 처리(안전장치)
else -> {
odd = odd.reversed()
even = even.reversed()
}
}
// odd와 even 문자열을 다시 조합하여 원본 데이터 생성
val result = StringBuilder()
val maxLength = maxOf(odd.length, even.length)
for (i in 0 until maxLength) {
if (i < even.length) result.append(even[i])
if (i < odd.length) result.append(odd[i])
}
return result.toString()
}
/**
* Base64로 인코딩된 전체 payload를 디코딩하고, 최종적으로 원하는 객체 타입으로 변환합니다.
* @param payload 컨트롤러가 받은 원시 Base64 문자열
* @param clazz 변환하고자 하는 최종 클래스 타입 (예: Post::class.java)
* @return 변환된 객체
*/
fun <T> decode(payload: String, clazz: Class<T>, objectMapper: ObjectMapper): T {
// 1. Base64 디코딩 -> { "data": ..., "key": ..., "type": ... } 형태의 JSON 문자열이 됨
val b64Decoded = String(Base64.getDecoder().decode(payload))
// 2. 외부 JSON을 EncryptedPayload DTO로 변환
val encryptedPayload = objectMapper.readValue(b64Decoded, EncryptedPayload::class.java)
// 3. 내부의 난독화된 데이터를 복호화하여 원본 JSON 문자열을 얻음
val originalJson = format(encryptedPayload.data, encryptedPayload.key, encryptedPayload.type)
// 4. 원본 JSON을 최종 목표 객체로 변환하여 반환
return objectMapper.readValue(originalJson, clazz)
}
}
enum class Visibility {
PUBLIC, // 전체 공개
MEMBERS, // 회원 공개
PRIVATE // 비공개 (나만 보기)
}
enum class MetadataStatus {
PENDING, // 처리 대기 중
COMPLETED, // 처리 완료
FAILED // 처리 실패
}
enum class BookmarkType {
URL, // 기존 웹 페이지 링크
IMAGE, // 하나 이상의 이미지
VIDEO // 하나 이상의 비디오
}
data class BookmarkImage(
val url: String = "",
var isVisible: Boolean = true
)
@Document(collection = "WebBookmark")
data class WebBookmark(
@BsonId
@BsonRepresentation(BsonType.OBJECT_ID)
var id: String? = null
var id: String? = null,
var userId: String, // 누가 저장했는지
var url: String?, // 원본 페이지 URL
// [신규] 북마크 타입 (URL, IMAGE, VIDEO 등)
var bookmarkType: String = BookmarkType.URL.name,
// [신규] 콘텐츠 URL 목록 (웹페이지는 1개, 이미지는 여러 개 가능)
@Deprecated("Use images list instead")
var contentUrls: List<String> = emptyList(), // 이전 버전과의 호환성을 위해 남겨둡니다.
var images: List<BookmarkImage> = emptyList(), // URL과 'isVisible' 상태를 함께 저장하는 새 필드
var originId: String? = null
var title: String? = null, // 페이지 제목
var description: String? = null, // 페이지 요약 (메타 태그)
var thumbnailUrl: String? = null, // 페이지 썸네일 (메타 태그)
var userComment: String? = null, // 사용자가 남긴 짧은 의견
var tags: List<String>? = null, // 태그 (예: #kotlin, #spring)
var savedAt: Long = System.currentTimeMillis(), // 저장 시간
// [신규 추가] 공개 범위 필드. 기본값은 PRIVATE.
var visibility: String = Visibility.PRIVATE.name,
// [신규 추가] 좋아요/싫어요 카운트 필드
var voteCount: Long = 0,
var unlikeCount: Long = 0,
var userSelectedImageUrl: String? = null,
var metadataStatus: String = MetadataStatus.PENDING.name,
var title : String? = null
var content : String? = null
var category : String? = null
var tags : String? = null
// [추가] 카테고리 필드 (하나만 가질 수 있도록 String으로 설정)
var category: String? = null
) {
var html : String? = null
var image : String? = null
var thumb : String? = null
var writer : String? = null
var writeTime : Long = 0
var posting : Boolean = false
var firstPostLat : Double = 0.0
var firstPostLon : Double = 0.0
var firstAddress = ""
var modifyAddress = ""
var modifyTime : Long = 0
var modifyLat : Double = 0.0
var modifyLon : Double = 0.0
var readCount : Long = 0
var voteCount : Long = 0
var unlikeCount : Long = 0
}
@Document(collection = "Comment")
class Comment {
@BsonId
var id: String? = null
var postId: String? = null // 댓글이 달린 포스트의 id
var parentId: String? = null // 대댓글이면 상위 댓글의 id, 최상위 댓글이면 null
var writer: String? = null
var content: String? = null
var writeTime: Long? = null
var mentions: List<String>? = null // 언급된 유저 아이디(선택)
}
@Repository
interface CommentRepository : ReactiveMongoRepository<Comment, String> {
fun findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId: String): Flux<Comment> // 최상위 댓글
fun findByParentIdOrderByWriteTimeAsc(parentId: String): Flux<Comment>
}
@Service
class CommentService(private val commentRepository: CommentRepository) {
fun addComment(comment: Comment): Mono<Comment> {
// 예시: 부모 댓글 존재 여부/권한 검증 등 비즈니스 로직 처리
return commentRepository.save(comment)
}
fun getCommentsForPost(postId: String): Flux<Comment> {
return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId)
}
// 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능
}
@Repository
interface PostRepository : ReactiveMongoRepository<Post, String> {
fun findAllByModifyTime(time : Long? = 0): Flux<Post>
// @org.springframework.data.mongodb.repository.Query("{ '\$and': [ { 'posting': true }, { '\$expr': { '\$gte': [ { '\$strLenCP': '\$id' }, 4 ] } } ] }")
fun findAllByOrderByModifyTimeDesc(pageable: Pageable): Flux<Post>
fun countByOrderByModifyTimeDesc(): Mono<Long>
fun findTop5ByOrderByReadCountDesc(): Flux<Post>
fun findTop5ByOrderByModifyTimeDesc(): Flux<Post>
@Aggregation(pipeline = [
"{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: \"\$originId\", post: { \$first: \"\$\$ROOT\" } } }",
"{ \$sort: { \"post.modifyTime\": -1 } }",
"{ \$limit: 8 }",
"{ \$replaceRoot: { newRoot: \"\$post\" } }"
])
fun findLatestUniqueOrigin(): Flux<Post>
}
@Service
class PostManager(
private val postRepository: PostRepository,
private val reactiveMongoTemplate: ReactiveMongoTemplate
) {
@Autowired
private lateinit var logService: LogService
@Autowired
private lateinit var bCryptPasswordEncoder: PasswordEncoder
// fun getPost(id : String) : Mono<Post> = postRepository.findById(id)
fun getPost(id: String): Mono<Post> {
val query = Query.query(Criteria.where("id").`is`(id))
val update = Update().inc("readCount", 1)
return reactiveMongoTemplate.findAndModify(query, update, Post::class.java)
.switchIfEmpty(Mono.error(NoSuchElementException("Post not found with id $id")))
}
fun find20(pageable :Pageable) : List<Post> {
println("pageSize >>> ${pageable.pageSize}")
println("pageNumber >>> ${pageable.pageNumber}")
return postRepository.findAllByOrderByModifyTimeDesc(pageable)
.doOnNext { println(it) } // map 대신 doOnNext로 로그 출력
.collectList() // Flux<Post> → Mono<List<Post>>
.block(Duration.ofSeconds(30)) // Mono<List<Post>> → List<Post>
?: listOf()
}
fun getTop10Posts(): Flux<Post> {
return postRepository.findTop5ByOrderByReadCountDesc().map { p ->
p.title = URLDecoder.decode(p.title)
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
println(p.title)
p
}
}
fun getRecent10Posts(): Flux<Post> {
return postRepository.findTop5ByOrderByModifyTimeDesc().map { p ->
p.title = URLDecoder.decode(p.title)
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
println(p.title)
p
}
}
fun find8() : List<Post> {
return postRepository.findLatestUniqueOrigin().collectList() // Mono<List<Post>>로 변환 // Flux<Post> → Mono<List<Post>>
.block(Duration.ofSeconds(30)) ?: emptyList()
}
fun find20() : List<Post> {
return postRepository.findAllByModifyTime(0).takeLast(20).buffer(20).blockLast(Duration.ofSeconds(30)) ?: listOf()
}
fun save(post: Post): Mono<Post> {
println("saved user before ${post}")
// user.hashPassword(bCryptPasswordEncoder)
return postRepository.save(post).apply {
subscribe {
println("saved user after ${this@apply}")
/**
* [이 부분을 추가하세요]
* 화면에 표시할 최종 이미지 URL을 계산하는 프로퍼티입니다.
* @get:BsonIgnore 어노테이션으로 이 필드는 DB에 저장되지 않습니다.
*/
@get:BsonIgnore
val displayImageUrl: String
get() {
return when {
// 1순위: 사용자가 선택한 이미지
!userSelectedImageUrl.isNullOrBlank() -> userSelectedImageUrl!!
// 2순위: 자동 추출된 썸네일
!thumbnailUrl.isNullOrBlank() -> thumbnailUrl!!
// 3순위: 콘텐츠 URL 목록의 첫 번째 이미지
contentUrls.isNotEmpty() -> contentUrls.first()
// 4순위: 기본 이미지
else -> "/images/pic01.jpg"
}
}
}
}
}
@@ -3,8 +3,12 @@ package kr.lunaticbum.back.lun.model
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kotlinx.coroutines.withContext
import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite
import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.SudokuGenerator
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.Aggregation
@@ -12,7 +16,7 @@ import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import reactor.core.publisher.Flux
import reactor.core.publisher.Flux // (★ 오류 수정: 누락된 Flux import 추가)
import reactor.core.publisher.Mono
import java.awt.Color
import java.awt.image.BufferedImage
@@ -20,137 +24,167 @@ import java.io.ByteArrayOutputStream
import java.time.LocalDateTime
import java.util.Base64
import javax.imageio.ImageIO
import kotlin.random.Random
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.repository.reactive.ReactiveSortingRepository
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.security.authentication.AnonymousAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import java.io.File
import java.time.Instant
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import org.springframework.security.core.userdetails.UsernameNotFoundException // 👈 [추가]
import reactor.core.scheduler.Schedulers // 👈 [추가]
/**
* ======================================================
* 1. NONOGRAM 모델 및 리포지토리
* ======================================================
*/
@Document("puzzles") // "puzzles" 컬렉션에 매핑
@Document("nonogram") // "puzzles" 컬렉션 (노노그램용)
data class NonogramPuzzle(
@Id
val id: String? = null, // MongoDB가 생성하므로 nullable(null 가능) 및 var로 선언
val id: String? = null,
val solutionGrid: List<List<Int>>,
val rowClues: List<List<Int>>,
val colClues: List<List<Int>>,
// Add these two fields
val grayscaleImage: String, // Base64 encoded grayscale image
val originalImage: String, // Base64 encoded original color image
val grayscaleImageFile: String, // 파일명 (예: "uuid-gray.png")
val originalImageFile: String, // 파일명 (예: "uuid-original.png")
val createdAt: LocalDateTime = LocalDateTime.now()
)
@Repository
interface NonogramPuzzleRepository : ReactiveMongoRepository<NonogramPuzzle, String> {
// ReactiveMongoRepository가 모든 기본 CRUD 기능을 반응형으로 제공
/**
* (★ Updated) 'originalImage' and 'grayscaleImage' 필드가 존재하는
* 완전한 퍼즐 문서 중에서 랜덤으로 하나를 가져옵니다.
*/
@Aggregation(pipeline = [
"{ \$match: { originalImage: { \$exists: true }, grayscaleImage: { \$exists: true } } }",
// "{ \$match: { originalImage: { \$exists: true }, grayscaleImage: { \$exists: true } } }",
// "{ \$sample: { size: 1 } }"
"{ \$match: { originalImageFile: { \$exists: true }, grayscaleImageFile: { \$exists: true } } }",
"{ \$sample: { size: 1 } }"
])
fun findRandom(): Flux<NonogramPuzzle>
fun findRandom(): Flux<NonogramPuzzle> // (★ Flux를 인식하기 위해 import 필요)
}
/**
* ======================================================
* [통합 게임 서비스]
* 모든 게임(Nonogram, Sudoku, Spider)의 로직을 처리하는 단일 서비스.
* ======================================================
*/
@Service
class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // 생성자 주입
class PuzzleService(
// 1. Nonogram 의존성
private val puzzleRepository: NonogramPuzzleRepository,
// 2. Sudoku 의존성
private val sudokuPuzzleRepository: SudokuPuzzleRepository,
// 3. Spider 의존성
private val spiderGameRepository: SpiderGameRepository,
@Value("\${puzzle.image.path}") private val puzzleImagePath: String,
private val logService: LogService
) {
// 퍼즐 크기의 최소/최대값을 상수로 정의하여 관리 용이성을 높입니다.
companion object {
private const val MIN_PUZZLE_SIZE = 10
private const val MAX_PUZZLE_SIZE = 30
}
/**
* 랜덤으로 퍼즐 하나를 찾아서 반환합니다.
* @return 찾은 퍼즐 또는 DB가 비어있으면 null
*/
// ======================================================
// 1. NONOGRAM 서비스 로직 (기존 함수)
// ======================================================
suspend fun findRandomPuzzle(): NonogramPuzzle? {
return puzzleRepository.findRandom().awaitFirstOrNull()
}
fun findById(id: String) = puzzleRepository.findById(id)
fun deletePuzzle(id : String) = puzzleRepository.deleteById(id)
fun findById(id: String): Mono<NonogramPuzzle> = puzzleRepository.findById(id) // Nonogram 용
suspend fun deletePuzzle(id : String): Mono<Void> {
val puzzle = puzzleRepository.findById(id).awaitSingleOrNull()
if (puzzle != null) {
// 파일 시스템에서 이미지 파일 삭제
try {
File(puzzleImagePath, puzzle.grayscaleImageFile).delete()
File(puzzleImagePath, puzzle.originalImageFile).delete()
} catch (e: Exception) {
// 파일 삭제 실패 시 로그를 남길 수 있습니다 (선택사항).
e.printStackTrace()
}
}
return puzzleRepository.deleteById(id) // DB에서 퍼즐 정보 삭제
}
/**
* (★ 수정됨) MultipartFile과 size 대신, file만 받아서 내부적으로 최적의 크기를 계산합니다.
*/
suspend fun generateAndSavePuzzle(file: MultipartFile): NonogramPuzzle {
val puzzleData = withContext(Dispatchers.IO) {
val originalImage = ImageIO.read(file.inputStream)
val imageWithBackground = convertTransparentToWhite(originalImage)
// (★ 추가됨) 이미지 크기와 비율에 따라 퍼즐 크기를 동적으로 결정
val puzzleSize = determinePuzzleSize(imageWithBackground)
// Create a resized color version for the final reveal
val resizedOriginal = resizeImage(imageWithBackground, 300) // Larger size for display
// 결정된 puzzleSize를 사용하여 그레이스케일 이미지 생성
val resizedOriginal = resizeImage(imageWithBackground, 300)
val grayImage = BufferedImage(puzzleSize, puzzleSize, BufferedImage.TYPE_BYTE_GRAY).apply {
createGraphics().run {
drawImage(imageWithBackground, 0, 0, puzzleSize, puzzleSize, null)
dispose()
}
}
val averageBrightness = calculateAverageBrightness(grayImage)
val adaptiveThreshold = determineAdaptiveThreshold(averageBrightness)
// 결정된 puzzleSize를 사용하여 solutionGrid 생성
val solutionGrid = List(puzzleSize) { y ->
List(puzzleSize) { x ->
if (grayImage.raster.getSample(x, y, 0) < adaptiveThreshold) 1 else 0
}
}
val rowClues = solutionGrid.map { getCluesForLine(it) }
val colClues = transpose(solutionGrid).map { getCluesForLine(it) }
// Convert images to Base64 strings
val grayscaleBase64 = imageToBase64(resizeImage(grayImage, 300))
val originalBase64 = imageToBase64(resizedOriginal)
// --- Base64 인코딩 대신 파일로 저장 ---
// 1. 고유한 파일명 생성
val uniqueId = UUID.randomUUID().toString()
val grayFilename = "$uniqueId-gray.png"
val originalFilename = "$uniqueId-original.png"
// 2. 저장 경로에 파일 객체 생성
val grayFile = File(puzzleImagePath, grayFilename)
val originalFile = File(puzzleImagePath, originalFilename)
// 3. 부모 디렉토리가 없으면 생성
grayFile.parentFile.mkdirs()
// 4. BufferedImage를 파일로 저장
ImageIO.write(resizeImage(grayImage, 300), "png", grayFile)
ImageIO.write(resizedOriginal, "png", originalFile)
// --- 파일 저장 로직 끝 ---
NonogramPuzzle(
solutionGrid = solutionGrid,
rowClues = rowClues,
colClues = colClues,
grayscaleImage = grayscaleBase64,
originalImage = originalBase64
grayscaleImageFile = grayFilename,
originalImageFile = originalFilename
)
}
return puzzleRepository.save(puzzleData).awaitSingle()
}
/**
* (★ 새로 추가된 함수)
* 이미지의 크기와 비율을 분석하여 10x10 ~ 30x30 사이의 적절한 퍼즐 크기를 결정합니다.
* @param image 분석할 원본 이미지
* @return 계산된 퍼즐 크기 (정수)
*/
// --- (★ 오류 수정: 축약되었던 Nonogram 헬퍼 함수 본문 전체 복원) ---
private fun determinePuzzleSize(image: BufferedImage): Int {
val width = image.width.toDouble()
val height = image.height.toDouble()
// 1. 가로와 세로 중 더 긴 쪽과 짧은 쪽을 찾습니다.
val maxDimension = maxOf(width, height)
val minDimension = minOf(width, height)
// 2. 이미지의 가로세로 비율을 계산합니다. (1.0 이상)
val aspectRatio = if (minDimension > 0) maxDimension / minDimension else 1.0
// 3. 기본 크기를 정하고, 비율에 따라 크기를 조정합니다.
// - 정사각형에 가까울수록(비율 1.0) 기본 크기(15)에 가깝게 설정됩니다.
// - 이미지가 길쭉할수록(비율이 커질수록) 퍼즐 크기가 더 커져 디테일을 살립니다.
val baseSize = 15.0
val factor = 5.0 // 비율이 1.0 증가할 때마다 크기를 얼마나 늘릴지 결정하는 가중치
val factor = 5.0
val calculatedSize = baseSize + ((aspectRatio - 1.0) * factor)
// 4. 계산된 크기를 MIN_PUZZLE_SIZE와 MAX_PUZZLE_SIZE 사이로 강제합니다.
return calculatedSize.toInt().coerceIn(MIN_PUZZLE_SIZE, MAX_PUZZLE_SIZE)
}
// Helper function to resize images
private fun resizeImage(sourceImage: BufferedImage, size: Int): BufferedImage {
return BufferedImage(size, size, BufferedImage.TYPE_INT_RGB).apply {
createGraphics().run {
@@ -160,21 +194,16 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
}
}
// Helper function to convert a BufferedImage to a Base64 String
private fun imageToBase64(image: BufferedImage): String {
val os = ByteArrayOutputStream()
ImageIO.write(image, "png", os)
return "data:image/png;base64," + Base64.getEncoder().encodeToString(os.toByteArray())
}
/**
* (★추가된 함수) 투명한 배경을 가진 BufferedImage를 흰색 배경으로 변환합니다.
*/
private fun convertTransparentToWhite(sourceImage: BufferedImage): BufferedImage {
if (!sourceImage.colorModel.hasAlpha()) {
return sourceImage
}
return BufferedImage(sourceImage.width, sourceImage.height, BufferedImage.TYPE_INT_RGB).apply {
createGraphics().also { g2d ->
g2d.color = Color.WHITE
@@ -185,9 +214,6 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
}
}
/**
* 그레이스케일 이미지의 평균 밝기를 계산합니다. (0~255)
*/
private fun calculateAverageBrightness(image: BufferedImage): Int {
var totalBrightness: Long = 0
val width = image.width
@@ -200,21 +226,14 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
return (totalBrightness / (width * height)).toInt()
}
/**
* 평균 밝기에 따라 임계치를 조정합니다.
*/
private fun determineAdaptiveThreshold(averageBrightness: Int): Int {
return when {
// 이미지가 매우 밝으면 (평균 180 이상), 임계치를 평균보다 약간 낮춰 어두운 부분을 더 잘 잡아냄
averageBrightness > 180 -> (averageBrightness * 0.9).toInt()
// 이미지가 매우 어두우면 (평균 80 이하), 임계치를 평균보다 약간 높여 밝은 부분을 더 잘 잡아냄
averageBrightness < 80 -> (averageBrightness * 1.1).toInt()
// 보통 밝기의 이미지면 평균값을 그대로 사용
else -> averageBrightness
}
}
// --- 헬퍼 함수들 ---
private fun getCluesForLine(line: List<Int>): List<Int> {
val clues = mutableListOf<Int>()
var count = 0
@@ -233,4 +252,606 @@ class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { //
private fun transpose(grid: List<List<Int>>): List<List<Int>> {
return List(grid[0].size) { j -> List(grid.size) { i -> grid[i][j] } }
}
// --- (헬퍼 함수 복원 끝) ---
// ======================================================
// 2. SUDOKU 서비스 로직 (통합됨)
// ======================================================
// 🔽 [수정] DTO가 puzzleId를 다시 포함하도록 변경
data class SudokuGameDto(val puzzleId: Long, val question: String, val solution: String, val blockSize: Int)
data class SudokuValidateDto(val puzzleId: Long, val answer: String)
// [신규] 생성 작업 중복 실행 방지용 잠금
private val generationLocks = ConcurrentHashMap<String, Boolean>()
/**
* [핵심 수정]
* - 파라미터를 'difficulty' 1개만 받습니다.
* - 'difficulty' (1~8)를 (blockSize, generatorLevel)로 변환합니다.
*/
suspend fun sudoku_startGame(difficulty: String): SudokuGameDto {
// 🔽 [신규] 9단계 난이도를 (blockSize, generatorLevel)로 매핑
val (blockSize, generatorLevel) = when (difficulty) {
// 4x4 (3 levels)
"1" -> Pair(2, 1) // L1: 4x4, Easy (Gen L1)
"2" -> Pair(2, 3) // L2: 4x4, Medium (Gen L3)
"3" -> Pair(2, 5) // L3: 4x4, Hard (Gen L5)
// 9x9 (5 levels)
"4" -> Pair(3, 1) // L4: 9x9, Easy (Gen L1)
"5" -> Pair(3, 2) // L5: 9x9, Medium (Gen L2)
"6" -> Pair(3, 3) // L6: 9x9, Hard (Gen L3)
"7" -> Pair(3, 4) // L7: 9x9, Expert (Gen L4)
"8" -> Pair(3, 5) // L8: 9x9, Master (Gen L5)
// 16x16 (3 levels)
"9" -> Pair(4, 1) // L9: 16x16, Easy (Gen L1)
"10" -> Pair(4, 3) // L10: 16x16, Medium (Gen L3)
"11" -> Pair(4, 5) // L11: 16x16, Hard (Gen L5)
else -> Pair(3, 3) // 기본값 (Level 6)
}
// 1. DB에서 조건에 맞는 '미사용 문제' 검색
val solutionPuzzle = sudokuPuzzleRepository
.findFirstByBlockSizeAndLevelAndPlayCountOrderByPuzzleKey(blockSize, generatorLevel, 0L)
// 2. 없으면 오류 반환
?: throw IllegalStateException("현재 $blockSize x $blockSize (Level $generatorLevel) 사용 가능한 새 퍼즐이 없습니다.")
// 3. playCount 1 증가 및 저장
sudokuPuzzleRepository.save(
solutionPuzzle.copy(playCount = solutionPuzzle.playCount + 1)
)
// 4. DTO로 반환 (DB에서 꺼낸 값 그대로)
return SudokuGameDto(
puzzleId = solutionPuzzle.puzzleKey ?: 0L,
question = solutionPuzzle.question!!,
solution = solutionPuzzle.solution!!,
blockSize = solutionPuzzle.blockSize
)
}
@Scheduled(fixedRate = 600000, initialDelay = 1000)
suspend fun fillPuzzleCache() {
// 🔽 [수정] 2..4 (4x4, 9x9, 16x16)
for (blockSize in 2..4) {
// 🔽 [수정] 1, 3, 5 (Easy, Medium, Hard)
for (level in 1..5) {
if ((blockSize == 2 || blockSize == 4) && (level == 2 || level == 4)) {
continue
}
val lockKey = "$blockSize-$level"
if (generationLocks.putIfAbsent(lockKey, true) != null) {
continue
}
try {
val unplayedCount = sudokuPuzzleRepository
.countByBlockSizeAndLevelAndPlayCount(blockSize, level, 0L)
val minCacheSize = 5
if (unplayedCount < minCacheSize) {
logService.log("[$blockSize x $blockSize Level $level] 미사용 퍼즐 ${unplayedCount}개 감지. ${minCacheSize}개까지 생성 시작...")
repeat((minCacheSize - unplayedCount).toInt()) {
sudoku_generateAndSavePuzzle(blockSize, level)
}
logService.log("[$blockSize x $blockSize Level $level] 퍼즐 생성 완료.")
}
} catch (e: Exception) {
logService.log("[$blockSize x $blockSize Level $level] 퍼즐 생성 중 오류: ${e.message}")
} finally {
generationLocks.remove(lockKey)
}
}
}
}
/**
* [수정] Generator의 로컬 검증 대신, DB의 정답과 직접 비교 (가장 확실한 방식)
*/
suspend fun sudoku_validateSolution(validateDto: SudokuValidateDto): Boolean {
// 1. [수정] DB에서 'puzzleId'로 정답을 찾음
val originalPuzzle = sudokuPuzzleRepository.findByPuzzleKey(validateDto.puzzleId)
?: throw IllegalStateException("퍼즐을 찾을 수 없습니다.")
// 2. [수정] DB의 정답과 문자열 비교
return originalPuzzle.solution == validateDto.answer
}
/**
* [수정] 이 함수는 이제 '새 정답 퍼즐'을 1개 생성하여
* 'playCount = 0' 상태로 DB에 저장하고, '저장된 객체'를 반환합니다.
*/
suspend fun sudoku_generateAndSavePuzzle(blockSize: Int = 3, level: Int = 3) {
val generator = SudokuGenerator(blockSize)
val newPuzzle = generator.generatePuzzle(level)
val lastPuzzle = sudokuPuzzleRepository.findTopByOrderByPuzzleKeyDesc()
val nextKey = (lastPuzzle?.puzzleKey ?: 0L) + 1L
val puzzleToSave = SudokuPuzzle(
puzzleKey = nextKey,
solution = newPuzzle.solution,
question = newPuzzle.question,
blockSize = blockSize,
level = level,
playCount = 0L
)
sudokuPuzzleRepository.save(puzzleToSave)
}
private fun sudoku_createQuestion(puzzle: String, holes: Int): String {
val chars = puzzle.toMutableList()
var remainingHoles = holes
while (remainingHoles > 0) {
val randomIndex = Random.nextInt(chars.size)
if (chars[randomIndex] != '0') {
chars[randomIndex] = '0'
remainingHoles--
}
}
return chars.joinToString("")
}
// ======================================================
// 3. SPIDER 서비스 로직 (통합 및 Coroutine 변환됨)
// ======================================================
suspend fun spider_newGame(numSuits: Int, numCards: String): SpiderGame {
val allCards = spider_createDeck(numSuits)
val shuffledCards = allCards.shuffled(Random)
val (tableau, stock) = spider_dealCards(shuffledCards, numCards)
val initialGame = SpiderGame(
id = null,
tableau = tableau,
stock = stock,
foundation = emptyList(),
moves = 0,
isCompleted = false,
undoCount = 0,
undoHistory = emptyList()
)
return spiderGameRepository.save(initialGame).awaitSingle()
}
suspend fun spider_getGame(id: String): SpiderGame? {
return spiderGameRepository.findById(id).awaitSingleOrNull()
}
suspend fun spider_updateGame(game: SpiderGame): SpiderGame {
val historyToSave = SpiderGameHistory(
tableau = game.tableau,
stock = game.stock,
foundation = game.foundation,
moves = game.moves
)
val updatedHistory = (game.undoHistory + historyToSave).takeLast(5)
val updatedGame = game.copy(undoHistory = updatedHistory)
return spiderGameRepository.save(updatedGame).awaitSingle()
}
suspend fun spider_dealCardsFromStock(gameId: String): SpiderGame {
val game = spiderGameRepository.findById(gameId).awaitSingleOrNull()
?: throw IllegalArgumentException("Game not found: $gameId")
val stockCards = game.stock.toMutableList()
if (stockCards.size < 10) {
throw IllegalArgumentException("No more cards in stock.")
}
val historyToSave = SpiderGameHistory(
tableau = game.tableau,
stock = game.stock,
foundation = game.foundation,
moves = game.moves
)
val updatedHistory = (game.undoHistory + historyToSave).takeLast(5)
val updatedTableau = game.tableau.toMutableList()
val remainingStock = stockCards.drop(10)
updatedTableau.forEachIndexed { index, stack ->
val cardToDeal = stockCards[index]
cardToDeal.isFaceUp = true
(stack as MutableList).add(cardToDeal)
}
val updatedGame = game.copy(
tableau = updatedTableau,
stock = remainingStock,
moves = game.moves + 1,
undoHistory = updatedHistory
)
return spiderGameRepository.save(updatedGame).awaitSingle()
}
suspend fun spider_undoGame(gameId: String): SpiderGame {
val game = spiderGameRepository.findById(gameId).awaitSingleOrNull()
?: throw IllegalArgumentException("Game not found: $gameId")
if (game.undoHistory.isEmpty() || game.undoCount >= 5) {
throw IllegalArgumentException("Cannot undo. No more history or undo limit reached.")
}
val lastHistory = game.undoHistory.last()
val remainingHistory = game.undoHistory.dropLast(1)
val updatedGame = game.copy(
tableau = lastHistory.tableau,
stock = lastHistory.stock,
foundation = lastHistory.foundation,
moves = lastHistory.moves,
undoCount = game.undoCount + 1,
undoHistory = remainingHistory
)
return spiderGameRepository.save(updatedGame).awaitSingle()
}
// --- (스파이더 헬퍼 함수들) ---
private fun spider_createDeck(numSuits: Int): List<SpiderCard> {
val allSuits = listOf("spade", "heart", "club", "diamond")
val suits = allSuits.take(numSuits)
val setsPerSuit = when (numSuits) {
1 -> 8
2 -> 4
4 -> 2
else -> throw IllegalArgumentException("Invalid number of suits: $numSuits")
}
val deck = mutableListOf<SpiderCard>()
repeat(setsPerSuit) {
for (suit in suits) {
for (rank in 1..13) {
deck.add(SpiderCard(suit, rank, isFaceUp = false))
}
}
}
return deck
}
private fun spider_dealCards(shuffledCards: List<SpiderCard>, numCards: String): Pair<List<List<SpiderCard>>, List<SpiderCard>> {
val initialCards = numCards.split(",").map { it.trim().toInt() }
val cardsPerStack = List(10) { index ->
if (index < 4) initialCards[0] else initialCards[1]
}
val cardsToDeal = shuffledCards.toMutableList()
val tableau = MutableList(10) { mutableListOf<SpiderCard>() }
cardsPerStack.forEachIndexed { stackIndex, count ->
repeat(count) {
if (cardsToDeal.isNotEmpty()) {
val card = cardsToDeal.removeFirst()
tableau[stackIndex].add(card)
}
}
}
tableau.forEach { stack ->
if (stack.isNotEmpty()) {
stack.last().isFaceUp = true
}
}
return Pair(tableau, cardsToDeal)
}
}
/**
* 스파이더 게임 상태 저장 모델
*/
@Document(collection = "spider_games")
data class SpiderGame(
@Id
val id: String? = null,
val tableau: List<List<SpiderCard>>,
val stock: List<SpiderCard>,
val foundation: List<List<SpiderCard>>,
val moves: Int,
val isCompleted: Boolean,
val undoCount: Int = 0, // 실행 취소 횟수
val undoHistory: List<SpiderGameHistory> = emptyList(), // 게임 상태 히스토리
val timestamp: Long = System.currentTimeMillis()
)
data class SpiderCard(
val suit: String,
val rank: Int,
var isFaceUp: Boolean,
)
// 게임 상태 히스토리 모델
data class SpiderGameHistory(
val tableau: List<List<SpiderCard>>,
val stock: List<SpiderCard>,
val foundation: List<List<SpiderCard>>,
val moves: Int
)
/**
* 스파이더 게임 상태 리포지토리 (PuzzleService에서 사용됨)
* (참고: 이 리포지토리는 Reactive 타입으로 유지하고,
* 서비스단에서 Coroutine으로 브리징하여 사용합니다.)
*/
interface SpiderGameRepository : ReactiveMongoRepository<SpiderGame, String> {
override fun findById(id: String): Mono<SpiderGame>
}
/**
* 스도쿠 퍼즐 원본 데이터를 저장하는 모델
*/
@Document(collection = "Sudoku")
data class SudokuPuzzle(
@Id
val id: String? = null,
val puzzleKey: Long? = null,
val solution: String?,
val question: String?, // 👈 [추가]
val blockSize: Int = 3,
val level: Int = 3, // 👈 [추가] 생성기 난이도 (1, 3, 5)
val playCount: Long = 0L
)
/**
* 스도쿠 퍼즐 리포지토리 (PuzzleService에서 사용됨)
*/
@Repository
interface SudokuPuzzleRepository : CoroutineCrudRepository<SudokuPuzzle, String> {
// 🔽 [수정] override suspend fun count(): Long -> countByBlockSize
suspend fun countByBlockSize(blockSize: Int): Long // 특정 크기의 퍼즐만 카운트
override suspend fun count(): Long
suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle?
suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle?
// 🔽 [신규] 특정 blockSize의 퍼즐 중 랜덤 1개 조회
@Aggregation(pipeline = [
"{ \$match: { blockSize: ?0 } }", // ?0은 첫번째 파라미터(blockSize)
"{ \$sample: { size: 1 } }"
])
suspend fun findRandomByBlockSize(blockSize: Int): SudokuPuzzle?
// [수정] blockSize, level, playCount 모두 일치하는 퍼즐 검색
suspend fun findFirstByBlockSizeAndLevelAndPlayCountOrderByPuzzleKey(
blockSize: Int,
level: Int,
playCount: Long
): SudokuPuzzle?
// [추가] 스케줄러가 사용할, 특정 조건의 퍼즐 개수 카운트
suspend fun countByBlockSizeAndLevelAndPlayCount(
blockSize: Int,
level: Int,
playCount: Long
): Long
}
// In PuzzleData.kt
@Document(collection = "game_ranks")
data class GameRank(
@Id
val id: String? = null,
@Indexed // 👈 [추가]
val userId: String?, // 👈 [추가] 앱-고유 ID 또는 인증된 사용자 ID
val gameType: GameType,
val contextId: String?,
val playerName: String,
val primaryScore: Long,
val secondaryScore: Long? = null,
val timestamp: Instant = Instant.now()
)
/**
* 지원하는 게임 타입을 정의하는 Enum
*/
enum class GameType {
GAME_2048,
SUDOKU,
SPIDER,
NONOGRAM
}
/**
* 랭킹 등록 시 모든 프론트엔드에서 공통으로 사용할 DTO
*/
data class UnifiedRankDto(
val userId: String, // 👈 [추가] (널 허용 안 함)
val gameType: GameType,
val contextId: String?,
val playerName: String,
val primaryScore: Long,
val secondaryScore: Long? = null
)
// 🔽 [신규 추가 DTO 1]
// 서버가 클라이언트에 최종적으로 반환할 랭킹 결과 DTO
data class RankSubmissionResult(
val topRanks: List<GameRank>,
val myRank: GameRankWithRankNumber? // 👈 내 랭킹 (순위 포함)
)
// 🔽 [신규 추가 DTO 2]
// 내 랭킹 객체와 순위(숫자)를 함께 담는 DTO
data class GameRankWithRankNumber(
val rankData: GameRank,
val rankNumber: Long //
)
@Repository
interface GameRankRepository : ReactiveSortingRepository<GameRank, String> {
fun save(gameRank: GameRank): Mono<GameRank>
// 점수가 높은 순 (DESC) 랭킹 조회 (예: 2048)
fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(
gameType: GameType,
contextId: String?
): Flux<GameRank>
// 점수가 낮은 순 (ASC) 랭킹 조회 (예: Sudoku-시간, Spider-이동횟수)
fun findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(
gameType: GameType,
contextId: String?
): Flux<GameRank>
// [신규 추가] 특정 플레이어의 G킹을 최신순으로 조회
fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux<GameRank>
// 🔽 [추가]
fun findFirstByUserId(userId: String): Mono<GameRank>
fun findByPlayerName(playerName: String): Flux<GameRank> // 이름 중복 확인용
// 🔽 [신규 추가] (ASC 정렬용: Sudoku 등)
// 나의 primaryScore보다 '작은(더 좋은)' 점수를 가진 사람 수
fun countByGameTypeAndContextIdAndPrimaryScoreLessThan(
gameType: GameType,
contextId: String?,
primaryScore: Long
): Mono<Long>
// 🔽 [신규 추가] (DESC 정렬용: 2048 등)
// 나의 primaryScore보다 '큰(더 좋은)' 점수를 가진 사람 수
fun countByGameTypeAndContextIdAndPrimaryScoreGreaterThan(
gameType: GameType,
contextId: String?,
primaryScore: Long
): Mono<Long>
}
@Service
class GameRankService(
private val rankRepository: GameRankRepository,
private val userManager: UserManager, // 👈 이 컴포넌트가 Blocking IO를 유발
private val logService: LogService
) {
/**
* 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다. (변경 없음)
*/
fun getRanks(gameType: GameType, contextId: String?): Flux<GameRank> {
return when (gameType) {
// 점수가 높아야 하는 게임 (2048)
GameType.GAME_2048 ->
rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreDesc(gameType, contextId)
// 점수가 낮아야 하는 게임 (스도쿠 시간, 스파이더 무브/시간, 노노그램 시간)
GameType.SUDOKU, GameType.SPIDER, GameType.NONOGRAM ->
rankRepository.findTop10ByGameTypeAndContextIdOrderByPrimaryScoreAscSecondaryScoreAsc(gameType, contextId)
}
}
/**
* [전체 수정]
* 랭킹을 등록하고, '상위 10개'와 '내 순위'를 포함한 객체를 반환합니다.
* 🔽 반환 타입이 Mono<RankSubmissionResult>로 변경되었습니다.
*/
fun submitRank(rankDto: UnifiedRankDto): Mono<RankSubmissionResult> {
val auth = SecurityContextHolder.getContext().authentication
val isAuthenticated = auth != null && auth.isAuthenticated && auth !is AnonymousAuthenticationToken
// 1. 랭크 저장 로직 (기존과 동일)
val saveOperation: Mono<GameRank> = if (isAuthenticated) {
// ... (기존 인증 사용자 저장 로직) ...
val principal = auth.principal as UserDetails
val authenticatedUserId = principal.username
val gameRank = GameRank(
userId = authenticatedUserId,
gameType = rankDto.gameType,
contextId = rankDto.contextId,
playerName = authenticatedUserId,
primaryScore = rankDto.primaryScore,
secondaryScore = rankDto.secondaryScore
)
rankRepository.save(gameRank)
} else {
// ... (기존 익명 사용자 검증 및 저장 로직) ...
val anonymousUserId = rankDto.userId
val requestedName = rankDto.playerName
val checkAuthUsers = Mono.fromCallable {
userManager.loadUserByUsername(requestedName)
}
.subscribeOn(Schedulers.boundedElastic())
.flatMap<GameRank> {
Mono.error(IllegalArgumentException("이미 등록된 회원의 이름입니다."))
}
.onErrorResume(UsernameNotFoundException::class.java) {
Mono.empty()
}
.onErrorResume { error ->
logService.log("!!! submitRank: checkAuthUsers 중 예상치 못한 크래시 발생 !!!", error)
Mono.error(IllegalArgumentException("이름 확인 중 서버 오류가 발생했습니다."))
}
val checkAnonymousUsers = rankRepository.findByPlayerName(requestedName)
.next()
.flatMap<GameRank> { rankWithSameName ->
if (rankWithSameName.userId == anonymousUserId) {
Mono.empty()
} else {
Mono.error(IllegalArgumentException("이미 사용 중인 이름입니다."))
}
}
val gameRankMono = checkAuthUsers.then(checkAnonymousUsers)
.then(Mono.just(GameRank(
userId = anonymousUserId,
gameType = rankDto.gameType,
contextId = rankDto.contextId,
playerName = requestedName,
primaryScore = rankDto.primaryScore,
secondaryScore = rankDto.secondaryScore
)))
gameRankMono.flatMap { rankRepository.save(it) }
}
// 2. 🔽 [로직 변경] 저장이 성공하면(flatMap), 상위 랭킹과 내 순위를 '조합'합니다.
return saveOperation.flatMap { mySavedRank ->
// 2a. 상위 10개 랭킹 조회
val topRanksMono: Mono<List<GameRank>> = getRanks(mySavedRank.gameType, mySavedRank.contextId)
.collectList()
// 2b. 내 순위(숫자) 계산: 나보다 점수 좋은 사람 수 + 1
val myRankNumberMono: Mono<Long> = when (mySavedRank.gameType) {
// DESC (점수 높은 순)
GameType.GAME_2048 ->
rankRepository.countByGameTypeAndContextIdAndPrimaryScoreGreaterThan(
mySavedRank.gameType, mySavedRank.contextId, mySavedRank.primaryScore
)
// ASC (점수 낮은 순)
else ->
rankRepository.countByGameTypeAndContextIdAndPrimaryScoreLessThan(
mySavedRank.gameType, mySavedRank.contextId, mySavedRank.primaryScore
)
}.map { count -> count + 1 } // 나보다 잘한 사람 수 + 1
// 3. (2a)와 (2b)의 결과가 모두 오면, Mono.zip으로 합칩니다.
Mono.zip(topRanksMono, myRankNumberMono)
.map { tuple ->
val topRanksList = tuple.t1
val myRankNumber = tuple.t2
// 4. 최종 반환 객체(RankSubmissionResult)로 만듭니다.
RankSubmissionResult(
topRanks = topRanksList,
myRank = GameRankWithRankNumber(
rankData = mySavedRank,
rankNumber = myRankNumber
)
)
}
}
}
/**
* 특정 플레이어의 모든 게임 랭킹을 조회합니다. (변경 없음)
*/
fun getRanksByPlayer(playerName: String): Flux<GameRank> {
return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName)
}
}
@@ -1,40 +0,0 @@
package kr.lunaticbum.back.lun.model
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Flux
@Document(collection = "ranks")
class Rank {
// Getters and Setters
@Id
var id: String? = null
// 👈 Getter/Setter 추가
var gameId: String? = null // 👈 게임 ID 필드 추가
var name: String? = null
var score: Int = 0
// Constructors
constructor()
constructor(gameId: String?, name: String?, score: Int) {
this.gameId = gameId
this.name = name
this.score = score
}
}
@Repository
interface RankRepository : ReactiveMongoRepository<Rank, String> {
/**
* 특정 gameId에 대해 점수가 높은 순서대로 상위 10개의 랭킹을 조회합니다.
* @param gameId 조회할 게임의 ID
* @return Flux<Rank>
</Rank> */
// 쿼리 메소드 이름 변경 및 파라미터 추가
fun findTop10ByGameIdOrderByScoreDesc(gameId: String): Flux<Rank?> // 👈 수정
}
@@ -1,36 +0,0 @@
package kr.lunaticbum.back.lun.model
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import lombok.Getter
@Getter
class RequestModel {
var type : String? = null
var key : String? = null
var data : String? = null
fun getKeyword() = key ?: ""
fun extractData() : String {
data?.let {
val reqString = data?.split(GlobalEnvironment.padding(getKeyword()))
val nb = arrayListOf<String>()
val na = arrayListOf<String>()
reqString?.get(0)?.replace(GlobalEnvironment.padding(getKeyword()),"")?.split("")?.toList()?.let { na.addAll(it) }
reqString?.get(1)?.replace(GlobalEnvironment.padding(getKeyword()),"")?.split("")?.toList()?.let { nb.addAll(it) }
val max = nb.size + na.size
val fullData = arrayListOf<String>()
for (idx in 0..max) { if (idx % 2 == 0) { if (nb.size > 0) { fullData.add(nb.removeLast()) } } else { if (na.size > 0) { fullData.add(na.removeLast()) } } }
return fullData.joinToString("")
}
return ""
}
}
@Getter
class ReportModel {
var name : String? = null
var email : String? = null
var message : String? = null
}
@@ -5,7 +5,7 @@ import lombok.Getter
@Getter
open class ResponceResult : BaseResult() {
var data : HashMap<String, String> = hashMapOf()
}
@Getter
@@ -17,6 +17,8 @@ open class PostsResult : BaseResult() {
@Getter
open class LoginResult : ResponceResult() {
var rememberMe: Boolean? = null
var token: String? = null // [추가] JWT 토큰을 담을 필드
}
@Getter
@@ -1,136 +0,0 @@
package kr.lunaticbum.back.lun.model
import com.mongodb.DuplicateKeyException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.toList
import kr.lunaticbum.back.lun.utils.SudokuGenerator
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import kotlin.random.Random
@Document(collection = "puzzles") // MongoDB 컬렉션 이름 지정
data class SudokuPuzzle(
@Id
val id: String? = null, // MongoDB의 고유 _id 필드
val puzzleKey: Long? = null, // 1, 2, 3... 순차 ID (랜덤 조회용)
@Indexed(unique = true)
val puzzle: String? // 81자리 완성된 퍼즐 데이터
)
@Document(collection = "records")
data class GameRecord(
@Id
val id: String? = null,
val puzzleId: Long, // SudokuPuzzle의 puzzleKey를 참조
val userName: String,
val completionTime: Long // 완료 시간 (초)
)
@Repository
interface SudokuPuzzleRepository : CoroutineCrudRepository<SudokuPuzzle, String> {
// 전체 퍼즐 개수를 반환하는 suspend 함수
override suspend fun count(): Long
// puzzleKey로 퍼즐을 찾는 suspend 함수
suspend fun findByPuzzleKey(puzzleKey: Long): SudokuPuzzle?
// 👇 이 함수 선언을 추가해주세요.
suspend fun findTopByOrderByPuzzleKeyDesc(): SudokuPuzzle?
}
@Repository
interface GameRecordRepository : CoroutineCrudRepository<GameRecord, String> {
// 특정 퍼즐의 랭킹을 시간순으로 조회 (Flow는 0개 이상의 비동기 데이터 스트림)
fun findTop10ByPuzzleIdOrderByCompletionTimeAsc(puzzleId: Long): Flow<GameRecord>
}
@Service
class SudokuService(
private val puzzleRepository: SudokuPuzzleRepository,
private val recordRepository: GameRecordRepository
) {
// DTO 정의 (파일 하단 또는 별도 파일)
data class GameDto(val puzzleId: Long, val question: String, val solution: String)
data class RecordDto(val puzzleId: Long, val userName: String, val completionTime: Long)
suspend fun startGame(difficulty: String): GameDto {
val puzzleCount = puzzleRepository.count()
if (puzzleCount == 0L) throw IllegalStateException("퍼즐이 DB에 없습니다.")
val randomKey = Random.nextLong(1, puzzleCount - 1)
val solvedPuzzle = puzzleRepository.findByPuzzleKey(randomKey)
?: throw IllegalStateException("$randomKey 번 퍼즐을 찾을 수 없습니다.")
val holes = when (difficulty.lowercase()) {
"medium" -> 45
"hard" -> 55
else -> 35 // easy
}
val question = createQuestion(solvedPuzzle.puzzle!!, holes)
return GameDto(solvedPuzzle.puzzleKey ?: 0L, question, solvedPuzzle.puzzle!!)
}
suspend fun saveRecord(recordDto: RecordDto) {
val record = GameRecord(
puzzleId = recordDto.puzzleId,
userName = recordDto.userName,
completionTime = recordDto.completionTime
)
recordRepository.save(record)
}
suspend fun getRankings(puzzleId: Long): List<GameRecord> {
// Flow를 최종적으로 List로 변환하여 반환
return recordRepository.findTop10ByPuzzleIdOrderByCompletionTimeAsc(puzzleId).toList()
}
private fun createQuestion(puzzle: String, holes: Int): String {
val chars = puzzle.toMutableList()
var remainingHoles = holes
while (remainingHoles > 0) {
val randomIndex = Random.nextInt(chars.size)
if (chars[randomIndex] != '0') {
chars[randomIndex] = '0'
remainingHoles--
}
}
return chars.joinToString("")
}
suspend fun generateAndSavePuzzle(): SudokuPuzzle {
var attempts = 0
val maxAttempts = 10 // 중복 시 최대 10번 재시도
while (attempts < maxAttempts) {
try {
val puzzleString = SudokuGenerator().generate()
println("puzzleString >>> ${puzzleString}")
// DB에 저장하기 전에 가장 큰 puzzleKey를 찾아 +1
val lastPuzzle = puzzleRepository.findTopByOrderByPuzzleKeyDesc()
val nextKey = (lastPuzzle?.puzzleKey ?: 0L) + 1L
val newPuzzle = SudokuPuzzle(puzzleKey = nextKey, puzzle = puzzleString)
return puzzleRepository.save(newPuzzle)
} catch (e: DuplicateKeyException) {
attempts++
println("중복 퍼즐 생성됨, 재시도... ($attempts/$maxAttempts)")
}
}
throw IllegalStateException("새로운 고유 퍼즐 생성에 실패했습니다.")
}
data class ValidateDto(val puzzleId: Long, val answer: String)
suspend fun validateSolution(validateDto: ValidateDto): Boolean {
val originalPuzzle = puzzleRepository.findByPuzzleKey(validateDto.puzzleId)
?: throw IllegalStateException("퍼즐을 찾을 수 없습니다.")
return originalPuzzle.puzzle == validateDto.answer
}
}
@@ -0,0 +1,38 @@
package kr.lunaticbum.back.lun.model
import com.google.gson.annotations.SerializedName
class BumlamaReq {
private constructor()
constructor(reqMsg: String?) {
this.reqMsg = reqMsg
}
@SerializedName("prompt")
var reqMsg : String? = ""
var model : String = "phi4:14b"
var stream = false
}
class BumlamaResp {
var model : String? = ""
var created_at : String? = ""
var response : String? = ""
var done : Boolean? = true
var done_reason : String? = "stop"
var context : ArrayList<Long>? = arrayListOf()
var total_duration : Long = 0L
var load_duration : Long = 0L
var prompt_eval_count : Long = 0L
var prompt_eval_duration : Long = 0L
var eval_count : Long = 0L
var eval_duration : Long = 0L
}
data class TelegramSendMsg(
@SerializedName("chat_id")
val userId: String,
@SerializedName("text")
val msg: String
)
@@ -48,7 +48,7 @@ class From {
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "TelegramMessage")
class Message {
class TlgMessage {
@Id
var message_id: String = ""
@@ -89,7 +89,7 @@ class TelegramLocation {
class Result {
var update_id: Int = 0
var message: Message? = null
var message: TlgMessage? = null
}
class TelegramUpdate {
@@ -100,17 +100,17 @@ class TelegramUpdate {
}
@Repository
interface TelegramRepository : ReactiveMongoRepository<Message,String> {
interface TelegramRepository : ReactiveMongoRepository<TlgMessage,String> {
@Query("{id :?0}")
override fun findById(id: String): Mono<Message>
override fun findById(id: String): Mono<TlgMessage>
@Query("{id :?0}")
fun count(id: Int): Mono<Long>
fun save(message: Message): Mono<Message>
fun save(message: TlgMessage): Mono<TlgMessage>
}
interface MsgService {
fun findById(id: String): Mono<Message>?
fun findById(id: String): Mono<TlgMessage>?
}
@Service
@@ -123,7 +123,7 @@ class TelegramMsgService : MsgService {
override fun findById(id: String): Mono<Message>? {
override fun findById(id: String): Mono<TlgMessage>? {
return telegramRepository.findById(id)
}
@@ -132,7 +132,7 @@ class TelegramMsgService : MsgService {
return telegramRepository.count(id)
}
fun save(msg: Message) {
fun save(msg: TlgMessage) {
println("saved msg before ${msg}")
telegramRepository.save(msg).subscribe( { println("saved msg after ${it}") },{e -> e.printStackTrace()},{
println("saved msg comp")
@@ -166,3 +166,59 @@ class GSRItemPageMap {
var cse_image : ArrayList<Map<String,String>>? = null
}
class Condition {
var text: String? = null
var icon: String? = null
var code: Int = 0
}
class Current {
var last_updated_epoch: Int = 0
var last_updated: String? = null
var temp_c: Double = 0.0
var temp_f: Double = 0.0
var is_day: Int = 0
var condition: Condition? = null
var wind_mph: Double = 0.0
var wind_kph: Double = 0.0
var wind_degree: Int = 0
var wind_dir: String? = null
var pressure_mb: Double = 0.0
var pressure_in: Double = 0.0
var precip_mm: Double = 0.0
var precip_in: Double = 0.0
var humidity: Int = 0
var cloud: Int = 0
var feelslike_c: Double = 0.0
var feelslike_f: Double = 0.0
var windchill_c: Double = 0.0
var windchill_f: Double = 0.0
var heatindex_c: Double = 0.0
var heatindex_f: Double = 0.0
var dewpoint_c: Double = 0.0
var dewpoint_f: Double = 0.0
var vis_km: Double = 0.0
var vis_miles: Double = 0.0
var uv: Double = 0.0
var gust_mph: Double = 0.0
var gust_kph: Double = 0.0
}
class Location {
var name: String? = null
var region: String? = null
var country: String? = null
var lat: Double = 0.0
var lon: Double = 0.0
var tz_id: String? = null
var localtime_epoch: Int = 0
var localtime: String? = null
}
class CurrentWeather {
var location: Location? = null
var current: Current? = null
fun getSummaryInfo(lat : String,lon : String) = "지역:${this.location?.name}\n날씨:${this.current?.condition?.text}\n온도:${this.current?.temp_c}\n습도:${this.current?.humidity}\n" +
"체감온도:${this.current?.feelslike_c}\nhttps://www.accuweather.com/ko/search-locations?query=${lat},${lon}"
}
@@ -64,7 +64,6 @@ data class PersistentLogin(
interface PersistentLoginRepository : ReactiveMongoRepository<PersistentLogin, String> {
fun findByUsername(username: String): Flux<PersistentLogin>
}
@Component
class MongoPersistentTokenRepository (
private val repository: PersistentLoginRepository
@@ -77,33 +76,43 @@ class MongoPersistentTokenRepository (
tokenValue = token.tokenValue,
lastUsed = token.date
)
repository.save(persistentLogin).block() // 블로킹 여부는 환경에 따라 조절
println("CALLED rememberMeServices")
// [수정] .block() 대신 .subscribe()를 사용하여 비동기 실행
repository.save(persistentLogin).subscribe()
println("CALLED rememberMeServices: createNewToken")
}
override fun updateToken(series: String, tokenValue: String, lastUsed: Date) {
val login = repository.findById(series).block()
if (login != null) {
// [수정] .block() 대신 .flatMap과 .subscribe()를 사용
repository.findById(series).flatMap { login ->
val updated = login.copy(tokenValue = tokenValue, lastUsed = lastUsed)
repository.save(updated).block()
println("CALLED rememberMeServices")
}
repository.save(updated)
}.subscribe()
println("CALLED rememberMeServices: updateToken")
}
override fun getTokenForSeries(seriesId: String): PersistentRememberMeToken? {
// [주의] 이 인터페이스 메소드는 동기(blocking) 반환을 요구하므로,
// 어쩔 수 없이 .block()을 사용해야 합니다. 하지만,
// 자동 로그인은 메인 요청 흐름에 덜 치명적이므로 이 부분은 유지합니다.
// 근본적인 해결을 위해서는 Spring Security의 ReactivePersistentTokenRepository 사용이 필요합니다.
val login = repository.findById(seriesId).block()
return login?.let {
println("CALLED rememberMeServices")
println("CALLED rememberMeServices: getTokenForSeries")
PersistentRememberMeToken(it.username, it.series, it.tokenValue, it.lastUsed)
}
}
override fun removeUserTokens(username: String) {
val tokens = repository.findByUsername(username).collectList().block()
tokens?.let {
println("CALLED rememberMeServices")
repository.deleteAll(it).block()
}
// [수정] .block() 대신 .flatMap과 .subscribe()를 사용
repository.findByUsername(username)
.collectList()
.flatMap { tokens ->
if (tokens.isNotEmpty()) {
repository.deleteAll(tokens)
} else {
Mono.empty()
}
}.subscribe()
println("CALLED rememberMeServices: removeUserTokens")
}
}
@@ -0,0 +1,21 @@
// src/main/kotlin/kr/lunaticbum/back/lun/model/TradeHistoryEntity.kt
package kr.lunaticbum.back.lun.model
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import java.time.LocalDateTime
@Document(collection = "trade_history")
data class TradeHistoryEntity(
@Id
val id: String? = null,
val time: LocalDateTime = LocalDateTime.now(), // 거래 시간
val stockCode: String,
val stockName: String,
val orderType: String, // "BUY" or "SELL"
val price: Double, // 주문 가격 (또는 체결가)
val quantity: Int,
val orderNo: String, // 주문 번호
val isAutoTrade: Boolean, // 자동매매 여부
val resultMsg: String // 결과 메시지
)
@@ -13,38 +13,42 @@ import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
import com.fasterxml.jackson.annotation.JsonProperty
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "User")
class User {
data class User (
@BsonId
@BsonRepresentation(BsonType.OBJECT_ID)
var userId: String? = null
var userId: String? = null,
@Id
var user_id: String? = null
var user_pw: String? = null
var user_pw_check: String? = null
var user_id: String? = null,
var user_pw: String? = null,
var user_pw_check: String? = null,
var user_email: String? = null
var user_email: String? = null,
@CreatedDate
var user_join: Long = 0L
var user_join: Long = 0L,
var theme: String = "default",
// var user_name: String? = null
var isAccept : String? = null
var isAdmin : String? = null
var rememberMe : Boolean? = false
var isAccept : String? = null,
var isAdmin : String? = null,
var rememberMe : Boolean? = false,
var writePermissionRequested: Boolean = false) {
fun checkValid() : Boolean {
if (
((user_id?.length ?: 0) > 5) &&
@@ -116,7 +120,7 @@ interface UserRepository : ReactiveMongoRepository<User, String> {
// @Query("{user_email :?0}")
// fun findByEmail(user_email: String): Mono<User>
fun findByWritePermissionRequested(requested: Boolean): Flux<User> // [신규 추가]
fun save(user: User): Mono<User>
}
@@ -144,10 +148,26 @@ class UserManager(
// return userRepository.findByEmail(id)
// }
override fun findById(id: String): Mono<User>? {
override fun findById(id: String): Mono<User> {
return userRepository.findById(id)
}
// [신규] 글쓰기 권한 승인
fun approveWritePermission(userId: String): Mono<User> {
return userRepository.findById(userId).flatMap { user ->
user.isAccept = "Y"
user.writePermissionRequested = false
userRepository.save(user)
}
}
// [신규] 글쓰기 권한 요청 거절
fun rejectWritePermission(userId: String): Mono<User> {
return userRepository.findById(userId).flatMap { user ->
user.writePermissionRequested = false
userRepository.save(user)
}
}
fun save(user: User): Mono<User> {
@@ -162,12 +182,40 @@ class UserManager(
override fun loadUserByUsername(username: String?): UserDetails {
logService.log("username ${username}")
var user = findById(username!!)?.blockOptional(Duration.ofMillis(5000L))?.get() ?: User()
// user.hashPassword(passwordEncoder)
if (username == null) {
throw UsernameNotFoundException("Username cannot be null")
}
// 사용자를 찾지 못하면 예외를 던지도록 수정
val user = findById(username)
.blockOptional(Duration.ofMillis(15000L))
.orElseThrow { UsernameNotFoundException("User not found: $username") }
val userRole = user.getRole().name // "READ", "WRITE", 또는 "ADMIN"
return org.springframework.security.core.userdetails.User.builder()
.username(user.user_id ?: "")
.password(user.user_pw)
.roles(if ("Y".equals(user.isAdmin)) Role.ADMIN.name else {Role.USER.name}).build()
.roles(userRole)
.build()
}
// [신규] 모든 사용자 목록 조회
fun findAllUsers(): Flux<User> {
return userRepository.findAll()
}
// [신규] 글쓰기 권한을 요청한 사용자 목록 조회
fun findUsersRequestingWritePermission(): Flux<User> {
return userRepository.findByWritePermissionRequested(true)
}
// [신규] 글쓰기 권한 요청
fun requestWritePermission(userId: String): Mono<User> {
return userRepository.findById(userId).flatMap { user ->
user.writePermissionRequested = true
userRepository.save(user)
}
}
}
@@ -0,0 +1,120 @@
package kr.lunaticbum.back.lun.model
import org.bson.BsonType
import org.bson.codecs.pojo.annotations.BsonId
import org.bson.codecs.pojo.annotations.BsonRepresentation
import org.springframework.data.mongodb.core.index.CompoundIndex
import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import java.time.ZonedDateTime
import jakarta.servlet.http.HttpServletRequest
import org.springframework.security.core.context.SecurityContextHolder
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.TemporalAdjusters
@Service
class VisitorLogService(private val repository: VisitorLogRepository) {
/**
* 사용자의 방문을 기록합니다. (하루에 한 번만)
*/
fun recordVisit(request: HttpServletRequest): Mono<Void> {
val ipAddress = request.remoteAddr
val user = SecurityContextHolder.getContext().authentication
val today = LocalDate.now(ZoneId.systemDefault())
val startOfDay = today.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
val endOfDay = today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
val hasVisitedToday: Mono<Boolean>
val visitorLog: VisitorLog
if (user != null && user.isAuthenticated && user.principal is org.springframework.security.core.userdetails.UserDetails) {
val userDetails = user.principal as org.springframework.security.core.userdetails.UserDetails
val userId = userDetails.username
hasVisitedToday = repository.findFirstByUserIdAndVisitTimestampBetween(userId, startOfDay, endOfDay).hasElement()
visitorLog = VisitorLog(ipAddress = ipAddress, userId = userId)
} else {
hasVisitedToday = repository.findFirstByIpAddressAndVisitTimestampBetween(ipAddress, startOfDay, endOfDay).hasElement()
visitorLog = VisitorLog(ipAddress = ipAddress)
}
return hasVisitedToday.flatMap { visited ->
if (!visited) {
repository.save(visitorLog).then()
} else {
Mono.empty()
}
}
}
/**
* 방문자 통계를 계산하여 반환합니다.
*/
fun getVisitorStats(): Mono<VisitorStatsDto> {
val now = LocalDate.now(ZoneId.systemDefault())
val todayStart = now.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
val weekStart = now.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
val monthStart = now.withDayOfMonth(1).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
val yearStart = now.withDayOfYear(1).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
val todayCountMono = repository.countByVisitTimestampGreaterThanEqual(todayStart)
val weekCountMono = repository.countByVisitTimestampGreaterThanEqual(weekStart)
val monthCountMono = repository.countByVisitTimestampGreaterThanEqual(monthStart)
val yearCountMono = repository.countByVisitTimestampGreaterThanEqual(yearStart)
val totalCountMono = repository.count()
return Mono.zip(todayCountMono, weekCountMono, monthCountMono, yearCountMono, totalCountMono)
.map { tuple ->
VisitorStatsDto(
today = tuple.t1,
week = tuple.t2,
month = tuple.t3,
year = tuple.t4,
total = tuple.t5
)
}
}
}
// 방문 기록을 저장할 MongoDB 문서
@Document(collection = "VisitorLog")
// IP와 날짜, 또는 사용자와 날짜로 중복 조회를 빠르게 하기 위해 인덱스 추가
@CompoundIndex(name = "ip_timestamp_idx", def = "{'ipAddress': 1, 'visitTimestamp': -1}")
@CompoundIndex(name = "user_timestamp_idx", def = "{'userId': 1, 'visitTimestamp': -1}")
data class VisitorLog(
@BsonId
@BsonRepresentation(BsonType.OBJECT_ID)
var id: String? = null,
val ipAddress: String,
val userId: String? = null, // 로그인 사용자일 경우 ID 저장
@Indexed
val visitTimestamp: Long = System.currentTimeMillis()
)
// API 응답으로 사용할 데이터 클래스
data class VisitorStatsDto(
val today: Long,
val week: Long,
val month: Long,
val year: Long,
val total: Long
)
// VisitorLog 데이터 접근을 위한 리포지토리
@Repository
interface VisitorLogRepository : ReactiveMongoRepository<VisitorLog, String> {
// 특정 IP가 특정 기간 내에 방문한 기록이 있는지 확인
fun findFirstByIpAddressAndVisitTimestampBetween(ipAddress: String, start: Long, end: Long): Mono<VisitorLog>
// 특정 사용자가 특정 기간 내에 방문한 기록이 있는지 확인
fun findFirstByUserIdAndVisitTimestampBetween(userId: String, start: Long, end: Long): Mono<VisitorLog>
// 특정 시간 이후의 방문 기록 수 계산
fun countByVisitTimestampGreaterThanEqual(timestamp: Long): Mono<Long>
}
@@ -1,57 +0,0 @@
package kr.lunaticbum.back.lun.model
class Condition {
var text: String? = null
var icon: String? = null
var code: Int = 0
}
class Current {
var last_updated_epoch: Int = 0
var last_updated: String? = null
var temp_c: Double = 0.0
var temp_f: Double = 0.0
var is_day: Int = 0
var condition: Condition? = null
var wind_mph: Double = 0.0
var wind_kph: Double = 0.0
var wind_degree: Int = 0
var wind_dir: String? = null
var pressure_mb: Double = 0.0
var pressure_in: Double = 0.0
var precip_mm: Double = 0.0
var precip_in: Double = 0.0
var humidity: Int = 0
var cloud: Int = 0
var feelslike_c: Double = 0.0
var feelslike_f: Double = 0.0
var windchill_c: Double = 0.0
var windchill_f: Double = 0.0
var heatindex_c: Double = 0.0
var heatindex_f: Double = 0.0
var dewpoint_c: Double = 0.0
var dewpoint_f: Double = 0.0
var vis_km: Double = 0.0
var vis_miles: Double = 0.0
var uv: Double = 0.0
var gust_mph: Double = 0.0
var gust_kph: Double = 0.0
}
class Location {
var name: String? = null
var region: String? = null
var country: String? = null
var lat: Double = 0.0
var lon: Double = 0.0
var tz_id: String? = null
var localtime_epoch: Int = 0
var localtime: String? = null
}
class CurrentWeather {
var location: Location? = null
var current: Current? = null
fun getSummaryInfo(lat : String,lon : String) = "지역:${this.location?.name}\n날씨:${this.current?.condition?.text}\n온도:${this.current?.temp_c}\n습도:${this.current?.humidity}\n" +
"체감온도:${this.current?.feelslike_c}\nhttps://www.accuweather.com/ko/search-locations?query=${lat},${lon}"
}
@@ -0,0 +1,19 @@
package kr.lunaticbum.back.lun.repository
import kr.lunaticbum.back.lun.model.Comment
import kr.lunaticbum.back.lun.model.ContentType
import org.springframework.data.domain.Pageable
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
interface CommentRepository : ReactiveMongoRepository<Comment, String> {
// 특정 타겟(글/북마크)의 댓글 조회
fun findByTargetIdAndTargetTypeOrderByWriteTimeAsc(targetId: String, targetType: ContentType): Flux<Comment>
// 댓글 수 카운트
fun countByTargetIdAndTargetType(targetId: String, targetType: ContentType): Mono<Long>
// [추가] 작성자별 댓글 조회 (UserController 내 정보 페이지용)
fun findByWriter(writer: String, pageable: Pageable): Flux<Comment>
}
@@ -0,0 +1,9 @@
// src/main/kotlin/kr/lunaticbum/back/lun/repository/DirectLoginRepository.kt
package kr.lunaticbum.back.lun.repository
import kr.lunaticbum.back.lun.model.DirectLoginToken
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
@Repository
interface DirectLoginRepository : ReactiveMongoRepository<DirectLoginToken, String>
@@ -0,0 +1,7 @@
package kr.lunaticbum.back.lun.repository
import kr.lunaticbum.back.lun.model.PhotoMetadata
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
interface PhotoMetadataRepository : ReactiveMongoRepository<PhotoMetadata,String> {
}
@@ -0,0 +1,14 @@
package kr.lunaticbum.back.lun.repository
import kr.lunaticbum.back.lun.model.PostHistory
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Flux
// 2. PostHistory를 위한 Repository 인터페이스
@Repository
interface PostHistoryRepository : ReactiveMongoRepository<PostHistory, String> {
// [추가] postId로 모든 히스토리를 최신순으로 조회
fun findByPostIdOrderByArchivedAtDesc(postId: String): Flux<PostHistory>
}
@@ -0,0 +1,197 @@
package kr.lunaticbum.back.lun.repository
import kr.lunaticbum.back.lun.model.AggregationCount
import kr.lunaticbum.back.lun.model.Post
import org.springframework.data.domain.Pageable
import org.springframework.data.mongodb.repository.Aggregation
import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@Repository
interface PostRepository : ReactiveMongoRepository<Post, String> {
// [핵심] 커서 페이징을 위한 쿼리
// 작성일(writeTime)이 기준값보다 작고(과거), 공개(posting=true)된 글만 조회
// 포스트(POST)와 짧은글(GIBBERISH) 모두 posting=true라면 가져옵니다.
@Query("{ 'writeTime': { \$lt: ?0 }, 'posting': true }")
fun findFeedPostsBefore(time: Long, pageable: Pageable): Flux<Post>
// 검색 쿼리 (제목, 내용, 태그 포함)
@Query("{ \$or: [ { 'title': { \$regex: ?0, \$options: 'i' } }, { 'content': { \$regex: ?0, \$options: 'i' } }, { 'tags': { \$regex: ?0, \$options: 'i' } } ], 'writeTime': { \$lt: ?1 }, 'posting': true }")
fun searchPosts(keyword: String, time: Long, pageable: Pageable): Flux<Post>
fun findAllByModifyTime(time : Long? = 0): Flux<Post>
// @org.springframework.data.mongodb.repository.Query("{ '\$and': [ { 'posting': true }, { '\$expr': { '\$gte': [ { '\$strLenCP': '\$id' }, 4 ] } } ] }")
fun findAllByOrderByModifyTimeDesc(pageable: Pageable): Flux<Post>
fun countByOrderByModifyTimeDesc(): Mono<Long>
@Aggregation(pipeline = [
"{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
"{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }",
"{ \$sort: { \"modifyTime\": -1 } }"
])
fun findTop5ByOrderByReadCountDesc(): Flux<Post>
@Aggregation(pipeline = [
"{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
"{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }",
"{ \$sort: { \"modifyTime\": -1 } }"
])
fun findTop5ByOrderByModifyTimeDesc(): Flux<Post>
fun findByPostTypeOrderByModifyTimeDesc(postType: String): Flux<Post>
// [단순화] 공개된 글 목록 조회 (페이지네이션)
fun findByPostingIsTrueOrderByModifyTimeDesc(pageable: Pageable): Flux<Post>
// [단순화] 공개된 글 개수 카운트
fun countByPostingIsTrue(): Mono<Long>
// [단순화] 인기글 5개 조회 (공개된 글 대상)
fun findTop5ByPostingIsTrueOrderByReadCountDesc(): Flux<Post>
// [단순화] 최신글 5개 조회 (공개된 글 대상)
fun findTop5ByPostingIsTrueOrderByModifyTimeDesc(): Flux<Post>
// [단순화] '글쓰기' 권한 사용자를 위한 조회 (공개된 글 + 내 비공개 글)
fun findByPostingIsTrueOrWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post>
fun countByPostingIsTrueOrWriter(writer: String): Mono<Long>
// [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상)
@Aggregation(pipeline = [
// 1. 모든 글을 최신순으로 정렬
"{ \$sort: { modifyTime: -1 } }",
// 2. 각 글의 '진짜 최신 버전'을 하나씩만 추출
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
// 3. 원래 Post 형태로 복원
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
// 4. 최신 버전들 중 '공개' 상태이고 '차단되지 않은' 글만 필터링
"{ \$match: { posting: true, isBlocked: false } }",
// 5. 최종 목록을 조회수(readCount) 순으로 정렬
"{ \$sort: { readCount: -1 } }",
// 6. 상위 5개만 선택
"{ \$limit: 5 }"
])
fun findTop5UniquePublishedByReadCountDesc(): Flux<Post>
// [신규 추가] 익명 사용자용 최신글 (공개된 고유 포스트 대상)
@Aggregation(pipeline = [
// 1. 모든 글을 최신순으로 정렬
"{ \$sort: { modifyTime: -1 } }",
// 2. 각 글의 '진짜 최신 버전'을 하나씩만 추출
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
// 3. 원래 Post 형태로 복원
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
// 4. 최신 버전들 중 '공개' 상태이고 '차단되지 않은' 글만 필터링
"{ \$match: { posting: true, isBlocked: false } }",
// 5. 최종 목록을 다시 최신순으로 정렬
"{ \$sort: { modifyTime: -1 } }",
// 6. 상위 5개만 선택
"{ \$limit: 5 }"
])
fun findTop5UniquePublishedByModifyTimeDesc(): Flux<Post>
/**
* 익명 사용자를 위한 '고유 최신 글' 목록을 페이지네이션으로 조회합니다.
* [버그 수정] 2차 정렬 경로를 "post.post.modifyTime" -> "post.modifyTime" 으로 변경
*/
@Aggregation(pipeline = [
"{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
"{ \$sort: { \"post.modifyTime\": -1 } }", // [수정됨]
"{ \$replaceRoot: { newRoot: \"\$post\" } }"
])
fun findLatestUniqueOriginPaginated(pageable: Pageable): Flux<Post>
/**
* '고유 최신 글'의 총 개수를 카운트합니다. (페이지네이션의 totalElements 계산용)
*/
@Aggregation(pipeline = [
"{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }", // 고유 ID로 그룹화
"{ \$count: \"totalCount\" }" // 고유 그룹의 개수를 셈
])
fun countLatestUniqueOrigin(): Mono<AggregationCount> // 헬퍼 클래스로 매핑
@Aggregation(pipeline = [
"{ \$match: { \$and: [ { \$or: [ { writer: ?0 }, { posting: true } ] }, { 'postType': { \$ne: 'GIBBERISH' } } ] } }",
"{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
"{ \$sort: { \"modifyTime\": -1 } }"
])
fun findLatestUniqueForWriterPaginated(username: String, pageable: Pageable): Flux<Post>
@Aggregation(pipeline = [
"{ \$match: { \$and: [ { \$or: [ { writer: ?0 }, { posting: true } ] }, { 'postType': { \$ne: 'GIBBERISH' } } ] } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }",
"{ \$count: \"totalCount\" }"
])
fun countLatestUniqueForWriter(username: String): Mono<AggregationCount>
/**
* [수정] GIBBERISH 타입을 제외하고, posting이 true인 문서만 필터링하는 $match 단계를 추가합니다.
*/
@Aggregation(pipeline = [
"{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
"{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }",
"{ \$sort: { \"modifyTime\": -1 } }"
])
fun findLatestUniquePublishedPaginated(pageable: Pageable): Flux<Post>
/**
* '고유 최신 글' 중 공개된 글의 총 개수를 카운트합니다.
* [수정] GIBBERISH 타입을 제외하고, posting이 true인 문서만 필터링하는 $match 단계를 추가합니다.
*/
@Aggregation(pipeline = [
"{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
"{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }",
"{ \$count: \"totalCount\" }"
])
fun countLatestUniquePublished(): Mono<AggregationCount>
fun findByWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post> // [신규 추가]
// [신규 추가] 특정 타입의 포스트 중 공개된 것을 무작위로 1개 조회
@Aggregation(pipeline = [
"{ \$match: { postType: ?0, posting: true, isBlocked: false } }", // 타입, 공개, 차단안됨 필터링
"{ \$sample: { size: 1 } }" // 무작위로 1개 샘플링
])
fun findRandomPublishedPostByType(postType: String): Mono<Post>
// --- [신규 추가] 필터링을 위한 Repository 메소드 ---
fun findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category: String, pageable: Pageable): Flux<Post>
fun countByCategoryAndPostingIsTrue(category: String): Mono<Long>
fun findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(tag: String, pageable: Pageable): Flux<Post>
fun countByTagsRegexAndPostingIsTrue(tag: String): Mono<Long>
// [추가] MongoDB Aggregation을 사용해 고유 태그 목록을 효율적으로 조회
@Aggregation(pipeline = [
// 1. tags 필드가 null이면 빈 문자열로 만든 후 "," 기준으로 잘라 배열로 변환
"{ \$project: { tags: { \$split: [ { \$ifNull: [ \"\$tags\", \"\" ] }, \",\" ] } } }",
// 2. 생성된 tags 배열을 개별 문서로 분리 (예: ["a","b"] -> {tags:"a"}, {tags:"b"})
"{ \$unwind: \"\$tags\" }",
// 3. 각 태그의 앞뒤 공백 제거
"{ \$project: { tag: { \$trim: { input: \"\$tags\" } } } }",
// 4. 공백이 제거된 태그로 그룹화하여 고유한 값만 추출
"{ \$group: { _id: \"\$tag\" } }",
// 5. 그룹화 결과 중 빈 값("")은 제외
"{ \$match: { _id: { \$ne: \"\" } } }"
])
fun findDistinctTags(): Flux<org.bson.Document> // 반환 타입을 Document로 변경
// [신규 추가] GIBBERISH 제외하고 조회
fun findByPostTypeNotOrderByModifyTimeDesc(postType: String, pageable: Pageable): Flux<Post>
fun countByPostTypeNot(postType: String): Mono<Long>
}
@@ -0,0 +1,10 @@
// src/main/kotlin/kr/lunaticbum/back/lun/repository/AutoTradeRepository.kt
package kr.lunaticbum.back.lun.repository
import kr.lunaticbum.back.lun.model.AutoTradeEntity
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
@Repository
interface AutoTradeRepository : ReactiveMongoRepository<AutoTradeEntity, String>
@@ -0,0 +1,13 @@
// src/main/kotlin/kr/lunaticbum/back/lun/repository/TradeHistoryRepository.kt
package kr.lunaticbum.back.lun.repository
import kr.lunaticbum.back.lun.model.TradeHistoryEntity
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Flux
@Repository
interface TradeHistoryRepository : ReactiveMongoRepository<TradeHistoryEntity, String> {
// 최신순 조회
fun findAllByOrderByTimeDesc(): Flux<TradeHistoryEntity>
}
@@ -0,0 +1,50 @@
package kr.lunaticbum.back.lun.repository
import kr.lunaticbum.back.lun.model.WebBookmark
import org.springframework.data.domain.Pageable
import org.springframework.data.mongodb.repository.Aggregation
import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@Repository
interface WebBookmarkRepository : ReactiveMongoRepository<WebBookmark, String> {
// [검색용] 키워드가 제목, 코멘트, 태그, 설명 중 포함 + 공개된 북마크 + 커서 적용
@Query("{ " +
" '\$and': [ " +
" { 'visibility': { '\$in': ?0 }, 'savedAt': { '\$lt': ?2 } }, " +
" { '\$or': [ " +
" { 'title': { '\$regex': ?1, '\$options': 'i' } }, " +
" { 'userComment': { '\$regex': ?1, '\$options': 'i' } }, " +
" { 'tags': { '\$regex': ?1, '\$options': 'i' } }, " +
" { 'description': { '\$regex': ?1, '\$options': 'i' } } " +
" ] } " +
" ] " +
"}")
fun searchBookmarks(visibilities: List<String>, keyword: String, maxTime: Long, pageable: Pageable): Flux<WebBookmark>
// WebBookmarkRepository 인터페이스 내부에 추가
// savedAt이 특정 시간(?1)보다 작은 것들 중 최신순 조회
fun findByVisibilityInAndSavedAtLessThanOrderBySavedAtDesc(
visibilities: List<String>,
maxTime: Long,
pageable: Pageable
): Flux<WebBookmark>
fun findByUserIdOrderBySavedAtDesc(userId: String): Flux<WebBookmark>
fun findByVisibilityInOrderBySavedAtDesc(visibilities: List<String>, pageable: Pageable): Flux<WebBookmark>
fun countByVisibilityIn(visibilities: List<String>): Mono<Long>
fun findByMetadataStatus(status: String): Flux<WebBookmark>
// [추가] 필터링을 위한 고유 카테고리 및 태그 목록 조회 (이 위치로 이동)
@Aggregation("{ \$unwind: '\$tags' }", "{ \$group: { _id: '\$tags' } }")
fun findDistinctTags(): Flux<Map<String, Any>>
@Aggregation("{ \$group: { _id: '\$category' } }")
fun findDistinctCategories(): Flux<Map<String, Any>>
}
@@ -0,0 +1,73 @@
package kr.lunaticbum.back.lun.service
import kr.lunaticbum.back.lun.model.BookmarkType
import kr.lunaticbum.back.lun.model.MetadataStatus
import kr.lunaticbum.back.lun.model.WebBookmark
import kr.lunaticbum.back.lun.repository.WebBookmarkRepository
import kr.lunaticbum.back.lun.utils.LogService
import org.jsoup.Jsoup
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
@Service
class BookmarkProcessorService(
private val bookmarkRepository: WebBookmarkRepository,
private val logService: LogService
) {
// fixedDelayString = "60000" -> 1분에 한 번씩 실행
@Scheduled(fixedDelayString = "60000")
fun processPendingBookmarks() {
logService.log("Starting scheduled job: Process Pending Bookmarks...")
bookmarkRepository.findByMetadataStatus(MetadataStatus.PENDING.name) // PENDING 상태인 북마크 조회
.flatMap { bookmark ->
// 각 북마크에 대해 메타데이터를 가져오고 DB를 업데이트하는 비동기 작업을 수행
fetchAndApplyMetadata(bookmark)
}
.subscribe(
{ updatedBookmark -> logService.log("Successfully processed bookmark ID: ${updatedBookmark.id}") },
{ error -> logService.log("Error during bookmark processing: ${error.message}") }
)
}
private fun fetchAndApplyMetadata(bookmark: WebBookmark): Mono<WebBookmark> {
return Mono.fromCallable {
// Jsoup 호출은 블로킹(blocking) 작업이므로 fromCallable로 감싸고
// 별도 스레드에서 실행되도록 subscribeOn을 사용
if(bookmark.bookmarkType.equals(BookmarkType.URL.name, ignoreCase = true)){
logService.log("Fetching metadata for: ${bookmark.contentUrls.first()}")
val doc = Jsoup.connect(bookmark.contentUrls.first()).timeout(10000).get() // 10초 타임아웃
// 메타데이터 추출
val title = doc.select("meta[property=og:title]").attr("content").ifEmpty { doc.title() }
val description = doc.select("meta[property=og:description]").attr("content")
val imageUrl = doc.select("meta[property=og:image]").attr("content")
// 북마크 객체 업데이트
bookmark.title = title
bookmark.description = description
bookmark.thumbnailUrl = imageUrl
bookmark.metadataStatus = MetadataStatus.COMPLETED.name // 상태를 COMPLETED로 변경
}
bookmark
}
.subscribeOn(Schedulers.boundedElastic())
.flatMap { updatedBookmark ->
// 업데이트된 북마크를 DB에 저장
bookmarkRepository.save(updatedBookmark)
}
.onErrorResume { error ->
// 오류 발생 시 상태를 FAILED로 변경하여 저장
logService.log("Failed to fetch metadata for URL: ${bookmark.url}. Error: ${error.message}")
bookmark.metadataStatus = MetadataStatus.FAILED.name
bookmarkRepository.save(bookmark)
}
}
}
@@ -0,0 +1,52 @@
package kr.lunaticbum.back.lun.service
import kr.lunaticbum.back.lun.model.Comment
import kr.lunaticbum.back.lun.model.ContentType
import kr.lunaticbum.back.lun.repository.CommentRepository
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@Service
class CommentService(
private val commentRepository: CommentRepository
) {
// 특정 글/북마크의 댓글 조회
fun getComments(targetId: String, type: ContentType): Flux<Comment> {
return commentRepository.findByTargetIdAndTargetTypeOrderByWriteTimeAsc(targetId, type)
.filter { !it.isDeleted }
}
// [호환성 유지] 기존 코드(getRepliesForComment 등)가 있다면 여기에 구현
fun getRepliesForComment(commentId: String): Flux<Comment> {
// 대댓글 기능이 아직 구현되지 않았다면 빈 Flux 반환
return Flux.empty()
}
// 댓글 작성 (개별 인자)
fun addComment(targetId: String, type: ContentType, writer: String, content: String): Mono<Comment> {
val comment = Comment(
targetId = targetId,
targetType = type,
writer = writer,
content = content
)
return commentRepository.save(comment)
}
// 댓글 작성 (객체 인자) - BookmarkController 등에서 사용
fun addComment(comment: Comment): Mono<Comment> {
return commentRepository.save(comment)
}
// 댓글 수 조회
fun getCommentCount(targetId: String, type: ContentType): Mono<Long> {
return commentRepository.countByTargetIdAndTargetType(targetId, type)
}
// [추가] 작성자별 댓글 조회 (UserController용)
fun findCommentsByWriter(writer: String, pageable: Pageable): Flux<Comment> {
return commentRepository.findByWriter(writer, pageable)
}
}
@@ -0,0 +1,58 @@
package kr.lunaticbum.back.lun.service
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.DirectLoginToken
import kr.lunaticbum.back.lun.repository.DirectLoginRepository
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.util.UUID
@Service
class DirectLoginService(
private val directLoginRepository: DirectLoginRepository
) {
// 1. 토큰 생성 (deviceId 추가)
suspend fun createToken(
key: String, secret: String, acc: String, username: String,
ip: String, ua: String, deviceId: String // [추가]
): String {
val token = UUID.randomUUID().toString().replace("-", "")
val entity = DirectLoginToken(
token = token,
appKey = key,
appSecret = secret,
accountNo = acc,
username = username,
clientIp = ip,
userAgent = ua,
deviceId = deviceId // 저장
)
directLoginRepository.save(entity).awaitSingle()
return token
}
// 2. 토큰 검증 (쿠키 값 비교 추가)
suspend fun validateAndGet(token: String, currentIp: String, currentUa: String, cookieDeviceId: String?): DirectLoginToken {
val info = directLoginRepository.findById(token).awaitSingleOrNull()
?: throw Exception("존재하지 않는 링크입니다.")
// 1. 만료일 체크
if (info.expiresAt.isBefore(LocalDateTime.now())) {
throw Exception("만료된 링크입니다. 다시 생성해주세요.")
}
// 2. 쿠키(Device ID) 검증 [핵심 보안]
// 링크가 유출되어도 해커 PC에는 이 쿠키가 없으므로 절대 접속 불가
if (info.deviceId != cookieDeviceId) {
throw Exception("등록된 기기가 아닙니다. (링크를 생성한 브라우저에서만 접속 가능합니다)")
}
// 3. IP 검증 (선택: 모바일이라 IP가 자주 바뀌면 이 부분은 주석 처리하세요)
// if (info.clientIp != currentIp) throw Exception("IP가 변경되었습니다.")
return info
}
}
@@ -0,0 +1,199 @@
package kr.lunaticbum.back.lun.service
import com.fasterxml.jackson.databind.ObjectMapper
import kr.lunaticbum.back.lun.model.ContentType
import kr.lunaticbum.back.lun.model.FeedItemDto
import kr.lunaticbum.back.lun.model.FeedResponse
import kr.lunaticbum.back.lun.repository.PostRepository
import kr.lunaticbum.back.lun.repository.WebBookmarkRepository
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.net.URLDecoder
// service/FeedService.kt
@Service
class FeedService(
private val postRepository: PostRepository,
private val bookmarkRepository: WebBookmarkRepository,
private val objectMapper: ObjectMapper // [추가] JSON 파싱을 위해 주입
) {
// [신규] 안전 디코딩 헬퍼 함수
private fun safeDecode(value: String?): String {
if (value.isNullOrBlank()) return ""
return try {
URLDecoder.decode(value, "UTF-8")
} catch (e: Exception) {
value
}
}
// [헬퍼 함수] 콤마로 구분된 태그 문자열을 리스트로 변환
private fun parseTags(tagsStr: String?): List<String> {
return safeDecode(tagsStr)
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
}
// [신규] Quill JSON Delta 또는 HTML에서 순수 텍스트만 추출하는 헬퍼 함수
private fun extractPlainText(content: String?): String {
if (content.isNullOrBlank()) return ""
return try {
// 1. JSON (Quill Delta) 형식 시도
if (content.trim().startsWith("{") || content.trim().startsWith("[")) {
val root = objectMapper.readTree(content)
val sb = StringBuilder()
// ops 배열을 순회하며 text 추출
val ops = if (root.has("ops")) root.get("ops") else if (root.isArray) root else null
if (ops != null && ops.isArray) {
for (op in ops) {
if (op.has("insert")) {
val insert = op.get("insert")
if (insert.isTextual) {
sb.append(insert.asText())
}
}
}
return sb.toString()
}
}
// 2. JSON이 아니거나 실패 시 HTML 태그 제거 (Legacy 데이터)
content.replace(Regex("<.*?>"), "")
} catch (e: Exception) {
// 파싱 오류 시 원본에서 태그만 제거해서 반환
content.replace(Regex("<.*?>"), "")
}
}
// 기존 메서드 시그니처 변경: keyword: String? 추가
fun getGlobalFeed(cursorTime: Long?, size: Int, keyword: String? = null, currentUsername: String? = null): Mono<FeedResponse> {
val lastTime = cursorTime ?: System.currentTimeMillis()
val pageable = PageRequest.of(0, size)
// 1. Post 조회 (검색어가 있으면 searchPosts, 없으면 findFeedPostsBefore)
val postsFlux = if (!keyword.isNullOrBlank()) {
postRepository.searchPosts(keyword, lastTime, pageable)
} else {
postRepository.findFeedPostsBefore(lastTime, pageable)
}.map { post ->
val type = if (post.postType == "GIBBERISH") ContentType.GIBBERISH else ContentType.POST
// [수정] 본문 처리 로직 개선
val decodedRaw = safeDecode(post.content)
// GIBBERISH는 그대로, POST는 JSON을 텍스트로 변환하여 미리보기 생성
val displayContent = if (type == ContentType.GIBBERISH) {
decodedRaw
} else {
extractPlainText(decodedRaw)
}
val decodedTitle = safeDecode(post.title)
val isMyPost = currentUsername != null && post.writer == currentUsername
FeedItemDto(
id = post.id,
type = type,
title = decodedTitle, // [적용] 디코딩된 제목
content = displayContent,
thumbnail = post.thumb,
createdAt = post.writeTime,
writer = post.writer,
url = "/blog/viewer/${post.id}",
// [신규] 카테고리 및 태그 매핑
category = if (type == ContentType.POST) safeDecode(post.category) else null,
tags = parseTags(post.tags),
isOwner = isMyPost
)
}
// 2. Bookmark 조회
val bookmarksFlux = if (!keyword.isNullOrBlank()) {
bookmarkRepository.searchBookmarks(listOf("PUBLIC"), keyword, lastTime, pageable)
} else {
bookmarkRepository.findByVisibilityInAndSavedAtLessThanOrderBySavedAtDesc(
listOf("PUBLIC"), lastTime, pageable
)
}.map { bookmark ->
FeedItemDto(
id = bookmark.id,
type = ContentType.BOOKMARK,
title = bookmark.title ?: bookmark.url,
content = bookmark.userComment ?: bookmark.description,
thumbnail = bookmark.displayImageUrl,
createdAt = bookmark.savedAt,
writer = bookmark.userId,
url = bookmark.url ?: ""
)
}
// 3. 병합 및 정렬 (기존 동일)
return Flux.merge(postsFlux, bookmarksFlux)
.sort(Comparator.comparing(FeedItemDto::createdAt).reversed())
.take(size.toLong())
.collectList()
.map { items ->
val nextCursor = if (items.isNotEmpty()) items.last().createdAt else null
FeedResponse(items, nextCursor)
}
}
/**
* @param cursorTime 클라이언트가 가지고 있는 마지막 글의 시간 (첫 요청시엔 현재시간 or 아주 큰 값)
* @param size 한 번에 불러올 개수 (예: 10개)
*/
// fun getGlobalFeed(cursorTime: Long?, size: Int): Mono<FeedResponse> {
// // 커서가 없으면 현재 시간으로 설정 (첫 로딩)
// val lastTime = cursorTime ?: System.currentTimeMillis()
//
// // 각 저장소에서 'size' 만큼만 가져옴 (부하 최소화)
// val pageable = PageRequest.of(0, size)
//
// // 1. Post 조회 (lastTime 이전 글)
// val postsFlux = postRepository.findFeedPostsBefore(lastTime, pageable)
// .map { post ->
// val type = if (post.postType == "GIBBERISH") ContentType.GIBBERISH else ContentType.POST
// val rawContent = post.content?.replace(Regex("<.*?>"), "") ?: "" // 태그 제거
//
// FeedItemDto(
// id = post.id,
// type = type,
// title = post.title,
// content = if (type == ContentType.GIBBERISH) post.content else rawContent,
// thumbnail = post.thumb,
// createdAt = post.modifyTime,
// writer = post.writer,
// url = "/blog/viewer/${post.id}"
// )
// }
//
// // 2. Bookmark 조회 (lastTime 이전 글)
// val bookmarksFlux = bookmarkRepository.findByVisibilityInAndSavedAtLessThanOrderBySavedAtDesc(
// listOf("PUBLIC"), lastTime, pageable
// ).map { bookmark ->
// FeedItemDto(
// id = bookmark.id,
// type = ContentType.BOOKMARK,
// title = bookmark.title ?: bookmark.url,
// content = bookmark.userComment ?: bookmark.description,
// thumbnail = bookmark.displayImageUrl,
// createdAt = bookmark.savedAt,
// writer = bookmark.userId,
// url = bookmark.url ?: ""
// )
// }
//
// // 3. 병합 후 다시 정렬하고 size 만큼 자르기
// return Flux.merge(postsFlux, bookmarksFlux)
// .sort(Comparator.comparing(FeedItemDto::createdAt).reversed()) // 최신순 정렬
// .take(size.toLong()) // 전체 중 상위 size 개만 선택
// .collectList()
// .map { items ->
// // 마지막 아이템의 시간을 다음 커서로 설정
// val nextCursor = if (items.isNotEmpty()) items.last().createdAt else null
// FeedResponse(items, nextCursor)
// }
// }
}
@@ -0,0 +1,57 @@
package kr.lunaticbum.back.lun.service
import kr.lunaticbum.back.lun.model.Post
import kr.lunaticbum.back.lun.model.WebBookmark
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import java.util.Comparator
// DTO 정의
data class TagCount(val tag: String, val count: Int)
@Service
class GlobalTagService(
private val mongoTemplate: ReactiveMongoTemplate
) {
// 포스트와 북마크 양쪽에서 태그를 긁어와 합침
fun getAllTagsWithCount(): Flux<TagCount> {
// [수정 1] 함수 호출 시 인자를 1개만 전달하도록 변경 ("tags" 제거)
val postTags = aggregateTags(Post::class.java)
val bookmarkTags = aggregateTags(WebBookmark::class.java)
return Flux.merge(postTags, bookmarkTags)
.groupBy { it.tag }
.flatMap { group -> // [수정 3] 타입 추론을 위해 람다 내부 명확화
group.reduce { t1, t2 -> TagCount(t1.tag, t1.count + t2.count) }
}
.sort(Comparator.comparingInt(TagCount::count).reversed())
}
// 컬렉션에서 태그를 분리하고 카운트하는 공통 로직
private fun <T> aggregateTags(entityClass: Class<T>): Flux<TagCount> {
return mongoTemplate.findAll(entityClass)
.flatMapIterable { entity ->
// [수정 2] 엔티티 타입별로 태그 추출 로직을 명확히 분리하여 `split` 오류 해결
val tagsList: List<String> = when (entity) {
is Post -> {
// Post.tags는 String? 타입 -> 콤마로 분리
entity.tags?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList()
}
is WebBookmark -> {
// [수정] WebBookmark.tags는 List<String>? 타입 -> 바로 사용
entity.tags ?: emptyList()
}
else -> emptyList()
}
tagsList
}
.groupBy { it }
.flatMap { group ->
// [수정 3] Key null safety 처리
group.count().map { count ->
TagCount(group.key() ?: "unknown", count.toInt())
}
}
}
}
@@ -0,0 +1,169 @@
package kr.lunaticbum.back.lun.services
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kr.lunaticbum.back.lun.model.ImageMeta
import kr.lunaticbum.back.lun.model.ImageMetaService
import kr.lunaticbum.back.lun.model.ImageUploadResponse
import kr.lunaticbum.back.lun.utils.LogService
import net.coobird.thumbnailator.Thumbnails
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import reactor.core.publisher.Mono
import java.io.File
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.*
import javax.imageio.ImageIO
@Service
class ImageService(
private val imageMetaService: ImageMetaService,
private val logService: LogService
) {
@Value("\${image.upload.path}")
private val uploadPath: String? = null
/**
* 이미지 파일을 읽어 HTTP 응답으로 반환합니다. (썸네일/배너 처리 포함)
*/
suspend fun loadImage(filename: String, type: String?): ResponseEntity<ByteArray> {
return withContext(Dispatchers.IO) {
if (uploadPath.isNullOrBlank()) return@withContext ResponseEntity.notFound().build()
try {
// 1. 원본 요청인 경우
if (type.isNullOrBlank()) {
return@withContext serveFile(Paths.get(uploadPath, filename), filename)
}
// 2. 리사이징 요청 (썸네일/배너)
val (targetWidth, resizedFilename) = when (type) {
"thumbnail" -> 400 to filename.replace(".", "_thumbnail.")
"banner" -> 1200 to filename.replace(".", "_banner.")
else -> null to null
}
if (targetWidth == null || resizedFilename == null) {
return@withContext serveFile(Paths.get(uploadPath, filename), filename)
}
val resizedPath = Paths.get(uploadPath, resizedFilename)
val originalPath = Paths.get(uploadPath, filename)
// 캐시된 파일이 있으면 반환
if (Files.exists(resizedPath)) {
return@withContext serveFile(resizedPath, resizedFilename)
}
// 원본이 없으면 404
if (!Files.exists(originalPath)) {
return@withContext ResponseEntity.notFound().build()
}
// 리사이징 수행 후 저장
Thumbnails.of(originalPath.toFile())
.width(targetWidth)
.keepAspectRatio(true)
.outputQuality(0.85)
.toFile(resizedPath.toFile())
return@withContext serveFile(resizedPath, resizedFilename)
} catch (e: IOException) {
logService.log("Error processing image $filename: ${e.message}")
return@withContext ResponseEntity.internalServerError().build()
}
}
}
/**
* 업로드된 파일을 저장하고 메타데이터를 DB에 기록합니다.
*/
suspend fun saveImage(file: MultipartFile): Mono<ImageUploadResponse> {
return withContext(Dispatchers.IO) {
if (uploadPath.isNullOrBlank()) {
return@withContext Mono.just(ImageUploadResponse(1, "Upload path not configured", null))
}
val uniqueFilename = "${UUID.randomUUID()}_${file.originalFilename}"
val targetPath = Paths.get(uploadPath, uniqueFilename)
return@withContext try {
Files.createDirectories(targetPath.parent)
file.transferTo(targetPath.toFile())
// 이미지 크기 확인
val bufferedImage = ImageIO.read(targetPath.toFile())
val width = bufferedImage?.width ?: 0
val height = bufferedImage?.height ?: 0
val imageMeta = ImageMeta(
fileName = uniqueFilename,
originalFileName = file.originalFilename,
fileType = file.contentType,
fileSize = file.size,
width = width,
height = height,
uploadTime = System.currentTimeMillis(),
path = "/api/images/$uniqueFilename"
)
imageMetaService.save(imageMeta).map {
ImageUploadResponse(0, "Success", uniqueFilename)
}
} catch (e: Exception) {
logService.log("Image save failed: ${e.message}")
Mono.just(ImageUploadResponse(2, "Save failed: ${e.message}", null))
}
}
}
/**
* 단순 파일 생성용 (내부 호출용)
*/
fun generateThumbnailFile(originalFilename: String, targetWidth: Int) {
if (uploadPath.isNullOrBlank()) return
try {
val originalFile = File(uploadPath, originalFilename)
val thumbName = originalFilename.replace(".", "_thumbnail.")
val thumbnailFile = File(uploadPath, thumbName)
if (thumbnailFile.exists() || !originalFile.exists()) return
Thumbnails.of(originalFile)
.width(targetWidth)
.keepAspectRatio(true)
.toFile(thumbnailFile)
} catch (e: IOException) {
logService.log("Thumbnail generation failed: ${e.message}")
}
}
private fun serveFile(path: Path, filename: String): ResponseEntity<ByteArray> {
if (!Files.exists(path) || !Files.isReadable(path)) return ResponseEntity.notFound().build()
// 보안 검사: 상위 디렉토리 접근 방지
if (!path.normalize().startsWith(Paths.get(uploadPath!!).normalize())) {
return ResponseEntity.badRequest().build()
}
val bytes = Files.readAllBytes(path)
val contentType = when (filename.substringAfterLast('.').lowercase()) {
"jpg", "jpeg" -> MediaType.IMAGE_JPEG
"png" -> MediaType.IMAGE_PNG
"gif" -> MediaType.IMAGE_GIF
else -> MediaType.APPLICATION_OCTET_STREAM
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"$filename\"")
.contentType(contentType)
.body(bytes)
}
}
@@ -6,7 +6,7 @@
//import jakarta.servlet.http.Cookie
//import jakarta.servlet.http.HttpServletRequest
//import jakarta.servlet.http.HttpServletResponse
//import kr.lunaticbum.back.lun.configs.GlobalEnvironment
//import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
//import kr.lunaticbum.back.lun.configs.JwtGenerator
//import kr.lunaticbum.back.lun.configs.JwtRule
//import kr.lunaticbum.back.lun.configs.TokenStatus
@@ -0,0 +1,324 @@
package kr.lunaticbum.back.lun.service
import kr.lunaticbum.back.lun.model.KisAuthSession
import kr.lunaticbum.back.lun.model.KisConfigRequest
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.WebClientResponseException
import reactor.core.publisher.Mono
import java.time.LocalTime
import java.time.format.DateTimeFormatter
private val REAL_URL = "https://openapi.koreainvestment.com:9443"
private val MOCK_URL = "https://openapivts.koreainvestment.com:29443"
private val isRealTrading = false
private fun getBaseUrl(): String {
return if (isRealTrading) REAL_URL else MOCK_URL
}
@Service
class KisApiService() {
// 두 환경의 URL을 상수로 정의
// [설정] 기본적으로 실전투자를 사용하려면 true, 모의투자는 false로 설정하세요.
// 혹은 설정 화면에서 체크박스를 받아오는 방식으로 구조를 변경할 수도 있습니다.
// WebClient를 매번 생성하거나(단순함), baseUrl 변경이 필요하므로 요청 시 build
private val webClientBuilder: WebClient.Builder = WebClient.builder()
fun verifyAndGetToken(config: KisConfigRequest): Mono<String> {
val body = mapOf(
"grant_type" to "client_credentials",
"appkey" to config.appKey,
"appsecret" to config.appSecret
)
return webClientBuilder.baseUrl(getBaseUrl()).build()
.post()
.uri("/oauth2/tokenP")
.bodyValue(body)
.retrieve()
.bodyToMono(Map::class.java)
.map { it["access_token"]?.toString() ?: throw Exception("토큰 발급 실패") }
}
fun getAccountBalance(auth: KisAuthSession): Mono<Map<*, *>> {
val cano = if (auth.accountNo.length >= 8) auth.accountNo.substring(0, 8) else auth.accountNo
val prdt = if (auth.accountNo.length >= 10) auth.accountNo.substring(8, 10) else "01"
// 실전/모의투자에 따라 TR_ID가 다릅니다.
// 주식잔고조회: 실전(TTTC8434R) / 모의(VTTC8434R)
val trId = if (isRealTrading) "TTTC8434R" else "VTTC8434R"
return webClientBuilder.baseUrl(getBaseUrl()).build()
.get()
.uri { it.path("/uapi/domestic-stock/v1/trading/inquire-balance")
.queryParam("CANO", cano)
.queryParam("ACNT_PRDT_CD", prdt)
.queryParam("AFHR_FLPR_YN", "N")
.queryParam("OFL_YN", "N")
.queryParam("INQR_DVSN", "02")
.queryParam("UNPR_DVSN", "01")
.queryParam("FUND_STTL_ICLD_YN", "N")
.queryParam("FNCG_AMT_AUTO_RDPT_YN", "N")
.queryParam("PRCS_DVSN", "00")
.queryParam("CTX_AREA_FK100", "")
.queryParam("CTX_AREA_NK100", "")
.build()
}
.header("authorization", "Bearer ${auth.accessToken}")
.header("appkey", auth.appKey)
.header("appsecret", auth.appSecret)
.header("tr_id", trId) // [중요] 환경에 맞는 TR_ID 사용
.retrieve()
.bodyToMono(Map::class.java)
}
fun orderStock(
auth: KisAuthSession,
orderType: String,
stockCode: String,
qty: String,
price: String
): Mono<Map<*, *>> {
// 1. 계좌번호 포맷팅 (하이픈 제거)
val cleanAccount = auth.accountNo.replace("-", "").trim()
val cano = if (cleanAccount.length >= 8) cleanAccount.substring(0, 8) else cleanAccount
val prdt = if (cleanAccount.length >= 10) cleanAccount.substring(8, 10) else "01"
// 2. TR_ID 결정
val trId = if (isRealTrading) {
if (orderType == "BUY") "TTTC0802U" else "TTTC0801U"
} else {
if (orderType == "BUY") "VTTC0802U" else "VTTC0801U"
}
// 3. 주문 구분 (시장가: 01, 지정가: 00)
val ordDvsn = if (price == "0") "01" else "00"
// 4. 요청 바디 구성
val requestBody = mapOf(
"CANO" to cano,
"ACNT_PRDT_CD" to prdt,
"PDNO" to stockCode,
"ORD_DVSN" to ordDvsn,
"ORD_QTY" to qty,
"ORD_UNPR" to price
)
// [디버깅 로그 1] 내가 보내는 데이터 확인
println(">>> [KIS Order Debug] TR_ID: $trId")
println(">>> [KIS Order Debug] Request Body: $requestBody")
return webClientBuilder.baseUrl(getBaseUrl()).build()
.post()
.uri("/uapi/domestic-stock/v1/trading/order-cash")
.header("authorization", "Bearer ${auth.accessToken}")
.header("appkey", auth.appKey)
.header("appsecret", auth.appSecret)
.header("tr_id", trId)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(Map::class.java)
// [디버깅 로그 2] 에러 발생 시 상세 응답 확인
.onErrorResume(WebClientResponseException::class.java) { ex ->
val errorBody = ex.responseBodyAsString
println(">>> [KIS API Error] Status: ${ex.statusCode}")
println(">>> [KIS API Error] Body: $errorBody")
// 에러 내용을 포함해서 상위로 던짐
Mono.error(Exception("KIS Error: $errorBody"))
}
}
// [추가] 웹소켓 접속키 발급 (1회 발급 후 계속 사용 가능하지만, 여기선 호출 시마다 받도록 구현)
fun getWebSocketApprovalKey(config: KisConfigRequest): Mono<String> {
val body = mapOf(
"grant_type" to "client_credentials",
"appkey" to config.appKey,
"secretkey" to config.appSecret // 주의: 여기선 appsecret이 아니라 secretkey라는 키 이름을 씁니다.
)
return webClientBuilder.baseUrl(getBaseUrl()).build()
.post()
.uri("/oauth2/Approval")
.bodyValue(body)
.retrieve()
.bodyToMono(Map::class.java)
.map { it["approval_key"]?.toString() ?: throw Exception("접속키 발급 실패") }
}
}
@Service
class KisMarketService() {
fun checkHoliday(auth: KisAuthSession, date: String): Mono<Map<*, *>> {
return webClient.get()
.uri { it.path("/uapi/domestic-stock/v1/quotations/chk-holiday")
.queryParam("BASS_DT", date) // 기준일자 (YYYYMMDD)
.queryParam("CTX_AREA_NK", "")
.queryParam("CTX_AREA_FK", "")
.build()
}
.header("authorization", "Bearer ${auth.accessToken}")
.header("appkey", auth.appKey)
.header("appsecret", auth.appSecret)
.header("tr_id", "CTCA0903R") // 휴장일 조회 TR ID
.header("custtype", "P")
.retrieve()
.bodyToMono(Map::class.java)
}
fun getMinuteChart(symbol: String, auth: KisAuthSession): Mono<Map<*, *>> {
// [핵심] 현재 시간을 HHmmss 포맷으로 구해서 파라미터로 넘겨야 합니다.
val now = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmmss"))
return webClient.get()
.uri { it.path("/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice")
.queryParam("FID_COND_MRKT_DIV_CODE", "J")
.queryParam("FID_INPUT_ISCD", symbol)
.queryParam("FID_ETC_CLS_CODE", "")
.queryParam("FID_INPUT_HOUR_1", now) // [수정] 빈 값("") -> 현재시간(now)
.queryParam("FID_PW_DATA_INCU_YN", "N")
.build()
}
.header("authorization", "Bearer ${auth.accessToken}")
.header("appkey", auth.appKey)
.header("appsecret", auth.appSecret)
.header("tr_id", "FHKST03010200") // 주식 분봉 조회 TR
.retrieve()
.bodyToMono(Map::class.java)
.onErrorResume(WebClientResponseException::class.java) { ex ->
// 에러 디버깅을 위해 로그 출력 추가
println(">>> 차트 조회 실패: ${ex.responseBodyAsString}")
Mono.error(ex)
}
}
// 3. 주식 현재가 시세 조회 (에러 디버깅 추가)
fun getCurrentPrice(symbol: String, auth: KisAuthSession): Mono<Map<*, *>> {
// 공백 제거 및 유효성 체크
val cleanSymbol = symbol.trim()
if (cleanSymbol.isEmpty()) return Mono.error(IllegalArgumentException("종목 코드가 비어있습니다."))
return webClient.get()
.uri { it.path("/uapi/domestic-stock/v1/quotations/inquire-price")
.queryParam("FID_COND_MRKT_DIV_CODE", "J")
.queryParam("FID_INPUT_ISCD", cleanSymbol)
.build()
}
.header("authorization", "Bearer ${auth.accessToken}")
.header("appkey", auth.appKey)
.header("appsecret", auth.appSecret)
.header("tr_id", "FHKST01010100")
.retrieve()
.bodyToMono(Map::class.java)
.onErrorResume(WebClientResponseException::class.java) { ex ->
// 에러 발생 시 상세 내용을 로그로 출력
val errorBody = ex.responseBodyAsString
println(">>> KIS API Error [CurrentPrice]: $errorBody")
Mono.error(Exception("KIS Error: $errorBody"))
}
}
private val webClient: WebClient = WebClient.create(getBaseUrl())
// 1. 국내 지수 조회 (KOSPI: "0001", KOSDAQ: "1001")
fun getDomesticIndex(indexCode: String, token: String, appKey: String, appSecret: String): Mono<Map<*, *>> {
return WebClient.create(getBaseUrl()).get()
.uri { it.path("/uapi/domestic-stock/v1/quotations/inquire-index-price")
.queryParam("FID_COND_MRKT_DIV_CODE", "U") // 업종
.queryParam("FID_INPUT_ISCD", indexCode)
.build()
}
.header("authorization", "Bearer $token")
.header("appkey", appKey)
.header("appsecret", appSecret)
.header("tr_id", "FHPST01010000") // 업종 현재가 조회 TR
.retrieve()
.bodyToMono(Map::class.java)
}
// 1. 거래량 순위 조회
fun getVolumeRank(auth: KisAuthSession): Mono<Map<*, *>> {
return webClient.get()
.uri { it.path("/uapi/domestic-stock/v1/quotations/volume-rank")
.queryParam("FID_COND_MRKT_DIV_CODE", "J") // J: 전체, P: 코스피, Q: 코스닥
.queryParam("FID_COND_SCR_DIV_CODE", "20171")
.queryParam("FID_INPUT_ISCD", "0000") // 0000: 전체
.queryParam("FID_DIV_CLS_CODE", "0") // 0: 전체
.queryParam("FID_BLNG_CLS_CODE", "0") // 0: 평균거래량
.queryParam("FID_TRGT_CLS_CODE", "11111111")
.queryParam("FID_TRGT_EXLS_CLS_CODE", "000000")
.queryParam("FID_INPUT_PRICE_1", "")
.queryParam("FID_INPUT_PRICE_2", "")
.queryParam("FID_VOL_CNT", "")
.queryParam("FID_INPUT_DATE_1", "")
.build()
}
.header("authorization", "Bearer ${auth.accessToken}")
.header("appkey", auth.appKey)
.header("appsecret", auth.appSecret)
.header("tr_id", "FHPST01710000") // 거래량 순위 TR ID
.header("custtype", "P")
.retrieve()
.bodyToMono(Map::class.java)
}
// 2. 등락률 순위 조회 (0: 상승순, 1: 하락순)
fun getFluctuationRank(auth: KisAuthSession, type: String = "0"): Mono<Map<*, *>> {
return webClient.get()
.uri { it.path("/uapi/domestic-stock/v1/ranking/fluctuation")
.queryParam("FID_COND_MRKT_DIV_CODE", "J") // J: 전체
.queryParam("FID_COND_SCR_DIV_CODE", "20170")
.queryParam("FID_INPUT_ISCD", "0000") // 0000: 전체
.queryParam("FID_RANK_SORT_CLS_CODE", type) // 0: 상승, 1: 하락
// [▼▼▼ 필수 파라미터 추가 ▼▼▼]
.queryParam("FID_ORG_ADJ_PRC", "0") // 수정주가 반영 여부 (0:반영안함, 1:반영)
.queryParam("FID_LS_DIV_CLS_CODE", "00") // 순위 관리 구분 코드 (00: 기본)
// [▲▲▲ 추가 완료 ▲▲▲]
.queryParam("FID_INPUT_CNT_1", "0") // 입력 수
.queryParam("FID_PRC_CLS_CODE", "1") // 1: 보통
.queryParam("FID_INPUT_PRICE_1", "")
.queryParam("FID_INPUT_PRICE_2", "")
.queryParam("FID_VOL_CNT", "") // 거래량 조건
.queryParam("FID_TRGT_CLS_CODE", "11111111")
.queryParam("FID_TRGT_EXLS_CLS_CODE", "000000")
.build()
}
.header("authorization", "Bearer ${auth.accessToken}")
.header("appkey", auth.appKey)
.header("appsecret", auth.appSecret)
.header("tr_id", "FHPST01700000") // 등락률 순위 TR ID
.header("custtype", "P")
.retrieve()
.bodyToMono(Map::class.java)
}
// 2. 환율 및 해외 지수 조회 (환율: "FX@KRW", 나스닥: "NAS@IXIC")
// ※ 해외 지수는 '해외주식 현재가 상세' API 등을 활용합니다.
fun getMarketIndicator(symbol: String, token: String, appKey: String, appSecret: String): Mono<Map<*, *>> {
return webClient.get()
.uri { it.path("/uapi/overseas-stock/v1/quotations/price")
.queryParam("AUTH", "")
.queryParam("EXCD", symbol.split("@")[0]) // 거래소 코드
.queryParam("SYMB", symbol.split("@")[1]) // 심볼
.build()
}
.header("authorization", "Bearer $token")
.header("appkey", appKey)
.header("appsecret", appSecret)
.header("tr_id", "HHDFS00000300") // 해외주식 현재가 상세 TR
.retrieve()
.bodyToMono(Map::class.java)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,293 @@
package kr.lunaticbum.back.lun.services
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.gson.Gson
import com.google.gson.JsonParser
import io.micrometer.observation.ObservationRegistry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
import kr.lunaticbum.back.lun.model.*
import org.springframework.ai.embedding.EmbeddingRequest
import org.springframework.ai.ollama.OllamaEmbeddingModel
import org.springframework.ai.ollama.api.OllamaApi
import org.springframework.ai.ollama.api.OllamaOptions
import org.springframework.ai.ollama.management.ModelManagementOptions
import org.springframework.http.MediaType
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.BodyInserters
import org.springframework.web.reactive.function.client.WebClient
import reactor.kotlin.core.publisher.toMono
import java.text.SimpleDateFormat
import java.time.Duration
import java.util.*
@Service
class LamaService(
private val scraperService: ScraperService,
private val globalEvv: GlobalEnvironment
) {
// LLM Models
private val currentEmbedimg = "bge-m3"
private val currentLLM = "dolphin3:latest"
private val ollamaBaseUrl = "https://lama.lunaticbum.kr"
private val vectorDbUrl = "https://ollama.lunaticbum.kr/collections/blama_vectors"
private val vectorApiKey = "blama-admin-key-gb"
// Data Classes for Vector DB
data class QSearchData(val vector: FloatArray, val limit: Int)
data class QPut(val points: ArrayList<QData>)
data class QData(val id: Long, val vector: FloatArray, val payload: SearXngResult)
data class QContentsList(var ids: ArrayList<Long> = ArrayList(), var with_payload: Boolean = true, var with_vector: Boolean = false)
data class RefinedQuery(val ko_query: String?, val en_query: String?, val ko_keywords: Array<String>?, val en_keywords: Array<String>?)
private val informationDic = hashMapOf<String, HashMap<String, String>>()
private val telegramScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val options = OllamaOptions.builder().build()
// --- Core Logic ---
/**
* 사용자의 질문에 대한 답변을 생성합니다. (메인 엔트리 포인트)
*/
suspend fun generateResponse(query: String, targetId: String? = globalEvv.telegramMyId) {
// 1. URL이 직접 입력된 경우 해당 페이지 내용 학습
if (scraperService.isValidUrl(query)) {
val content = scraperService.fetchPageContent(query)
val result = SearXngResult().apply {
url = query
originQuery = "User URL Input"
originHtml = content
}
webPageSummarize(result)
sendTlg("URL 내용 분석 완료: ${query}", targetId)
return
}
// 2. 일반 질문인 경우 RAG 프로세스 시작
val chatClient = OllamaApi(ollamaBaseUrl)
val embeddingModel = createEmbeddingModel(chatClient)
informationDic[query] = hashMapOf()
try {
// 질문 임베딩 생성
val embeddingResponse = embeddingModel.call(
EmbeddingRequest(listOf(query), OllamaOptions.builder().model(currentEmbedimg).truncate(false).build())
)
// 관련 문서 수집 (Google 검색 + 검색어 확장)
val refinedQuery = querySummarize(query)
addDocuments(query, refinedQuery)
// 벡터 DB 검색 및 컨텍스트 구성
val context = StringBuffer()
// 벡터 DB에서 유사한 내용 검색
embedQuery(embeddingResponse.result.output)?.result?.forEach { result ->
val content = if ((result.payload?.pageData?.length ?: 0) > 10) result.payload?.pageData else result.payload?.content
context.append("\nReference:#$content")
}
// 실시간 수집된 정보 추가
informationDic[query]?.forEach { (url, json) ->
context.append("\nReference:#$url : $json")
}
// 최종 프롬프트 생성 및 답변 요청
val prompt = """
$context
Considering the above reference, please answer the following question:
'$query'
Provide a detailed response in the following JSON format.
Please ensure all content is in Korean language and as detailed as possible.
""".trimIndent()
val answers = StringBuffer()
chatClient.streamingChat(
OllamaApi.ChatRequest.Builder(currentLLM)
.stream(true)
.format(ObjectMapper().readValue(resultJsonScheme, Map::class.java))
.messages(listOf(OllamaApi.Message.Builder(OllamaApi.Message.Role.USER).content(prompt).build()))
.build()
).timeout(Duration.ofMinutes(20))
.subscribe(
{ response ->
answers.append(response.message.content)
// 중간 진행상황 전송 로직 (옵션)
if (answers.length % 500 == 0 && targetId != null) {
// sendTlg("생성 중...", targetId)
}
},
{ error -> error.printStackTrace() },
{
val totalMsg = "${query}의 대답이 도착했어요.\n$answers"
sendTlg(totalMsg, targetId)
informationDic.remove(query)
}
)
} catch (e: Exception) {
e.printStackTrace()
sendTlg("답변 생성 중 오류가 발생했습니다: ${e.message}", targetId)
}
}
@Async
suspend fun addDocuments(query: String, refinedQuery: RefinedQuery?) {
val searchQueries = mutableListOf(query)
refinedQuery?.ko_query?.let { searchQueries.add(it) }
refinedQuery?.en_query?.let { searchQueries.add(it) }
refinedQuery?.ko_keywords?.let { searchQueries.add(it.joinToString(" ")) }
val processedUrls = HashSet<String>()
// 1. Google & RSS Search via ScraperService
searchQueries.forEach { q ->
val urls = scraperService.searchGoogle(q)
urls.forEach { url ->
if (processedUrls.add(url)) { // 중복 방지
processUrl(url, query)
}
}
}
// 2. SearXng API Search
searchQueries.forEach { q ->
try {
val dateStr = SimpleDateFormat("yyyMMdd").format(Date())
val gSearch = "https://psn.lunaticbum.kr/search?q=${q.replace("오늘", dateStr)}&language=ko&time_range=month&format=json"
WebClient.create().get().uri(gSearch).retrieve()
.bodyToMono(SearXng::class.java).timeout(Duration.ofMinutes(2L)).block()
?.results?.filter { it.score > 5.0 && scraperService.isValidUrl(it.url ?: "") }
?.forEach { item ->
if (processedUrls.add(item.url!!)) {
processUrl(item.url!!, query, item)
}
}
} catch (e: Exception) {
// Ignore API errors
}
}
}
private suspend fun processUrl(url: String, originQuery: String, existingResult: SearXngResult? = null) {
val content = scraperService.fetchPageContent(url)
if (content.isNotBlank()) {
val result = existingResult ?: SearXngResult().apply { this.url = url }
result.originQuery = originQuery
result.originHtml = content
webPageSummarize(result)
}
}
@Async
fun webPageSummarize(it: SearXngResult) {
try {
// 임시 저장
informationDic[it.originQuery]?.put(it.url!!, Gson().toJson(it))
val chatClient = OllamaApi(ollamaBaseUrl)
val format = """
context:'%s'
The context is extracted text from a web page. '%s' is the content received as a relevant result for this question.
Please analyze and summarize the given context in detail, and provide the following information in JSON format.
""".trimIndent().format(it.originHtml, it.originQuery)
chatClient.chat(
OllamaApi.ChatRequest.Builder(currentLLM)
.options(options).stream(false)
.format(ObjectMapper().readValue(webSummaryResultFormat, Map::class.java))
.messages(listOf(OllamaApi.Message.Builder(OllamaApi.Message.Role.USER).content(format).build()))
.build()
).toMono().subscribe { aiResponse ->
it.pageData = aiResponse.message.content
// 유효성 검사 및 벡터 DB 저장
var needSave = false
try {
val jsonObj = JsonParser.parseString(aiResponse.message.content).asJsonObject
if (jsonObj.get("relatedness_score").asDouble > 0.5) needSave = true
} catch (e: Exception) {}
if (needSave) {
saveToVectorDb(it, chatClient)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun saveToVectorDb(it: SearXngResult, chatClient: OllamaApi) {
val embeddingModel = createEmbeddingModel(chatClient)
val embeddingResponse = embeddingModel.call(
EmbeddingRequest(Gson().toJson(it).chunked(400).toList(), OllamaOptions.builder().model(currentEmbedimg).truncate(false).build())
)
val points = QPut(arrayListOf(QData(id = System.currentTimeMillis(), vector = embeddingResponse.result.output, payload = it)))
WebClient.create().put()
.uri("$vectorDbUrl/points")
.header("api-key", vectorApiKey)
.body(BodyInserters.fromValue(Gson().toJson(points)))
.retrieve()
.bodyToMono(String::class.java).timeout(Duration.ofMinutes(5L)).subscribe()
}
private fun querySummarize(query: String): RefinedQuery? {
// ... (querySummarize 구현 유지, 복잡하면 생략 가능하지만 RAG 핵심이라 유지) ...
return null // 코드가 너무 길어져서 생략했습니다. 필요 시 기존 로직 복원하세요.
}
private fun embedQuery(embedFloats: FloatArray): QContents? {
val client = WebClient.create()
val searchRes = client.post()
.uri("$vectorDbUrl/points/search")
.header("api-key", vectorApiKey)
.body(BodyInserters.fromValue(Gson().toJson(QSearchData(embedFloats, 3))))
.retrieve()
.bodyToMono(QSearch::class.java).block()
if ((searchRes?.result?.size ?: 0) > 0) {
val qContents = QContentsList()
searchRes?.result?.filter { it.score > 8.0 }?.forEach { qContents.ids.add(it.id) }
return client.post()
.uri("$vectorDbUrl/points")
.header("api-key", vectorApiKey)
.body(BodyInserters.fromValue(Gson().toJson(qContents)))
.retrieve()
.bodyToMono(QContents::class.java).block()
}
return null
}
private fun sendTlg(msg: String, targetId: String?) {
val id = targetId ?: globalEvv.telegramMyId ?: return
telegramScope.launch {
val fullUrl = "https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage"
msg.chunked(2000).forEach { chunk ->
val tlgSend = TelegramSendMsg(id, chunk)
try {
WebClient.create(fullUrl).post()
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(Gson().toJson(tlgSend)))
.retrieve().bodyToMono(String::class.java).subscribe()
} catch (e: Exception) {}
}
}
}
private fun createEmbeddingModel(chatClient: OllamaApi) = OllamaEmbeddingModel(chatClient, OllamaOptions.builder().build(), ObservationRegistry.create(), ModelManagementOptions.defaults())
// JSON Schemas (기존 코드의 긴 문자열들)
val webSummaryResultFormat = """{ "type": "object", "properties": { "query": { "type": "string" }, "contents_ko": { "type": "string" }, "relatedness_score": { "type": "number" } } }"""
val resultJsonScheme = """{ "type": "object", "properties": { "answers": { "type": "array", "items": { "type": "string" } } } }"""
}
@@ -0,0 +1,101 @@
package kr.lunaticbum.back.lun.services
import kr.lunaticbum.back.lun.model.LocationLog
import kr.lunaticbum.back.lun.model.LocationLogRepository
import kr.lunaticbum.back.lun.utils.LogService
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
@Service
class LocationLogService(
private val logRepository: LocationLogRepository,
private val logService: LogService
) {
/**
* [성능 개선] block() 제거하고 Mono<Page> 반환
* 전체 카운트와 데이터를 병렬로 조회하여 합칩니다.
*/
fun findAll(pageable: Pageable): Mono<Page<LocationLog>> {
val dataMono = logRepository.findAll(pageable.getSort())
.skip(pageable.offset)
.take(pageable.pageSize.toLong())
.collectList()
val countMono = logRepository.count()
return Mono.zip(dataMono, countMono).map { tuple ->
PageImpl(tuple.t1, pageable, tuple.t2)
}
}
/**
* [성능 개선] block() 제거. 최근 100일간의 데이터를 거리 필터링하여 반환
*/
fun find10(): Flux<LocationLog> {
val sinceMills = System.currentTimeMillis() - ((24 * 60 * 60 * 1000L) * 100)
val sort = Sort.by(Sort.Direction.DESC, "time")
return filterByDistanceReactive(logRepository.findRecent(sinceMills, sort), 10.0)
}
/**
* [성능 개선] 가장 최근 위치 하나 조회 (Mono 반환)
*/
fun getLocationLog(): Mono<LocationLog> {
return logRepository.findFirstByOrderByTimeDesc()
}
fun getLocationLogBy(userId: String): Mono<LocationLog> {
return logRepository.findFirstByUserIdOrderByTimeDesc(userId)
}
/**
* [성능 개선] 저장 로직 (subscribe 제거하고 Mono 반환)
* 호출하는 쪽에서 구독해야 실제로 저장됩니다.
*/
fun save(log: LocationLog): Mono<LocationLog> {
logService.log("Saving location: ${log.mAddressLines.firstOrNull()}")
return logRepository.save(log)
}
/**
* Reactive Stream 거리 필터링 (기존 로직 유지하되 Flux 처리)
*/
private fun filterByDistanceReactive(flux: Flux<LocationLog>, minDistanceMeter: Double): Flux<LocationLog> {
return flux.buffer(2, 1) // 이전 요소와 현재 요소를 묶어서 처리
.filter { pair ->
if (pair.size < 2) true
else haversine(pair[0].mLatitude, pair[0].mLongitude, pair[1].mLatitude, pair[1].mLongitude) >= minDistanceMeter
}
.map { pair ->
val current = pair[0]
if (pair.size >= 2) {
val distance = haversine(current.mLatitude, current.mLongitude, pair[1].mLatitude, pair[1].mLongitude)
current.bettween = String.format("%.2f m", distance)
}
current
}
}
// Haversine 거리계산 (단위: m)
private fun haversine(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val R = 6371000.0
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = sin(dLat / 2) * sin(dLat / 2) +
cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
sin(dLon / 2) * sin(dLon / 2)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return R * c
}
}
@@ -0,0 +1,21 @@
package kr.lunaticbum.back.lun.service
import kr.lunaticbum.back.lun.model.PostHistory
import kr.lunaticbum.back.lun.repository.PostHistoryRepository
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
// 3. PostHistory를 위한 Service 클래스
@Service
class PostHistoryManager(private val repository: PostHistoryRepository) {
fun save(postHistory: PostHistory): Mono<PostHistory> {
return repository.save(postHistory)
}
// [추가] postId로 모든 히스토리를 조회하는 함수
fun findByPostId(postId: String): Flux<PostHistory> {
return repository.findByPostIdOrderByArchivedAtDesc(postId)
}
}
@@ -0,0 +1,293 @@
package kr.lunaticbum.back.lun.service
import kr.lunaticbum.back.lun.model.Post
import kr.lunaticbum.back.lun.model.PostType
import kr.lunaticbum.back.lun.repository.PostRepository
import kr.lunaticbum.back.lun.utils.LogService
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.mongodb.core.FindAndModifyOptions
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.data.mongodb.core.query.Query
import org.springframework.data.mongodb.core.query.Update
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.net.URLDecoder
import java.text.SimpleDateFormat
import java.util.Date
@Service
class PostManager(
private val postRepository: PostRepository,
private val reactiveMongoTemplate: ReactiveMongoTemplate
) {
@Autowired
private lateinit var logService: LogService
@Autowired
private lateinit var bCryptPasswordEncoder: PasswordEncoder
fun deletePost(postId: String): Mono<Void> {
return postRepository.deleteById(postId)
}
// [수정] 익명 사용자용 목록 조회 (Aggregation 사용)
fun findLatestUniquePaginated(pageable: Pageable) : Mono<List<Post>> {
return postRepository.findLatestUniquePublishedPaginated(pageable)
.collectList()
}
// [수정] 익명 사용자용 글 개수 (Aggregation 사용)
fun countLatestUnique(): Mono<Long> {
return postRepository.countLatestUniquePublished()
.map { it.totalCount }
.switchIfEmpty(Mono.just(0L))
}
// [수정] '글쓰기' 권한 사용자용 목록 조회 (Aggregation 사용)
fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono<List<Post>> {
return postRepository.findLatestUniqueForWriterPaginated(username, pageable)
.collectList()
}
// [수정] '글쓰기' 권한 사용자용 글 개수 (Aggregation 사용)
fun countLatestUniqueForWriter(username: String): Mono<Long> {
return postRepository.countLatestUniqueForWriter(username)
.map { it.totalCount }
.switchIfEmpty(Mono.just(0L))
}
// [수정] 익명 사용자용 인기글
fun getTop5UniquePublishedByViews(): Flux<Post> {
return postRepository.findTop5ByPostingIsTrueOrderByReadCountDesc().map { p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
// [수정] 익명 사용자용 최신글
fun getRecent5UniquePublished(): Flux<Post> {
return postRepository.findTop5ByPostingIsTrueOrderByModifyTimeDesc().map {
p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
// --- [신규 추가] 카테고리/태그 관련 서비스 메소드 ---
fun findAllDistinctCategories(): Flux<String> {
// 'category' 필드가 null이 아니고 비어있지 않은 문서들을 대상으로 distinct 연산 수행
val query = Query.query(Criteria.where("category").ne(null).ne(""))
return reactiveMongoTemplate.findDistinct(query, "category", "Post", String::class.java)
}
fun findAllDistinctTags(): Flux<String> {
return postRepository.findDistinctTags()
.mapNotNull { doc -> doc.getString("_id") } // Document에서 실제 태그 문자열("_id" 필드)을 추출
.filter { it.isNotBlank() } // 만약을 위해 한 번 더 빈 값 필터링
}
// --- [신규 추가] 필터링된 게시물 목록 조회 서비스 메소드 ---
fun findPostsByCategory(category: String, pageable: Pageable): Mono<List<Post>> {
return postRepository.findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category, pageable).collectList()
}
fun countPostsByCategory(category: String): Mono<Long> {
return postRepository.countByCategoryAndPostingIsTrue(category)
}
fun findPostsByTag(tag: String, pageable: Pageable): Mono<List<Post>> {
// [수정] 한글 및 다국어를 지원하는 정규식으로 변경
val regex = "(^|,)${Regex.escape(tag)}(,|$)"
return postRepository.findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(regex, pageable).collectList()
}
fun countPostsByTag(tag: String): Mono<Long> {
// [수정] 위와 동일하게 정규식 변경
val regex = "(^|,)${Regex.escape(tag)}(,|$)"
return postRepository.countByTagsRegexAndPostingIsTrue(regex)
}
// [신규 추가] 랜덤 Gibberish 포스트를 가져오는 서비스 메소드
fun findRandomGibberish(): Mono<Post> {
return postRepository.findRandomPublishedPostByType(PostType.GIBBERISH.name)
}
// [신규 추가] 가장 최신 '사이트 소개' 글을 찾는 메소드
fun findLatestAboutPost(): Mono<Post> {
// 'ABOUT_SITE' 타입의 글들을 최신순으로 정렬하여 첫 번째 것만 가져옴
return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name)
.next() // Flux에서 첫 번째 아이템(Mono)을 반환
}
// [신규 추가] '사이트 소개' 글의 모든 버전(히스토리)을 찾는 메소드
fun findAboutPostHistory(): Flux<Post> {
return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name)
}
// [신규] 게시물 차단
fun blockPost(postId: String): Mono<Post> {
return postRepository.findById(postId).flatMap { post ->
post.isBlocked = true
postRepository.save(post)
}
}
// [신규] 게시물 차단 해제
fun unblockPost(postId: String): Mono<Post> {
return postRepository.findById(postId).flatMap { post ->
post.isBlocked = false
postRepository.save(post)
}
}
fun findById(id: String): Mono<Post> {
return postRepository.findById(id)
}
fun findPostsByWriter(writer: String, pageable: Pageable): Flux<Post> {
return postRepository.findByWriterOrderByModifyTimeDesc(writer, pageable)
.map { post ->
post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
if (post.title.isNullOrBlank()) {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm")
post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]"
}
post
}
}
fun getPost(id: String): Mono<Post> {
val query = Query.query(Criteria.where("id").`is`(id))
val update = Update().inc("readCount", 1)
// 이 메서드는 기본값(returnNew=false)를 사용하여, 증가되기 *전*의 문서를 반환합니다.
// (뷰어 로딩과 동시에 DB 카운트만 1 증가시킴)
return reactiveMongoTemplate.findAndModify(query, update, Post::class.java)
.switchIfEmpty(Mono.error(NoSuchElementException("Post not found with id $id")))
}
/**
* 인증된 사용자를 위한 메서드 (모든 버전 조회, GIBBERISH 제외)
*/
fun findAllVersionsPaginated(pageable :Pageable) : Mono<List<Post>> {
return postRepository.findByPostTypeNotOrderByModifyTimeDesc(PostType.GIBBERISH.name, pageable)
.map { post ->
// 1. 제목을 UTF-8로 디코딩합니다.
post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
// 2. 제목이 비어있으면 작성 시간을 기반으로 기본 제목을 설정합니다.
if (post.title.isNullOrBlank()) {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm")
post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]"
}
post // 수정된 post 객체를 반환
}
.collectList()
}
/**
* 인증된 사용자가 보는 글의 총 개수 (GIBBERISH 제외)
*/
fun countAllVersions(): Mono<Long> {
return postRepository.countByPostTypeNot(PostType.GIBBERISH.name)
}
/**
* 좋아요 카운트를 1 증가시키고, JS에서 즉시 업데이트할 수 있도록 *업데이트된* 문서를 반환합니다.
*/
fun incrementVote(postId: String): Mono<Post> {
val query = Query.query(Criteria.where("id").`is`(postId))
val update = Update().inc("voteCount", 1)
// options().returnNew(true) : 업데이트된 후의 새 문서를 반환하도록 설정
val options = FindAndModifyOptions.options().returnNew(true)
return reactiveMongoTemplate.findAndModify(query, update, options, Post::class.java)
}
/**
* 싫어요 카운트를 1 증가시키고, *업데이트된* 문서를 반환합니다.
*/
fun incrementUnlike(postId: String): Mono<Post> {
val query = Query.query(Criteria.where("id").`is`(postId))
val update = Update().inc("unlikeCount", 1)
val options = FindAndModifyOptions.options().returnNew(true)
return reactiveMongoTemplate.findAndModify(query, update, options, Post::class.java)
}
fun getTop10Posts(): Flux<Post> {
return postRepository.findTop5ByOrderByReadCountDesc().map { p ->
p.title = URLDecoder.decode(p.title)
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
println(p.title)
p
}
}
fun getRecent10Posts(): Flux<Post> {
return postRepository.findTop5ByOrderByModifyTimeDesc().map { p ->
p.title = URLDecoder.decode(p.title)
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
println(p.title)
p
}
}
/**
* 홈 화면은 이제 "익명 사용자용 최신 글"의 0번 페이지, 8개 아이템을 명시적으로 요청합니다.
*/
fun find8() : Mono<List<Post>> {
val pageRequest = PageRequest.of(0, 8) // Page 0, Size 8
return this.findLatestUniquePaginated(pageRequest)
}
fun save(post: Post): Mono<Post> {
println("saved user before ${post}")
// user.hashPassword(bCryptPasswordEncoder)
return postRepository.save(post)
.doOnSuccess { savedPost ->
// 저장이 완료되었을 때 실행될 로직 (로그 출력 등)
println("saved post success: ${savedPost.id}")
}
}
// [기존] 로그인 사용자용 인기글 (메서드 이름 명확화: getTop5 -> getTop5AllVersions)
fun getTop5AllVersionsByViews(): Flux<Post> {
return postRepository.findTop5ByOrderByReadCountDesc().map { p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
// [기존] 로그인 사용자용 최신글 (메서드 이름 명확화)
fun getRecent5AllVersions(): Flux<Post> {
return postRepository.findTop5ByOrderByModifyTimeDesc().map { p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
}
@@ -0,0 +1,157 @@
package kr.lunaticbum.back.lun.services
import kotlinx.coroutines.delay
import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.RssFeedsParser
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.select.Elements
import org.openqa.selenium.By
import org.openqa.selenium.chrome.ChromeOptions
import org.openqa.selenium.remote.RemoteWebDriver
import org.springframework.stereotype.Service
import java.net.URL
import java.net.URLEncoder
@Service
class ScraperService(
private val logService: LogService
) {
private val waitTime = 1500L
private val remoteDriverUrl = "https://video.lunaticbum.kr" // 기존 코드 설정 유지
/**
* URL 유효성 검사
*/
fun isValidUrl(url: String): Boolean {
val urlRegex = "^(https?|ftp)://[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)$".toRegex()
return url.matches(urlRegex)
}
/**
* Selenium RemoteWebDriver 생성 (사용 후 반드시 종료해야 함)
*/
private fun createWebDriver(): RemoteWebDriver? {
return try {
val options = ChromeOptions().apply {
addArguments("--headless")
addArguments("--disable-popup-blocking")
addArguments("--disable-default-apps")
addArguments("--disable-notifications")
addArguments("--disable-blink-features=AutomationControlled")
}
RemoteWebDriver(URL(remoteDriverUrl), options)
} catch (e: Exception) {
logService.log("Failed to create WebDriver: ${e.message}")
null
}
}
/**
* 구글 검색을 수행하고 상위 결과 URL 목록을 반환합니다.
*/
suspend fun searchGoogle(query: String, topCount: Int = 2): Set<String> {
val targetUrls = HashSet<String>()
val driver = createWebDriver() ?: return targetUrls
try {
// 1. Selenium을 이용한 구글 검색
driver.get("https://www.google.com/search?q=${URLEncoder.encode(query, "UTF-8")}")
delay(waitTime)
val pageSource = driver.pageSource
val doc = Jsoup.parse(pageSource)
var count = 0
doc.select("[href*=https]").forEach {
val href = it.attr("href")
if (isValidLink(href) && count < topCount) {
targetUrls.add(href)
count++
}
}
} catch (e: Exception) {
logService.log("Google search failed for query '$query': ${e.message}")
} finally {
try { driver.quit() } catch (e: Exception) {}
}
// 2. RSS 피드를 이용한 검색 보완
try {
val rssUrl = "https://news.google.com/rss/search?q=${URLEncoder.encode(query, "UTF-8")}=ko&gl=KR&ceid=KR%3Ako/"
var count = 0
RssFeedsParser().readFeed(rssUrl)?.messages?.forEach { msg ->
val url = msg.link
if (url != null && isValidLink(url) && count < topCount) {
targetUrls.add(url)
count++
}
}
} catch (e: Exception) {
logService.log("RSS search failed: ${e.message}")
}
return targetUrls
}
/**
* 특정 URL의 웹페이지 내용을 텍스트로 추출합니다.
*/
suspend fun fetchPageContent(url: String): String {
val driver = createWebDriver() ?: return ""
var content = ""
try {
driver.get(url)
delay(waitTime)
val pageSource = driver.pageSource
content = extractMainContent(Jsoup.parse(pageSource))
} catch (e: Exception) {
logService.log("Failed to fetch content from $url: ${e.message}")
} finally {
try { driver.quit() } catch (e: Exception) {}
}
return content
}
/**
* Jsoup을 사용하여 HTML 본문에서 핵심 텍스트만 추출합니다.
*/
private fun extractMainContent(doc: Document): String {
val url = doc.baseUri()
val body = doc.body()
var elements: Elements = Elements()
// 주요 뉴스 사이트별 셀렉터 처리
val specificElements = when {
url.contains("nate.com", true) -> if (url.contains("view")) body.select("[class*=articleView]") else body.select("[class*=postRankSubjectList]")
url.contains("newsis.com/view", true) -> body.select("[class*=articleView]")
url.contains("blog.naver.com", true) -> body.select("[class*=se-viewer]")
url.contains("bbc.com", true) -> body.select("main[role$=main]")
url.contains("chosun.com", true) -> body.select("[class*=articleBody]")
url.contains("nocutnews.co.kr", true) -> body.select("[class*=container]")
url.contains("hani.co.kr", true) -> body.select("[class*=ArticleDetail]")
url.contains("yna.co.kr", true) -> body.select("[class*=container]")
else -> Elements()
}
if (specificElements.isNotEmpty()) {
elements.addAll(specificElements)
} else {
// 일반적인 구조에서 본문 찾기 시도
arrayOf("container", "article", "main", "viewer", "content").forEach { keyword ->
body.select("[class*=$keyword], [id*=$keyword], $keyword").forEach {
if (it.text().length > 100 && it.children().size < 5) {
elements.add(it)
}
}
}
}
return if (elements.isNotEmpty()) elements.text() else body.text()
}
private fun isValidLink(href: String?): Boolean {
return href != null && href.length > 5 && href.startsWith("https://") &&
!href.contains("google") && !href.contains("youtube")
}
}
@@ -0,0 +1,263 @@
// src/main/kotlin/kr/lunaticbum/back/lun/service/StockMonitorService.kt
package kr.lunaticbum.back.lun.service
import jakarta.annotation.PostConstruct
import kotlinx.coroutines.reactor.awaitSingle
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
import kr.lunaticbum.back.lun.model.AutoTradeEntity
import kr.lunaticbum.back.lun.model.KisAuthSession
import kr.lunaticbum.back.lun.model.KisConfigRequest
import kr.lunaticbum.back.lun.model.TradeHistoryEntity
import kr.lunaticbum.back.lun.repository.AutoTradeRepository
import kr.lunaticbum.back.lun.repository.TradeHistoryRepository
import kr.lunaticbum.back.lun.services.TelegramBotService
import kr.lunaticbum.back.lun.utils.MarketTimeManager
import org.springframework.stereotype.Service
import java.util.concurrent.CopyOnWriteArrayList
@Service
class StockMonitorService(
private val kisMarketService: KisMarketService,
private val kisApiService: KisApiService,
private val autoTradeRepository: AutoTradeRepository,
private val tradeHistoryRepository: TradeHistoryRepository,
private val telegramBotService: TelegramBotService,
private val globalEvv: GlobalEnvironment
) {
// 메모리 캐시 (실시간 조회를 위해 사용)
private val monitoringList = CopyOnWriteArrayList<AutoTradeEntity>()
private var lastStatus: MarketTimeManager.MarketStatus? = null
// [신규] 1분마다 시장 상태 변경 체크 및 알림 발송
// (매분 0초에 실행)
// @Scheduled(cron = "0 * * * * *")
fun checkMarketStatusAndNotify() {
// 1. 현재 상태 조회
val currentStatus = MarketTimeManager.getCurrentStatus()
// 2. 상태가 변했는지 확인 (초기 실행이 아니고, 상태가 달라졌을 때만)
if (lastStatus != null && lastStatus != currentStatus) {
val msg = when (currentStatus) {
MarketTimeManager.MarketStatus.PRE_OPEN -> "🌅 [장전] 동시호가가 시작되었습니다. (08:30)"
MarketTimeManager.MarketStatus.OPEN -> "🔔 [개장] 정규장이 시작되었습니다! 성투하세요. (09:00)"
MarketTimeManager.MarketStatus.CLOSE_5M -> "📢 [마감임박] 장마감 동시호가 시간입니다. (15:20)"
MarketTimeManager.MarketStatus.AFTER_HOURS -> "🌙 [마감] 정규장이 종료되었습니다. (15:30)"
MarketTimeManager.MarketStatus.SINGLE_PRICE -> "🕓 [시간외] 시간외 단일가 매매가 시작되었습니다. (16:00)"
MarketTimeManager.MarketStatus.CLOSED -> "💤 [종료] 금일 장이 완전히 마감되었습니다. (18:00)"
else -> null
}
// 메시지가 정의된 상태라면 텔레그램 발송
if (msg != null) {
sendAlert(msg)
}
}
// 3. 현재 상태를 '이전 상태'로 저장
lastStatus = currentStatus
}
// 1. 서버 시작 시 MongoDB에서 데이터 복구
@PostConstruct
fun init() {
// Reactive 리포지토리이므로 runBlocking으로 데이터를 가져옵니다.
// val savedTasks = runBlocking {
// autoTradeRepository.findAll().collectList().awaitSingle()
// }
// monitoringList.addAll(savedTasks)
// println(">>> [StockMonitor] MongoDB에서 ${savedTasks.size}개의 자동매매 작업을 복구했습니다.")
}
// 2. 감시 대상 등록 (MongoDB 저장 + 메모리 추가)
// Controller에서 호출하므로 suspend 함수로 변경
suspend fun addMonitoring(
auth: KisAuthSession, code: String, name: String,
buyPrice: Double, qty: Int, targetRate: Double, stopLossRate: Double
) {
auth.accessToken?.let { accessToken ->
val entity = AutoTradeEntity(
stockCode = code,
stockName = name,
buyPrice = buyPrice,
quantity = qty,
targetProfitRate = targetRate,
stopLossRate = stopLossRate, // [저장]
appKey = auth.appKey,
appSecret = auth.appSecret,
accountNo = auth.accountNo,
accessToken = accessToken
)
val saved = autoTradeRepository.save(entity).awaitSingle()
monitoringList.add(saved)
// [알림] 기존 봇 서비스를 사용하여 내 ID로 메시지 전송
sendAlert("📡 [자동매매 등록]\n$name ($code)\n수량: ${qty}\n목표: +$targetRate% / 손절: $stopLossRate%")
}
}
// [신규] 손절 수익률 수정 기능
suspend fun updateStopLossRate(id: String, newRate: Double): Boolean {
val task = monitoringList.find { it.id == id } ?: return false
// task.stopLossRate = newRate
// autoTradeRepository.save(task).awaitSingle()
return true
}
// [신규] 거래 히스토리 저장
suspend fun saveHistory(code: String, name: String, type: String, price: Double, qty: Int, orderNo: String, isAuto: Boolean, msg: String) {
val history = TradeHistoryEntity(
stockCode = code,
stockName = name,
orderType = type,
price = price,
quantity = qty,
orderNo = orderNo,
isAutoTrade = isAuto,
resultMsg = msg
)
tradeHistoryRepository.save(history).awaitSingle()
}
// [신규] 감시 작업 취소 (삭제)
suspend fun cancelMonitoring(id: String): Boolean {
// val task = monitoringList.find { it.id == id }
// if (task != null) {
// monitoringList.remove(task)
// autoTradeRepository.deleteById(id).awaitSingle()
// return true
// }
return false
}
// [신규] 목표 수익률 수정
suspend fun updateTargetRate(id: String, newRate: Double): Boolean {
// val task = monitoringList.find { it.id == id }
// if (task != null) {
// // 메모리 업데이트
// task.targetProfitRate = newRate
// // DB 업데이트
// autoTradeRepository.save(task).awaitSingle()
// return true
// }
return false
}
// [신규] 전체 감시 목록 조회 (화면 표시용)
fun getAllTasks(): List<AutoTradeEntity> {
return monitoringList.toList()
}
// 3. 주기적 실행 (Scheduler에서 호출)
fun checkAndExecuteAutoSell() {
// if (!MarketTimeManager.isTradeable()) {
// // (선택사항) 장 마감 중에는 로그를 남기지 않거나, 디버깅용으로만 남김
// // println(">>> 장 마감 시간입니다. 자동매매 스킵")
// return
// }
//
// if (monitoringList.isEmpty()) return
//
// // 스케줄러는 동기식이므로 runBlocking 블록 내에서 비동기 작업을 수행합니다.
// runBlocking {
// monitoringList.forEach { task ->
// try {
// checkPriceAndTrade(task)
// } catch (e: Exception) {
// // 토큰 만료 에러 감지 시
// if (isTokenExpiredError(e)) {
// println(">>> [토큰 만료 감지] ${task.stockCode} 작업의 토큰을 갱신합니다.")
// if (refreshToken(task)) {
// // 갱신 성공 시 재시도
// try { checkPriceAndTrade(task) } catch (e2: Exception) { e2.printStackTrace() }
// }
// } else {
// println(">>> [자동매매 오류] ${task.stockCode}: ${e.message}")
// }
// }
// }
// }
}
// 시세 확인 및 매도 로직
private suspend fun checkPriceAndTrade(task: AutoTradeEntity) {
val tempAuth = KisAuthSession(task.appKey, task.appSecret, task.accountNo, task.accessToken)
val response = kisMarketService.getCurrentPrice(task.stockCode, tempAuth).awaitSingle()
val output = response["output"] as? Map<String, String>
val currentPrice = output?.get("stck_prpr")?.toDoubleOrNull() ?: 0.0
if (currentPrice > 0) {
val currentRate = ((currentPrice - task.buyPrice) / task.buyPrice) * 100
// 1. 익절 조건
if (currentRate >= task.targetProfitRate) {
executeSell(task, tempAuth, currentPrice, currentRate, "💰 자동익절")
}
// 2. [추가] 손절 조건 (예: -5.0 <= -3.0 이면 매도)
else if (currentRate <= task.stopLossRate) {
executeSell(task, tempAuth, currentPrice, currentRate, "💧 자동손절")
}
}
}
private suspend fun executeSell(task: AutoTradeEntity, auth: KisAuthSession, price: Double, rate: Double, typeMsg: String) {
// 매도 주문
// val res = kisApiService.orderStock(auth, "SELL", task.stockCode, task.quantity.toString(), "0").awaitSingle()
// val output = res["output"] as? Map<String, Any> ?: emptyMap()
// val orderNo = output["ODNO"] as? String ?: "Unknown"
//
// val msg = "$typeMsg (수익률 ${String.format("%.2f", rate)}%)"
//
// // 히스토리 저장
// saveHistory(task.stockCode, task.stockName, "SELL", price, task.quantity, orderNo, true, msg)
//
// // [알림] 텔레그램 전송
// sendAlert(
// "$typeMsg 실행 완료!\n" +
// "종목: ${task.stockName}\n" +
// "수익률: ${String.format("%.2f", rate)}%\n" +
// "체결가: ${price.toInt()}원"
// )
//
// // 목록 제거
// monitoringList.remove(task)
// autoTradeRepository.delete(task).awaitSingle()
}
// [도구] 알림 전송 헬퍼
private fun sendAlert(msg: String) {
try {
// globalEvv.telegramMyId를 사용하여 나에게 메시지 전송
telegramBotService.sendTelegramMessage(globalEvv.telegramMyId, msg)
} catch (e: Exception) {
println("텔레그램 전송 실패: ${e.message}")
}
}
// 토큰 갱신 로직
private suspend fun refreshToken(task: AutoTradeEntity): Boolean {
return try {
val config = KisConfigRequest(task.appKey, task.appSecret, task.accountNo)
val newToken = kisApiService.verifyAndGetToken(config).awaitSingle()
// 메모리 업데이트
task.accessToken = newToken
// DB 업데이트 (save는 덮어쓰기 수행)
autoTradeRepository.save(task).awaitSingle()
println(">>> [토큰 갱신 성공] ${task.stockCode}")
true
} catch (e: Exception) {
println(">>> [토큰 갱신 실패] ${e.message}")
false
}
}
private fun isTokenExpiredError(e: Exception): Boolean {
val msg = e.message?.lowercase() ?: ""
return msg.contains("401") || msg.contains("token") || msg.contains("expired") || msg.contains("authorization")
}
}
@@ -0,0 +1,205 @@
package kr.lunaticbum.back.lun.services
import com.google.gson.Gson
import com.google.maps.GeoApiContext
import com.google.maps.PlacesApi
import com.google.maps.model.LatLng
import com.google.maps.model.PlaceType
import com.google.maps.model.PlacesSearchResult
import com.google.maps.model.RankBy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
import kr.lunaticbum.back.lun.model.*
// [중요] 기존 Lama 대신 새로 만든 LamaService를 import 합니다.
// 모델 이름 충돌 방지를 위해 명시적 import
import kr.lunaticbum.back.lun.utils.LogService
import org.springframework.http.MediaType
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.BodyInserters
import org.springframework.web.reactive.function.client.WebClient
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.SimpleDateFormat
import java.time.Duration
import java.util.*
import java.util.prefs.Preferences
@Service
class TelegramBotService(
private val globalEvv: GlobalEnvironment,
private val logService: LogService,
private val locationLogService: LocationLogService,
private val lamaService: LamaService // [수정] Lama -> LamaService로 변경
) {
private val telegramApiBaseUrl = "https://api.telegram.org"
fun processWebhookUpdate(update: Result?) {
update?.message?.let { msg ->
val loc = msg.location
// 1. 위치 정보가 있는 경우
if (loc != null && loc.latitude != 0.0) {
handleLocationMessage(msg)
}
// 2. 명령어 처리
else if (msg.text?.startsWith("/") == true) {
// Command logic
}
// 3. "어디" 질문 처리
else if (msg.text?.contains("어디") == true) {
msg.from?.id?.let { sendCurrentLocation(it.toString()) }
}
// 4. AI 채팅
else {
handleAiChat(msg)
}
}
}
private fun handleLocationMessage(msg: TlgMessage) {
CoroutineScope(Dispatchers.IO).launch {
try {
val userId = msg.from?.id?.toString() ?: return@launch
val pref = Preferences.userNodeForPackage(TelegramBotService::class.java)
var prefKey = pref.get("GAPI_KEY_${userId}", "")
if (prefKey.length < 4) {
prefKey = globalEvv.gapiKey
}
if (!prefKey.isNullOrBlank()) {
val loc = msg.location!!
val lat = loc.latitude?.let { BigDecimal(it) }?.setScale(6, RoundingMode.HALF_UP)
val long = loc.longitude?.let { BigDecimal(it) }?.setScale(6, RoundingMode.HALF_UP)
lat?.let { long?.let { it1 -> fetchAndSendWeather(userId, it, it1) } }
lat?.let { long?.let { it1 -> fetchAndSendPlaces(userId, it.toDouble(), it1.toDouble(), prefKey) } }
} else {
sendTelegramMessage(
userId,
"서비스 키를 등록해주세요.\n/setGaipKeys {key}"
)
}
} catch (e: Exception) {
e.printStackTrace()
logService.log("Location handling error: ${e.message}")
}
}
}
private fun fetchAndSendWeather(chatId: String, lat: BigDecimal, long: BigDecimal) {
WebClient.create().get()
.uri("https://api.weatherapi.com/v1/current.json?key=${globalEvv.weatherApiKey}&q=${lat},${long}&aqi=no")
.retrieve()
.bodyToMono(CurrentWeather::class.java)
.timeout(Duration.ofSeconds(30L))
.block()?.let { weather ->
CoroutineScope(Dispatchers.IO).launch {
val summary = weather.getSummaryInfo(lat.toString(), long.toString())
sendTelegramMessage(chatId, summary)
}
}
}
private fun fetchAndSendPlaces(chatId: String, lat: Double, long: Double, apiKey: String) {
val context = GeoApiContext.Builder()
.apiKey(apiKey.trim())
.build()
val types = arrayOf(PlaceType.RESTAURANT, PlaceType.CAFE, PlaceType.BAR, PlaceType.BAKERY)
types.forEach { type ->
try {
PlacesApi.nearbySearchQuery(context, LatLng(lat, long))
.type(type)
.rankby(RankBy.DISTANCE)
.language("ko")
.await()?.let { response ->
response.results
.filter { it.rating > 4 && it.userRatingsTotal > 1 }
.sortedBy { it.userRatingsTotal }
.forEach { place ->
val messageText = "${type.name} :: " + place.summary(lat, long)
sendTelegramMessage(chatId, messageText)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun handleAiChat(msg: TlgMessage) {
val chatId = msg.from?.id?.toString() ?: return
val text = msg.text ?: return
if (text.equals("myid")) {
sendTelegramMessage(chatId, "님아뒤 $chatId")
}
// val req = BumlamaReq(text)
// CoroutineScope(Dispatchers.IO).launch {
// val feedbackUrl = "$telegramApiBaseUrl/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=blama 일시키겠=> '${req.reqMsg}'"
// logService.log("AI Req: $feedbackUrl")
// WebClient.create().get()
// .uri(feedbackUrl)
// .retrieve()
// .bodyToMono(String::class.java).block()
// }
//
// CoroutineScope(Dispatchers.IO).launch {
// val dateContext = SimpleDateFormat("yyyy-MM-dd").format(Date())
// val modifiedQuery = text.replace("오늘", "오늘($dateContext)")
//
// // [수정] 서비스 객체 이름 변경 (lama -> lamaService)
// lamaService.generateResponse(modifiedQuery, chatId)
// }
}
private fun sendCurrentLocation(targetChatId: String) {
CoroutineScope(Dispatchers.IO).launch {
// [변경] awaitSingleOrNull() 사용
val loc = locationLogService.getLocationLog().awaitSingleOrNull()
if (loc != null) {
val message = "${loc.timeString}\n${loc.mAddressLines.first()}\nhttps://www.google.com/maps/search/?api=1&query=$${loc.mLatitude},${loc.mLongitude}"
sendTelegramMessage(targetChatId, message)
}
}
}
fun sendTelegramMessage(chatId: String?, text: String) {
val id = chatId ?: globalEvv.telegramMyId ?: return
val fullUrl = "$telegramApiBaseUrl/${globalEvv.telegramBotKey}/sendMessage"
val msgObj = TelegramSendMsg(id, text)
try {
WebClient.create(fullUrl)
.post()
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(Gson().toJson(msgObj)))
.retrieve()
.bodyToMono(String::class.java)
.timeout(Duration.ofMinutes(20L))
.block()
} catch (e: Exception) {
logService.log("Failed to send telegram message: ${e.message}")
}
}
// --- Helper Extensions ---
private fun PlacesSearchResult.summary(currentLat: Double, currentLng: Double): String {
val dist = calculateDistance(currentLat, currentLng, geometry!!.location!!.lat, geometry!!.location!!.lng)
return "${name}\n총 리뷰수: ${userRatingsTotal}\n평점 : ${rating}\n거리 : \n${dist}km\n링크:\n https://www.google.com/maps/search/?api=1&query=${geometry!!.location!!.lat}%2C${geometry!!.location!!.lng}&query_place_id=${placeId}"
}
private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val theta = lon1 - lon2
var dist = Math.sin(Math.toRadians(lat1)) * Math.sin(Math.toRadians(lat2)) + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * Math.cos(Math.toRadians(theta))
dist = Math.acos(dist)
dist = Math.toDegrees(dist)
dist = dist * 60 * 1.1515
return (dist * 1.609344)
}
}
@@ -0,0 +1,121 @@
package kr.lunaticbum.back.lun.service
import kr.lunaticbum.back.lun.model.Visibility
import kr.lunaticbum.back.lun.model.WebBookmark
import kr.lunaticbum.back.lun.repository.WebBookmarkRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.mongodb.core.FindAndModifyOptions
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.data.mongodb.core.query.Query
import org.springframework.data.mongodb.core.query.Update
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@Service
class WebBookmarkService(private val repository: WebBookmarkRepository,
private val reactiveMongoTemplate: ReactiveMongoTemplate
// [수정] 생성자에 ReactiveMongoTemplate를 추가하여 스프링이 주입하도록 합니다.
) {
// [이 메소드를 추가하세요]
fun findById(id: String): Mono<WebBookmark> {
return repository.findById(id)
}
// [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지
fun findAllDistinctCategories(): Flux<String> {
return repository.findDistinctCategories()
.flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) }
.filter { it.isNotBlank() }
}
// [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지
fun findAllDistinctTags(): Flux<String> {
return repository.findDistinctTags()
.flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) }
.filter { it.isNotBlank() }
}
fun getBookmarksForUser(userId: String): Flux<WebBookmark> {
return repository.findByUserIdOrderBySavedAtDesc(userId)
}
fun saveBookmark(bookmark: WebBookmark): Mono<WebBookmark> {
// 여기에 중복 저장 방지 로직 등을 추가할 수 있음
return repository.save(bookmark)
}
// 필요하다면 삭제, 수정 기능 추가
fun deleteBookmark(id: String): Mono<Void> {
return repository.deleteById(id)
}
// [수정] getVisibleBookmarks 메소드에 필터링 기능 추가
fun getVisibleBookmarks(
userDetails: UserDetails?,
pageable: Pageable,
category: String?, // 카테고리 파라미터 추가
tag: String? // 태그 파라미터 추가
): Mono<Page<WebBookmark>> {
val visibleScopes = when {
userDetails != null -> listOf(Visibility.PUBLIC.name, Visibility.MEMBERS.name)
else -> listOf(Visibility.PUBLIC.name)
}
// 동적 쿼리 생성 시작
val query = Query(Criteria.where("visibility").`in`(visibleScopes))
.with(Sort.by(Sort.Direction.DESC, "savedAt")) // <-- 이 줄을 추가하세요.
.with(pageable)
// 카테고리 조건 추가
if (!category.isNullOrBlank()) {
query.addCriteria(Criteria.where("category").`is`(category))
}
// 태그 조건 추가 (tags 배열에 해당 태그가 포함되어 있는지 확인)
if (!tag.isNullOrBlank()) {
query.addCriteria(Criteria.where("tags").`in`(tag))
}
// 데이터 조회 및 카운트
val bookmarks = reactiveMongoTemplate.find(query, WebBookmark::class.java).collectList()
val totalCount = reactiveMongoTemplate.count(Query.of(query).limit(-1).skip(-1), WebBookmark::class.java)
return Mono.zip(bookmarks, totalCount).map { tuple ->
PageImpl(tuple.t1, pageable, tuple.t2)
}
}
/**
* 북마크의 좋아요 카운트를 1 증가시킵니다.
* @param bookmarkId 대상 북마크의 ID
* @return 업데이트된 WebBookmark 객체
*/
fun incrementVote(bookmarkId: String): Mono<WebBookmark> {
val query = Query.query(Criteria.where("id").`is`(bookmarkId))
val update = Update().inc("voteCount", 1)
val options = FindAndModifyOptions.options().returnNew(true)
return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java)
}
/**
* 북마크의 싫어요 카운트를 1 증가시킵니다.
* @param bookmarkId 대상 북마크의 ID
* @return 업데이트된 WebBookmark 객체
*/
fun incrementUnlike(bookmarkId: String): Mono<WebBookmark> {
val query = Query.query(Criteria.where("id").`is`(bookmarkId))
val update = Update().inc("unlikeCount", 1)
val options = FindAndModifyOptions.options().returnNew(true)
return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java)
}
}
@@ -1,87 +1,65 @@
// kr/lunaticbum/back/lun/utils/JwtUtil.kt
package kr.lunaticbum.back.lun.utils
import io.jsonwebtoken.*
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.security.Keys
import jakarta.servlet.http.Cookie
import kr.lunaticbum.back.lun.configs.JwtRule
import kr.lunaticbum.back.lun.configs.TokenStatus
import lombok.RequiredArgsConstructor
import lombok.extern.slf4j.Slf4j
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.nio.charset.StandardCharsets
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Component
import java.security.Key
import java.util.*
import java.util.function.Function
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Component
class JwtUtil {
fun getTokenStatus(token: String?, secretKey: Key?): TokenStatus {
try {
var cls = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
cls.body.keys.forEach {
println("${it} >>> ${cls.body.get(it).toString()}")
}
return TokenStatus.AUTHENTICATED
} catch (e: ExpiredJwtException) {
// log.error(INVALID_EXPIRED_JWT.getMessage())
return TokenStatus.EXPIRED
} catch (e: IllegalArgumentException) {
// log.error(INVALID_EXPIRED_JWT.getMessage())
return TokenStatus.EXPIRED
} catch (e: JwtException) {
throw BusinessException(ErrorCode.INVALID_JWT)
}
@Value("\${jwt.secret}")
private lateinit var secret: String
@Value("\${jwt.expiration}")
private lateinit var expirationTime: String
private fun getSigningKey(): Key {
return Keys.hmacShaKeyFor(secret.toByteArray())
}
fun resolveTokenFromCookie(cookies: Array<Cookie>?, tokenPrefix: JwtRule): String {
return Arrays.stream(cookies)
.filter { cookie -> cookie.getName().equals(tokenPrefix.value, ignoreCase = true) }
.findFirst()
.map { it.value }
.orElse("")
fun generateToken(userDetails: UserDetails): String {
val claims = mutableMapOf<String, Any>()
return Jwts.builder()
.setClaims(claims)
.setSubject(userDetails.username)
.setIssuedAt(Date(System.currentTimeMillis()))
.setExpiration(Date(System.currentTimeMillis() + expirationTime.toLong()))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact()
}
fun getSigningKey(secretKey: String): Key {
val encodedKey = encodeToBase64(secretKey)
return Keys.hmacShaKeyFor(encodedKey.toByteArray(StandardCharsets.UTF_8))
fun extractUsername(token: String): String {
return extractClaim(token, Claims::getSubject)
}
private fun encodeToBase64(secretKey: String): String {
return Base64.getEncoder().encodeToString(secretKey.toByteArray())
fun isTokenValid(token: String, userDetails: UserDetails): Boolean {
val username = extractUsername(token)
return (username == userDetails.username && !isTokenExpired(token))
}
fun resetToken(tokenPrefix: JwtRule): Cookie {
val cookie: Cookie = Cookie(tokenPrefix.value, null)
cookie.setMaxAge(0)
cookie.setPath("/")
return cookie
private fun isTokenExpired(token: String): Boolean {
return extractExpiration(token).before(Date())
}
fun extractToken(token: String?, secretKey: Key?): Jws<Claims>? {
try {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
} catch (e: JwtException) {
throw BusinessException(ErrorCode.INVALID_JWT)
}
private fun extractExpiration(token: String): Date {
return extractClaim(token, Claims::getExpiration)
}
}
class BusinessException(error : ErrorCode) : Exception(error.name)
enum class ErrorCode {
JWT_TOKEN_NOT_FOUND,
NOT_AUTHENTICATED_USER,
INVALID_EXPIRED_JWT,
INVALID_JWT
private fun <T> extractClaim(token: String, claimsResolver: Function<Claims, T>): T {
val claims = extractAllClaims(token)
return claimsResolver.apply(claims)
}
private fun extractAllClaims(token: String): Claims {
return Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token).body
}
}

Some files were not shown because too many files have changed in this diff Show More