Skip to content

Commit 9b1014e

Browse files
committed
Merge branch 'feature' into dev
2 parents ce5fa5f + e7e7ff1 commit 9b1014e

File tree

482 files changed

+50395
-1078
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

482 files changed

+50395
-1078
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
name: Backend Deploy
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'backend/**'
8+
9+
concurrency:
10+
group: maru-backend-prod
11+
cancel-in-progress: true
12+
13+
jobs:
14+
deploy:
15+
runs-on: ubuntu-latest
16+
environment: MARU_PROD_ENV
17+
defaults:
18+
run:
19+
working-directory: backend
20+
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@v4
24+
25+
- name: Setup Java 21
26+
uses: actions/setup-java@v4
27+
with:
28+
java-version: '21'
29+
distribution: 'temurin'
30+
cache: 'gradle'
31+
32+
- name: Build with Gradle
33+
run: ./gradlew clean build
34+
35+
- name: Prepare JAR artifact
36+
run: cp build/libs/maru-backend.jar ../maru-backend.jar
37+
38+
- name: Create env file
39+
working-directory: .
40+
run: |
41+
{
42+
printf 'SPRING_DATASOURCE_URL=%s\n' "${{ secrets.SPRING_DATASOURCE_URL }}"
43+
printf 'SPRING_DATASOURCE_USERNAME=%s\n' "${{ secrets.SPRING_DATASOURCE_USERNAME }}"
44+
printf 'SPRING_DATASOURCE_PASSWORD=%s\n' "${{ secrets.SPRING_DATASOURCE_PASSWORD }}"
45+
printf 'JWT_SECRET=%s\n' "${{ secrets.JWT_SECRET }}"
46+
printf 'OAUTH_GOOGLE_CLIENT_ID=%s\n' "${{ secrets.OAUTH_GOOGLE_CLIENT_ID }}"
47+
printf 'OAUTH_GOOGLE_CLIENT_SECRET=%s\n' "${{ secrets.OAUTH_GOOGLE_CLIENT_SECRET }}"
48+
printf 'OAUTH_KAKAO_CLIENT_ID=%s\n' "${{ secrets.OAUTH_KAKAO_CLIENT_ID }}"
49+
printf 'OAUTH_KAKAO_CLIENT_SECRET=%s\n' "${{ secrets.OAUTH_KAKAO_CLIENT_SECRET }}"
50+
printf 'SMS_SOLAPI_API_KEY=%s\n' "${{ secrets.SMS_SOLAPI_API_KEY }}"
51+
printf 'SMS_SOLAPI_API_SECRET=%s\n' "${{ secrets.SMS_SOLAPI_API_SECRET }}"
52+
printf 'SMS_SOLAPI_SENDER_NUMBER=%s\n' "${{ secrets.SMS_SOLAPI_SENDER_NUMBER }}"
53+
} > maru-backend.env
54+
55+
- name: Verify artifacts
56+
working-directory: .
57+
run: ls -al maru-backend.jar maru-backend.env
58+
59+
- name: Upload JAR to EC2
60+
uses: appleboy/scp-action@v0.1.7
61+
with:
62+
host: ${{ secrets.EC2_HOST }}
63+
username: ec2-user
64+
key: ${{ secrets.EC2_SSH_KEY }}
65+
source: maru-backend.jar
66+
target: /tmp/
67+
68+
- name: Upload env file to EC2
69+
uses: appleboy/scp-action@v0.1.7
70+
with:
71+
host: ${{ secrets.EC2_HOST }}
72+
username: ec2-user
73+
key: ${{ secrets.EC2_SSH_KEY }}
74+
source: maru-backend.env
75+
target: /tmp/
76+
77+
- name: Deploy and restart service
78+
uses: appleboy/ssh-action@v1.0.3
79+
with:
80+
host: ${{ secrets.EC2_HOST }}
81+
username: ec2-user
82+
key: ${{ secrets.EC2_SSH_KEY }}
83+
script: |
84+
set -euo pipefail
85+
86+
# env 디렉토리 설정
87+
sudo mkdir -p /etc/maru
88+
sudo chown root:root /etc/maru
89+
sudo chmod 700 /etc/maru
90+
91+
# env 파일 원자적 교체
92+
sudo mv -f /tmp/maru-backend.env /etc/maru/maru-backend.env.new
93+
sudo mv -f /etc/maru/maru-backend.env.new /etc/maru/maru-backend.env
94+
sudo chown root:root /etc/maru/maru-backend.env
95+
sudo chmod 600 /etc/maru/maru-backend.env
96+
97+
# JAR 백업 (롤백용)
98+
cp -f /home/ec2-user/maru-backend.jar /home/ec2-user/maru-backend.jar.bak || true
99+
100+
# JAR 원자적 교체
101+
mv -f /tmp/maru-backend.jar /home/ec2-user/maru-backend.jar.new
102+
mv -f /home/ec2-user/maru-backend.jar.new /home/ec2-user/maru-backend.jar
103+
104+
# 서비스 재시작
105+
sudo systemctl restart maru-backend
106+
107+
# 스모크 테스트
108+
sleep 5
109+
curl -f http://localhost:8080/actuator/health || {
110+
echo "Health check failed. Rolling back..."
111+
mv -f /home/ec2-user/maru-backend.jar.bak /home/ec2-user/maru-backend.jar || true
112+
sudo systemctl restart maru-backend
113+
echo "Rollback complete. Service status:"
114+
sudo systemctl status maru-backend --no-pager
115+
exit 1
116+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: Frontend Deploy
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'frontend/**'
8+
9+
concurrency:
10+
group: maru-frontend-prod
11+
cancel-in-progress: true
12+
13+
jobs:
14+
deploy:
15+
runs-on: ubuntu-latest
16+
environment: MARU_PROD_ENV
17+
defaults:
18+
run:
19+
working-directory: frontend
20+
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@v4
24+
25+
- name: Setup Node.js
26+
uses: actions/setup-node@v4
27+
with:
28+
node-version: '20'
29+
cache: 'npm'
30+
cache-dependency-path: frontend/package-lock.json
31+
32+
- name: Install dependencies
33+
run: npm ci
34+
35+
- name: Type check
36+
run: npm run type-check
37+
38+
- name: Build
39+
run: npm run build
40+
env:
41+
VITE_API_BASE_URL: ${{ secrets.VITE_API_BASE_URL }}
42+
43+
- name: Configure AWS credentials
44+
uses: aws-actions/configure-aws-credentials@v4
45+
with:
46+
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
47+
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
48+
aws-region: ${{ secrets.AWS_REGION }}
49+
50+
- name: Deploy to S3
51+
run: aws s3 sync build/ s3://${{ secrets.S3_BUCKET_NAME }} --delete
52+
53+
- name: Invalidate CloudFront cache
54+
run: |
55+
aws cloudfront create-invalidation \
56+
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
57+
--paths "/*"

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,5 @@ db/init/
8383
# ----------------------------
8484
# Docs
8585
# ----------------------------
86-
README-Docs/
86+
README-Docs/
87+
dojang_list.xlsx

