feat(settings): 관리자 설정 기반 신규 사용자 자동승인 + 기본 롤 부여 #15

병합
htlee develop 에서 main 로 4 commits 를 머지했습니다 2026-02-16 23:36:32 +09:00
19개의 변경된 파일539개의 추가작업 그리고 9개의 파일을 삭제

파일 보기

@ -3,9 +3,12 @@ package com.gcsc.guide.auth;
import com.gcsc.guide.dto.AuthResponse;
import com.gcsc.guide.dto.GoogleLoginRequest;
import com.gcsc.guide.dto.UserResponse;
import com.gcsc.guide.entity.Role;
import com.gcsc.guide.entity.User;
import com.gcsc.guide.repository.RoleRepository;
import com.gcsc.guide.repository.UserRepository;
import com.gcsc.guide.service.ActivityService;
import com.gcsc.guide.service.SettingsService;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
@ -18,6 +21,10 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@ -36,7 +43,9 @@ public class AuthController {
private final GoogleTokenVerifier googleTokenVerifier;
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final ActivityService activityService;
private final SettingsService settingsService;
@Operation(summary = "Google 로그인",
description = "Google ID Token을 검증하고 JWT를 발급합니다. "
@ -119,6 +128,16 @@ public class AuthController {
newUser.activate();
newUser.grantAdmin();
log.info("관리자 자동 승인: {}", email);
} else if (settingsService.isAutoApproveEnabled()) {
newUser.activate();
log.info("자동 승인 (설정 활성화): {}", email);
}
List<Role> defaultRoles = roleRepository.findByDefaultGrantTrue();
if (!defaultRoles.isEmpty()) {
newUser.updateRoles(new HashSet<>(defaultRoles));
log.info("기본 롤 부여: {} → {}", email,
defaultRoles.stream().map(Role::getName).toList());
}
newUser.updateLastLogin();

파일 보기

@ -8,7 +8,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@Slf4j
@Component
@ -18,12 +20,26 @@ public class GoogleTokenVerifier {
private final String allowedEmailDomain;
public GoogleTokenVerifier(
@Value("${app.google.client-id}") String clientId,
@Value("${app.google.client-ids:}") String clientIdsCsv,
@Value("${app.google.client-id:}") String clientId,
@Value("${app.allowed-email-domain}") String allowedEmailDomain
) {
List<String> audiences = new ArrayList<>();
if (clientIdsCsv != null && !clientIdsCsv.isBlank()) {
for (String part : clientIdsCsv.split(",")) {
String trimmed = part == null ? "" : part.trim();
if (!trimmed.isEmpty()) audiences.add(trimmed);
}
}
if (audiences.isEmpty() && clientId != null && !clientId.isBlank()) {
audiences.add(clientId.trim());
}
if (audiences.isEmpty()) {
log.warn("Google client id is not configured (app.google.client-id / app.google.client-ids empty). Google login will fail.");
}
this.verifier = new GoogleIdTokenVerifier.Builder(
new NetHttpTransport(), GsonFactory.getDefaultInstance())
.setAudience(Collections.singletonList(clientId))
.setAudience(audiences.isEmpty() ? Collections.emptyList() : audiences)
.build();
this.allowedEmailDomain = allowedEmailDomain;
}

파일 보기

@ -3,6 +3,7 @@ package com.gcsc.guide.controller;
import com.gcsc.guide.dto.AddPermissionRequest;
import com.gcsc.guide.dto.CreateRoleRequest;
import com.gcsc.guide.dto.RoleResponse;
import com.gcsc.guide.dto.UpdateDefaultGrantRequest;
import com.gcsc.guide.service.RoleService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@ -119,6 +120,22 @@ public class AdminRoleController {
return ResponseEntity.ok(roleService.addPermission(id, request.urlPattern()));
}
@Operation(summary = "롤 기본 부여 토글",
description = "신규 가입자에게 해당 롤을 기본 부여할지 설정합니다. defaultGrant=true인 롤은 첫 로그인 시 자동 할당됩니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "수정 성공",
content = @Content(schema = @Schema(implementation = RoleResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content),
@ApiResponse(responseCode = "404", description = "롤을 찾을 수 없음", content = @Content)
})
@PutMapping("/{id}/default-grant")
public ResponseEntity<RoleResponse> updateDefaultGrant(
@Parameter(description = "롤 ID", required = true) @PathVariable Long id,
@Valid @RequestBody UpdateDefaultGrantRequest request) {
return ResponseEntity.ok(roleService.updateDefaultGrant(id, request.defaultGrant()));
}
@Operation(summary = "URL 패턴 삭제",
description = "특정 URL 패턴(권한)을 삭제합니다.")
@ApiResponses({

파일 보기

@ -0,0 +1,53 @@
package com.gcsc.guide.controller;
import com.gcsc.guide.dto.RegistrationSettingsResponse;
import com.gcsc.guide.dto.UpdateRegistrationSettingsRequest;
import com.gcsc.guide.service.SettingsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/admin/settings")
@RequiredArgsConstructor
@Tag(name = "07. 관리자 - 설정", description = "신규 가입 자동승인, 기본 롤 부여 등 시스템 설정 관리")
@SecurityRequirement(name = "Bearer JWT")
public class AdminSettingsController {
private final SettingsService settingsService;
@Operation(summary = "가입 설정 조회",
description = "자동 승인 여부와 기본 부여 롤 목록을 조회합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(schema = @Schema(implementation = RegistrationSettingsResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content)
})
@GetMapping("/registration")
public ResponseEntity<RegistrationSettingsResponse> getRegistrationSettings() {
return ResponseEntity.ok(settingsService.getRegistrationSettings());
}
@Operation(summary = "가입 설정 수정",
description = "자동 승인 여부를 변경합니다. true로 설정하면 신규 가입자가 즉시 ACTIVE 상태가 됩니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "수정 성공",
content = @Content(schema = @Schema(implementation = RegistrationSettingsResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content)
})
@PutMapping("/registration")
public ResponseEntity<RegistrationSettingsResponse> updateRegistrationSettings(
@Valid @RequestBody UpdateRegistrationSettingsRequest request) {
return ResponseEntity.ok(settingsService.updateAutoApprove(request.autoApprove()));
}
}

파일 보기

@ -0,0 +1,215 @@
package com.gcsc.guide.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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 java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/wing/ais-target")
@RequiredArgsConstructor
@Tag(name = "WING · AIS", description = "WING demo AIS proxy (JWT required)")
public class WingAisController {
private final ObjectMapper objectMapper;
@Value("${app.wing.ais.upstream-base:http://211.208.115.83:8041}")
private String upstreamBase;
@Value("${app.wing.ais.timeout-ms:20000}")
private long timeoutMs;
private HttpClient httpClient;
@PostConstruct
void initHttpClient() {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
}
private record Bbox(double lonMin, double latMin, double lonMax, double latMax) {
}
@GetMapping("/search")
public ResponseEntity<?> search(
@RequestParam(name = "minutes") String minutesRaw,
@RequestParam(name = "bbox", required = false) String bboxRaw,
@RequestParam(name = "centerLon", required = false) Double centerLon,
@RequestParam(name = "centerLat", required = false) Double centerLat,
@RequestParam(name = "radiusMeters", required = false) Double radiusMeters
) {
Integer minutes = parseMinutes(minutesRaw);
if (minutes == null) {
return error(HttpStatus.BAD_REQUEST, "invalid minutes", "BAD_REQUEST");
}
Bbox bbox = parseBbox(bboxRaw);
if (bboxRaw != null && bbox == null) {
return error(HttpStatus.BAD_REQUEST, "invalid bbox", "BAD_REQUEST");
}
URI upstreamUrl = buildUpstreamUrl(minutes, centerLon, centerLat, radiusMeters);
HttpRequest req = HttpRequest.newBuilder(upstreamUrl)
.timeout(Duration.ofMillis(timeoutMs))
.header("accept", "application/json")
.GET()
.build();
int status;
String body;
try {
HttpResponse<String> res = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
status = res.statusCode();
body = res.body() == null ? "" : res.body();
} catch (java.net.http.HttpTimeoutException e) {
log.warn("AIS upstream timeout ({}ms): {}", timeoutMs, upstreamUrl);
return error(HttpStatus.GATEWAY_TIMEOUT, "upstream timeout (" + timeoutMs + "ms)", "UPSTREAM_TIMEOUT");
} catch (Exception e) {
log.warn("AIS upstream fetch failed: {} ({})", upstreamUrl, e.toString());
return error(HttpStatus.BAD_GATEWAY, "upstream fetch failed", "UPSTREAM_FETCH_FAILED");
}
if (status < 200 || status >= 300) {
log.warn("AIS upstream error: status={} url={}", status, upstreamUrl);
return error(HttpStatus.BAD_GATEWAY, "upstream error", "UPSTREAM");
}
// Fast path: no bbox requested, proxy raw payload.
if (bbox == null) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(body);
}
try {
Map<String, Object> json = objectMapper.readValue(body, new TypeReference<>() {
});
Object dataObj = json.get("data");
List<?> rows = dataObj instanceof List<?> l ? l : List.of();
List<Object> filtered = new ArrayList<>(rows.size());
for (Object row : rows) {
if (inBbox(row, bbox)) {
filtered.add(row);
}
}
json.put("data", filtered);
Object msgObj = json.get("message");
String msg = msgObj instanceof String s ? s : "";
String suffix = " (bbox: " + filtered.size() + "/" + rows.size() + ")";
json.put("message", (msg + suffix).trim());
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(json);
} catch (Exception e) {
log.warn("AIS upstream JSON parse/filter failed: {}", e.toString());
return error(HttpStatus.BAD_GATEWAY, "upstream invalid json", "UPSTREAM_INVALID_JSON");
}
}
private Integer parseMinutes(String raw) {
if (raw == null) return null;
int minutes;
try {
minutes = Integer.parseInt(raw);
} catch (NumberFormatException e) {
return null;
}
if (minutes <= 0 || minutes > 60 * 24) return null;
return minutes;
}
private Bbox parseBbox(String raw) {
if (raw == null || raw.isBlank()) return null;
String[] parts = raw.split(",");
if (parts.length != 4) return null;
Double lonMin = toDouble(parts[0]);
Double latMin = toDouble(parts[1]);
Double lonMax = toDouble(parts[2]);
Double latMax = toDouble(parts[3]);
if (lonMin == null || latMin == null || lonMax == null || latMax == null) return null;
boolean ok =
lonMin >= -180 && lonMax <= 180 &&
latMin >= -90 && latMax <= 90 &&
lonMin < lonMax &&
latMin < latMax;
if (!ok) return null;
return new Bbox(lonMin, latMin, lonMax, latMax);
}
private boolean inBbox(Object row, Bbox bbox) {
if (!(row instanceof Map<?, ?> m)) return false;
Double lon = toDouble(m.get("lon"));
Double lat = toDouble(m.get("lat"));
if (lon == null || lat == null) return false;
return lon >= bbox.lonMin && lon <= bbox.lonMax && lat >= bbox.latMin && lat <= bbox.latMax;
}
private Double toDouble(Object value) {
if (value == null) return null;
if (value instanceof Number n) return n.doubleValue();
if (value instanceof String s) {
String t = s.trim();
if (t.isEmpty()) return null;
try {
return Double.parseDouble(t);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
private URI buildUpstreamUrl(int minutes, Double centerLon, Double centerLat, Double radiusMeters) {
String base = upstreamBase == null ? "" : upstreamBase.trim();
if (base.endsWith("/")) base = base.substring(0, base.length() - 1);
StringBuilder sb = new StringBuilder(base);
sb.append("/snp-api/api/ais-target/search");
sb.append("?minutes=").append(minutes);
// Upstream supports center/radius filtering; bbox is ignored (filtered server-side here).
if (centerLon != null && Double.isFinite(centerLon)) sb.append("&centerLon=").append(centerLon);
if (centerLat != null && Double.isFinite(centerLat)) sb.append("&centerLat=").append(centerLat);
if (radiusMeters != null && Double.isFinite(radiusMeters)) sb.append("&radiusMeters=").append(radiusMeters);
return URI.create(sb.toString());
}
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, String errorCode) {
return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of(
"success", false,
"message", message,
"data", List.of(),
"errorCode", errorCode
));
}
}

파일 보기

@ -0,0 +1,57 @@
package com.gcsc.guide.controller;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
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.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.concurrent.TimeUnit;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController
@RequestMapping("/api/wing/data")
@RequiredArgsConstructor
@Tag(name = "WING · Data", description = "WING embedded datasets (JWT required)")
public class WingDataController {
@GetMapping(value = "/zones", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Resource> zones() {
return serveJson("wing-data/zones.wgs84.geojson");
}
@GetMapping(value = "/legacy", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Resource> legacyChinesePermitted() {
return serveJson("wing-data/chinese-permitted.v1.json");
}
@GetMapping(value = "/subcables/geo", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Resource> subcablesGeo() {
return serveJson("wing-data/subcables/cable-geo.json");
}
@GetMapping(value = "/subcables/details", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Resource> subcablesDetails() {
return serveJson("wing-data/subcables/cable-details.min.json");
}
private ResponseEntity<Resource> serveJson(String classpathLocation) {
Resource resource = new ClassPathResource(classpathLocation);
if (!resource.exists()) {
throw new ResponseStatusException(NOT_FOUND, "Resource not found: " + classpathLocation);
}
return ResponseEntity.ok()
// Authenticated endpoint: allow browser caching but keep it private.
.cacheControl(CacheControl.maxAge(6, TimeUnit.HOURS).cachePrivate())
.contentType(MediaType.APPLICATION_JSON)
.body(resource);
}
}

파일 보기

@ -0,0 +1,8 @@
package com.gcsc.guide.dto;
import java.util.List;
public record RegistrationSettingsResponse(
boolean autoApprove,
List<RoleResponse> defaultRoles
) {}

파일 보기

@ -9,7 +9,8 @@ public record RoleResponse(
Long id,
String name,
String description,
List<String> urlPatterns
List<String> urlPatterns,
boolean defaultGrant
) {
public static RoleResponse from(Role role) {
@ -21,7 +22,8 @@ public record RoleResponse(
role.getId(),
role.getName(),
role.getDescription(),
patterns
patterns,
role.isDefaultGrant()
);
}
}

파일 보기

@ -0,0 +1,7 @@
package com.gcsc.guide.dto;
import jakarta.validation.constraints.NotNull;
public record UpdateDefaultGrantRequest(
@NotNull Boolean defaultGrant
) {}

파일 보기

@ -0,0 +1,7 @@
package com.gcsc.guide.dto;
import jakarta.validation.constraints.NotNull;
public record UpdateRegistrationSettingsRequest(
@NotNull Boolean autoApprove
) {}

파일 보기

@ -0,0 +1,46 @@
package com.gcsc.guide.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "app_settings")
@Getter
@NoArgsConstructor
public class AppSetting {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "setting_key", nullable = false, unique = true, length = 100)
private String settingKey;
@Column(name = "setting_value", nullable = false, length = 500)
private String settingValue;
@Column(length = 255)
private String description;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public AppSetting(String settingKey, String settingValue, String description) {
this.settingKey = settingKey;
this.settingValue = settingValue;
this.description = description;
}
public void updateValue(String value) {
this.settingValue = value;
this.updatedAt = LocalDateTime.now();
}
@PrePersist
protected void onCreate() {
this.updatedAt = LocalDateTime.now();
}
}

파일 보기

@ -24,6 +24,9 @@ public class Role {
@Column(length = 255)
private String description;
@Column(name = "default_grant")
private boolean defaultGrant = false;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@ -40,6 +43,10 @@ public class Role {
this.description = description;
}
public void updateDefaultGrant(boolean defaultGrant) {
this.defaultGrant = defaultGrant;
}
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();

파일 보기

@ -0,0 +1,11 @@
package com.gcsc.guide.repository;
import com.gcsc.guide.entity.AppSetting;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface AppSettingRepository extends JpaRepository<AppSetting, Long> {
Optional<AppSetting> findBySettingKey(String settingKey);
}

파일 보기

@ -16,4 +16,6 @@ public interface RoleRepository extends JpaRepository<Role, Long> {
@Query("SELECT r FROM Role r LEFT JOIN FETCH r.urlPatterns WHERE r.id = :id")
Optional<Role> findByIdWithUrlPatterns(Long id);
List<Role> findByDefaultGrantTrue();
}

파일 보기

@ -81,6 +81,14 @@ public class RoleService {
roleUrlPatternRepository.deleteById(permissionId);
}
@Transactional
public RoleResponse updateDefaultGrant(Long roleId, boolean defaultGrant) {
Role role = roleRepository.findByIdWithUrlPatterns(roleId)
.orElseThrow(() -> new ResourceNotFoundException("", roleId));
role.updateDefaultGrant(defaultGrant);
return RoleResponse.from(roleRepository.save(role));
}
private Role findRoleById(Long roleId) {
return roleRepository.findById(roleId)
.orElseThrow(() -> new ResourceNotFoundException("", roleId));

파일 보기

@ -0,0 +1,47 @@
package com.gcsc.guide.service;
import com.gcsc.guide.dto.RegistrationSettingsResponse;
import com.gcsc.guide.dto.RoleResponse;
import com.gcsc.guide.entity.AppSetting;
import com.gcsc.guide.repository.AppSettingRepository;
import com.gcsc.guide.repository.RoleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class SettingsService {
private static final String AUTO_APPROVE_KEY = "registration.auto-approve";
private final AppSettingRepository appSettingRepository;
private final RoleRepository roleRepository;
@Transactional(readOnly = true)
public boolean isAutoApproveEnabled() {
return appSettingRepository.findBySettingKey(AUTO_APPROVE_KEY)
.map(s -> Boolean.parseBoolean(s.getSettingValue()))
.orElse(false);
}
@Transactional(readOnly = true)
public RegistrationSettingsResponse getRegistrationSettings() {
boolean autoApprove = isAutoApproveEnabled();
List<RoleResponse> defaultRoles = roleRepository.findByDefaultGrantTrue().stream()
.map(RoleResponse::from)
.toList();
return new RegistrationSettingsResponse(autoApprove, defaultRoles);
}
@Transactional
public RegistrationSettingsResponse updateAutoApprove(boolean autoApprove) {
AppSetting setting = appSettingRepository.findBySettingKey(AUTO_APPROVE_KEY)
.orElseGet(() -> new AppSetting(AUTO_APPROVE_KEY, "false", "신규 가입자 자동 승인 여부"));
setting.updateValue(String.valueOf(autoApprove));
appSettingRepository.save(setting);
return getRegistrationSettings();
}
}

파일 보기

@ -1,11 +1,17 @@
-- 초기 롤 시드 데이터
INSERT INTO roles (name, description, created_at) VALUES
('ADMIN', '전체 접근 권한 (관리자 페이지 포함)', NOW()),
('DEVELOPER', '전체 개발 가이드 접근', NOW()),
('FRONT_DEV', '프론트엔드 개발 가이드만', NOW());
INSERT INTO roles (name, description, default_grant, created_at) VALUES
('ADMIN', '전체 접근 권한 (관리자 페이지 포함)', false, NOW()),
('DEVELOPER', '전체 개발 가이드 접근', false, NOW()),
('FRONT_DEV', '프론트엔드 개발 가이드만', false, NOW()),
('WING_PERMIT', 'Wing 데모 사이트 접근 권한', true, NOW());
-- 롤별 URL 패턴
INSERT INTO role_url_patterns (role_id, url_pattern, created_at) VALUES
((SELECT id FROM roles WHERE name = 'ADMIN'), '/**', NOW()),
((SELECT id FROM roles WHERE name = 'DEVELOPER'), '/dev/**', NOW()),
((SELECT id FROM roles WHERE name = 'FRONT_DEV'), '/dev/front/**', NOW());
((SELECT id FROM roles WHERE name = 'FRONT_DEV'), '/dev/front/**', NOW()),
((SELECT id FROM roles WHERE name = 'WING_PERMIT'), '/wing/**', NOW());
-- 시스템 설정
INSERT INTO app_settings (setting_key, setting_value, description, updated_at) VALUES
('registration.auto-approve', 'true', '신규 가입자 자동 승인 여부', NOW());

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long