Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ dependencies {
implementation 'org.hibernate.validator:hibernate-validator'
implementation 'com.amazonaws:aws-java-sdk-s3:1.12.782'
implementation 'org.springframework.boot:spring-boot-starter-websocket'

// Database Proxy
implementation 'net.ttddyy.observation:datasource-micrometer:1.2.0'
}

tasks.named('test', Test) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.example.solidconnection.admin.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.solidconnection.admin.dto.UserBanRequest;
import com.example.solidconnection.admin.service.AdminUserBanService;
import com.example.solidconnection.common.resolver.AuthorizedUser;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@RequestMapping("/admin/users")
@RestController
public class AdminUserBanController {
private final AdminUserBanService adminUserBanService;

@PostMapping("/{user-id}/ban")
public ResponseEntity<Void> banUser(
@AuthorizedUser long adminId,
@PathVariable(name = "user-id") long userId,
@Valid @RequestBody UserBanRequest request
) {
adminUserBanService.banUser(userId, adminId, request);
return ResponseEntity.ok().build();
}

@PatchMapping("/{user-id}/unban")
public ResponseEntity<Void> unbanUser(
@AuthorizedUser long adminId,
@PathVariable(name = "user-id") long userId
) {
adminUserBanService.unbanUser(userId, adminId);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.solidconnection.admin.dto;

import com.example.solidconnection.siteuser.domain.UserBanDuration;

import jakarta.validation.constraints.NotNull;

public record UserBanRequest(
@NotNull(message = "차단 기간을 입력해주세요.")
UserBanDuration duration
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.example.solidconnection.admin.service;

import static java.time.ZoneOffset.UTC;

import com.example.solidconnection.admin.dto.UserBanRequest;
import com.example.solidconnection.chat.repository.ChatMessageRepository;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.common.exception.ErrorCode;
import com.example.solidconnection.community.post.repository.PostRepository;
import com.example.solidconnection.report.repository.ReportRepository;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.domain.UserBan;
import com.example.solidconnection.siteuser.domain.UserStatus;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import com.example.solidconnection.siteuser.repository.UserBanRepository;
import java.time.ZonedDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@RequiredArgsConstructor
@Service
public class AdminUserBanService {

private final UserBanRepository userBanRepository;
private final ReportRepository reportRepository;
private final SiteUserRepository siteUserRepository;
private final PostRepository postRepository;
private final ChatMessageRepository chatMessageRepository;

@Transactional
public void banUser(long userId, long adminId, UserBanRequest request) {
SiteUser user = siteUserRepository.findById(userId)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
validateNotAlreadyBanned(userId);
validateReportExists(userId);

user.updateUserStatus(UserStatus.BANNED);
updateReportedContentIsDeleted(userId, true);
createUserBan(userId, adminId, request);
}

private void validateNotAlreadyBanned(long userId) {
if (userBanRepository.existsByBannedUserIdAndIsExpiredFalseAndExpiredAtAfter(userId, ZonedDateTime.now(UTC))) {
throw new CustomException(ErrorCode.ALREADY_BANNED_USER);
}
}

private void validateReportExists(long userId) {
if (!reportRepository.existsByReportedId(userId)) {
throw new CustomException(ErrorCode.REPORT_NOT_FOUND);
}
}

private void updateReportedContentIsDeleted(long userId, boolean isDeleted) {
postRepository.updateReportedPostsIsDeleted(userId, isDeleted);
chatMessageRepository.updateReportedChatMessagesIsDeleted(userId, isDeleted);
}

private void createUserBan(long userId, long adminId, UserBanRequest request) {
ZonedDateTime now = ZonedDateTime.now(UTC);
ZonedDateTime expiredAt = now.plusDays(request.duration().getDays());
UserBan userBan = new UserBan(userId, adminId, request.duration(), expiredAt);
userBanRepository.save(userBan);
}

@Transactional
public void unbanUser(long userId, long adminId) {
SiteUser user = siteUserRepository.findById(userId)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
UserBan userBan = findActiveBan(userId);
userBan.manuallyUnban(adminId);

user.updateUserStatus(UserStatus.REPORTED);
updateReportedContentIsDeleted(userId, false);
}

private UserBan findActiveBan(long userId) {
return userBanRepository
.findByBannedUserIdAndIsExpiredFalseAndExpiredAtAfter(userId, ZonedDateTime.now(UTC))
.orElseThrow(() -> new CustomException(ErrorCode.NOT_BANNED_USER));
}

@Transactional
@Scheduled(cron = "0 0 0 * * *")
public void expireUserBans() {
try {
ZonedDateTime now = ZonedDateTime.now(UTC);
List<Long> expiredUserIds = userBanRepository.findExpiredBannedUserIds(now);

if (expiredUserIds.isEmpty()) {
return;
}

userBanRepository.bulkExpireUserBans(now);
siteUserRepository.bulkUpdateUserStatus(expiredUserIds, UserStatus.REPORTED);
bulkUpdateReportedContentIsDeleted(expiredUserIds);
log.info("Finished processing expired blocks:: userIds={}", expiredUserIds);
} catch (Exception e) {
log.error("Failed to process expired blocks", e);
}
}

private void bulkUpdateReportedContentIsDeleted(List<Long> expiredUserIds) {
postRepository.bulkUpdateReportedPostsIsDeleted(expiredUserIds, false);
chatMessageRepository.bulkUpdateReportedChatMessagesIsDeleted(expiredUserIds, false);
}

}
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
package com.example.solidconnection.auth.dto;

import com.example.solidconnection.siteuser.domain.AuthType;
import com.example.solidconnection.siteuser.domain.ExchangeStatus;
import com.example.solidconnection.siteuser.domain.Role;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
Expand All @@ -20,27 +17,4 @@ public record SignUpRequest(

@NotBlank(message = "닉네임을 입력해주세요.")
String nickname) {

public SiteUser toOAuthSiteUser(String email, AuthType authType) {
return new SiteUser(
email,
this.nickname,
this.profileImageUrl,
this.exchangeStatus,
Role.MENTEE,
authType
);
}

public SiteUser toEmailSiteUser(String email, String encodedPassword) {
return new SiteUser(
email,
this.nickname,
this.profileImageUrl,
this.exchangeStatus,
Role.MENTEE,
AuthType.EMAIL,
encodedPassword
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import com.example.solidconnection.siteuser.domain.AuthType;
import com.example.solidconnection.siteuser.domain.Role;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.domain.UserStatus;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -56,7 +57,8 @@ public SignInResponse signUp(SignUpRequest signUpRequest) {
signUpRequest.exchangeStatus(),
Role.MENTEE,
authType,
password
password,
UserStatus.ACTIVE
));

// 관심 지역, 국가 저장
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Where;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Where(clause = "is_deleted = false")
public class ChatMessage extends BaseEntity {

@Id
Expand All @@ -33,6 +35,9 @@ public class ChatMessage extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
private ChatRoom chatRoom;

@Column(name = "is_deleted", columnDefinition = "boolean default false", nullable = false)
private boolean isDeleted = false;

@OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL, orphanRemoval = true)
private final List<ChatAttachment> chatAttachments = new ArrayList<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

Expand Down Expand Up @@ -48,4 +49,20 @@ SELECT MAX(cm2.id)
GROUP BY cm.chatRoom.id
""")
List<UnreadCountDto> countUnreadMessagesBatch(@Param("chatRoomIds") List<Long> chatRoomIds, @Param("userId") long userId);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = """
UPDATE chat_message cm SET cm.is_deleted = :isDeleted
WHERE cm.id IN (SELECT r.target_id FROM report r WHERE r.target_type = 'CHAT')
AND cm.sender_id IN (SELECT cp.id FROM chat_participant cp WHERE cp.site_user_id = :siteUserId)
""", nativeQuery = true)
void updateReportedChatMessagesIsDeleted(@Param("siteUserId") long siteUserId, @Param("isDeleted") boolean isDeleted);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = """
UPDATE chat_message cm SET cm.is_deleted = :isDeleted
WHERE cm.id IN (SELECT r.target_id FROM report r WHERE r.target_type = 'CHAT')
AND cm.sender_id IN (SELECT cp.id FROM chat_participant cp WHERE cp.site_user_id IN :siteUserIds)
""", nativeQuery = true)
void bulkUpdateReportedChatMessagesIsDeleted(@Param("siteUserIds") List<Long> siteUserIds, @Param("isDeleted") boolean isDeleted);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.example.solidconnection.common.config.datasource;

import com.example.solidconnection.common.listener.QueryMetricsListener;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import lombok.RequiredArgsConstructor;
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.flyway.FlywayDataSource;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@RequiredArgsConstructor
@Configuration
public class DataSourceConfig {

private final QueryMetricsListener queryMetricsListener;

// Driver
public static final String FLYWAY_MYSQL_DRIVER = "com.mysql.cj.jdbc.Driver";

// Pool Name
public static final String FLYWAY_POOL_NAME = "FlywayPool";

// Connection Pool Settings
public static final int FLYWAY_MINIMUM_IDLE = 0; // 유휴 커넥션을 0으로 설정하면 사용하지 않을 때 커넥션을 즉시 반납
public static final int FLYWAY_MAXIMUM_POOL_SIZE = 2;
public static final long FLYWAY_CONNECTION_TIMEOUT = 10000L;
public static final long FLYWAY_IDLE_TIMEOUT = 60000L; // 1분
public static final long FLYWAY_MAX_LIFETIME = 300000L; // 5분

@Bean
@Primary
public DataSource proxyDataSource(DataSourceProperties props) {
DataSource dataSource = props.initializeDataSourceBuilder().build();

return ProxyDataSourceBuilder
.create(dataSource)
.listener(queryMetricsListener)
.name("main")
.build();
}

// Flyway 전용 DataSource (Proxy 미적용)
@Bean
@FlywayDataSource
public DataSource flywayDataSource(
@Value("${spring.datasource.url}") String url,
@Value("${spring.flyway.user:${spring.datasource.username}}") String username,
@Value("${spring.flyway.password:${spring.datasource.password}}") String password
) {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(url);
dataSource.setUsername(username);
dataSource.setPassword(password);
dataSource.setDriverClassName(FLYWAY_MYSQL_DRIVER);
dataSource.setPoolName(FLYWAY_POOL_NAME);

dataSource.setMinimumIdle(FLYWAY_MINIMUM_IDLE);
dataSource.setMaximumPoolSize(FLYWAY_MAXIMUM_POOL_SIZE);
dataSource.setConnectionTimeout(FLYWAY_CONNECTION_TIMEOUT);
dataSource.setIdleTimeout(FLYWAY_IDLE_TIMEOUT); // 1분으로 단축
dataSource.setMaxLifetime(FLYWAY_MAX_LIFETIME); // 최대 5분

return dataSource;
}
}
Loading
Loading