backend/build.gradle

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ ext {
2121
lombokVersion = '1.18.30'
2222
jjwtVersion = '0.12.3'
2323
springdocVersion = '2.2.0'
24+
hypersistenceTsidVersion = '2.1.4'
2425
}
2526

2627
configurations {
@@ -55,6 +56,9 @@ dependencies {
5556
implementation 'org.flywaydb:flyway-core'
5657
implementation 'org.flywaydb:flyway-mysql'
5758

59+
// TSID (Time-Sorted Unique Identifier)
60+
implementation "io.hypersistence:hypersistence-tsid:${hypersistenceTsidVersion}"
61+
5862
// ============== Security & Authentication==============
5963
// Spring Security
6064
implementation 'org.springframework.boot:spring-boot-starter-security'
@@ -76,6 +80,10 @@ dependencies {
7680
// SpringDoc OpenAPI (Swagger UI)
7781
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
7882

83+
// ============== Caching ==============
84+
implementation 'org.springframework.boot:spring-boot-starter-cache'
85+
implementation 'com.github.ben-manes.caffeine:caffeine'
86+
7987
// ============== External Services ==============
8088
// Solapi SMS SDK
8189
implementation 'com.solapi:sdk:1.0.3'
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.maru.common.aop;
2+
3+
import org.springframework.core.Ordered;
4+
5+
public final class AopOrder {
6+
7+
private AopOrder() {}
8+
9+
public static final int SECURITY_VALIDATION = Ordered.HIGHEST_PRECEDENCE + 100;
10+
public static final int PERMISSION_CHECK = Ordered.HIGHEST_PRECEDENCE + 200;
11+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package com.maru.common.aop;
2+
3+
import com.maru.security.DojangAccessValidator;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.aspectj.lang.JoinPoint;
7+
import org.aspectj.lang.annotation.Aspect;
8+
import org.aspectj.lang.annotation.Before;
9+
import org.aspectj.lang.annotation.Pointcut;
10+
import org.aspectj.lang.reflect.MethodSignature;
11+
import org.springframework.core.annotation.Order;
12+
import org.springframework.stereotype.Component;
13+
14+
/**
15+
* 도장 접근 검증 AOP
16+
*
17+
* @see AopOrder
18+
*/
19+
@Slf4j
20+
@Aspect
21+
@Component
22+
@Order(AopOrder.SECURITY_VALIDATION)
23+
@RequiredArgsConstructor
24+
public class DojangAccessAspect {
25+
26+
private final DojangAccessValidator validator;
27+
28+
// 1. 클래스에 @ValidateDojangAccess가 붙어있는지 확인
29+
@Pointcut("@within(validateDojangAccess)")
30+
public void hasValidateDojangAccess(ValidateDojangAccess validateDojangAccess) {}
31+
32+
// 2. public 메서드인지 확인
33+
@Pointcut("execution(public * *(..))")
34+
public void isPublicMethod() {}
35+
36+
// 3. @SkipDojangValidation이 붙어있지 않은지 확인
37+
@Pointcut("!@annotation(com.maru.common.aop.SkipDojangValidation)")
38+
public void isNotSkipped() {}
39+
40+
@Before("hasValidateDojangAccess(validateDojangAccess) && isPublicMethod() && isNotSkipped()")
41+
public void validateAccess(JoinPoint joinPoint, ValidateDojangAccess validateDojangAccess) {
42+
String paramName = validateDojangAccess.paramName();
43+
String dojangId = extractDojangId(joinPoint, paramName);
44+
45+
if (dojangId == null) {
46+
log.debug("dojangId 파라미터 없음, 검증 스킵: {}", joinPoint.getSignature().toShortString());
47+
return;
48+
}
49+
50+
validator.validate(dojangId);
51+
}
52+
53+
private String extractDojangId(JoinPoint joinPoint, String paramName) {
54+
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
55+
Object[] args = joinPoint.getArgs();
56+
String[] parameterNames = signature.getParameterNames();
57+
58+
if (parameterNames == null) {
59+
return null;
60+
}
61+
62+
for (int i = 0; i < parameterNames.length; i++) {
63+
if (paramName.equals(parameterNames[i])) {
64+
return (String) args[i];
65+
}
66+
}
67+
return null;
68+
}
69+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.maru.common.aop;
2+
3+
import com.maru.domain.permission.PermissionType;
4+
import com.maru.security.PermissionCache;
5+
import com.maru.security.RequirePermission;
6+
import com.maru.security.TenantContextHolder;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.aspectj.lang.annotation.Aspect;
10+
import org.aspectj.lang.annotation.Before;
11+
import org.springframework.core.annotation.Order;
12+
import org.springframework.security.access.AccessDeniedException;
13+
import org.springframework.stereotype.Component;
14+
15+
import java.util.Arrays;
16+
import java.util.stream.Collectors;
17+
18+
/**
19+
* 권한 검증 AOP
20+
*
21+
* @see RequirePermission
22+
* @see AopOrder
23+
*/
24+
@Slf4j
25+
@Aspect
26+
@Component
27+
@Order(AopOrder.PERMISSION_CHECK)
28+
@RequiredArgsConstructor
29+
public class PermissionCheckAspect {
30+
31+
private final PermissionCache permissionCache;
32+
33+
@Before("@annotation(requirePermission)")
34+
public void checkPermission(RequirePermission requirePermission) {
35+
if (TenantContextHolder.isOwner() || TenantContextHolder.isSystemUser()) {
36+
log.debug("OWNER 또는 SYSTEM 사용자 - 권한 검증 스킵");
37+
return;
38+
}
39+
40+
String userId = TenantContextHolder.getUserId();
41+
String tenantId = TenantContextHolder.getTenantId();
42+
String dojangId = TenantContextHolder.getDojangId();
43+
44+
if (userId == null || tenantId == null || dojangId == null) {
45+
log.warn("권한 검증 실패 - 컨텍스트 정보 누락: userId={}, tenantId={}, dojangId={}",
46+
userId, tenantId, dojangId);
47+
throw new AccessDeniedException("인증 정보가 없습니다");
48+
}
49+
50+
PermissionType[] requiredPermissions = requirePermission.value();
51+
RequirePermission.LogicalOperator operator = requirePermission.operator();
52+
53+
boolean hasPermission = checkPermissions(userId, tenantId, dojangId, requiredPermissions, operator);
54+
55+
if (!hasPermission) {
56+
String permissionCodes = Arrays.stream(requiredPermissions)
57+
.map(PermissionType::getCode)
58+
.collect(Collectors.joining(", "));
59+
log.warn("권한 검증 실패: userId={}, dojangId={}, required=[{}], operator={}",
60+
userId, dojangId, permissionCodes, operator);
61+
throw new AccessDeniedException("권한이 없습니다");
62+
}
63+
64+
log.debug("권한 검증 성공: userId={}, dojangId={}", userId, dojangId);
65+
}
66+
67+
private boolean checkPermissions(String userId, String tenantId, String dojangId,
68+
PermissionType[] permissions, RequirePermission.LogicalOperator operator) {
69+
if (operator == RequirePermission.LogicalOperator.AND) {
70+
return Arrays.stream(permissions).allMatch(p ->
71+
permissionCache.hasPermission(userId, tenantId, dojangId, p.getResource(), p.getAction()));
72+
} else {
73+
return Arrays.stream(permissions).anyMatch(p ->
74+
permissionCache.hasPermission(userId, tenantId, dojangId, p.getResource(), p.getAction()));
75+
}
76+
}
77+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.maru.common.aop;
2+
3+
import java.lang.annotation.Documented;
4+
import java.lang.annotation.ElementType;
5+
import java.lang.annotation.Retention;
6+
import java.lang.annotation.RetentionPolicy;
7+
import java.lang.annotation.Target;
8+
9+
/**
10+
* 도장 접근 검증 스킵
11+
*/
12+
@Target(ElementType.METHOD)
13+
@Retention(RetentionPolicy.RUNTIME)
14+
@Documented
15+
public @interface SkipDojangValidation {
16+
}

0 commit comments

Comments
 (0)