Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d664ec2
[#484] sync 갱신된 파드만 알림 가게 수정
eeeeeaaan Oct 4, 2026
48996e0
[FIX/#488] config 커밋 포인터 갱신
BAEK0111 Oct 4, 2026
6badf5d
Merge pull request #489 from ASSU-dev/fix/#488-jwt-token-seperate
BAEK0111 Oct 4, 2026
f69f52c
[FEAT/#443] Prod DB → Dev DB 동기화 백오피스 API 추가
BAEK0111 Oct 5, 2026
873ca54
Merge pull request #491 from ASSU-dev/feat/#443-add-prod-to-dev-dump-…
BAEK0111 Oct 5, 2026
a19d85a
[#484] 백오피스 파트너 정보조회 profileUrl 추가
eeeeeaaan Oct 5, 2026
2ca02c3
Merge pull request #493 from ASSU-dev/feat/#484-add-partner-profile-i…
eeeeeaaan Oct 5, 2026
21add68
[FEAT/#487] Inquiry · Student usable · Device Token 커스텀 도메인 메트릭 추가
leesumin0526 Oct 4, 2026
800faec
[FEAT/#487] student.usable.queried 메트릭 제거 및 UserPaper 동기화 로그 추가
leesumin0526 Oct 4, 2026
9278f3f
[FIX/#487] 회원가입 중 UserPaper 동기화 실패 시 회원가입 롤백되는 버그 수정
leesumin0526 Oct 4, 2026
8ea1ba7
[FEAT/#487] 문의 접수 Discord 알림 및 프로필 이미지 S3 삭제 실패 메트릭 추가
leesumin0526 Oct 4, 2026
ece84ec
[FEAT/#487] 신고 접수·회원가입 승인 대기 Discord 알림 추가
leesumin0526 Oct 4, 2026
3f1ad46
[FEAT/#487] 처리 완료 Discord 알림 추가 (문의 답변·신고·회원가입)
leesumin0526 Oct 4, 2026
493bcd0
[FIX/#487] SignUpServiceImplTest 생성자에 DiscordNotifier 누락 수정
leesumin0526 Oct 5, 2026
9e50aae
[FIX/#487] 테스트 누락 의존성 추가 (DiscordNotifier, MeterRegistry)
leesumin0526 Oct 5, 2026
55ed738
[REFACTOR/#487] DiscordNotifier 알림 메서드를 send(message) 단일 메서드로 통합
leesumin0526 Oct 6, 2026
ed43289
[MERGE/#487] develop 브랜치 병합
leesumin0526 Oct 6, 2026
3c4e773
Merge pull request #490 from ASSU-dev/feat/#487-add-domain-metrics
leesumin0526 Oct 6, 2026
92bd51c
[FEAT/#507] 제휴 컨텐츠 조회 API 응답에 제휴기간 필드 추가
leesumin0526 Oct 13, 2026
5ed7d5c
[FIX/#506] 회원가입 UserPaper 동기화를 트랜잭션 커밋 이후로 이동
leesumin0526 Oct 13, 2026
ee95ead
Merge pull request #510 from ASSU-dev/fix/#506-sync-user-paper-after-…
leesumin0526 Oct 13, 2026
9f009fc
Merge pull request #508 from ASSU-dev/feat/#507-add-partnership-perio…
leesumin0526 Oct 13, 2026
2355d39
[MERGE] main 브랜치 병합 (v.81 배포 충돌 해결)
leesumin0526 Oct 13, 2026
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
2 changes: 1 addition & 1 deletion config
2 changes: 1 addition & 1 deletion manifest
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,23 @@
import com.assu.server.domain.student.repository.StudentRepository;
import com.assu.server.domain.student.service.StudentService;
import com.assu.server.global.apiPayload.code.status.ErrorStatus;
import com.assu.server.infra.discord.DiscordNotifier;
import com.assu.server.infra.s3.AmazonS3Manager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;
import java.util.Optional;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
Expand All @@ -64,6 +69,7 @@ public class SignUpServiceImpl implements SignUpService {
private final StudentService studentService;
private final PhoneAuthService phoneAuthService;
private final CommonAuthRepository commonAuthRepository;
private final DiscordNotifier discordNotifier;

private RealmAuthAdapter pickAdapter(AuthRealm realm) {
return realmAuthAdapters.stream()
Expand Down Expand Up @@ -122,7 +128,9 @@ public SignUpResponseDTO signupSsuStudent(StudentTokenSignUpRequestDTO req) {
member.setProfile(student);

// 6) 가입 시점 사용 가능 제휴 동기화 (자정 배치와 별개로 즉시 반영)
studentService.syncUserPapersForStudent(student.getId());
// 커밋 전 Student는 REQUIRES_NEW 트랜잭션에서 조회되지 않으므로 커밋 이후 실행
Long studentId = student.getId();
runAfterCommit(() -> syncUserPapersSafely(studentId));

// 7) JWT 토큰 발급
TokensDTO tokens = jwtUtil.issueTokens(
Expand Down Expand Up @@ -158,8 +166,9 @@ private SignUpResponseDTO restoreWithdrawnStudent(
);
studentRepository.save(student);

// 탈퇴 기간 중 변동된 제휴를 반영한다
studentService.syncUserPapersForStudent(student.getId());
// 탈퇴 기간 중 변동된 제휴를 반영한다 (갱신된 학과 정보 기준으로 커밋 이후 실행)
Long studentId = student.getId();
runAfterCommit(() -> syncUserPapersSafely(studentId));

TokensDTO tokens = jwtUtil.issueTokens(
member.getId(),
Expand Down Expand Up @@ -228,6 +237,8 @@ public SignUpResponseDTO signupPartner(PartnerSignUpRequestDTO req, MultipartFil

linkStore(partner, info.name(), address, info.detailAddress(), lat, lng, point);

runAfterCommit(() -> discordNotifier.send("🔔 새 제휴업체 가입 승인 요청이 있습니다. 백오피스에서 확인해 주세요."));

return SignUpResponseDTO.from(member, null);
}

Expand Down Expand Up @@ -413,6 +424,8 @@ public SignUpResponseDTO signupAdmin(AdminSignUpRequestDTO req, MultipartFile si
.build());
member.setProfile(admin);

runAfterCommit(() -> discordNotifier.send("🔔 새 학생회 가입 승인 요청이 있습니다. 백오피스에서 확인해 주세요."));

return SignUpResponseDTO.from(member, null);
}

Expand Down Expand Up @@ -493,4 +506,25 @@ private Point toPoint(Double lat, Double lng) {
private String pickDisplayAddress(String road, String jibun) {
return (road != null && !road.isBlank()) ? road : jibun;
}

private void syncUserPapersSafely(Long studentId) {
try {
studentService.syncUserPapersForStudent(studentId);
} catch (Exception e) {
log.error("[SignUp] UserPaper 동기화 실패 studentId={}", studentId, e);
}
}

private void runAfterCommit(Runnable task) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
task.run();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
task.run();
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.assu.server.domain.backoffice.controller;

import com.assu.server.domain.backoffice.annotation.BackofficeAudited;
import com.assu.server.domain.backoffice.dto.BackofficeDevSyncResponseDTO;
import com.assu.server.domain.backoffice.service.BackofficeDevSyncService;
import com.assu.server.global.apiPayload.BaseResponse;
import com.assu.server.global.apiPayload.code.status.SuccessStatus;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Backoffice", description = "백오피스 운영 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/backoffice/dev-sync")
@PreAuthorize("hasRole('BACKOFFICE')")
public class BackofficeDevSyncController {

private final BackofficeDevSyncService backofficeDevSyncService;

@BackofficeAudited(action = "DEV_SYNC")
@Operation(
summary = "Prod DB → Dev DB 동기화 API",
description = "# [v1.0 (2026-10-04)]\n" +
"- 현재 서버의 DB 스키마를 `dev-sync.target-schema` 스키마로 통째로 복사합니다.\n" +
"- 대상 스키마의 기존 테이블은 모두 삭제되고, 원본과 동일한 구조/데이터로 다시 생성됩니다.\n" +
"- 복사 후 대상 스키마의 `device_token`, `notification_outbox`는 비웁니다.\n" +
"- **완료 후 dev 서버를 재시작해야** develop에만 있는 컬럼이 `ddl-auto`로 다시 생성됩니다.\n" +
"- `BACKOFFICE` 역할 및 `aud=backoffice` JWT가 필요합니다.\n\n" +
"**Response:**\n" +
"- 성공 시 200(OK)과 `BackofficeDevSyncResponseDTO` 반환\n" +
" - `sourceSchema` (String): 복사 원본 스키마\n" +
" - `targetSchema` (String): 복사 대상 스키마\n" +
" - `copiedTables` (List<String>): 복사한 테이블 목록\n" +
"- 401(UNAUTHORIZED): 인증되지 않았거나 audience 불일치\n" +
"- 403(FORBIDDEN): BACKOFFICE 권한 없음\n" +
"- 500(INTERNAL_SERVER_ERROR): 대상 스키마 미설정 또는 원본과 동일"
)
@PostMapping
public BaseResponse<BackofficeDevSyncResponseDTO> syncToDev() {
return BaseResponse.onSuccess(SuccessStatus._OK, backofficeDevSyncService.syncToDev());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.assu.server.domain.backoffice.dto;

import io.swagger.v3.oas.annotations.media.Schema;

import java.util.List;

public record BackofficeDevSyncResponseDTO(

@Schema(description = "복사 원본 스키마", example = "assu_db")
String sourceSchema,

@Schema(description = "복사 대상 스키마", example = "assu_dev_db")
String targetSchema,

@Schema(description = "복사한 테이블 목록", example = "[\"member\", \"student\"]")
List<String> copiedTables
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.assu.server.domain.backoffice.service;

import com.assu.server.domain.backoffice.dto.BackofficeDevSyncResponseDTO;
import com.assu.server.global.apiPayload.code.status.ErrorStatus;
import com.assu.server.global.exception.GeneralException;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;

// DDL은 암묵적 커밋이 발생해 트랜잭션으로 묶을 수 없으므로 @Transactional을 적용하지 않는다.
@Service
@RequiredArgsConstructor
public class BackofficeDevSyncService {

private static final List<String> TABLES_TO_CLEAR = List.of("device_token", "notification_outbox");

private final JdbcTemplate jdbcTemplate;

@Value("${dev-sync.target-schema:}")
private String targetSchema;

public BackofficeDevSyncResponseDTO syncToDev() {
String sourceSchema = jdbcTemplate.queryForObject("SELECT DATABASE()", String.class);
if (targetSchema.isBlank() || targetSchema.equals(sourceSchema)) {
throw new GeneralException(ErrorStatus.INVALID_DEV_SYNC_TARGET);
}

List<String> sourceTables = findObjects(sourceSchema, "BASE TABLE");
List<String> sourceSequences = findObjects(sourceSchema, "SEQUENCE");
List<String> targetObjects = new ArrayList<>(findObjects(targetSchema, "BASE TABLE"));
targetObjects.addAll(findObjects(targetSchema, "SEQUENCE"));

jdbcTemplate.execute((ConnectionCallback<Void>) connection -> {
try (Statement statement = connection.createStatement()) {
statement.execute("SET FOREIGN_KEY_CHECKS = 0");
try {
for (String object : targetObjects) {
statement.execute("DROP TABLE " + qualify(targetSchema, object));
}
for (String table : sourceTables) {
statement.execute(toTargetCreateTable(statement, sourceSchema, table));
statement.execute("INSERT INTO " + qualify(targetSchema, table) + " SELECT * FROM " + qualify(sourceSchema, table));
}
for (String sequence : sourceSequences) {
statement.execute("CREATE TABLE " + qualify(targetSchema, sequence) + " LIKE " + qualify(sourceSchema, sequence));
statement.execute("INSERT INTO " + qualify(targetSchema, sequence) + " SELECT * FROM " + qualify(sourceSchema, sequence));
}
for (String table : TABLES_TO_CLEAR) {
if (sourceTables.contains(table)) {
statement.execute("TRUNCATE TABLE " + qualify(targetSchema, table));
}
}
} finally {
statement.execute("SET FOREIGN_KEY_CHECKS = 1");
}
}
return null;
});

List<String> copiedObjects = new ArrayList<>(sourceTables);
copiedObjects.addAll(sourceSequences);
return new BackofficeDevSyncResponseDTO(sourceSchema, targetSchema, copiedObjects);
}

private List<String> findObjects(String schema, String tableType) {
return jdbcTemplate.queryForList(
"SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = ?",
String.class,
schema,
tableType
);
}

// CREATE TABLE LIKE는 FK를 복사하지 않으므로 원본 DDL을 사용한다. 스키마 없는 FK 참조는 생성되는 테이블의 스키마로 해석된다.
private String toTargetCreateTable(Statement statement, String sourceSchema, String table) throws SQLException {
String ddl;
try (ResultSet resultSet = statement.executeQuery("SHOW CREATE TABLE " + qualify(sourceSchema, table))) {
resultSet.next();
ddl = resultSet.getString(2);
}
String header = "CREATE TABLE `" + table + "`";
if (!ddl.startsWith(header)) {
throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR);
}
return "CREATE TABLE " + qualify(targetSchema, table) + ddl.substring(header.length());
}

private String qualify(String schema, String table) {
return "`" + schema + "`.`" + table + "`";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@
import com.assu.server.domain.store.entity.Store;
import com.assu.server.domain.store.repository.StoreRepository;
import com.assu.server.global.apiPayload.code.status.ErrorStatus;
import com.assu.server.infra.discord.DiscordNotifier;
import com.assu.server.infra.s3.AmazonS3Manager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;

import java.time.LocalDateTime;
Expand All @@ -44,6 +47,7 @@ public class BackofficeMemberServiceImpl implements BackofficeMemberService {
private final WithdrawalService withdrawalService;
private final AmazonS3Manager amazonS3Manager;
private final ProfileImageService profileImageService;
private final DiscordNotifier discordNotifier;

@Override
@Transactional(readOnly = true)
Expand Down Expand Up @@ -93,6 +97,9 @@ public BackofficeMemberSummaryDTO approveMember(Long memberId) {
admin.setSignVerifiedAt(now);
}

String roleLabel = member.getRole() == UserRole.PARTNER ? "제휴업체" : "학생회";
runAfterCommit(() -> discordNotifier.send("✅ " + roleLabel + " 회원가입이 승인되었습니다."));

return BackofficeMemberSummaryDTO.from(member);
}

Expand All @@ -103,6 +110,10 @@ public BackofficeMemberSummaryDTO rejectMember(Long memberId) {
assertPendingApproval(member);

member.setIsActivated(ActivationStatus.INACTIVE);

String roleLabel = member.getRole() == UserRole.PARTNER ? "제휴업체" : "학생회";
runAfterCommit(() -> discordNotifier.send("❌ " + roleLabel + " 회원가입이 거절되었습니다."));

return BackofficeMemberSummaryDTO.from(member);
}

Expand Down Expand Up @@ -310,6 +321,19 @@ private void assertApprovable(Member member) {
}
}

private void runAfterCommit(Runnable task) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
task.run();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
task.run();
}
});
}

private void activatePartnerStore(Partner partner) {
Store store = storeRepository.findByPartner(partner)
.orElseThrow(() -> {
Expand Down
Loading
Loading