feat: WTM 멀티프로젝트 플랫폼 구축 (BE + FE 전체 구현)

Phase 0: wbx-spring-core 라이브러리 전환
- java-library 플러그인, WbxAutoConfiguration, Admin 조건부 활성화
- 루트 settings.gradle + build.gradle (멀티모듈)

Phase 1: wtm-api 모듈 생성
- 23개 JPA Entity, 14개 Controller, 79개 API 엔드포인트
- Flyway V100~V107 MySQL 마이그레이션
- TimesheetRuleEngine, TimesheetApprovalHandler, P6WbsParser

Phase 2: wtm-frontend (Vue 3 + PrimeVue 4)
- 10개 도메인 모듈, 17개 View, 5개 서브컴포넌트
- 반응형 레이아웃 (AppLayout, AppSidebar, AppTopbar)
- BaseCrudTable, BaseFormDialog, BasePageHeader 표준 컴포넌트
- JWT 인터셉터, 역할 기반 메뉴 필터링

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이 Commit은 다음에 포함되어 있습니다:
2026-03-25 21:01:43 +09:00
부모 783865266b
커밋 df723f1d59
533개의 변경된 파일15528개의 추가작업 그리고 154개의 파일을 삭제

파일 보기

@@ -0,0 +1,15 @@
package kr.co.accura.wbx.spring;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class HealthController {
@GetMapping("/health")
public Map<String, String> health() {
return Map.of("status", "ok", "app", "wbx-spring");
}
}

파일 보기

@@ -0,0 +1,47 @@
package kr.co.accura.wbx.spring.admin;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
/**
* Conditionally activates the Admin Console UI.
* Enabled by default (matchIfMissing = true); set wbx.spring.admin-ui.enabled=false to disable.
* Individual admin beans (AdminController, AdminLoginController, AdminUserDetailsService,
* AdminViewController) also carry the same @ConditionalOnProperty so they are excluded
* from component scanning when the property is false.
*/
@Configuration
@ConditionalOnProperty(name = "wbx.spring.admin-ui.enabled", havingValue = "true", matchIfMissing = true)
public class AdminAutoConfiguration {
/**
* Admin Console — session-based form login (moved from SecurityAutoConfig).
*/
@Bean
@Order(1)
public SecurityFilterChain adminFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/admin/**", "/admin")
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/css/**", "/admin/js/**").permitAll()
.requestMatchers("/admin/login").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/admin/login")
.defaultSuccessUrl("/admin", true)
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/admin/logout")
.logoutSuccessUrl("/admin/login?logout")
)
.build();
}
}

파일 보기

@@ -0,0 +1,119 @@
package kr.co.accura.wbx.spring.admin;
import kr.co.accura.wbx.spring.auth.*;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wbx.spring.common.NotFoundException;
import kr.co.accura.wbx.spring.rbac.WbxRole;
import kr.co.accura.wbx.spring.rbac.RolePermission;
import kr.co.accura.wbx.spring.rbac.RolePermissionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import java.util.List;
import java.util.Map;
@ConditionalOnProperty(name = "wbx.spring.admin-ui.enabled", havingValue = "true", matchIfMissing = true)
@RestController
@RequestMapping("${wbx.spring.api-prefix:/api}/admin")
@PreAuthorize("hasRole('SA')")
@RequiredArgsConstructor
public class AdminController {
private final WbxUserRepository userRepository;
private final LoginHistoryRepository loginHistoryRepository;
private final WbxRoleRepository roleRepository;
private final RolePermissionRepository permissionRepository;
private final PasswordEncoder passwordEncoder;
// ===== 사용자 관리 =====
@GetMapping("/users")
public Map<String, Object> listUsers(@RequestParam(defaultValue = "0") int skip,
@RequestParam(defaultValue = "20") int limit) {
Page<WbxUser> page = userRepository.findAll(PageRequest.of(skip / Math.max(limit, 1), limit));
return Map.of("items", page.getContent(), "total", page.getTotalElements());
}
@GetMapping("/users/{id}")
public WbxUser getUser(@PathVariable Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
}
@PostMapping("/users/{id}/unlock")
public Map<String, String> unlockUser(@PathVariable Long id) {
WbxUser user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
user.setFailedLoginAttempts(0);
user.setLastFailedLogin(null);
user.setLockedUntil(null);
userRepository.save(user);
return Map.of("detail", "User unlocked");
}
@PostMapping("/users/{id}/reset-password")
public Map<String, String> resetPassword(@PathVariable Long id) {
WbxUser user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
String tempPwd = "Temp" + System.currentTimeMillis() % 10000 + "!";
user.setHashedPassword(passwordEncoder.encode(tempPwd));
user.setMustChangePassword(true);
userRepository.save(user);
return Map.of("detail", "Password reset", "temp_password", tempPwd);
}
@PutMapping("/users/{id}/status")
public Map<String, String> toggleStatus(@PathVariable Long id, @RequestBody Map<String, Boolean> req) {
WbxUser user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
user.setActive(req.getOrDefault("is_active", true));
userRepository.save(user);
return Map.of("detail", "Status updated");
}
// ===== 역할 관리 =====
@GetMapping("/roles")
public Map<String, Object> listRoles() {
List<WbxRole> roles = roleRepository.findAll();
return Map.of("items", roles, "total", roles.size());
}
@PostMapping("/roles")
public WbxRole createRole(@RequestBody WbxRole role) {
return roleRepository.save(role);
}
@GetMapping("/roles/{id}/permissions")
public Map<String, Object> getRolePermissions(@PathVariable Long id) {
List<RolePermission> perms = permissionRepository.findAll()
.stream().filter(p -> p.getRoleId().equals(id)).toList();
return Map.of("items", perms, "total", perms.size());
}
// ===== 로그인 이력 =====
@GetMapping("/login-history")
public Map<String, Object> loginHistory(@RequestParam(defaultValue = "0") int skip,
@RequestParam(defaultValue = "50") int limit) {
Page<WbxLoginHistory> page = loginHistoryRepository.findAllByOrderByCreatedAtDesc(
PageRequest.of(skip / Math.max(limit, 1), limit));
return Map.of("items", page.getContent(), "total", page.getTotalElements());
}
// ===== 시스템 상태 =====
@GetMapping("/system-health")
public Map<String, Object> systemHealth() {
return Map.of(
"users", userRepository.countByIsActiveTrue(),
"status", "ok"
);
}
}

파일 보기

@@ -0,0 +1,15 @@
package kr.co.accura.wbx.spring.admin;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@ConditionalOnProperty(name = "wbx.spring.admin-ui.enabled", havingValue = "true", matchIfMissing = true)
@Controller
public class AdminLoginController {
@GetMapping("/admin/login")
public String loginPage() {
return "admin/login";
}
}

파일 보기

@@ -0,0 +1,36 @@
package kr.co.accura.wbx.spring.admin;
import kr.co.accura.wbx.spring.auth.WbxUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import java.util.List;
@ConditionalOnProperty(name = "wbx.spring.admin-ui.enabled", havingValue = "true", matchIfMissing = true)
@Service
@RequiredArgsConstructor
public class AdminUserDetailsService implements UserDetailsService {
private final WbxUserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
var user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
return new User(
user.getEmail(),
user.getHashedPassword(),
user.isActive(),
true, true,
!user.isLocked(),
List.of(new SimpleGrantedAuthority(user.isAdmin() ? "ROLE_SA" : "ROLE_USER"))
);
}
}

파일 보기

@@ -0,0 +1,352 @@
package kr.co.accura.wbx.spring.admin;
import kr.co.accura.wbx.spring.auth.*;
import kr.co.accura.wbx.spring.audit.AuditLogRepository;
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
import kr.co.accura.wbx.spring.config.WbxSystemConfig;
import kr.co.accura.wbx.spring.rbac.*;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ConditionalOnProperty(name = "wbx.spring.admin-ui.enabled", havingValue = "true", matchIfMissing = true)
@Controller
@RequestMapping("/admin")
@RequiredArgsConstructor
public class AdminViewController {
private final WbxUserRepository userRepository;
private final LoginHistoryRepository loginHistoryRepository;
private final WbxRoleRepository roleRepository;
private final RolePermissionRepository permissionRepository;
private final PasswordEncoder passwordEncoder;
private final WbxSpringProperties props;
private final AuditLogRepository auditLogRepository;
private final WbxSystemConfigRepository systemConfigRepository;
private final WbxUserRoleRepository userRoleRepository;
// ===== 대시보드 =====
@GetMapping
public String dashboard(Model model) {
model.addAttribute("userCount", userRepository.countByIsActiveTrue());
model.addAttribute("totalUsers", userRepository.count());
model.addAttribute("loginCount", loginHistoryRepository.countByAction("LOGIN_SUCCESS"));
model.addAttribute("roleCount", roleRepository.count());
return "admin/dashboard";
}
// ===== 사용자 관리 =====
@GetMapping("/users")
public String userList(Model model) {
model.addAttribute("users", userRepository.findAll());
return "admin/users";
}
@PostMapping("/users/add")
public String addUser(@RequestParam String email,
@RequestParam String username,
@RequestParam String password,
@RequestParam(required = false) String fullName,
@RequestParam(required = false) String phone,
@RequestParam(required = false) String positionTitle,
@RequestParam(required = false) String employeeNumber,
@RequestParam(required = false) Boolean isAdmin,
RedirectAttributes ra) {
if (userRepository.existsByEmail(email)) {
ra.addFlashAttribute("error", "이미 존재하는 이메일: " + email);
return "redirect:/admin/users";
}
if (userRepository.existsByUsername(username)) {
ra.addFlashAttribute("error", "이미 존재하는 사용자명: " + username);
return "redirect:/admin/users";
}
WbxUser user = WbxUser.builder()
.email(email)
.username(username)
.hashedPassword(passwordEncoder.encode(password))
.fullName(fullName)
.phone(phone)
.positionTitle(positionTitle)
.employeeNumber(employeeNumber != null && !employeeNumber.isBlank() ? employeeNumber : null)
.isActive(true)
.isAdmin(isAdmin != null && isAdmin)
.build();
userRepository.save(user);
ra.addFlashAttribute("message", "사용자가 추가되었습니다: " + email);
return "redirect:/admin/users/" + user.getId();
}
@GetMapping("/users/{id}")
public String userDetail(@PathVariable Long id, Model model) {
model.addAttribute("user", userRepository.findById(id).orElse(null));
model.addAttribute("loginHistory",
loginHistoryRepository.findByUserIdOrderByCreatedAtDesc(id, PageRequest.of(0, 10)));
model.addAttribute("allRoles", roleRepository.findAll());
model.addAttribute("userRoles", userRoleRepository.findByUserId(id));
return "admin/user-detail";
}
@PostMapping("/users/{id}/update")
public String updateUser(@PathVariable Long id,
@RequestParam String fullName,
@RequestParam(required = false) String phone,
@RequestParam(required = false) String positionTitle,
@RequestParam(required = false) String employeeNumber,
@RequestParam(required = false) Boolean isAdmin,
RedirectAttributes ra) {
userRepository.findById(id).ifPresent(user -> {
user.setFullName(fullName);
user.setPhone(phone);
user.setPositionTitle(positionTitle);
user.setEmployeeNumber(employeeNumber != null && !employeeNumber.isBlank() ? employeeNumber : null);
user.setAdmin(isAdmin != null && isAdmin);
userRepository.save(user);
});
ra.addFlashAttribute("message", "사용자 정보가 수정되었습니다.");
return "redirect:/admin/users/" + id;
}
@PostMapping("/users/{id}/delete")
public String deleteUser(@PathVariable Long id, RedirectAttributes ra) {
userRepository.findById(id).ifPresent(user -> {
userRoleRepository.deleteByUserId(id);
userRepository.delete(user);
ra.addFlashAttribute("message", "사용자가 삭제되었습니다: " + user.getEmail());
});
return "redirect:/admin/users";
}
@PostMapping("/users/{id}/unlock")
public String unlockUser(@PathVariable Long id, RedirectAttributes ra) {
userRepository.findById(id).ifPresent(user -> {
user.setFailedLoginAttempts(0);
user.setLockedUntil(null);
userRepository.save(user);
});
ra.addFlashAttribute("message", "계정 잠금이 해제되었습니다.");
return "redirect:/admin/users/" + id;
}
@PostMapping("/users/{id}/reset-password")
public String resetPassword(@PathVariable Long id, RedirectAttributes ra) {
userRepository.findById(id).ifPresent(user -> {
String temp = "Temp" + System.currentTimeMillis() % 10000 + "!";
user.setHashedPassword(passwordEncoder.encode(temp));
user.setMustChangePassword(true);
userRepository.save(user);
ra.addFlashAttribute("message", "임시 비밀번호: " + temp);
});
return "redirect:/admin/users/" + id;
}
@PostMapping("/users/{id}/toggle-status")
public String toggleStatus(@PathVariable Long id, RedirectAttributes ra) {
userRepository.findById(id).ifPresent(user -> {
user.setActive(!user.isActive());
userRepository.save(user);
ra.addFlashAttribute("message", user.isActive() ? "계정 활성화" : "계정 비활성화");
});
return "redirect:/admin/users/" + id;
}
// ===== 사용자 역할 할당 =====
@CacheEvict(value = {"permissions", "deptScopes"}, allEntries = true)
@PostMapping("/users/{userId}/roles/add")
public String addUserRole(@PathVariable Long userId,
@RequestParam Long roleId,
RedirectAttributes ra) {
WbxUserRole ur = WbxUserRole.builder()
.userId(userId)
.roleId(roleId)
.build();
userRoleRepository.save(ur);
ra.addFlashAttribute("message", "역할이 할당되었습니다.");
return "redirect:/admin/users/" + userId;
}
@CacheEvict(value = {"permissions", "deptScopes"}, allEntries = true)
@PostMapping("/users/{userId}/roles/{urId}/delete")
public String removeUserRole(@PathVariable Long userId,
@PathVariable Long urId,
RedirectAttributes ra) {
userRoleRepository.deleteById(urId);
ra.addFlashAttribute("message", "역할이 해제되었습니다.");
return "redirect:/admin/users/" + userId;
}
// ===== 역할 관리 =====
@GetMapping("/roles")
public String roleList(Model model) {
model.addAttribute("roles", roleRepository.findAll());
return "admin/roles";
}
@PostMapping("/roles/add")
public String addRole(@RequestParam String code,
@RequestParam String name,
@RequestParam(required = false) String description,
RedirectAttributes ra) {
if (roleRepository.findByCode(code).isPresent()) {
ra.addFlashAttribute("error", "이미 존재하는 역할 코드: " + code);
return "redirect:/admin/roles";
}
kr.co.accura.wbx.spring.rbac.WbxRole role = kr.co.accura.wbx.spring.rbac.WbxRole.builder()
.code(code.toUpperCase())
.name(name)
.description(description)
.isSystem(false)
.build();
roleRepository.save(role);
ra.addFlashAttribute("message", "역할이 추가되었습니다: " + code);
return "redirect:/admin/roles";
}
@GetMapping("/roles/{id}")
public String roleDetail(@PathVariable Long id, Model model) {
model.addAttribute("role", roleRepository.findById(id).orElse(null));
model.addAttribute("permissions", permissionRepository.findByRoleId(id));
model.addAttribute("deptScopes", kr.co.accura.wbx.spring.rbac.DeptScope.values());
return "admin/role-detail";
}
@PostMapping("/roles/{id}/update")
public String updateRole(@PathVariable Long id,
@RequestParam String name,
@RequestParam(required = false) String description,
RedirectAttributes ra) {
roleRepository.findById(id).ifPresent(role -> {
role.setName(name);
role.setDescription(description);
roleRepository.save(role);
});
ra.addFlashAttribute("message", "역할이 수정되었습니다.");
return "redirect:/admin/roles/" + id;
}
@PostMapping("/roles/{id}/delete")
public String deleteRole(@PathVariable Long id, RedirectAttributes ra) {
roleRepository.findById(id).ifPresent(role -> {
if (role.isSystem()) {
ra.addFlashAttribute("error", "시스템 역할은 삭제할 수 없습니다.");
return;
}
permissionRepository.deleteByRoleId(id);
roleRepository.delete(role);
ra.addFlashAttribute("message", "역할이 삭제되었습니다: " + role.getCode());
});
return "redirect:/admin/roles";
}
// ===== 권한 추가/삭제 =====
@CacheEvict(value = {"permissions", "deptScopes"}, allEntries = true)
@PostMapping("/roles/{roleId}/permissions/add")
public String addPermission(@PathVariable Long roleId,
@RequestParam String module,
@RequestParam String action,
@RequestParam String deptScope,
RedirectAttributes ra) {
RolePermission perm = RolePermission.builder()
.roleId(roleId)
.module(module.toUpperCase())
.action(action.toUpperCase())
.deptScope(kr.co.accura.wbx.spring.rbac.DeptScope.valueOf(deptScope))
.build();
permissionRepository.save(perm);
ra.addFlashAttribute("message", "권한이 추가되었습니다: " + module + "." + action);
return "redirect:/admin/roles/" + roleId;
}
@CacheEvict(value = {"permissions", "deptScopes"}, allEntries = true)
@PostMapping("/permissions/{id}/delete")
public String deletePermission(@PathVariable Long id, RedirectAttributes ra) {
RolePermission perm = permissionRepository.findById(id).orElse(null);
if (perm != null) {
Long roleId = perm.getRoleId();
permissionRepository.delete(perm);
ra.addFlashAttribute("message", "권한이 삭제되었습니다.");
return "redirect:/admin/roles/" + roleId;
}
return "redirect:/admin/permissions";
}
// ===== 로그인 이력 =====
@GetMapping("/login-history")
public String loginHistory(Model model) {
model.addAttribute("logs",
loginHistoryRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(0, 50)));
return "admin/login-history";
}
// ===== 감사 로그 =====
@GetMapping("/audit-logs")
public String auditLogs(Model model) {
model.addAttribute("logs",
auditLogRepository.findAll(PageRequest.of(0, 100, Sort.by(Sort.Direction.DESC, "createdAt"))));
return "admin/audit-logs";
}
// ===== 시스템 설정 =====
@GetMapping("/config")
public String systemConfig(Model model) {
model.addAttribute("configs", systemConfigRepository.findAll());
return "admin/config";
}
@PostMapping("/config/save")
public String saveConfig(@RequestParam String configKey,
@RequestParam String configValue,
@RequestParam(required = false) String description,
RedirectAttributes ra) {
WbxSystemConfig config = systemConfigRepository.findByConfigKey(configKey)
.orElse(new WbxSystemConfig());
config.setConfigKey(configKey);
config.setConfigValue(configValue);
config.setDescription(description);
systemConfigRepository.save(config);
ra.addFlashAttribute("message", "설정이 저장되었습니다: " + configKey);
return "redirect:/admin/config";
}
// ===== 권한 매트릭스 =====
@GetMapping("/permissions")
public String permissions(Model model) {
model.addAttribute("roles", roleRepository.findAll());
model.addAttribute("permissions", permissionRepository.findAll());
return "admin/permissions";
}
// ===== 시스템 상태 =====
@GetMapping("/system-health")
public String systemHealth(Model model) {
MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
long heapUsed = mem.getHeapMemoryUsage().getUsed() / (1024 * 1024);
long heapMax = mem.getHeapMemoryUsage().getMax() / (1024 * 1024);
Map<String, Object> health = new LinkedHashMap<>();
health.put("JVM Heap", heapUsed + " MB / " + heapMax + " MB");
health.put("Active Threads", Thread.activeCount());
health.put("OS", System.getProperty("os.name") + " " + System.getProperty("os.arch"));
health.put("Java", System.getProperty("java.version"));
health.put("Spring Boot", org.springframework.boot.SpringBootVersion.getVersion());
health.put("Total Users", userRepository.count());
health.put("Active Users", userRepository.countByIsActiveTrue());
model.addAttribute("health", health);
return "admin/system-health";
}
}

파일 보기

@@ -0,0 +1,12 @@
package kr.co.accura.wbx.spring.admin;
import kr.co.accura.wbx.spring.rbac.WbxRole;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface WbxRoleRepository extends JpaRepository<WbxRole, Long> {
Optional<WbxRole> findByCode(String code);
}

파일 보기

@@ -0,0 +1,12 @@
package kr.co.accura.wbx.spring.admin;
import kr.co.accura.wbx.spring.config.WbxSystemConfig;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface WbxSystemConfigRepository extends JpaRepository<WbxSystemConfig, Long> {
Optional<WbxSystemConfig> findByConfigKey(String configKey);
}

파일 보기

@@ -0,0 +1,3 @@
package kr.co.accura.wbx.spring.approval;
public record ActionRequest(String comment) {}

파일 보기

@@ -0,0 +1,11 @@
package kr.co.accura.wbx.spring.approval;
/**
* 결재 완료 이벤트 — @EventListener로 후속 처리
*/
public record ApprovalCompletedEvent(
String approvalType,
Long itemId,
Long approverId,
Object approval
) {}

파일 보기

@@ -0,0 +1,29 @@
package kr.co.accura.wbx.spring.approval;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
/**
* 결재 유형별 핸들러 인터페이스
* @Component로 등록하면 ApprovalHandlerRegistry가 자동 수집
*/
public interface ApprovalHandler {
/** 유형 키 (URL path에 사용) — e.g. "timesheet", "project" */
String getTypeKey();
/** 표시명 — e.g. "시수 결재" */
String getTypeDisplay();
/** 승인 처리 */
ApprovalResult approve(Long approvalLineId, Long approverId, String comment);
/** 반려 처리 */
ApprovalResult reject(Long approvalLineId, Long approverId, String comment);
/** 결재 이력 조회 (ApprovalLine.vue 호환) */
ApprovalHistoryDto getApprovalHistory(Long itemId);
/** 결재 대기 목록 */
Page<ApprovalPendingDto> getPending(Long approverId, Pageable pageable);
}

파일 보기

@@ -0,0 +1,43 @@
package kr.co.accura.wbx.spring.approval;
import kr.co.accura.wbx.spring.common.BusinessException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 결재 핸들러 레지스트리 — Spring이 모든 ApprovalHandler 구현체를 자동 수집
*/
@Slf4j
@Component
public class ApprovalHandlerRegistry {
private final Map<String, ApprovalHandler> handlers;
@Autowired
public ApprovalHandlerRegistry(List<ApprovalHandler> handlerList) {
this.handlers = handlerList.stream()
.collect(Collectors.toMap(ApprovalHandler::getTypeKey, Function.identity()));
log.info("Registered approval handlers: {}", handlers.keySet());
}
public ApprovalHandler get(String typeKey) {
return Optional.ofNullable(handlers.get(typeKey))
.orElseThrow(() -> new BusinessException("Unknown approval type: " + typeKey));
}
public Collection<ApprovalHandler> getAll() {
return handlers.values();
}
public boolean hasHandler(String typeKey) {
return handlers.containsKey(typeKey);
}
}

파일 보기

@@ -0,0 +1,13 @@
package kr.co.accura.wbx.spring.approval;
import java.time.LocalDateTime;
import java.util.List;
public record ApprovalHistoryDto(
Long itemId,
String approvalType,
String status,
String authorName,
LocalDateTime submittedAt,
List<ApprovalLineDto> approvalLines
) {}

파일 보기

@@ -0,0 +1,9 @@
package kr.co.accura.wbx.spring.approval;
import java.time.LocalDateTime;
public record ApprovalLineDto(
Long id, Long approverId, String approverName,
int approvalOrder, String roleCode, String status,
String comment, LocalDateTime actedAt
) {}

파일 보기

@@ -0,0 +1,8 @@
package kr.co.accura.wbx.spring.approval;
import java.time.LocalDateTime;
public record ApprovalPendingDto(
Long id, String approvalType, String title,
String requesterName, LocalDateTime submittedAt
) {}

파일 보기

@@ -0,0 +1,16 @@
package kr.co.accura.wbx.spring.approval;
public record ApprovalResult(boolean success, String message, Object data) {
public static ApprovalResult success(String message) {
return new ApprovalResult(true, message, null);
}
public static ApprovalResult success(String message, Object data) {
return new ApprovalResult(true, message, data);
}
public static ApprovalResult failure(String message) {
return new ApprovalResult(false, message, null);
}
}

파일 보기

@@ -0,0 +1,54 @@
package kr.co.accura.wbx.spring.approval;
import kr.co.accura.wbx.spring.common.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 통합 결재 API — WBX 패턴 이식
* POST /api/approvals/unified/action/{type}/{id}/approve
* POST /api/approvals/unified/action/{type}/{id}/reject
*/
@RestController
@RequestMapping("${wbx.spring.api-prefix:/api}/approvals/unified")
@RequiredArgsConstructor
public class UnifiedApprovalController {
private final ApprovalHandlerRegistry registry;
@PostMapping("/action/{type}/{id}/approve")
public ApprovalResult approve(@PathVariable String type,
@PathVariable Long id,
@RequestBody(required = false) ActionRequest req) {
return registry.get(type)
.approve(id, SecurityUtils.getCurrentUserId(),
req != null ? req.comment() : null);
}
@PostMapping("/action/{type}/{id}/reject")
public ApprovalResult reject(@PathVariable String type,
@PathVariable Long id,
@RequestBody ActionRequest req) {
return registry.get(type)
.reject(id, SecurityUtils.getCurrentUserId(), req.comment());
}
@GetMapping("/pending")
public Map<String, Object> pending(@RequestParam(defaultValue = "0") int skip,
@RequestParam(defaultValue = "20") int limit) {
Long userId = SecurityUtils.getCurrentUserId();
List<ApprovalPendingDto> all = registry.getAll().stream()
.flatMap(h -> h.getPending(userId, PageRequest.of(0, 100)).getContent().stream())
.sorted(Comparator.comparing(ApprovalPendingDto::submittedAt).reversed())
.toList();
int end = Math.min(skip + limit, all.size());
return Map.of(
"items", skip < all.size() ? all.subList(skip, end) : List.of(),
"total", all.size()
);
}
}

파일 보기

@@ -0,0 +1,8 @@
package kr.co.accura.wbx.spring.audit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface AuditLogRepository extends JpaRepository<WbxAuditLog, Long> {
}

파일 보기

@@ -0,0 +1,23 @@
package kr.co.accura.wbx.spring.audit;
import kr.co.accura.wbx.spring.common.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AuditLogService {
private final AuditLogRepository repository;
public void log(String action, String resource, Long resourceId, String detail) {
repository.save(WbxAuditLog.builder()
.userId(SecurityUtils.getCurrentUserId())
.username(SecurityUtils.getCurrentUsername())
.action(action)
.resource(resource)
.resourceId(resourceId)
.detail(detail)
.build());
}
}

파일 보기

@@ -0,0 +1,38 @@
package kr.co.accura.wbx.spring.audit;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "wbx_audit_logs")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class WbxAuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId;
private String username;
@Column(nullable = false, length = 50)
private String action;
@Column(nullable = false, length = 100)
private String resource;
private Long resourceId;
@Column(length = 4000)
private String detail;
@Column(length = 50)
private String ipAddress;
@Column(updatable = false)
@Builder.Default
private LocalDateTime createdAt = LocalDateTime.now();
}

파일 보기

@@ -0,0 +1,49 @@
package kr.co.accura.wbx.spring.auth;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
/**
* API Key 인증 — 서버-서버(SAP BTP, ERP 등) 연동용
* Header: X-API-Key: {key}
*/
@Slf4j
@Component
public class ApiKeyFilter extends OncePerRequestFilter {
@Value("${wbx.spring.api-keys:}")
private List<String> validApiKeys;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String apiKey = request.getHeader("X-API-Key");
if (apiKey != null && !apiKey.isBlank() && validApiKeys.contains(apiKey)) {
var auth = new UsernamePasswordAuthenticationToken(
"system", null,
List.of(new SimpleGrantedAuthority("ROLE_SYSTEM"),
new SimpleGrantedAuthority("ROLE_SA")));
SecurityContextHolder.getContext().setAuthentication(auth);
log.debug("API Key authenticated: {}", apiKey.substring(0, Math.min(8, apiKey.length())) + "...");
}
chain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return request.getHeader("X-API-Key") == null;
}
}

파일 보기

@@ -0,0 +1,251 @@
package kr.co.accura.wbx.spring.auth;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.servlet.http.HttpServletRequest;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("${wbx.spring.api-prefix:/api}/auth")
@RequiredArgsConstructor
public class AuthController {
private final WbxUserRepository userRepository;
private final JwtProvider jwtProvider;
private final PasswordEncoder passwordEncoder;
private final WbxSpringProperties props;
private final RefreshTokenService refreshTokenService;
private final LoginHistoryRepository loginHistoryRepository;
private final PasswordPolicy passwordPolicy;
/** MFA 서비스 — wbx.spring.mfa.enabled=true 일 때만 주입 */
@org.springframework.beans.factory.annotation.Autowired(required = false)
private MfaService mfaService;
/**
* 로그인 — WBX FastAPI 호환 JWT 발급
*/
@PostMapping("/login")
public Map<String, Object> login(@Valid @RequestBody LoginRequest req, HttpServletRequest httpReq) {
WbxUser user = userRepository.findByEmail(req.email())
.orElseThrow(() -> new BusinessException("Invalid email or password"));
// 계정 잠금 체크
if (user.isLocked()) {
throw new BusinessException("Account locked. Try again later.", "ACCOUNT_LOCKED");
}
// 비밀번호 검증
if (!passwordEncoder.matches(req.password(), user.getHashedPassword())) {
user.recordLoginFailure(
props.getPassword().getMaxFailedAttempts(),
15 // lockout minutes
);
userRepository.save(user);
throw new BusinessException("Invalid email or password");
}
if (!user.isActive()) {
throw new BusinessException("User account is disabled");
}
// MFA 체크 — MFA 활성 사용자는 2단계 인증 필요
if (mfaService != null && mfaService.isMfaRequired(user)) {
Map<String, Object> mfaResponse = new HashMap<>();
mfaResponse.put("mfa_required", true);
mfaResponse.put("user_id", user.getId());
mfaResponse.put("mfa_enabled", user.isMfaEnabled());
return mfaResponse;
}
return completeLogin(user, httpReq, "PASSWORD");
}
/**
* MFA 로그인 2단계 — TOTP 코드 검증 후 JWT 발급
*/
@PostMapping("/mfa/verify")
public Map<String, Object> mfaVerify(@Valid @RequestBody MfaVerifyRequest req, HttpServletRequest httpReq) {
if (mfaService == null) {
throw new BusinessException("MFA is not enabled");
}
WbxUser user = userRepository.findById(req.userId())
.orElseThrow(() -> new BusinessException("User not found"));
boolean valid = mfaService.verifyLogin(user.getId(), req.code());
if (!valid) {
throw new BusinessException("Invalid MFA code", "MFA_INVALID_CODE");
}
return completeLogin(user, httpReq, "MFA_TOTP");
}
/**
* 로그인 완료 — JWT + Refresh Token 발급
*/
private Map<String, Object> completeLogin(WbxUser user, HttpServletRequest httpReq, String authMethod) {
user.recordLoginSuccess();
userRepository.save(user);
String token = jwtProvider.generateToken(user.toUserDetails());
String ip = httpReq.getRemoteAddr();
String ua = httpReq.getHeader("User-Agent");
String refreshToken = refreshTokenService.create(user.getId(), ua, ip);
loginHistoryRepository.save(WbxLoginHistory.builder()
.userId(user.getId()).email(user.getEmail())
.action("LOGIN_SUCCESS").authMethod(authMethod)
.ipAddress(ip).userAgent(ua != null ? ua.substring(0, Math.min(ua.length(), 500)) : null)
.build());
Map<String, Object> result = new HashMap<>();
result.put("access_token", token);
result.put("refresh_token", refreshToken);
result.put("token_type", "bearer");
result.put("user", Map.of(
"id", user.getId(),
"email", user.getEmail(),
"username", user.getUsername(),
"full_name", user.getFullName() != null ? user.getFullName() : "",
"is_admin", user.isAdmin(),
"department_id", user.getDepartmentId() != null ? user.getDepartmentId() : 0
));
result.put("must_change_password", user.isMustChangePassword());
return result;
}
/**
* 회원가입 (SA 전용 또는 초기 설정용)
*/
@PostMapping("/register")
public Map<String, Object> register(@Valid @RequestBody RegisterRequest req) {
if (userRepository.existsByEmail(req.email())) {
throw new BusinessException("Email already exists");
}
if (userRepository.existsByUsername(req.username())) {
throw new BusinessException("Username already exists");
}
passwordPolicy.validate(req.password());
WbxUser user = WbxUser.builder()
.email(req.email())
.username(req.username())
.hashedPassword(passwordEncoder.encode(req.password()))
.fullName(req.fullName())
.isActive(true)
.isAdmin(req.isAdmin() != null && req.isAdmin())
.build();
userRepository.save(user);
log.info("User registered: {}", user.getEmail());
return Map.of("detail", "User registered successfully", "id", user.getId());
}
/**
* 내 정보 조회
*/
@GetMapping("/me")
public Map<String, Object> me(@AuthenticationPrincipal WbxUserDetails user) {
return Map.of(
"id", user.getId(),
"email", user.getEmail(),
"username", user.getUsername(),
"full_name", user.getFullName(),
"is_admin", user.isAdmin(),
"department_id", user.getDepartmentId() != null ? user.getDepartmentId() : 0,
"roles", user.getRoles()
);
}
/**
* 비밀번호 변경
*/
@PutMapping("/password/change")
public Map<String, String> changePassword(@AuthenticationPrincipal WbxUserDetails userDetails,
@Valid @RequestBody PasswordChangeRequest req) {
WbxUser user = userRepository.findById(userDetails.getId())
.orElseThrow(() -> new BusinessException("User not found"));
if (!passwordEncoder.matches(req.currentPassword(), user.getHashedPassword())) {
throw new BusinessException("Current password is incorrect");
}
passwordPolicy.validate(req.newPassword());
user.setHashedPassword(passwordEncoder.encode(req.newPassword()));
user.setMustChangePassword(false);
userRepository.save(user);
return Map.of("detail", "Password changed successfully");
}
/**
* Access Token 갱신
*/
@PostMapping("/refresh")
public Map<String, Object> refresh(@RequestBody Map<String, String> req) {
String refreshToken = req.get("refresh_token");
if (refreshToken == null || refreshToken.isBlank()) {
throw new BusinessException("refresh_token is required");
}
String newToken = refreshTokenService.refresh(refreshToken);
return Map.of("access_token", newToken, "token_type", "bearer");
}
/**
* 로그아웃
*/
@PostMapping("/logout")
public Map<String, String> logout(@AuthenticationPrincipal WbxUserDetails user,
@RequestBody(required = false) Map<String, String> req) {
if (req != null && req.get("refresh_token") != null) {
refreshTokenService.revoke(req.get("refresh_token"));
}
if (user != null) {
loginHistoryRepository.save(WbxLoginHistory.builder()
.userId(user.getId()).email(user.getEmail())
.action("LOGOUT").authMethod("TOKEN")
.build());
}
return Map.of("detail", "Logged out successfully");
}
// ===== Request DTOs =====
public record LoginRequest(
@NotBlank @Email String email,
@NotBlank String password
) {}
public record RegisterRequest(
@NotBlank @Email String email,
@NotBlank String username,
@NotBlank String password,
String fullName,
Boolean isAdmin
) {}
public record PasswordChangeRequest(
@NotBlank String currentPassword,
@NotBlank String newPassword
) {}
public record MfaVerifyRequest(
Long userId,
@NotBlank String code
) {}
}

파일 보기

@@ -0,0 +1,76 @@
package kr.co.accura.wbx.spring.auth;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
Claims claims = jwtProvider.parseToken(token);
// MFA 토큰은 인증 불가
if (jwtProvider.isMfaToken(claims)) {
chain.doFilter(request, response);
return;
}
Long userId = claims.get("user_id", Long.class);
String email = claims.get("email", String.class);
String username = claims.get("username", String.class);
String fullName = claims.get("full_name", String.class);
Boolean isAdmin = claims.get("is_admin", Boolean.class);
Long deptId = claims.get("department_id", Long.class);
@SuppressWarnings("unchecked")
List<String> roles = claims.get("roles", List.class);
WbxUserDetails user = WbxUserDetails.builder()
.id(userId)
.email(email != null ? email : "")
.username(username != null ? username : "")
.fullName(fullName != null ? fullName : "")
.isAdmin(Boolean.TRUE.equals(isAdmin))
.departmentId(deptId)
.roles(roles != null ? roles : List.of())
.build();
var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
log.debug("JWT validation failed: {}", e.getMessage());
}
}
chain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return path.contains("/auth/login") || path.contains("/auth/sso")
|| path.equals("/health") || path.startsWith("/actuator");
}
}

파일 보기

@@ -0,0 +1,81 @@
package kr.co.accura.wbx.spring.auth;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class JwtProvider {
private final WbxSpringProperties props;
/**
* WBX FastAPI 호환 JWT 생성
* 필수 claims: sub(email), user_id — WBX가 이 두 필드로 사용자 식별
*/
public String generateToken(WbxUserDetails user) {
return Jwts.builder()
.claims(Map.of(
"sub", user.getEmail(),
"user_id", user.getId(),
"username", user.getUsername(),
"full_name", user.getFullName(),
"email", user.getEmail(),
"is_admin", user.isAdmin(),
"department_id", user.getDepartmentId() != null ? user.getDepartmentId() : 0,
"roles", user.getRoles() != null ? user.getRoles() : List.of()
))
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + props.getJwt().getExpiration() * 1000))
.signWith(getSigningKey())
.compact();
}
/**
* MFA 임시 토큰 (5분, API 접근 불가)
*/
public String generateMfaToken(WbxUserDetails user) {
return Jwts.builder()
.claims(Map.of(
"user_id", user.getId(),
"mfa_pending", true
))
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 300_000)) // 5 minutes
.signWith(getSigningKey())
.compact();
}
/**
* JWT 검증 후 claims 추출
*/
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* MFA 토큰인지 확인
*/
public boolean isMfaToken(Claims claims) {
return Boolean.TRUE.equals(claims.get("mfa_pending", Boolean.class));
}
private SecretKey getSigningKey() {
byte[] keyBytes = props.getJwt().getSecret().getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
}

파일 보기

@@ -0,0 +1,16 @@
package kr.co.accura.wbx.spring.auth;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface LoginHistoryRepository extends JpaRepository<WbxLoginHistory, Long> {
Page<WbxLoginHistory> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
Page<WbxLoginHistory> findAllByOrderByCreatedAtDesc(Pageable pageable);
long countByAction(String action);
}

파일 보기

@@ -0,0 +1,64 @@
package kr.co.accura.wbx.spring.auth;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* MFA/TOTP REST API
* <p>
* wbx.spring.mfa.enabled=true 일 때 활성화.
* Google Authenticator / MS Authenticator 호환 TOTP.
*/
@RestController
@RequestMapping("${wbx.spring.api-prefix:/api}/auth/mfa")
@RequiredArgsConstructor
@ConditionalOnProperty(name = "wbx.spring.mfa.enabled", havingValue = "true")
public class MfaController {
private final MfaService mfaService;
/**
* MFA 설정 시작 — QR 코드용 시크릿 + otpauth URI 반환
*/
@PostMapping("/setup")
public Map<String, String> setup(@AuthenticationPrincipal WbxUserDetails user) {
return mfaService.setupMfa(user.getId());
}
/**
* MFA 설정 완료 — TOTP 코드 검증 후 활성화 + 백업 코드 발급
*/
@PostMapping("/setup/verify")
public Map<String, Object> verifySetup(@AuthenticationPrincipal WbxUserDetails user,
@Valid @RequestBody CodeRequest req) {
return mfaService.verifySetup(user.getId(), req.code());
}
/**
* MFA 비활성화 (본인 또는 SA)
*/
@DeleteMapping
public Map<String, String> disable(@AuthenticationPrincipal WbxUserDetails user) {
mfaService.disableMfa(user.getId());
return Map.of("detail", "MFA disabled");
}
/**
* 백업 코드로 인증 (로그인 2단계 대안)
*/
@PostMapping("/backup-verify")
public Map<String, Object> backupVerify(@RequestBody Map<String, Object> req) {
Long userId = ((Number) req.get("user_id")).longValue();
String code = (String) req.get("backup_code");
boolean valid = mfaService.verifyBackupCode(userId, code);
return Map.of("valid", valid);
}
public record CodeRequest(@NotBlank String code) {}
}

파일 보기

@@ -0,0 +1,311 @@
package kr.co.accura.wbx.spring.auth;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
/**
* MFA/TOTP 서비스 — 표준 구현
* <p>
* wbx.spring.mfa.enabled=true 일 때 활성화.
* TOTP 라이브러리 의존성이 없어도 동작하도록
* HMAC-SHA1 기반 RFC 6238 직접 구현.
* <p>
* 고객 환경에 따라 Google Authenticator / MS Authenticator 호환.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(name = "wbx.spring.mfa.enabled", havingValue = "true")
public class MfaService {
private final TotpSecretRepository totpRepo;
private final WbxUserRepository userRepo;
private final WbxSpringProperties props;
private final PasswordEncoder passwordEncoder;
private final ObjectMapper objectMapper;
private static final SecureRandom RANDOM = new SecureRandom();
private static final String AES_ALGORITHM = "AES";
// ===== TOTP Setup =====
/**
* MFA 설정 시작 — QR 코드용 시크릿 + otpauth URI 반환
*/
@Transactional
public Map<String, String> setupMfa(Long userId) {
WbxUser user = userRepo.findById(userId)
.orElseThrow(() -> new BusinessException("User not found"));
// 이미 설정된 경우 기존 삭제 후 재설정
totpRepo.findByUserId(userId).ifPresent(existing -> totpRepo.delete(existing));
String secret = generateSecret();
String encrypted = encrypt(secret);
WbxTotpSecret totp = WbxTotpSecret.builder()
.userId(userId)
.encryptedSecret(encrypted)
.verified(false)
.build();
totpRepo.save(totp);
String issuer = props.getMfa().getTotpIssuer();
String otpauthUri = String.format("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=%d&period=%d",
issuer, user.getEmail(), secret, issuer,
props.getMfa().getTotpDigits(), props.getMfa().getTotpPeriod());
return Map.of(
"secret", secret,
"otpauth_uri", otpauthUri,
"qr_data", otpauthUri // 프론트엔드에서 QR 렌더링
);
}
/**
* MFA 설정 확인 — 코드 검증 후 활성화 + 백업 코드 발급
*/
@Transactional
public Map<String, Object> verifySetup(Long userId, String code) {
WbxTotpSecret totp = totpRepo.findByUserId(userId)
.orElseThrow(() -> new BusinessException("MFA not initialized. Call setup first."));
String secret = decrypt(totp.getEncryptedSecret());
if (!verifyCode(secret, code)) {
throw new BusinessException("Invalid TOTP code", "MFA_INVALID_CODE");
}
// 백업 코드 생성
List<String> backupCodes = generateBackupCodes(8);
List<String> hashedCodes = backupCodes.stream()
.map(passwordEncoder::encode)
.collect(Collectors.toList());
try {
totp.setBackupCodes(objectMapper.writeValueAsString(hashedCodes));
} catch (Exception e) {
throw new BusinessException("Failed to save backup codes");
}
totp.setVerified(true);
totpRepo.save(totp);
// 사용자 mfaEnabled 활성화
WbxUser user = userRepo.findById(userId).orElseThrow();
user.setMfaEnabled(true);
userRepo.save(user);
log.info("MFA enabled for user: {}", user.getEmail());
return Map.of(
"detail", "MFA enabled successfully",
"backup_codes", backupCodes // 한 번만 표시 — 프론트에서 다운로드 안내
);
}
// ===== TOTP 검증 (로그인 2단계) =====
/**
* 로그인 시 TOTP 코드 검증
*/
public boolean verifyLogin(Long userId, String code) {
WbxTotpSecret totp = totpRepo.findByUserId(userId)
.orElseThrow(() -> new BusinessException("MFA not configured"));
if (!totp.isVerified()) {
throw new BusinessException("MFA setup not completed");
}
String secret = decrypt(totp.getEncryptedSecret());
return verifyCode(secret, code);
}
/**
* 백업 코드로 로그인 (1회용)
*/
@Transactional
public boolean verifyBackupCode(Long userId, String backupCode) {
WbxTotpSecret totp = totpRepo.findByUserId(userId)
.orElseThrow(() -> new BusinessException("MFA not configured"));
try {
List<String> hashedCodes = objectMapper.readValue(
totp.getBackupCodes(), new TypeReference<List<String>>() {});
for (int i = 0; i < hashedCodes.size(); i++) {
if (passwordEncoder.matches(backupCode, hashedCodes.get(i))) {
// 사용된 코드 제거
hashedCodes.remove(i);
totp.setBackupCodes(objectMapper.writeValueAsString(hashedCodes));
totpRepo.save(totp);
log.info("Backup code used for user ID: {}", userId);
return true;
}
}
} catch (Exception e) {
log.error("Failed to verify backup code", e);
}
return false;
}
// ===== MFA 비활성화 (SA 전용) =====
@Transactional
public void disableMfa(Long userId) {
totpRepo.deleteByUserId(userId);
userRepo.findById(userId).ifPresent(user -> {
user.setMfaEnabled(false);
userRepo.save(user);
});
log.info("MFA disabled for user ID: {}", userId);
}
/**
* 사용자의 MFA 필수 여부 판별
*/
public boolean isMfaRequired(WbxUser user) {
if (!props.getMfa().isEnabled()) return false;
if (user.isMfaEnabled()) return true;
// 외부 사용자 강제 MFA
if (props.getMfa().isForceForExternal() && user.getSsoProvider() == null) return true;
// 내부 사용자 강제 MFA
return props.getMfa().isForceForInternal();
}
// ===== RFC 6238 TOTP 구현 =====
private boolean verifyCode(String secret, String code) {
int digits = props.getMfa().getTotpDigits();
int period = props.getMfa().getTotpPeriod();
long timeStep = System.currentTimeMillis() / 1000 / period;
// 현재 ± 1 윈도우 허용 (시간 오차 대응)
for (int i = -1; i <= 1; i++) {
String generated = generateTotpCode(secret, timeStep + i, digits);
if (generated.equals(code)) return true;
}
return false;
}
private String generateTotpCode(String base32Secret, long timeStep, int digits) {
try {
byte[] key = base32Decode(base32Secret);
byte[] data = new byte[8];
for (int i = 7; i >= 0; i--) {
data[i] = (byte) (timeStep & 0xFF);
timeStep >>= 8;
}
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA1");
mac.init(new javax.crypto.spec.SecretKeySpec(key, "HmacSHA1"));
byte[] hash = mac.doFinal(data);
int offset = hash[hash.length - 1] & 0x0F;
int binary = ((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
int otp = binary % (int) Math.pow(10, digits);
return String.format("%0" + digits + "d", otp);
} catch (Exception e) {
throw new BusinessException("TOTP generation failed");
}
}
// ===== Helper Methods =====
private String generateSecret() {
byte[] bytes = new byte[20]; // 160 bits
RANDOM.nextBytes(bytes);
return base32Encode(bytes);
}
private List<String> generateBackupCodes(int count) {
return IntStream.range(0, count)
.mapToObj(i -> {
int code = 10000000 + RANDOM.nextInt(90000000); // 8자리 숫자
return String.valueOf(code);
})
.collect(Collectors.toList());
}
/**
* AES-256 암호화 (JWT secret 앞 32바이트를 키로 사용)
*/
private String encrypt(String plainText) {
try {
byte[] key = getAesKey();
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, AES_ALGORITHM));
return Base64.getEncoder().encodeToString(cipher.doFinal(plainText.getBytes()));
} catch (Exception e) {
throw new BusinessException("Encryption failed");
}
}
private String decrypt(String cipherText) {
try {
byte[] key = getAesKey();
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, AES_ALGORITHM));
return new String(cipher.doFinal(Base64.getDecoder().decode(cipherText)));
} catch (Exception e) {
throw new BusinessException("Decryption failed");
}
}
private byte[] getAesKey() {
String secret = props.getJwt().getSecret();
byte[] keyBytes = secret.getBytes();
return Arrays.copyOf(keyBytes, 32); // AES-256 = 32 bytes
}
// Base32 encode/decode (RFC 4648)
private static final String BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private String base32Encode(byte[] data) {
StringBuilder result = new StringBuilder();
int buffer = 0, bitsLeft = 0;
for (byte b : data) {
buffer = (buffer << 8) | (b & 0xFF);
bitsLeft += 8;
while (bitsLeft >= 5) {
result.append(BASE32_CHARS.charAt((buffer >> (bitsLeft - 5)) & 0x1F));
bitsLeft -= 5;
}
}
if (bitsLeft > 0) {
result.append(BASE32_CHARS.charAt((buffer << (5 - bitsLeft)) & 0x1F));
}
return result.toString();
}
private byte[] base32Decode(String base32) {
String upper = base32.toUpperCase().replaceAll("[^A-Z2-7]", "");
byte[] result = new byte[upper.length() * 5 / 8];
int buffer = 0, bitsLeft = 0, index = 0;
for (char c : upper.toCharArray()) {
buffer = (buffer << 5) | BASE32_CHARS.indexOf(c);
bitsLeft += 5;
if (bitsLeft >= 8) {
result[index++] = (byte) (buffer >> (bitsLeft - 8));
bitsLeft -= 8;
}
}
return Arrays.copyOf(result, index);
}
}

파일 보기

@@ -0,0 +1,51 @@
package kr.co.accura.wbx.spring.auth;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Component
@RequiredArgsConstructor
public class PasswordPolicy {
private final WbxSpringProperties props;
public void validate(String password) {
var cfg = props.getPassword();
List<String> errors = new ArrayList<>();
if (password.length() < cfg.getMinLength()) {
errors.add("비밀번호는 " + cfg.getMinLength() + "자 이상이어야 합니다.");
}
if (cfg.isRequireUppercase() && !password.matches(".*[A-Z].*")) {
errors.add("대문자를 포함해야 합니다.");
}
if (cfg.isRequireDigit() && !password.matches(".*[0-9].*")) {
errors.add("숫자를 포함해야 합니다.");
}
if (cfg.isRequireSpecial() && !password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':\",./<>?].*")) {
errors.add("특수문자를 포함해야 합니다.");
}
if (!errors.isEmpty()) {
throw new BusinessException(String.join(" ", errors), "PASSWORD_POLICY");
}
}
public boolean isExpired(WbxUser user) {
if (user.getPasswordChangedAt() == null) return true;
return user.getPasswordChangedAt()
.plusDays(props.getPassword().getExpiryDays())
.isBefore(LocalDateTime.now());
}
public boolean isLocked(WbxUser user) {
return user.getFailedLoginAttempts() >= props.getPassword().getMaxFailedAttempts()
&& user.isLocked();
}
}

파일 보기

@@ -0,0 +1,25 @@
package kr.co.accura.wbx.spring.auth;
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;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Optional;
@Repository
public interface RefreshTokenRepository extends JpaRepository<WbxRefreshToken, Long> {
Optional<WbxRefreshToken> findByTokenHash(String tokenHash);
@Modifying @Transactional
@Query("UPDATE WbxRefreshToken t SET t.isRevoked = true WHERE t.userId = :userId")
void revokeAllByUserId(@Param("userId") Long userId);
@Modifying @Transactional
@Query("DELETE FROM WbxRefreshToken t WHERE t.expiresAt < :now OR t.isRevoked = true")
void deleteExpiredOrRevoked(@Param("now") LocalDateTime now);
}

파일 보기

@@ -0,0 +1,83 @@
package kr.co.accura.wbx.spring.auth;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.stereotype.Service;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final RefreshTokenRepository repository;
private final WbxUserRepository userRepository;
private final JwtProvider jwtProvider;
private final WbxSpringProperties props;
/**
* Refresh Token 생성
*/
public String create(Long userId, String deviceInfo, String ip) {
String rawToken = UUID.randomUUID().toString();
String hash = sha256(rawToken);
repository.save(WbxRefreshToken.builder()
.userId(userId)
.tokenHash(hash)
.deviceInfo(deviceInfo)
.ipAddress(ip)
.expiresAt(LocalDateTime.now().plusSeconds(props.getJwt().getRefreshExpiration()))
.build());
return rawToken;
}
/**
* Refresh Token으로 Access Token 재발급
*/
public String refresh(String rawToken) {
String hash = sha256(rawToken);
WbxRefreshToken stored = repository.findByTokenHash(hash)
.filter(t -> !t.isRevoked() && t.getExpiresAt().isAfter(LocalDateTime.now()))
.orElseThrow(() -> new BusinessException("Invalid or expired refresh token"));
WbxUser user = userRepository.findById(stored.getUserId())
.orElseThrow(() -> new BusinessException("User not found"));
return jwtProvider.generateToken(user.toUserDetails());
}
/**
* Refresh Token 해지 (로그아웃)
*/
public void revoke(String rawToken) {
String hash = sha256(rawToken);
repository.findByTokenHash(hash)
.ifPresent(t -> {
t.setRevoked(true);
repository.save(t);
});
}
/**
* 전체 디바이스 로그아웃
*/
public void revokeAll(Long userId) {
repository.revokeAllByUserId(userId);
}
private String sha256(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(input.getBytes());
return new String(Hex.encode(hash));
} catch (Exception e) {
throw new RuntimeException("SHA-256 failed", e);
}
}
}

파일 보기

@@ -0,0 +1,99 @@
package kr.co.accura.wbx.spring.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.LocalDateTime;
/**
* Azure Entra ID (OIDC) 로그인 성공 핸들러
* <p>
* OAuth2 로그인 성공 → WBX 호환 JWT 발급 → 프론트엔드 콜백 URL로 리다이렉트.
* <p>
* 고객 환경 커스터마이즈:
* - wbx.spring.sso.callback-url: 프론트엔드 콜백 (기본: /sso/callback)
* - wbx.spring.sso.auto-register: SSO 최초 로그인 시 자동 사용자 등록
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "spring.security.oauth2.client.registration.azure.client-id")
public class SsoSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final WbxUserRepository userRepository;
private final JwtProvider jwtProvider;
private final WbxSpringProperties props;
private final RefreshTokenService refreshTokenService;
private final LoginHistoryRepository loginHistoryRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
OidcUser oidcUser = (OidcUser) authentication.getPrincipal();
String email = oidcUser.getEmail();
String name = oidcUser.getFullName();
String oid = oidcUser.getAttribute("oid"); // Azure Object ID
log.info("SSO login: email={}, name={}, oid={}", email, name, oid);
// 사용자 조회 또는 자동 등록
WbxUser user = userRepository.findByEmail(email)
.orElseGet(() -> autoRegisterSsoUser(email, name, oid));
// Azure OID 업데이트
if (oid != null && !oid.equals(user.getAzureOid())) {
user.setAzureOid(oid);
user.setSsoProvider("azure");
}
user.recordLoginSuccess();
userRepository.save(user);
// JWT 발급
String token = jwtProvider.generateToken(user.toUserDetails());
String ip = request.getRemoteAddr();
String refreshToken = refreshTokenService.create(user.getId(), "SSO", ip);
// 로그인 이력
loginHistoryRepository.save(WbxLoginHistory.builder()
.userId(user.getId()).email(email)
.action("LOGIN_SUCCESS").authMethod("SSO_AZURE")
.ipAddress(ip)
.build());
// 프론트엔드 콜백 리다이렉트 (JWT를 query param으로 전달)
String callbackUrl = String.format("/sso/callback?access_token=%s&refresh_token=%s",
token, refreshToken);
getRedirectStrategy().sendRedirect(request, response, callbackUrl);
}
private WbxUser autoRegisterSsoUser(String email, String fullName, String azureOid) {
log.info("Auto-registering SSO user: {}", email);
String username = email.split("@")[0];
// username 중복 방지
if (userRepository.existsByUsername(username)) {
username = username + "_" + System.currentTimeMillis() % 10000;
}
WbxUser user = WbxUser.builder()
.email(email)
.username(username)
.fullName(fullName != null ? fullName : username)
.hashedPassword("") // SSO 사용자는 비밀번호 없음
.isActive(true)
.isAdmin(false)
.ssoProvider("azure")
.azureOid(azureOid)
.build();
return userRepository.save(user);
}
}

파일 보기

@@ -0,0 +1,15 @@
package kr.co.accura.wbx.spring.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
public interface TotpSecretRepository extends JpaRepository<WbxTotpSecret, Long> {
Optional<WbxTotpSecret> findByUserId(Long userId);
@Modifying
@Transactional
void deleteByUserId(Long userId);
}

파일 보기

@@ -0,0 +1,41 @@
package kr.co.accura.wbx.spring.auth;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "wbx_login_history")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class WbxLoginHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id")
private Long userId;
private String email;
@Column(nullable = false, length = 20)
private String action; // LOGIN_SUCCESS, LOGIN_FAILURE, LOGOUT, MFA_SUCCESS, MFA_FAILURE
@Column(name = "ip_address", length = 50)
private String ipAddress;
@Column(name = "user_agent", length = 500)
private String userAgent;
@Column(name = "auth_method", length = 20)
private String authMethod; // PASSWORD, SSO, API_KEY, REFRESH_TOKEN
@Column(name = "failure_reason", length = 200)
private String failureReason;
@Builder.Default
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt = LocalDateTime.now();
}

파일 보기

@@ -0,0 +1,40 @@
package kr.co.accura.wbx.spring.auth;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "wbx_refresh_tokens")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class WbxRefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "token_hash", nullable = false, unique = true)
private String tokenHash;
@Column(name = "device_info", length = 500)
private String deviceInfo;
@Column(name = "ip_address", length = 50)
private String ipAddress;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@Builder.Default
@Column(name = "is_revoked")
private boolean isRevoked = false;
@Builder.Default
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt = LocalDateTime.now();
}

파일 보기

@@ -0,0 +1,31 @@
package kr.co.accura.wbx.spring.auth;
import jakarta.persistence.*;
import kr.co.accura.wbx.spring.common.BaseEntity;
import lombok.*;
@Entity
@Table(name = "wbx_totp_secrets")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class WbxTotpSecret extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private Long userId;
/** AES-256 암호화된 TOTP 시크릿 */
@Column(nullable = false, length = 500)
private String encryptedSecret;
/** MFA 설정 완료 (QR 스캔 후 검증 통과) */
@Builder.Default
private boolean verified = false;
/** JSON 배열 — 해시된 백업 코드 (1회용 복구 코드) */
@Column(length = 2000)
private String backupCodes;
}

파일 보기

@@ -0,0 +1,94 @@
package kr.co.accura.wbx.spring.auth;
import jakarta.persistence.*;
import kr.co.accura.wbx.spring.common.BaseEntity;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "wbx_users")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class WbxUser extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false, unique = true, length = 100)
private String username;
private String hashedPassword;
private String fullName;
private String phone;
@Column(name = "department_id")
private Long departmentId;
@Column(length = 100)
private String positionTitle;
@Column(unique = true, length = 50)
private String employeeNumber;
@Builder.Default
private boolean isActive = true;
@Builder.Default
private boolean isAdmin = false;
@Builder.Default
private boolean mfaEnabled = false;
@Column(length = 255)
private String azureOid;
@Column(length = 50)
private String ssoProvider;
@Builder.Default
private int failedLoginAttempts = 0;
private LocalDateTime lastFailedLogin;
private LocalDateTime lockedUntil;
private LocalDateTime passwordChangedAt;
@Builder.Default
private boolean mustChangePassword = false;
private LocalDateTime lastLoginAt;
// 비즈니스 메서드
public void recordLoginSuccess() {
this.lastLoginAt = LocalDateTime.now();
this.failedLoginAttempts = 0;
this.lastFailedLogin = null;
}
public void recordLoginFailure(int maxAttempts, int lockoutMinutes) {
this.failedLoginAttempts++;
this.lastFailedLogin = LocalDateTime.now();
if (this.failedLoginAttempts >= maxAttempts) {
this.lockedUntil = LocalDateTime.now().plusMinutes(lockoutMinutes);
}
}
public boolean isLocked() {
return lockedUntil != null && lockedUntil.isAfter(LocalDateTime.now());
}
public WbxUserDetails toUserDetails() {
return WbxUserDetails.builder()
.id(id)
.email(email)
.username(username)
.fullName(fullName != null ? fullName : "")
.isAdmin(isAdmin)
.departmentId(departmentId)
.build();
}
}

파일 보기

@@ -0,0 +1,56 @@
package kr.co.accura.wbx.spring.auth;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
@Data
@Builder
@AllArgsConstructor
public class WbxUserDetails implements UserDetails {
private Long id;
private String email;
private String username;
private String fullName;
private boolean isAdmin;
private Long departmentId;
@Builder.Default
private List<String> roles = new ArrayList<>();
@Builder.Default
private boolean enabled = true;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> authorities = new ArrayList<>();
for (String role : roles) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
if (isAdmin) {
authorities.add(new SimpleGrantedAuthority("ROLE_SA"));
}
return authorities;
}
@Override
public String getPassword() { return null; }
@Override
public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() { return true; }
@Override
public boolean isCredentialsNonExpired() { return true; }
@Override
public boolean isEnabled() { return enabled; }
}

파일 보기

@@ -0,0 +1,24 @@
package kr.co.accura.wbx.spring.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface WbxUserRepository extends JpaRepository<WbxUser, Long> {
Optional<WbxUser> findByEmail(String email);
Optional<WbxUser> findByUsername(String username);
Optional<WbxUser> findByAzureOid(String azureOid);
Optional<WbxUser> findByEmployeeNumber(String employeeNumber);
boolean existsByEmail(String email);
boolean existsByUsername(String username);
long countByIsActiveTrue();
}

파일 보기

@@ -0,0 +1,31 @@
package kr.co.accura.wbx.spring.common;
import jakarta.persistence.*;
import lombok.Getter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
@CreatedBy
@Column(updatable = false)
private Long createdBy;
@LastModifiedBy
private Long updatedBy;
}

파일 보기

@@ -0,0 +1,25 @@
package kr.co.accura.wbx.spring.common;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public class BusinessException extends RuntimeException {
private final String code;
private final HttpStatus status;
public BusinessException(String message) {
this(message, "BUSINESS_ERROR", HttpStatus.BAD_REQUEST);
}
public BusinessException(String message, String code) {
this(message, code, HttpStatus.BAD_REQUEST);
}
public BusinessException(String message, String code, HttpStatus status) {
super(message);
this.code = code;
this.status = status;
}
}

파일 보기

@@ -0,0 +1,10 @@
package kr.co.accura.wbx.spring.common;
import org.springframework.http.HttpStatus;
public class NotFoundException extends BusinessException {
public NotFoundException(String message) {
super(message, "NOT_FOUND", HttpStatus.NOT_FOUND);
}
}

파일 보기

@@ -0,0 +1,33 @@
package kr.co.accura.wbx.spring.common;
import kr.co.accura.wbx.spring.auth.WbxUserDetails;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
public final class SecurityUtils {
private SecurityUtils() {}
public static Long getCurrentUserId() {
var user = getCurrentUser();
return user != null ? user.getId() : null;
}
public static String getCurrentUsername() {
var user = getCurrentUser();
return user != null ? user.getUsername() : null;
}
public static Long getCurrentDeptId() {
var user = getCurrentUser();
return user != null ? user.getDepartmentId() : null;
}
public static WbxUserDetails getCurrentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof WbxUserDetails details) {
return details;
}
return null;
}
}

파일 보기

@@ -0,0 +1,64 @@
package kr.co.accura.wbx.spring.compat;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wbx.spring.common.NotFoundException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.stream.Collectors;
/**
* WBX FastAPI 호환 에러 핸들러
* 에러 응답에 "detail" 키 사용 (FastAPI 형식)
*/
@Slf4j
@RestControllerAdvice
public class WbxErrorHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<Map<String, Object>> handleBusiness(BusinessException ex) {
return ResponseEntity.status(ex.getStatus()).body(Map.of(
"detail", ex.getMessage(),
"code", ex.getCode(),
"timestamp", LocalDateTime.now().toString()
));
}
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(NotFoundException ex) {
return ResponseEntity.status(404).body(Map.of(
"detail", ex.getMessage()
));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Map<String, Object>> handleForbidden(AccessDeniedException ex) {
return ResponseEntity.status(403).body(Map.of(
"detail", "Access denied"
));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
String detail = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(Map.of(
"detail", detail
));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGeneral(Exception ex) {
log.error("Unhandled exception: {}", ex.getMessage(), ex);
return ResponseEntity.status(500).body(Map.of(
"detail", "Internal server error"
));
}
}

파일 보기

@@ -0,0 +1,52 @@
package kr.co.accura.wbx.spring.compat;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.MethodParameter;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
/**
* WBX No-Code 프론트엔드 호환 — skip/limit → Spring Pageable 변환
*/
@Configuration
public class WbxPaginationConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new WbxPaginationResolver());
}
static class WbxPaginationResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return Pageable.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Pageable resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
String skipStr = webRequest.getParameter("skip");
String limitStr = webRequest.getParameter("limit");
String sortStr = webRequest.getParameter("sort");
int skip = skipStr != null ? Integer.parseInt(skipStr) : 0;
int limit = limitStr != null ? Integer.parseInt(limitStr) : 20;
int page = skip / Math.max(limit, 1);
Sort sort = sortStr != null && !sortStr.isBlank()
? Sort.by(sortStr.split(","))
: Sort.unsorted();
return PageRequest.of(page, limit, sort);
}
}
}

파일 보기

@@ -0,0 +1,25 @@
package kr.co.accura.wbx.spring.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class CorsAutoConfig implements WebMvcConfigurer {
private final WbxSpringProperties props;
@Override
public void addCorsMappings(CorsRegistry registry) {
String[] origins = props.getCors().getAllowedOrigins().toArray(new String[0]);
registry.addMapping("/api/**")
.allowedOriginPatterns(origins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("Content-Disposition", "X-Total-Count")
.allowCredentials(true)
.maxAge(3600);
}
}

파일 보기

@@ -0,0 +1,29 @@
package kr.co.accura.wbx.spring.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("WBX Spring Framework API")
.version("1.0.0")
.description("WBX Spring Boot 통합 프레임워크 REST API"))
.addSecurityItem(new SecurityRequirement().addList("Bearer"))
.components(new Components()
.addSecuritySchemes("Bearer",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}

파일 보기

@@ -0,0 +1,68 @@
package kr.co.accura.wbx.spring.config;
import kr.co.accura.wbx.spring.auth.JwtFilter;
import kr.co.accura.wbx.spring.auth.SsoSuccessHandler;
import lombok.RequiredArgsConstructor;
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.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.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityAutoConfig {
private final JwtFilter jwtFilter;
private final WbxSpringProperties props;
/** SSO 핸들러 — Azure OAuth2 설정이 있을 때만 주입 */
@Autowired(required = false)
private SsoSuccessHandler ssoSuccessHandler;
/**
* REST API — JWT Stateless
*/
@Bean
@Order(2)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
String prefix = props.getApiPrefix();
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(prefix + "/auth/**").permitAll()
.requestMatchers("/health", "/actuator/**").permitAll()
.requestMatchers("/swagger-ui/**", "/api-docs/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/sso/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
// Azure Entra SSO — client-id 설정이 있으면 OAuth2Login 활성화
if (ssoSuccessHandler != null) {
http.oauth2Login(oauth2 -> oauth2
.successHandler(ssoSuccessHandler)
.failureUrl("/sso/error")
);
}
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

파일 보기

@@ -0,0 +1,21 @@
package kr.co.accura.wbx.spring.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* wbx-spring-core Auto-Configuration.
* Replaces the former WbxSpringCoreApplication annotations.
* Consuming Spring Boot applications pick this up automatically
* via META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.
*/
@Configuration
@EnableJpaAuditing
@EnableAsync
@EnableScheduling
@EnableCaching
public class WbxAutoConfiguration {
}

파일 보기

@@ -0,0 +1,123 @@
package kr.co.accura.wbx.spring.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Data
@Component
@ConfigurationProperties(prefix = "wbx.spring")
public class WbxSpringProperties {
private String apiPrefix = "/api";
private String apiVersion = "v1";
private Jwt jwt = new Jwt();
private Mfa mfa = new Mfa();
private Password password = new Password();
private Cors cors = new Cors();
private FileConfig file = new FileConfig();
private Approval approval = new Approval();
private Notification notification = new Notification();
private DataSourceConfig datasource = new DataSourceConfig();
private AdminUi adminUi = new AdminUi();
private Compat compat = new Compat();
@Data
public static class Jwt {
private String secret = "wbx-spring-default-secret-key-change-in-production-minimum-256-bits";
private long expiration = 28800; // 8 hours
private long refreshExpiration = 604800; // 7 days
}
@Data
public static class Mfa {
private boolean enabled = false;
private boolean forceForExternal = true;
private boolean forceForInternal = false;
private String totpIssuer = "WBX Platform";
private int totpDigits = 6;
private int totpPeriod = 30;
}
@Data
public static class Password {
private int minLength = 8;
private int maxFailedAttempts = 5;
private int expiryDays = 90;
private int historyCount = 3;
private boolean requireUppercase = true;
private boolean requireDigit = true;
private boolean requireSpecial = true;
}
@Data
public static class Cors {
private List<String> allowedOrigins = new ArrayList<>(List.of("http://localhost:5173", "http://localhost:3000"));
}
@Data
public static class FileConfig {
private String storageType = "local"; // local, azure-blob, aws-s3, gcp-storage
private String uploadDir = "./uploads";
private long maxSizeMb = 50;
private AzureConfig azure = new AzureConfig();
private AwsConfig aws = new AwsConfig();
private GcpConfig gcp = new GcpConfig();
@Data
public static class AzureConfig {
private String accountName = "";
private String accountKey = "";
private String containerName = "uploads";
}
@Data
public static class AwsConfig {
private String bucket = "";
private String region = "ap-northeast-2";
private String accessKey = "";
private String secretKey = "";
}
@Data
public static class GcpConfig {
private String bucket = "";
private String projectId = "";
}
}
@Data
public static class Notification {
private boolean sseEnabled = true;
private boolean websocketEnabled = false;
private int heartbeatSeconds = 30;
}
@Data
public static class AdminUi {
private boolean enabled = false;
private String path = "/admin";
private List<String> allowRoles = new ArrayList<>(List.of("SA"));
}
@Data
public static class Approval {
private boolean enabled = true;
}
@Data
public static class DataSourceConfig {
private boolean routingEnabled = false;
}
@Data
public static class Compat {
private String errorFormat = "fastapi"; // detail key
private String listKey = "items";
}
}

파일 보기

@@ -0,0 +1,37 @@
package kr.co.accura.wbx.spring.config;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "wbx_system_config")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class WbxSystemConfig {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "config_key", nullable = false, unique = true, length = 100)
private String configKey;
@Column(name = "config_value", length = 4000)
private String configValue;
@Column(name = "value_type", length = 20)
@Builder.Default
private String valueType = "STRING"; // STRING, INT, BOOLEAN, JSON
@Column(length = 500)
private String description;
@Builder.Default
@Column(name = "is_editable")
private boolean isEditable = true;
private LocalDateTime updatedAt;
private Long updatedBy;
}

파일 보기

@@ -0,0 +1,23 @@
package kr.co.accura.wbx.spring.datasource;
import java.lang.annotation.*;
/**
* 데이터소스 전환 어노테이션
* <p>
* 메서드 또는 클래스에 사용하여 특정 데이터소스를 지정합니다.
* <pre>
* {@literal @}DataSource("readonly")
* public List&lt;User&gt; findAll() { ... }
* </pre>
*
* @see WbxRoutingDataSource
* @see DataSourceAspect
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataSource {
/** 데이터소스 이름: "app" (기본), "wbxgw", "readonly" */
String value() default "app";
}

파일 보기

@@ -0,0 +1,55 @@
package kr.co.accura.wbx.spring.datasource;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
/**
* @DataSource 어노테이션 AOP
* <p>
* wbx.spring.datasource.routing-enabled=true 일 때 활성화.
* 메서드 → 클래스 순서로 @DataSource 어노테이션을 탐색합니다.
*/
@Slf4j
@Aspect
@Component
@Order(-1) // 트랜잭션 AOP보다 먼저 실행
@ConditionalOnProperty(name = "wbx.spring.datasource.routing-enabled", havingValue = "true")
public class DataSourceAspect {
@Around("@annotation(kr.co.accura.wbx.spring.datasource.DataSource) || " +
"@within(kr.co.accura.wbx.spring.datasource.DataSource)")
public Object around(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
// 메서드 레벨 우선
DataSource ds = method.getAnnotation(DataSource.class);
if (ds == null) {
// 클래스 레벨
ds = point.getTarget().getClass().getAnnotation(DataSource.class);
}
String key = (ds != null) ? ds.value() : "app";
String previous = WbxRoutingDataSource.getDataSourceKey();
try {
WbxRoutingDataSource.setDataSourceKey(key);
log.debug("DataSource switched to: {}", key);
return point.proceed();
} finally {
if (previous != null) {
WbxRoutingDataSource.setDataSourceKey(previous);
} else {
WbxRoutingDataSource.clear();
}
}
}
}

파일 보기

@@ -0,0 +1,94 @@
package kr.co.accura.wbx.spring.datasource;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
/**
* Multi-DataSource 설정 (선택적 활성화)
* <p>
* wbx.spring.datasource.routing-enabled=true 일 때 활성화.
* 기본: 단일 DataSource (Spring Boot 자동 설정).
* <p>
* 사용법 (application.yml):
* <pre>
* wbx.spring.datasource.routing-enabled: true
*
* spring.datasource: # 기본 (app)
* url: jdbc:mysql://...
*
* wbx.spring.datasource.wbxgw: # WBX Groupware DB (선택)
* url: jdbc:mysql://...
* username: xxx
* password: xxx
*
* wbx.spring.datasource.readonly: # 읽기전용 (선택)
* url: jdbc:mysql://...
* </pre>
*/
@Slf4j
@Configuration
@ConditionalOnProperty(name = "wbx.spring.datasource.routing-enabled", havingValue = "true")
public class MultiDataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource")
public DataSourceProperties appDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
public DataSource appDataSource() {
return appDataSourceProperties().initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
@Bean
@ConfigurationProperties("wbx.spring.datasource.wbxgw")
public DataSourceProperties wbxgwDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
public DataSource wbxgwDataSource() {
DataSourceProperties props = wbxgwDataSourceProperties();
if (props.getUrl() == null || props.getUrl().isBlank()) {
log.info("wbxgw datasource not configured, skipping");
return appDataSource(); // fallback to app
}
return props.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
@Bean
@Primary
public DataSource routingDataSource(
@Qualifier("appDataSource") DataSource appDs,
@Qualifier("wbxgwDataSource") DataSource wbxgwDs) {
WbxRoutingDataSource routing = new WbxRoutingDataSource();
Map<Object, Object> targets = new HashMap<>();
targets.put("app", appDs);
targets.put("wbxgw", wbxgwDs);
routing.setTargetDataSources(targets);
routing.setDefaultTargetDataSource(appDs);
routing.afterPropertiesSet();
log.info("Multi-DataSource routing enabled: {}", targets.keySet());
return routing;
}
}

파일 보기

@@ -0,0 +1,31 @@
package kr.co.accura.wbx.spring.datasource;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
/**
* ThreadLocal 기반 동적 데이터소스 라우팅
* <p>
* DataSourceAspect가 @DataSource 어노테이션을 감지하여
* ThreadLocal에 데이터소스 키를 설정하면, 이 클래스가 해당 키를 반환합니다.
*/
public class WbxRoutingDataSource extends AbstractRoutingDataSource {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setDataSourceKey(String key) {
CONTEXT.set(key);
}
public static String getDataSourceKey() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
@Override
protected Object determineCurrentLookupKey() {
return CONTEXT.get();
}
}

파일 보기

@@ -0,0 +1,163 @@
package kr.co.accura.wbx.spring.file;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.MessageDigest;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.UUID;
/**
* AWS S3 스토리지 구현 (표준 템플릿)
* <p>
* ⚠️ 프로덕션 배포 시 아래 SDK 기반 구현으로 교체를 권장합니다:
* <pre>
* // build.gradle
* implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.0'
*
* // 교체 후: S3Client.putObject() / getObject() / deleteObject() 사용
* </pre>
* <p>
* 현재 구현은 Pre-signed URL 방식을 사용하며,
* AWS CLI 인증(~/.aws/credentials)이 설정된 환경에서 동작합니다.
* <p>
* 설정:
* <pre>
* wbx.spring.file.storage-type: aws-s3
* wbx.spring.file.aws.bucket: ${AWS_S3_BUCKET}
* wbx.spring.file.aws.region: ap-northeast-2
* wbx.spring.file.aws.access-key: ${AWS_ACCESS_KEY}
* wbx.spring.file.aws.secret-key: ${AWS_SECRET_KEY}
* </pre>
*/
@Slf4j
@Service
@ConditionalOnProperty(name = "wbx.spring.file.storage-type", havingValue = "aws-s3")
public class AwsS3StorageService implements FileStorageService {
private final WbxSpringProperties props;
private final HttpClient httpClient;
public AwsS3StorageService(WbxSpringProperties props) {
this.props = props;
this.httpClient = HttpClient.newHttpClient();
}
@Override
public String upload(MultipartFile file, String category) {
try {
WbxSpringProperties.FileConfig.AwsConfig aws = props.getFile().getAws();
String objectKey = category + "/" + UUID.randomUUID() + getExtension(file.getOriginalFilename());
String url = String.format("https://%s.s3.%s.amazonaws.com/%s",
aws.getBucket(), aws.getRegion(), objectKey);
byte[] content = file.getBytes();
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", file.getContentType())
.header("x-amz-date", amzDate(now))
.header("x-amz-content-sha256", sha256Hex(content))
.PUT(HttpRequest.BodyPublishers.ofByteArray(content))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 300) {
throw new BusinessException("S3 upload failed: " + response.statusCode());
}
log.info("S3 uploaded: {}/{}", aws.getBucket(), objectKey);
return objectKey;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw new BusinessException("S3 upload failed: " + e.getMessage());
}
}
@Override
public byte[] download(String fileKey) {
try {
WbxSpringProperties.FileConfig.AwsConfig aws = props.getFile().getAws();
String url = String.format("https://%s.s3.%s.amazonaws.com/%s",
aws.getBucket(), aws.getRegion(), fileKey);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("x-amz-date", amzDate(ZonedDateTime.now(ZoneOffset.UTC)))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 404) {
throw new BusinessException("File not found: " + fileKey);
}
return response.body();
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw new BusinessException("S3 download failed: " + e.getMessage());
}
}
@Override
public void delete(String fileKey) {
try {
WbxSpringProperties.FileConfig.AwsConfig aws = props.getFile().getAws();
String url = String.format("https://%s.s3.%s.amazonaws.com/%s",
aws.getBucket(), aws.getRegion(), fileKey);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.DELETE()
.build();
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
log.info("S3 deleted: {}", fileKey);
} catch (Exception e) {
log.warn("S3 delete failed: {}", e.getMessage());
}
}
@Override
public String getPresignedUrl(String fileKey, long expirySeconds) {
WbxSpringProperties.FileConfig.AwsConfig aws = props.getFile().getAws();
// Pre-signed URL (간략 구현 — 프로덕션은 AWS SDK 권장)
return String.format("https://%s.s3.%s.amazonaws.com/%s?X-Amz-Expires=%d",
aws.getBucket(), aws.getRegion(), fileKey, expirySeconds);
}
// ===== Helper =====
private String amzDate(ZonedDateTime dt) {
return DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").format(dt);
}
private String sha256Hex(byte[] data) {
try {
byte[] hash = MessageDigest.getInstance("SHA-256").digest(data);
return HexFormat.of().formatHex(hash);
} catch (Exception e) {
return "UNSIGNED-PAYLOAD";
}
}
private String getExtension(String filename) {
if (filename == null) return "";
int dot = filename.lastIndexOf('.');
return dot > 0 ? "." + filename.substring(dot + 1).toLowerCase() : "";
}
}

파일 보기

@@ -0,0 +1,182 @@
package kr.co.accura.wbx.spring.file;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.UUID;
/**
* Azure Blob Storage 구현
* <p>
* Azure SDK 없이 REST API 직접 호출 (의존성 최소화).
* 고객사에 Azure SDK 사용 가능하면 spring-cloud-azure-starter-storage-blob으로 교체 권장.
* <p>
* 설정:
* <pre>
* wbx.spring.file.storage-type: azure-blob
* wbx.spring.file.azure.account-name: ${AZURE_STORAGE_ACCOUNT}
* wbx.spring.file.azure.account-key: ${AZURE_STORAGE_KEY}
* wbx.spring.file.azure.container-name: uploads
* </pre>
*/
@Slf4j
@Service
@ConditionalOnProperty(name = "wbx.spring.file.storage-type", havingValue = "azure-blob")
public class AzureBlobStorageService implements FileStorageService {
private final WbxSpringProperties props;
private final HttpClient httpClient;
public AzureBlobStorageService(WbxSpringProperties props) {
this.props = props;
this.httpClient = HttpClient.newHttpClient();
}
@Override
public String upload(MultipartFile file, String category) {
try {
String blobName = category + "/" + UUID.randomUUID() + getExtension(file.getOriginalFilename());
// SAS Token 방식 — SharedKey 서명 없이 URL에 인증 포함
String sasUrl = generateSasUrl(blobName, "rcw", 3600); // read+create+write, 1시간
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(sasUrl))
.header("x-ms-blob-type", "BlockBlob")
.header("Content-Type", file.getContentType() != null ? file.getContentType() : "application/octet-stream")
.header("x-ms-version", "2023-11-03")
.PUT(HttpRequest.BodyPublishers.ofByteArray(file.getBytes()))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 300) {
throw new BusinessException("Azure Blob upload failed: HTTP " + response.statusCode() + " " + response.body());
}
log.info("Azure Blob uploaded: {}", blobName);
return blobName;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw new BusinessException("Azure Blob upload failed: " + e.getMessage());
}
}
@Override
public byte[] download(String fileKey) {
try {
String sasUrl = generateSasUrl(fileKey, "r", 3600); // read, 1시간
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(sasUrl))
.header("x-ms-version", "2023-11-03")
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 404) {
throw new BusinessException("File not found: " + fileKey);
}
return response.body();
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw new BusinessException("Azure Blob download failed: " + e.getMessage());
}
}
@Override
public void delete(String fileKey) {
try {
String sasUrl = generateSasUrl(fileKey, "d", 3600); // delete, 1시간
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(sasUrl))
.header("x-ms-version", "2023-11-03")
.DELETE()
.build();
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
log.info("Azure Blob deleted: {}", fileKey);
} catch (Exception e) {
log.warn("Azure Blob delete failed: {}", e.getMessage());
}
}
@Override
public String getPresignedUrl(String fileKey, long expirySeconds) {
return generateSasUrl(fileKey, "r", expirySeconds);
}
/**
* Azure SAS Token URL 생성 (Service SAS)
* @param blobName Blob 경로
* @param permissions 권한: r=read, w=write, d=delete, c=create 조합
* @param expirySeconds 유효기간 (초)
*/
private String generateSasUrl(String blobName, String permissions, long expirySeconds) {
WbxSpringProperties.FileConfig.AzureConfig azure = props.getFile().getAzure();
String accountName = azure.getAccountName();
String containerName = azure.getContainerName();
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(5); // 시간 오차 대비
OffsetDateTime expiry = OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(expirySeconds);
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
String stringToSign = String.join("\n",
permissions,
fmt.format(start),
fmt.format(expiry),
String.format("/blob/%s/%s/%s", accountName, containerName, blobName),
"", // identifier
"", // IP
"https", // protocol
"2023-11-03", // version
"b", // resource (blob)
"", "", "", "" // snapshot, encryption, cache, content
);
String signature = hmacSha256(azure.getAccountKey(), stringToSign);
return String.format("%s?sp=%s&st=%s&se=%s&spr=https&sv=2023-11-03&sr=b&sig=%s",
getBlobUrl(blobName), permissions, fmt.format(start), fmt.format(expiry),
java.net.URLEncoder.encode(signature, java.nio.charset.StandardCharsets.UTF_8));
}
// ===== Helper =====
private String getBlobUrl(String blobName) {
WbxSpringProperties.FileConfig.AzureConfig azure = props.getFile().getAzure();
return String.format("https://%s.blob.core.windows.net/%s/%s",
azure.getAccountName(), azure.getContainerName(), blobName);
}
private String gmtDate() {
return DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneOffset.UTC));
}
private String hmacSha256(String key, String data) {
try {
byte[] keyBytes = Base64.getDecoder().decode(key);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(keyBytes, "HmacSHA256"));
return Base64.getEncoder().encodeToString(mac.doFinal(data.getBytes()));
} catch (Exception e) {
throw new BusinessException("HMAC signing failed");
}
}
private String getExtension(String filename) {
if (filename == null) return "";
int dot = filename.lastIndexOf('.');
return dot > 0 ? "." + filename.substring(dot + 1).toLowerCase() : "";
}
}

파일 보기

@@ -0,0 +1,17 @@
package kr.co.accura.wbx.spring.file;
import org.springframework.web.multipart.MultipartFile;
public interface FileStorageService {
String upload(MultipartFile file, String category);
byte[] download(String fileKey);
void delete(String fileKey);
/**
* 임시 다운로드 URL 생성 (클라우드 스토리지용).
* 로컬 구현은 빈 문자열 반환.
*/
default String getPresignedUrl(String fileKey, long expirySeconds) {
return "";
}
}

파일 보기

@@ -0,0 +1,134 @@
package kr.co.accura.wbx.spring.file;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;
/**
* Google Cloud Storage 구현 (표준 템플릿)
* <p>
* ⚠️ 프로덕션 배포 시 아래 SDK 기반 구현으로 교체를 권장합니다:
* <pre>
* // build.gradle
* implementation 'com.google.cloud:spring-cloud-gcp-starter-storage:5.9.0'
*
* // 교체 후: Storage.create() / readAllBytes() / delete() 사용
* </pre>
* <p>
* 현재 구현은 GOOGLE_APPLICATION_CREDENTIALS 환경변수(서비스 계정 JSON)로
* 인증된 환경에서 GCP JSON API를 직접 호출합니다.
* <p>
* 설정:
* <pre>
* wbx.spring.file.storage-type: gcp-storage
* wbx.spring.file.gcp.bucket: ${GCP_STORAGE_BUCKET}
* wbx.spring.file.gcp.project-id: ${GCP_PROJECT_ID}
* </pre>
*/
@Slf4j
@Service
@ConditionalOnProperty(name = "wbx.spring.file.storage-type", havingValue = "gcp-storage")
public class GcpStorageService implements FileStorageService {
private final WbxSpringProperties props;
private final HttpClient httpClient;
public GcpStorageService(WbxSpringProperties props) {
this.props = props;
this.httpClient = HttpClient.newHttpClient();
}
@Override
public String upload(MultipartFile file, String category) {
try {
WbxSpringProperties.FileConfig.GcpConfig gcp = props.getFile().getGcp();
String objectName = category + "/" + UUID.randomUUID() + getExtension(file.getOriginalFilename());
String url = String.format(
"https://storage.googleapis.com/upload/storage/v1/b/%s/o?uploadType=media&name=%s",
gcp.getBucket(), objectName);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", file.getContentType())
.POST(HttpRequest.BodyPublishers.ofByteArray(file.getBytes()))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 300) {
throw new BusinessException("GCS upload failed: " + response.statusCode());
}
log.info("GCS uploaded: gs://{}/{}", gcp.getBucket(), objectName);
return objectName;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw new BusinessException("GCS upload failed: " + e.getMessage());
}
}
@Override
public byte[] download(String fileKey) {
try {
WbxSpringProperties.FileConfig.GcpConfig gcp = props.getFile().getGcp();
String url = String.format("https://storage.googleapis.com/storage/v1/b/%s/o/%s?alt=media",
gcp.getBucket(), fileKey);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 404) {
throw new BusinessException("File not found: " + fileKey);
}
return response.body();
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw new BusinessException("GCS download failed: " + e.getMessage());
}
}
@Override
public void delete(String fileKey) {
try {
WbxSpringProperties.FileConfig.GcpConfig gcp = props.getFile().getGcp();
String url = String.format("https://storage.googleapis.com/storage/v1/b/%s/o/%s",
gcp.getBucket(), fileKey);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.DELETE()
.build();
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
log.info("GCS deleted: {}", fileKey);
} catch (Exception e) {
log.warn("GCS delete failed: {}", e.getMessage());
}
}
@Override
public String getPresignedUrl(String fileKey, long expirySeconds) {
WbxSpringProperties.FileConfig.GcpConfig gcp = props.getFile().getGcp();
// Signed URL (간략 구현 — 프로덕션은 GCP SDK 권장)
return String.format("https://storage.googleapis.com/%s/%s", gcp.getBucket(), fileKey);
}
private String getExtension(String filename) {
if (filename == null) return "";
int dot = filename.lastIndexOf('.');
return dot > 0 ? "." + filename.substring(dot + 1).toLowerCase() : "";
}
}

파일 보기

@@ -0,0 +1,73 @@
package kr.co.accura.wbx.spring.file;
import kr.co.accura.wbx.spring.common.BusinessException;
import kr.co.accura.wbx.spring.common.NotFoundException;
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(name = "wbx.spring.file.storage-type", havingValue = "local", matchIfMissing = true)
public class LocalFileStorageService implements FileStorageService {
private final WbxSpringProperties props;
@Override
public String upload(MultipartFile file, String category) {
try {
Path dir = Paths.get(props.getFile().getUploadDir(), category);
Files.createDirectories(dir);
String ext = getExtension(file.getOriginalFilename());
String storedName = UUID.randomUUID() + (ext.isEmpty() ? "" : "." + ext);
Path target = dir.resolve(storedName);
file.transferTo(target.toFile());
log.info("File uploaded: {} → {}", file.getOriginalFilename(), target);
return category + "/" + storedName;
} catch (IOException e) {
throw new BusinessException("File upload failed: " + e.getMessage());
}
}
@Override
public byte[] download(String fileKey) {
try {
Path path = Paths.get(props.getFile().getUploadDir(), fileKey);
if (!Files.exists(path)) {
throw new NotFoundException("File not found: " + fileKey);
}
return Files.readAllBytes(path);
} catch (IOException e) {
throw new BusinessException("File download failed: " + e.getMessage());
}
}
@Override
public void delete(String fileKey) {
try {
Path path = Paths.get(props.getFile().getUploadDir(), fileKey);
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("File delete failed: {}", e.getMessage());
}
}
private String getExtension(String filename) {
if (filename == null) return "";
int dot = filename.lastIndexOf('.');
return dot > 0 ? filename.substring(dot + 1).toLowerCase() : "";
}
}

파일 보기

@@ -0,0 +1,50 @@
package kr.co.accura.wbx.spring.file;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "wbx_file_uploads")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class WbxFileUpload {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id")
private Long userId;
@Column(name = "original_name", nullable = false, length = 500)
private String originalName;
@Column(name = "stored_name", nullable = false, length = 500)
private String storedName;
@Column(name = "storage_type", nullable = false, length = 20)
@Builder.Default
private String storageType = "LOCAL";
@Column(name = "storage_path", nullable = false, length = 1000)
private String storagePath;
@Column(name = "content_type", length = 200)
private String contentType;
@Column(name = "file_size")
private Long fileSize;
@Column(length = 50)
private String category;
@Builder.Default
@Column(name = "is_deleted")
private boolean isDeleted = false;
@Builder.Default
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt = LocalDateTime.now();
}

파일 보기

@@ -0,0 +1,49 @@
package kr.co.accura.wbx.spring.notification;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "wbx_notifications")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(nullable = false, length = 50)
private String type;
@Column(nullable = false, length = 200)
private String title;
@Column(length = 2000)
private String message;
@Column(length = 500)
private String link;
@Column(name = "source_type", length = 50)
private String sourceType;
@Column(name = "source_id")
private Long sourceId;
@Builder.Default
@Column(name = "is_read")
private boolean isRead = false;
@Column(name = "read_at")
private LocalDateTime readAt;
@Builder.Default
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt = LocalDateTime.now();
}

파일 보기

@@ -0,0 +1,28 @@
package kr.co.accura.wbx.spring.notification;
import kr.co.accura.wbx.spring.auth.WbxUserDetails;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map;
@RestController
@RequestMapping("${wbx.spring.api-prefix:/api}/notifications")
@RequiredArgsConstructor
public class NotificationController {
private final SseNotificationService sseService;
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@AuthenticationPrincipal WbxUserDetails user) {
return sseService.connect(user.getId());
}
@GetMapping("/unread-count")
public Map<String, Object> unreadCount(@AuthenticationPrincipal WbxUserDetails user) {
return Map.of("count", 0); // TODO: DB 연동 후 구현
}
}

파일 보기

@@ -0,0 +1,23 @@
package kr.co.accura.wbx.spring.notification;
import lombok.Builder;
@Builder
public record NotificationDto(
String type,
String title,
String message,
String link
) {
public static NotificationDto approvalRequest(String title, String message) {
return NotificationDto.builder().type("APPROVAL_REQUEST").title(title).message(message).build();
}
public static NotificationDto approvalComplete(String title, String message) {
return NotificationDto.builder().type("APPROVAL_COMPLETE").title(title).message(message).build();
}
public static NotificationDto reminder(String message) {
return NotificationDto.builder().type("REMINDER").title("리마인더").message(message).build();
}
}

파일 보기

@@ -0,0 +1,68 @@
package kr.co.accura.wbx.spring.notification;
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map;
import java.util.concurrent.*;
@Slf4j
@Service
@RequiredArgsConstructor
public class SseNotificationService {
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
private final WbxSpringProperties props;
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
/**
* SSE 연결 생성 (브라우저 EventSource)
*/
public SseEmitter connect(Long userId) {
SseEmitter emitter = new SseEmitter(3600_000L); // 1 hour
emitters.put(userId, emitter);
emitter.onCompletion(() -> emitters.remove(userId));
emitter.onTimeout(() -> emitters.remove(userId));
emitter.onError(e -> emitters.remove(userId));
// Heartbeat
int heartbeat = props.getNotification().getHeartbeatSeconds();
ScheduledFuture<?> heartbeatTask = scheduler.scheduleAtFixedRate(() -> {
try {
emitter.send(SseEmitter.event().name("heartbeat").data(""));
} catch (Exception e) {
emitters.remove(userId);
}
}, heartbeat, heartbeat, TimeUnit.SECONDS);
emitter.onCompletion(() -> heartbeatTask.cancel(true));
emitter.onTimeout(() -> heartbeatTask.cancel(true));
return emitter;
}
/**
* 특정 사용자에게 알림 전송
*/
public void sendToUser(Long userId, NotificationDto notification) {
SseEmitter emitter = emitters.get(userId);
if (emitter != null) {
try {
emitter.send(SseEmitter.event()
.name("notification")
.data(notification));
} catch (Exception e) {
emitters.remove(userId);
log.debug("SSE send failed for user {}: {}", userId, e.getMessage());
}
}
}
public int getActiveConnections() {
return emitters.size();
}
}

파일 보기

@@ -0,0 +1,29 @@
package kr.co.accura.wbx.spring.rbac;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import org.springframework.data.jpa.domain.Specification;
/**
* WBX 데이터 접근 범위
* OWN = 본인 데이터만
* DEPT = 소속 부서 데이터
* COMPANY = 전사 데이터
*/
public enum DeptScope {
OWN,
DEPT,
COMPANY;
/**
* JPA Specification 자동 생성
*/
public <T> Specification<T> toSpec(Long userId, Long deptId, String userField, String deptField) {
return switch (this) {
case OWN -> (root, query, cb) -> cb.equal(root.get(userField), userId);
case DEPT -> (root, query, cb) -> cb.equal(root.get(deptField), deptId);
case COMPANY -> (root, query, cb) -> cb.conjunction(); // no filter
};
}
}

파일 보기

@@ -0,0 +1,38 @@
package kr.co.accura.wbx.spring.rbac;
import kr.co.accura.wbx.spring.common.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;
/**
* WBX RBAC 권한 체크
* Controller에서: @PreAuthorize("@wbx.check('TIMESHEET', 'VIEW')")
*/
@Component("wbx")
@RequiredArgsConstructor
public class PermissionEvaluator {
private final RolePermissionRepository permRepo;
/**
* 모듈-액션 기반 권한 체크
*/
@Cacheable(value = "permissions", key = "#module + ':' + #action + ':' + T(kr.co.accura.wbx.spring.common.SecurityUtils).getCurrentUserId()")
public boolean check(String module, String action) {
Long userId = SecurityUtils.getCurrentUserId();
if (userId == null) return false;
return permRepo.existsByUserIdAndModuleAndAction(userId, module, action);
}
/**
* dept_scope 반환: 데이터 필터링 범위 결정
*/
@Cacheable(value = "deptScopes", key = "#module + ':' + #action + ':' + T(kr.co.accura.wbx.spring.common.SecurityUtils).getCurrentUserId()")
public DeptScope getScope(String module, String action) {
Long userId = SecurityUtils.getCurrentUserId();
if (userId == null) return DeptScope.OWN;
return permRepo.findMaxDeptScope(userId, module, action)
.orElse(DeptScope.OWN);
}
}

파일 보기

@@ -0,0 +1,30 @@
package kr.co.accura.wbx.spring.rbac;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "wbx_role_permissions",
uniqueConstraints = @UniqueConstraint(columnNames = {"role_id", "module", "action"}))
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class RolePermission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "role_id", nullable = false)
private Long roleId;
@Column(nullable = false, length = 50)
private String module;
@Column(nullable = false, length = 30)
private String action;
@Enumerated(EnumType.STRING)
@Column(name = "dept_scope", length = 10)
@Builder.Default
private DeptScope deptScope = DeptScope.OWN;
}

파일 보기

@@ -0,0 +1,41 @@
package kr.co.accura.wbx.spring.rbac;
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;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Repository
public interface RolePermissionRepository extends JpaRepository<RolePermission, Long> {
@Query("""
SELECT CASE WHEN COUNT(rp) > 0 THEN true ELSE false END
FROM RolePermission rp
JOIN kr.co.accura.wbx.spring.rbac.WbxUserRole ur ON rp.roleId = ur.roleId
WHERE ur.userId = :userId AND rp.module = :module AND rp.action = :action
""")
boolean existsByUserIdAndModuleAndAction(@Param("userId") Long userId,
@Param("module") String module,
@Param("action") String action);
@Query("""
SELECT rp.deptScope FROM RolePermission rp
JOIN kr.co.accura.wbx.spring.rbac.WbxUserRole ur ON rp.roleId = ur.roleId
WHERE ur.userId = :userId AND rp.module = :module AND rp.action = :action
ORDER BY CASE rp.deptScope WHEN 'COMPANY' THEN 3 WHEN 'DEPT' THEN 2 ELSE 1 END DESC
""")
Optional<DeptScope> findMaxDeptScope(@Param("userId") Long userId,
@Param("module") String module,
@Param("action") String action);
List<RolePermission> findByRoleId(Long roleId);
@Modifying
@Transactional
void deleteByRoleId(Long roleId);
}

파일 보기

@@ -0,0 +1,28 @@
package kr.co.accura.wbx.spring.rbac;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "wbx_roles")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class WbxRole {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 30)
private String code;
@Column(nullable = false, length = 100)
private String name;
@Column(length = 500)
private String description;
@Builder.Default
@Column(name = "is_system")
private boolean isSystem = false;
}

파일 보기

@@ -0,0 +1,34 @@
package kr.co.accura.wbx.spring.rbac;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "wbx_user_roles",
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role_id", "scope_id"}))
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class WbxUserRole {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "role_id", nullable = false)
private Long roleId;
@Column(name = "scope_type", length = 20)
private String scopeType; // PROJECT, DEPARTMENT, null
@Column(name = "scope_id")
private Long scopeId;
@Column(name = "granted_at")
@Builder.Default
private LocalDateTime grantedAt = LocalDateTime.now();
}

파일 보기

@@ -0,0 +1,17 @@
package kr.co.accura.wbx.spring.rbac;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Repository
public interface WbxUserRoleRepository extends JpaRepository<WbxUserRole, Long> {
List<WbxUserRole> findByUserId(Long userId);
@Modifying
@Transactional
void deleteByUserId(Long userId);
}

파일 보기

@@ -0,0 +1,2 @@
kr.co.accura.wbx.spring.config.WbxAutoConfiguration
kr.co.accura.wbx.spring.admin.AdminAutoConfiguration

파일 보기

@@ -0,0 +1,29 @@
# ===== AWS 클라우드 프로필 =====
# 사용법: --spring.profiles.active=prod,postgresql,aws
# AWS Cognito SSO + S3 연동
spring:
security:
oauth2:
client:
registration:
cognito:
client-id: ${AWS_COGNITO_CLIENT_ID}
client-secret: ${AWS_COGNITO_CLIENT_SECRET}
scope: openid,profile,email
provider:
cognito:
issuer-uri: https://cognito-idp.${AWS_REGION:ap-northeast-2}.amazonaws.com/${AWS_USER_POOL_ID}
wbx:
spring:
mfa:
enabled: true
force-for-external: true
file:
storage-type: aws-s3
aws:
bucket: ${AWS_S3_BUCKET}
region: ${AWS_REGION:ap-northeast-2}
access-key: ${AWS_ACCESS_KEY}
secret-key: ${AWS_SECRET_KEY}

파일 보기

@@ -0,0 +1,28 @@
# ===== Azure 클라우드 프로필 =====
# 사용법: --spring.profiles.active=prod,mssql,azure
# Azure Entra SSO + Blob Storage + Key Vault 연동
spring:
security:
oauth2:
client:
registration:
azure:
client-id: ${AZURE_CLIENT_ID}
client-secret: ${AZURE_CLIENT_SECRET}
scope: openid,profile,email
provider:
azure:
issuer-uri: https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0
wbx:
spring:
mfa:
enabled: false # Azure Entra Conditional Access가 MFA 처리
force-for-external: true
file:
storage-type: azure-blob
azure:
account-name: ${AZURE_STORAGE_ACCOUNT}
account-key: ${AZURE_STORAGE_KEY}
container-name: ${AZURE_CONTAINER:uploads}

파일 보기

@@ -0,0 +1,69 @@
spring:
application:
name: wbx-spring-core
jpa:
hibernate:
ddl-auto: update
open-in-view: false
database-platform: org.hibernate.dialect.MySQLDialect
properties:
hibernate:
default_batch_fetch_size: 100
format_sql: true
dialect: org.hibernate.dialect.MySQLDialect
flyway:
enabled: false # 개발 시 hibernate ddl-auto 사용, 프로덕션 시 true
datasource:
url: jdbc:mysql://${DB_HOST:ws.ubuilder.co.kr}:${DB_PORT:3306}/${DB_NAME:mos}?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
username: ${DB_USER:jsh}
password: ${DB_PASS:jsh@}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
data:
redis:
host: localhost
port: 6379
server:
port: 8080
forward-headers-strategy: native
servlet:
context-path: ${SERVER_CONTEXT_PATH:/}
# WBX Spring Framework
wbx:
spring:
api-prefix: /api
jwt:
secret: ${JWT_SECRET:wbx-spring-dev-secret-key-change-in-production-minimum-256-bits-long}
expiration: 28800
cors:
allowed-origins: ${CORS_ORIGINS:http://localhost:5173,http://localhost:3000,http://localhost:8080}
notification:
sse-enabled: true
heartbeat-seconds: 30
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when-authorized
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui
packages-to-scan: kr.co.accura.wbx.spring
spring.mvc.problemdetail.enabled: false

파일 보기

@@ -0,0 +1,22 @@
# ===== MSSQL 프로필 =====
# 사용법: --spring.profiles.active=prod,mssql
spring:
datasource:
url: jdbc:sqlserver://${DB_HOST:localhost}:${DB_PORT:1433};databaseName=${DB_NAME:wbx_spring};encrypt=true;trustServerCertificate=true
username: ${DB_USER:sa}
password: ${DB_PASS:password}
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
hikari:
maximum-pool-size: ${DB_POOL_SIZE:20}
minimum-idle: 5
connection-timeout: 30000
jpa:
database-platform: org.hibernate.dialect.SQLServerDialect
properties:
hibernate:
dialect: org.hibernate.dialect.SQLServerDialect
flyway:
locations: classpath:db/migration/common,classpath:db/migration/mssql

파일 보기

@@ -0,0 +1,22 @@
# ===== MySQL 프로필 =====
# 사용법: --spring.profiles.active=prod,mysql
spring:
datasource:
url: jdbc:mysql://${DB_HOST:ws.ubuilder.co.kr}:${DB_PORT:3306}/${DB_NAME:mos}?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
username: ${DB_USER:jsh}
password: ${DB_PASS:jsh@}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: ${DB_POOL_SIZE:20}
minimum-idle: 5
connection-timeout: 30000
jpa:
database-platform: org.hibernate.dialect.MySQLDialect
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
flyway:
locations: classpath:db/migration/common,classpath:db/migration/mysql

파일 보기

@@ -0,0 +1,22 @@
# ===== Oracle 프로필 =====
# 사용법: --spring.profiles.active=prod,oracle
spring:
datasource:
url: jdbc:oracle:thin:@${DB_HOST:localhost}:${DB_PORT:1521}:${DB_SID:ORCL}
username: ${DB_USER:wbxapp}
password: ${DB_PASS:password}
driver-class-name: oracle.jdbc.OracleDriver
hikari:
maximum-pool-size: ${DB_POOL_SIZE:20}
minimum-idle: 5
connection-timeout: 30000
jpa:
database-platform: org.hibernate.dialect.OracleDialect
properties:
hibernate:
dialect: org.hibernate.dialect.OracleDialect
flyway:
locations: classpath:db/migration/common,classpath:db/migration/oracle

파일 보기

@@ -0,0 +1,22 @@
# ===== PostgreSQL 프로필 =====
# 사용법: --spring.profiles.active=prod,postgresql
spring:
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:wbx_spring}
username: ${DB_USER:wbxapp}
password: ${DB_PASS:password}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: ${DB_POOL_SIZE:20}
minimum-idle: 5
connection-timeout: 30000
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
flyway:
locations: classpath:db/migration/common,classpath:db/migration/postgresql

파일 보기

@@ -0,0 +1,52 @@
# ===== WBX Spring Framework — 프로덕션 프로필 =====
# 사용법: java -jar app.jar --spring.profiles.active=prod,mysql
# java -jar app.jar --spring.profiles.active=prod,postgresql
server:
port: 8080
forward-headers-strategy: native
servlet:
context-path: ${SERVER_CONTEXT_PATH:/}
spring:
jpa:
hibernate:
ddl-auto: validate # 프로덕션: Flyway 사용, DDL 검증만
open-in-view: false
properties:
hibernate:
default_batch_fetch_size: 100
flyway:
enabled: true
wbx:
spring:
jwt:
secret: ${JWT_SECRET}
expiration: 28800
cors:
allowed-origins: ${CORS_ORIGINS:https://app.company.com}
notification:
sse-enabled: true
heartbeat-seconds: 30
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
springdoc:
swagger-ui:
enabled: false # 프로덕션 Swagger 비활성화
logging:
level:
root: WARN
kr.co.accura.wbx.spring: INFO
file:
name: ${LOG_PATH:/opt/wbx-app/logs/app.log}

파일 보기

@@ -0,0 +1,38 @@
# ===== 테스트 프로필 =====
# 사용법: ./gradlew test (자동 적용)
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
database-platform: org.hibernate.dialect.H2Dialect
flyway:
enabled: false
data:
redis:
host: localhost
port: 6379
wbx:
spring:
jwt:
secret: test-secret-key-minimum-256-bits-for-hmac-sha-algorithm
expiration: 3600
mfa:
enabled: false
file:
storage-type: local
upload-dir: ./build/test-uploads
logging:
level:
root: WARN
kr.co.accura.wbx.spring: DEBUG

파일 보기

@@ -0,0 +1,8 @@
-- WBX Spring Framework — 초기 역할 데이터
INSERT IGNORE INTO wbx_roles (code, name, description, is_system) VALUES
('SA', 'System Administrator', '전체 시스템 관리', true),
('PM', 'Project Manager', '프로젝트 관리, 최종 결재', true),
('PCM', 'Project Control Mgr', 'WBS/TEAL 관리', true),
('PTK', 'Project Timekeeper', '시수 관리', true),
('DL', 'Discipline Lead', '1차 결재, Discipline 관리', true),
('USER', 'General User', '일반 사용자', true);

파일 보기

@@ -0,0 +1,10 @@
-- WBX Spring Framework — 초기 시스템 설정
INSERT IGNORE INTO wbx_system_config (config_key, config_value, value_type, description) VALUES
('auth.max_failed_attempts', '5', 'INT', '최대 로그인 실패 횟수'),
('auth.lockout_minutes', '15', 'INT', '계정 잠금 시간(분)'),
('auth.password_expiry_days', '90', 'INT', '비밀번호 만료 기간(일)'),
('auth.session_timeout_minutes', '480', 'INT', '세션 타임아웃(분)'),
('notification.sse_heartbeat_seconds', '30', 'INT', 'SSE 하트비트 주기(초)'),
('file.max_upload_size_mb', '50', 'INT', '최대 업로드 크기(MB)'),
('app.timezone', 'Asia/Seoul', 'STRING', '시스템 타임존'),
('app.date_format', 'yyyy-MM-dd', 'STRING', '날짜 표시 형식');

파일 보기

@@ -0,0 +1,134 @@
-- WBX Spring Framework — MSSQL 스키마
CREATE TABLE wbx_users (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
email NVARCHAR(255) NOT NULL UNIQUE,
username NVARCHAR(100) NOT NULL UNIQUE,
hashed_password NVARCHAR(500),
full_name NVARCHAR(255),
phone NVARCHAR(50),
department_id BIGINT,
position_title NVARCHAR(100),
employee_number NVARCHAR(50) UNIQUE,
is_active BIT DEFAULT 1,
is_admin BIT DEFAULT 0,
mfa_enabled BIT DEFAULT 0,
azure_oid NVARCHAR(255),
sso_provider NVARCHAR(50),
failed_login_attempts INT DEFAULT 0,
last_failed_login DATETIME2,
locked_until DATETIME2,
password_changed_at DATETIME2,
must_change_password BIT DEFAULT 0,
last_login_at DATETIME2,
created_at DATETIME2 DEFAULT GETDATE(),
updated_at DATETIME2 DEFAULT GETDATE(),
created_by BIGINT,
updated_by BIGINT
);
CREATE TABLE wbx_roles (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
code NVARCHAR(50) NOT NULL UNIQUE,
name NVARCHAR(100) NOT NULL,
description NVARCHAR(500),
is_system BIT DEFAULT 0,
created_at DATETIME2 DEFAULT GETDATE(),
updated_at DATETIME2 DEFAULT GETDATE()
);
CREATE TABLE wbx_user_roles (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
user_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_users(id),
role_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_roles(id),
CONSTRAINT uk_wbx_user_role UNIQUE (user_id, role_id)
);
CREATE TABLE wbx_role_permissions (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
role_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_roles(id),
module NVARCHAR(100) NOT NULL,
action NVARCHAR(50) NOT NULL,
dept_scope NVARCHAR(20) DEFAULT 'OWN',
CONSTRAINT uk_wbx_role_perm UNIQUE (role_id, module, action)
);
CREATE TABLE wbx_refresh_tokens (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
user_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_users(id),
token_hash NVARCHAR(500) NOT NULL,
device_info NVARCHAR(500),
ip_address NVARCHAR(50),
expires_at DATETIME2 NOT NULL,
created_at DATETIME2 DEFAULT GETDATE()
);
CREATE TABLE wbx_totp_secrets (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE FOREIGN KEY REFERENCES wbx_users(id),
encrypted_secret NVARCHAR(500) NOT NULL,
verified BIT DEFAULT 0,
backup_codes NVARCHAR(2000),
created_at DATETIME2 DEFAULT GETDATE(),
updated_at DATETIME2 DEFAULT GETDATE(),
created_by BIGINT,
updated_by BIGINT
);
CREATE TABLE wbx_login_history (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
user_id BIGINT,
email NVARCHAR(255),
action NVARCHAR(50),
auth_method NVARCHAR(50),
ip_address NVARCHAR(50),
user_agent NVARCHAR(500),
failure_reason NVARCHAR(500),
created_at DATETIME2 DEFAULT GETDATE()
);
CREATE TABLE wbx_notifications (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
user_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_users(id),
title NVARCHAR(500),
message NVARCHAR(MAX),
type NVARCHAR(50),
is_read BIT DEFAULT 0,
link NVARCHAR(1000),
created_at DATETIME2 DEFAULT GETDATE()
);
CREATE TABLE wbx_audit_logs (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
user_id BIGINT,
username NVARCHAR(255),
action NVARCHAR(50) NOT NULL,
resource NVARCHAR(100) NOT NULL,
resource_id BIGINT,
detail NVARCHAR(4000),
ip_address NVARCHAR(50),
created_at DATETIME2 DEFAULT GETDATE()
);
CREATE TABLE wbx_file_uploads (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
user_id BIGINT,
original_name NVARCHAR(500),
stored_name NVARCHAR(500),
file_key NVARCHAR(500),
content_type NVARCHAR(200),
file_size BIGINT,
category NVARCHAR(100),
created_at DATETIME2 DEFAULT GETDATE()
);
CREATE TABLE wbx_system_config (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
config_key NVARCHAR(100) NOT NULL UNIQUE,
config_value NVARCHAR(4000),
value_type NVARCHAR(20) DEFAULT 'STRING',
description NVARCHAR(500),
is_editable BIT DEFAULT 1,
updated_at DATETIME2 DEFAULT GETDATE(),
updated_by BIGINT
);

파일 보기

@@ -0,0 +1,141 @@
-- WBX Spring Framework — MySQL 스키마 (utf8mb4)
-- Hibernate ddl-auto와 병행 사용 가능 (Flyway 활성화 시 이 파일 사용)
CREATE TABLE IF NOT EXISTS wbx_users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
username VARCHAR(100) NOT NULL UNIQUE,
hashed_password VARCHAR(500),
full_name VARCHAR(255),
phone VARCHAR(50),
department_id BIGINT,
position_title VARCHAR(100),
employee_number VARCHAR(50) UNIQUE,
is_active TINYINT(1) DEFAULT 1,
is_admin TINYINT(1) DEFAULT 0,
mfa_enabled TINYINT(1) DEFAULT 0,
azure_oid VARCHAR(255),
sso_provider VARCHAR(50),
failed_login_attempts INT DEFAULT 0,
last_failed_login DATETIME,
locked_until DATETIME,
password_changed_at DATETIME,
must_change_password TINYINT(1) DEFAULT 0,
last_login_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by BIGINT,
updated_by BIGINT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS wbx_roles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description VARCHAR(500),
is_system TINYINT(1) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS wbx_user_roles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
UNIQUE KEY uk_user_role (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES wbx_users(id),
FOREIGN KEY (role_id) REFERENCES wbx_roles(id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_role_permissions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
role_id BIGINT NOT NULL,
module VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL,
dept_scope VARCHAR(20) DEFAULT 'OWN',
UNIQUE KEY uk_role_perm (role_id, module, action),
FOREIGN KEY (role_id) REFERENCES wbx_roles(id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_refresh_tokens (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
token_hash VARCHAR(500) NOT NULL,
device_info VARCHAR(500),
ip_address VARCHAR(50),
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES wbx_users(id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_totp_secrets (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE,
encrypted_secret VARCHAR(500) NOT NULL,
verified TINYINT(1) DEFAULT 0,
backup_codes VARCHAR(2000),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by BIGINT,
updated_by BIGINT,
FOREIGN KEY (user_id) REFERENCES wbx_users(id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_login_history (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT,
email VARCHAR(255),
action VARCHAR(50),
auth_method VARCHAR(50),
ip_address VARCHAR(50),
user_agent VARCHAR(500),
failure_reason VARCHAR(500),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_notifications (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(500),
message TEXT,
type VARCHAR(50),
is_read TINYINT(1) DEFAULT 0,
link VARCHAR(1000),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES wbx_users(id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_audit_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT,
username VARCHAR(255),
action VARCHAR(50) NOT NULL,
resource VARCHAR(100) NOT NULL,
resource_id BIGINT,
detail VARCHAR(4000),
ip_address VARCHAR(50),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_file_uploads (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT,
original_name VARCHAR(500),
stored_name VARCHAR(500),
file_key VARCHAR(500),
content_type VARCHAR(200),
file_size BIGINT,
category VARCHAR(100),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wbx_system_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
config_key VARCHAR(100) NOT NULL UNIQUE,
config_value VARCHAR(4000),
value_type VARCHAR(20) DEFAULT 'STRING',
description VARCHAR(500),
is_editable TINYINT(1) DEFAULT 1,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
updated_by BIGINT
) ENGINE=InnoDB;

파일 보기

@@ -0,0 +1,134 @@
-- WBX Spring Framework — Oracle 스키마
CREATE TABLE wbx_users (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email VARCHAR2(255) NOT NULL UNIQUE,
username VARCHAR2(100) NOT NULL UNIQUE,
hashed_password VARCHAR2(500),
full_name VARCHAR2(255),
phone VARCHAR2(50),
department_id NUMBER(19),
position_title VARCHAR2(100),
employee_number VARCHAR2(50) UNIQUE,
is_active NUMBER(1) DEFAULT 1,
is_admin NUMBER(1) DEFAULT 0,
mfa_enabled NUMBER(1) DEFAULT 0,
azure_oid VARCHAR2(255),
sso_provider VARCHAR2(50),
failed_login_attempts NUMBER(10) DEFAULT 0,
last_failed_login TIMESTAMP,
locked_until TIMESTAMP,
password_changed_at TIMESTAMP,
must_change_password NUMBER(1) DEFAULT 0,
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP,
created_by NUMBER(19),
updated_by NUMBER(19)
);
CREATE TABLE wbx_roles (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code VARCHAR2(50) NOT NULL UNIQUE,
name VARCHAR2(100) NOT NULL,
description VARCHAR2(500),
is_system NUMBER(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP
);
CREATE TABLE wbx_user_roles (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id NUMBER(19) NOT NULL REFERENCES wbx_users(id),
role_id NUMBER(19) NOT NULL REFERENCES wbx_roles(id),
CONSTRAINT uk_wbx_user_role UNIQUE (user_id, role_id)
);
CREATE TABLE wbx_role_permissions (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
role_id NUMBER(19) NOT NULL REFERENCES wbx_roles(id),
module VARCHAR2(100) NOT NULL,
action VARCHAR2(50) NOT NULL,
dept_scope VARCHAR2(20) DEFAULT 'OWN',
CONSTRAINT uk_wbx_role_perm UNIQUE (role_id, module, action)
);
CREATE TABLE wbx_refresh_tokens (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id NUMBER(19) NOT NULL REFERENCES wbx_users(id),
token_hash VARCHAR2(500) NOT NULL,
device_info VARCHAR2(500),
ip_address VARCHAR2(50),
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
);
CREATE TABLE wbx_totp_secrets (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id NUMBER(19) NOT NULL UNIQUE REFERENCES wbx_users(id),
encrypted_secret VARCHAR2(500) NOT NULL,
verified NUMBER(1) DEFAULT 0,
backup_codes VARCHAR2(2000),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP,
created_by NUMBER(19),
updated_by NUMBER(19)
);
CREATE TABLE wbx_login_history (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id NUMBER(19),
email VARCHAR2(255),
action VARCHAR2(50),
auth_method VARCHAR2(50),
ip_address VARCHAR2(50),
user_agent VARCHAR2(500),
failure_reason VARCHAR2(500),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
);
CREATE TABLE wbx_notifications (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id NUMBER(19) NOT NULL REFERENCES wbx_users(id),
title VARCHAR2(500),
message CLOB,
type VARCHAR2(50),
is_read NUMBER(1) DEFAULT 0,
link VARCHAR2(1000),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
);
CREATE TABLE wbx_audit_logs (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id NUMBER(19),
username VARCHAR2(255),
action VARCHAR2(50) NOT NULL,
resource VARCHAR2(100) NOT NULL,
resource_id NUMBER(19),
detail VARCHAR2(4000),
ip_address VARCHAR2(50),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
);
CREATE TABLE wbx_file_uploads (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id NUMBER(19),
original_name VARCHAR2(500),
stored_name VARCHAR2(500),
file_key VARCHAR2(500),
content_type VARCHAR2(200),
file_size NUMBER(19),
category VARCHAR2(100),
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
);
CREATE TABLE wbx_system_config (
id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
config_key VARCHAR2(100) NOT NULL UNIQUE,
config_value VARCHAR2(4000),
value_type VARCHAR2(20) DEFAULT 'STRING',
description VARCHAR2(500),
is_editable NUMBER(1) DEFAULT 1,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP,
updated_by NUMBER(19)
);

파일 보기

@@ -0,0 +1,134 @@
-- WBX Spring Framework — PostgreSQL 스키마
CREATE TABLE IF NOT EXISTS wbx_users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
username VARCHAR(100) NOT NULL UNIQUE,
hashed_password VARCHAR(500),
full_name VARCHAR(255),
phone VARCHAR(50),
department_id BIGINT,
position_title VARCHAR(100),
employee_number VARCHAR(50) UNIQUE,
is_active BOOLEAN DEFAULT TRUE,
is_admin BOOLEAN DEFAULT FALSE,
mfa_enabled BOOLEAN DEFAULT FALSE,
azure_oid VARCHAR(255),
sso_provider VARCHAR(50),
failed_login_attempts INT DEFAULT 0,
last_failed_login TIMESTAMP,
locked_until TIMESTAMP,
password_changed_at TIMESTAMP,
must_change_password BOOLEAN DEFAULT FALSE,
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
created_by BIGINT,
updated_by BIGINT
);
CREATE TABLE IF NOT EXISTS wbx_roles (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description VARCHAR(500),
is_system BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wbx_user_roles (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES wbx_users(id),
role_id BIGINT NOT NULL REFERENCES wbx_roles(id),
UNIQUE (user_id, role_id)
);
CREATE TABLE IF NOT EXISTS wbx_role_permissions (
id BIGSERIAL PRIMARY KEY,
role_id BIGINT NOT NULL REFERENCES wbx_roles(id),
module VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL,
dept_scope VARCHAR(20) DEFAULT 'OWN',
UNIQUE (role_id, module, action)
);
CREATE TABLE IF NOT EXISTS wbx_refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES wbx_users(id),
token_hash VARCHAR(500) NOT NULL,
device_info VARCHAR(500),
ip_address VARCHAR(50),
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wbx_totp_secrets (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE REFERENCES wbx_users(id),
encrypted_secret VARCHAR(500) NOT NULL,
verified BOOLEAN DEFAULT FALSE,
backup_codes VARCHAR(2000),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
created_by BIGINT,
updated_by BIGINT
);
CREATE TABLE IF NOT EXISTS wbx_login_history (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
email VARCHAR(255),
action VARCHAR(50),
auth_method VARCHAR(50),
ip_address VARCHAR(50),
user_agent VARCHAR(500),
failure_reason VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wbx_notifications (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES wbx_users(id),
title VARCHAR(500),
message TEXT,
type VARCHAR(50),
is_read BOOLEAN DEFAULT FALSE,
link VARCHAR(1000),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wbx_audit_logs (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
username VARCHAR(255),
action VARCHAR(50) NOT NULL,
resource VARCHAR(100) NOT NULL,
resource_id BIGINT,
detail VARCHAR(4000),
ip_address VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wbx_file_uploads (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
original_name VARCHAR(500),
stored_name VARCHAR(500),
file_key VARCHAR(500),
content_type VARCHAR(200),
file_size BIGINT,
category VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wbx_system_config (
id BIGSERIAL PRIMARY KEY,
config_key VARCHAR(100) NOT NULL UNIQUE,
config_value VARCHAR(4000),
value_type VARCHAR(20) DEFAULT 'STRING',
description VARCHAR(500),
is_editable BOOLEAN DEFAULT TRUE,
updated_at TIMESTAMP DEFAULT NOW(),
updated_by BIGINT
);

파일 보기

@@ -0,0 +1,64 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Malgun Gothic', -apple-system, sans-serif; background: #f0f2f5; color: #333; }
/* Layout */
.admin-layout { display: flex; min-height: 100vh; }
.admin-sidebar { width: 220px; background: #1e3c78; color: #fff; padding: 0; flex-shrink: 0; }
.admin-sidebar .logo { padding: 20px; font-size: 18px; font-weight: bold; border-bottom: 1px solid rgba(255,255,255,0.1); }
.admin-sidebar nav a { display: block; padding: 12px 20px; color: rgba(255,255,255,0.7); text-decoration: none; font-size: 14px; border-left: 3px solid transparent; }
.admin-sidebar nav a:hover { background: rgba(255,255,255,0.05); color: #fff; }
.admin-sidebar nav a.active { background: rgba(255,255,255,0.1); color: #fff; border-left-color: #4da6ff; }
.admin-content { flex: 1; padding: 24px; }
/* Header */
.page-header { margin-bottom: 24px; }
.page-header h1 { font-size: 22px; color: #1e3c78; }
.page-header p { color: #888; font-size: 13px; margin-top: 4px; }
/* Cards */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.stat-card .label { font-size: 13px; color: #888; }
.stat-card .value { font-size: 28px; font-weight: bold; color: #1e3c78; margin-top: 4px; }
.stat-card .value.green { color: #2e7d32; }
.stat-card .value.orange { color: #e65100; }
/* Table */
.data-table { width: 100%; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.data-table table { width: 100%; border-collapse: collapse; }
.data-table th { background: #1e3c78; color: #fff; padding: 10px 14px; text-align: left; font-size: 13px; font-weight: 500; }
.data-table td { padding: 10px 14px; border-bottom: 1px solid #f0f0f0; font-size: 13px; }
.data-table tr:hover td { background: #f8f9ff; }
/* Badges */
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
.badge-success { background: #e8f5e9; color: #2e7d32; }
.badge-danger { background: #ffebee; color: #c62828; }
.badge-warning { background: #fff8e1; color: #e65100; }
.badge-info { background: #e3f2fd; color: #1565c0; }
/* Buttons */
.btn { display: inline-block; padding: 6px 14px; border-radius: 4px; font-size: 13px; text-decoration: none; border: none; cursor: pointer; }
.btn-primary { background: #1e3c78; color: #fff; }
.btn-danger { background: #c62828; color: #fff; }
.btn-outline { background: #fff; border: 1px solid #ddd; color: #555; }
.btn:hover { opacity: 0.85; }
/* Alert */
.alert { padding: 12px 16px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; }
.alert-success { background: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; }
.alert-info { background: #e3f2fd; color: #1565c0; }
/* Detail */
.detail-grid { display: grid; grid-template-columns: 140px 1fr; gap: 8px 16px; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 16px; }
.detail-grid .label { font-weight: 500; color: #888; font-size: 13px; }
.detail-grid .value { font-size: 14px; }
/* Login */
.login-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #1e3c78; }
.login-box { background: #fff; padding: 40px; border-radius: 12px; width: 380px; box-shadow: 0 4px 20px rgba(0,0,0,0.2); }
.login-box h2 { text-align: center; color: #1e3c78; margin-bottom: 24px; }
.login-box input { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; margin-bottom: 12px; font-size: 14px; }
.login-box button { width: 100%; padding: 12px; background: #1e3c78; color: #fff; border: none; border-radius: 6px; font-size: 15px; cursor: pointer; }
.login-box button:hover { background: #15306a; }
.login-box .error { color: #c62828; font-size: 13px; text-align: center; margin-bottom: 12px; }

파일 보기

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - Audit Logs</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('audit-logs')}"></div>
<div class="admin-content">
<div class="page-header"><h1>감사 로그</h1><p>최근 100건</p></div>
<div class="data-table">
<table>
<thead><tr><th>시간</th><th>사용자ID</th><th>액션</th><th>리소스</th><th>리소스ID</th><th>IP</th><th>상세</th></tr></thead>
<tbody>
<tr th:each="log : ${logs}">
<td th:text="${log.createdAt != null ? #temporals.format(log.createdAt, 'yy-MM-dd HH:mm:ss') : ''}"></td>
<td th:text="${log.userId}"></td>
<td><span class="badge" th:text="${log.action}"></span></td>
<td th:text="${log.resource}"></td>
<td th:text="${log.resourceId}"></td>
<td th:text="${log.ipAddress}"></td>
<td th:text="${log.detail != null ? (#strings.abbreviate(log.detail, 80)) : ''}"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - System Config</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('config')}"></div>
<div class="admin-content">
<div class="page-header"><h1>시스템 설정</h1><p>Key-Value 설정 관리</p></div>
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
<!-- 설정 추가 폼 -->
<div class="card" style="margin-bottom:20px; padding:16px;">
<h3>설정 추가/수정</h3>
<form th:action="@{/admin/config/save}" method="post" style="display:flex; gap:10px; align-items:end; flex-wrap:wrap;">
<div><label>Key</label><input name="configKey" required style="display:block; padding:6px; border:1px solid #ddd; border-radius:4px;"></div>
<div><label>Value</label><input name="configValue" required style="display:block; padding:6px; border:1px solid #ddd; border-radius:4px; min-width:200px;"></div>
<div><label>설명</label><input name="description" style="display:block; padding:6px; border:1px solid #ddd; border-radius:4px;"></div>
<button type="submit" class="btn btn-primary" style="padding:6px 16px;">저장</button>
</form>
</div>
<!-- 기존 설정 목록 -->
<div class="data-table">
<table>
<thead><tr><th>Key</th><th>Value</th><th>설명</th><th>수정일</th></tr></thead>
<tbody>
<tr th:each="cfg : ${configs}">
<td><code th:text="${cfg.configKey}"></code></td>
<td th:text="${cfg.configValue}"></td>
<td th:text="${cfg.description}"></td>
<td th:text="${cfg.updatedAt != null ? #temporals.format(cfg.updatedAt, 'yy-MM-dd HH:mm') : ''}"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>WBX Admin - Dashboard</title>
<link rel="stylesheet" th:href="@{/admin/css/admin.css}">
</head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('dashboard')}"></div>
<div class="admin-content">
<div class="page-header">
<h1>대시보드</h1>
<p>WBX Spring Framework 관리 콘솔</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="label">활성 사용자</div>
<div class="value" th:text="${userCount}">0</div>
</div>
<div class="stat-card">
<div class="label">전체 사용자</div>
<div class="value" th:text="${totalUsers}">0</div>
</div>
<div class="stat-card">
<div class="label">로그인 성공</div>
<div class="value green" th:text="${loginCount}">0</div>
</div>
<div class="stat-card">
<div class="label">등록 역할</div>
<div class="value" th:text="${roleCount}">0</div>
</div>
</div>
<div class="page-header"><h1>빠른 링크</h1></div>
<a th:href="@{/admin/users}" class="btn btn-primary" style="margin-right:8px">사용자 관리</a>
<a th:href="@{/admin/roles}" class="btn btn-outline" style="margin-right:8px">역할 관리</a>
<a th:href="@{/admin/login-history}" class="btn btn-outline">로그인 이력</a>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="sidebar(active)" class="admin-sidebar">
<div class="logo">WBX Admin</div>
<nav>
<a th:href="@{/admin}" th:classappend="${active == 'dashboard'} ? 'active'">📊 대시보드</a>
<a th:href="@{/admin/users}" th:classappend="${active == 'users'} ? 'active'">👥 사용자 관리</a>
<a th:href="@{/admin/roles}" th:classappend="${active == 'roles'} ? 'active'">🔑 역할/권한</a>
<a th:href="@{/admin/login-history}" th:classappend="${active == 'login-history'} ? 'active'">📋 로그인 이력</a>
<a th:href="@{/admin/audit-logs}" th:classappend="${active == 'audit-logs'} ? 'active'">📝 감사 로그</a>
<a th:href="@{/admin/permissions}" th:classappend="${active == 'permissions'} ? 'active'">🛡 권한 매트릭스</a>
<a th:href="@{/admin/config}" th:classappend="${active == 'config'} ? 'active'">⚙ 시스템 설정</a>
<a th:href="@{/admin/system-health}" th:classappend="${active == 'system-health'} ? 'active'">💻 시스템 상태</a>
<a th:href="@{/admin/logout}" style="margin-top:auto; border-top:1px solid rgba(255,255,255,0.1); padding-top:16px;">🚪 로그아웃</a>
</nav>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - Login History</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('login-history')}"></div>
<div class="admin-content">
<div class="page-header"><h1>로그인 이력</h1><p>최근 50건</p></div>
<div class="data-table">
<table>
<thead><tr><th>시간</th><th>이메일</th><th>액션</th><th>IP</th><th>인증방법</th><th>사유</th></tr></thead>
<tbody>
<tr th:each="log : ${logs}">
<td th:text="${#temporals.format(log.createdAt, 'yy-MM-dd HH:mm:ss')}"></td>
<td th:text="${log.email}"></td>
<td>
<span th:if="${log.action == 'LOGIN_SUCCESS'}" class="badge badge-success">성공</span>
<span th:if="${log.action == 'LOGIN_FAILURE'}" class="badge badge-danger">실패</span>
<span th:if="${log.action == 'LOGOUT'}" class="badge badge-info">로그아웃</span>
</td>
<td th:text="${log.ipAddress}"></td>
<td th:text="${log.authMethod}"></td>
<td th:text="${log.failureReason}"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>WBX Admin - Login</title>
<link rel="stylesheet" th:href="@{/admin/css/admin.css}">
</head>
<body>
<div class="login-container">
<div class="login-box">
<h2>WBX Admin</h2>
<div th:if="${param.error}" class="error">이메일 또는 비밀번호가 올바르지 않습니다.</div>
<div th:if="${param.logout}" class="alert alert-info">로그아웃되었습니다.</div>
<form th:action="@{/admin/login}" method="post">
<input type="email" name="username" placeholder="이메일" required autofocus>
<input type="password" name="password" placeholder="비밀번호" required>
<button type="submit">로그인</button>
</form>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - Permissions</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('permissions')}"></div>
<div class="admin-content">
<div class="page-header">
<h1>권한 매트릭스</h1>
<p>전체 역할-모듈-액션 권한 현황. 개별 역할 편집은 <a th:href="@{/admin/roles}" style="color:#1e3c78;">역할 관리</a>에서 가능합니다.</p>
</div>
<div class="data-table">
<table>
<thead><tr><th>역할</th><th>모듈</th><th>액션</th><th>범위</th><th>관리</th></tr></thead>
<tbody>
<tr th:each="perm : ${permissions}">
<td>
<span th:each="role : ${roles}" th:if="${role.id == perm.roleId}">
<a th:href="@{/admin/roles/{id}(id=${role.id})}" class="badge badge-info" style="text-decoration:none;" th:text="${role.code}"></a>
</span>
</td>
<td th:text="${perm.module}"></td>
<td th:text="${perm.action}"></td>
<td>
<span class="badge" th:classappend="${perm.deptScope?.name() == 'COMPANY'} ? 'badge-success' : (${perm.deptScope?.name() == 'DEPT'} ? 'badge-warning' : '')"
th:text="${perm.deptScope?.name()}"></span>
</td>
<td>
<form th:action="@{/admin/permissions/{id}/delete(id=${perm.id})}" method="post" style="display:inline;" onsubmit="return confirm('삭제하시겠습니까?')">
<button type="submit" class="btn btn-danger" style="padding:2px 8px;font-size:11px;">삭제</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(permissions)}"><td colspan="5" style="text-align:center;color:#888;padding:20px;">등록된 권한이 없습니다. 역할 관리에서 권한을 추가하세요.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - Role Detail</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('roles')}"></div>
<div class="admin-content" th:if="${role}">
<div class="page-header">
<h1>역할 상세: <span th:text="${role.code}"></span></h1>
<p><a th:href="@{/admin/roles}" style="color:#1e3c78;">← 역할 목록으로</a></p>
</div>
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
<div th:if="${error}" class="alert" style="background:#ffebee;color:#c62828;border:1px solid #ffcdd2;padding:12px 16px;border-radius:6px;margin-bottom:16px;font-size:13px;" th:text="${error}"></div>
<!-- 역할 정보 수정 -->
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">역할 정보</h3>
<form th:action="@{/admin/roles/{id}/update(id=${role.id})}" method="post" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">코드 (변경 불가)</label>
<input th:value="${role.code}" disabled style="padding:6px 10px;border:1px solid #eee;border-radius:4px;font-size:13px;background:#f5f5f5;width:140px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이름</label>
<input name="name" th:value="${role.name}" required style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:160px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">설명</label>
<input name="description" th:value="${role.description}" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:280px;">
</div>
<button type="submit" class="btn btn-primary">수정</button>
</form>
</div>
<!-- 권한 추가 -->
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">권한 추가</h3>
<form th:action="@{/admin/roles/{id}/permissions/add(id=${role.id})}" method="post" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">모듈</label>
<input name="module" required placeholder="예: TIMESHEET" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:160px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">액션</label>
<select name="action" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
<option value="VIEW">VIEW (조회)</option>
<option value="CREATE">CREATE (생성)</option>
<option value="UPDATE">UPDATE (수정)</option>
<option value="DELETE">DELETE (삭제)</option>
<option value="APPROVE">APPROVE (결재)</option>
<option value="EXPORT">EXPORT (내보내기)</option>
</select>
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">데이터 범위</label>
<select name="deptScope" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
<option th:each="s : ${deptScopes}" th:value="${s.name()}" th:text="${s.name() == 'OWN' ? 'OWN (본인)' : (s.name() == 'DEPT' ? 'DEPT (부서)' : 'COMPANY (전사)')}"></option>
</select>
</div>
<button type="submit" class="btn btn-primary">추가</button>
</form>
</div>
<!-- 현재 권한 목록 -->
<div class="data-table">
<table>
<thead><tr><th>모듈</th><th>액션</th><th>범위</th><th>관리</th></tr></thead>
<tbody>
<tr th:each="perm : ${permissions}">
<td th:text="${perm.module}"></td>
<td><span class="badge badge-info" th:text="${perm.action}"></span></td>
<td>
<span class="badge" th:classappend="${perm.deptScope?.name() == 'COMPANY'} ? 'badge-success' : (${perm.deptScope?.name() == 'DEPT'} ? 'badge-warning' : '')"
th:text="${perm.deptScope?.name()}"></span>
</td>
<td>
<form th:action="@{/admin/permissions/{id}/delete(id=${perm.id})}" method="post" style="display:inline;" onsubmit="return confirm('이 권한을 삭제하시겠습니까?')">
<button type="submit" class="btn btn-danger" style="padding:3px 10px;font-size:12px;">삭제</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(permissions)}"><td colspan="4" style="text-align:center;color:#888;padding:20px;">설정된 권한이 없습니다. 위 폼에서 권한을 추가하세요.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - Roles</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('roles')}"></div>
<div class="admin-content">
<div class="page-header"><h1>역할 관리</h1><p>역할 추가/수정/삭제 및 권한 설정</p></div>
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
<div th:if="${error}" class="alert" style="background:#ffebee;color:#c62828;border:1px solid #ffcdd2;padding:12px 16px;border-radius:6px;margin-bottom:16px;font-size:13px;" th:text="${error}"></div>
<!-- 역할 추가 폼 -->
<div style="background:#fff;border-radius:8px;padding:16px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">새 역할 추가</h3>
<form th:action="@{/admin/roles/add}" method="post" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">코드</label>
<input name="code" required placeholder="예: MANAGER" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:140px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이름</label>
<input name="name" required placeholder="예: 관리자" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:160px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">설명</label>
<input name="description" placeholder="역할 설명" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:240px;">
</div>
<button type="submit" class="btn btn-primary">추가</button>
</form>
</div>
<!-- 역할 목록 -->
<div class="data-table">
<table>
<thead><tr><th>ID</th><th>코드</th><th>이름</th><th>설명</th><th>시스템</th><th>관리</th></tr></thead>
<tbody>
<tr th:each="role : ${roles}">
<td th:text="${role.id}"></td>
<td><span class="badge badge-info" th:text="${role.code}"></span></td>
<td th:text="${role.name}"></td>
<td th:text="${role.description}"></td>
<td th:text="${role.system} ? 'Y' : ''"></td>
<td>
<a th:href="@{/admin/roles/{id}(id=${role.id})}" class="btn btn-outline" style="padding:3px 10px;font-size:12px;">상세/권한</a>
<form th:unless="${role.system}" th:action="@{/admin/roles/{id}/delete(id=${role.id})}" method="post" style="display:inline;" onsubmit="return confirm('삭제하시겠습니까?')">
<button type="submit" class="btn btn-danger" style="padding:3px 10px;font-size:12px;">삭제</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(roles)}"><td colspan="6" style="text-align:center;color:#888">등록된 역할이 없습니다.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - System Health</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('system-health')}"></div>
<div class="admin-content">
<div class="page-header"><h1>시스템 상태</h1><p>서버 런타임 정보</p></div>
<div class="data-table">
<table>
<thead><tr><th>항목</th><th></th></tr></thead>
<tbody>
<tr th:each="entry : ${health}">
<td th:text="${entry.key}"></td>
<td th:text="${entry.value}"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - User Detail</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('users')}"></div>
<div class="admin-content" th:if="${user}">
<div class="page-header">
<h1>사용자 상세: <span th:text="${user.email}"></span></h1>
<p><a th:href="@{/admin/users}" style="color:#1e3c78;">← 사용자 목록으로</a></p>
</div>
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
<div th:if="${error}" class="alert" style="background:#ffebee;color:#c62828;border:1px solid #ffcdd2;padding:12px 16px;border-radius:6px;margin-bottom:16px;font-size:13px;" th:text="${error}"></div>
<!-- 사용자 정보 수정 폼 -->
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">기본 정보</h3>
<form th:action="@{/admin/users/{id}/update(id=${user.id})}" method="post">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이메일 (변경 불가)</label>
<input th:value="${user.email}" disabled style="width:100%;padding:6px 10px;border:1px solid #eee;border-radius:4px;font-size:13px;background:#f5f5f5;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">사용자명 (변경 불가)</label>
<input th:value="${user.username}" disabled style="width:100%;padding:6px 10px;border:1px solid #eee;border-radius:4px;font-size:13px;background:#f5f5f5;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이름</label>
<input name="fullName" th:value="${user.fullName}" style="width:100%;padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">전화번호</label>
<input name="phone" th:value="${user.phone}" style="width:100%;padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">직위</label>
<input name="positionTitle" th:value="${user.positionTitle}" style="width:100%;padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">사번</label>
<input name="employeeNumber" th:value="${user.employeeNumber}" style="width:100%;padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
</div>
</div>
<div style="display:flex;align-items:center;gap:16px;">
<label style="display:flex;align-items:center;gap:4px;font-size:13px;">
<input name="isAdmin" type="checkbox" value="true" th:checked="${user.admin}"> 관리자 (SA)
</label>
<button type="submit" class="btn btn-primary">정보 수정</button>
</div>
</form>
</div>
<!-- 상태 정보 + 액션 버튼 -->
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">계정 상태</h3>
<div class="detail-grid" style="box-shadow:none;padding:0;margin-bottom:12px;">
<div class="label">상태</div>
<div class="value">
<span th:if="${user.active}" class="badge badge-success">활성</span>
<span th:unless="${user.active}" class="badge badge-danger">비활성</span>
</div>
<div class="label">잠금</div>
<div class="value">
<span th:if="${user.locked}" class="badge badge-danger">잠금 (실패 <span th:text="${user.failedLoginAttempts}"></span>회)</span>
<span th:unless="${user.locked}">정상</span>
</div>
<div class="label">MFA</div>
<div class="value" th:text="${user.mfaEnabled} ? '활성화' : '미설정'"></div>
<div class="label">SSO</div>
<div class="value" th:text="${user.ssoProvider != null} ? ${user.ssoProvider} : '미연동'"></div>
<div class="label">최종 로그인</div>
<div class="value" th:text="${user.lastLoginAt != null} ? ${#temporals.format(user.lastLoginAt, 'yyyy-MM-dd HH:mm:ss')} : '없음'"></div>
<div class="label">생성일</div>
<div class="value" th:text="${user.createdAt != null} ? ${#temporals.format(user.createdAt, 'yyyy-MM-dd HH:mm')} : ''"></div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<form th:action="@{/admin/users/{id}/unlock(id=${user.id})}" method="post" style="display:inline">
<button class="btn btn-primary" type="submit">잠금 해제</button>
</form>
<form th:action="@{/admin/users/{id}/reset-password(id=${user.id})}" method="post" style="display:inline">
<button class="btn btn-outline" type="submit">비밀번호 초기화</button>
</form>
<form th:action="@{/admin/users/{id}/toggle-status(id=${user.id})}" method="post" style="display:inline">
<button class="btn" th:classappend="${user.active} ? 'btn-danger' : 'btn-primary'" type="submit" th:text="${user.active} ? '비활성화' : '활성화'"></button>
</form>
<form th:action="@{/admin/users/{id}/delete(id=${user.id})}" method="post" style="display:inline;margin-left:auto;" onsubmit="return confirm('이 사용자를 삭제하시겠습니까? 복구할 수 없습니다.')">
<button class="btn btn-danger" type="submit">사용자 삭제</button>
</form>
</div>
</div>
<!-- 역할 할당 -->
<div style="background:#fff;border-radius:8px;padding:20px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">역할 할당</h3>
<form th:action="@{/admin/users/{id}/roles/add(id=${user.id})}" method="post" style="display:flex;gap:10px;align-items:end;margin-bottom:16px;">
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">역할 선택</label>
<select name="roleId" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
<option th:each="role : ${allRoles}" th:value="${role.id}" th:text="${role.code + ' — ' + role.name}"></option>
</select>
</div>
<button type="submit" class="btn btn-primary">역할 추가</button>
</form>
<div class="data-table" th:if="${!userRoles.isEmpty()}">
<table>
<thead><tr><th>역할 코드</th><th>역할 이름</th><th>할당일</th><th>관리</th></tr></thead>
<tbody>
<tr th:each="ur : ${userRoles}">
<td>
<span th:each="role : ${allRoles}" th:if="${role.id == ur.roleId}" class="badge badge-info" th:text="${role.code}"></span>
</td>
<td>
<span th:each="role : ${allRoles}" th:if="${role.id == ur.roleId}" th:text="${role.name}"></span>
</td>
<td th:text="${ur.grantedAt != null} ? ${#temporals.format(ur.grantedAt, 'yy-MM-dd HH:mm')} : ''"></td>
<td>
<form th:action="@{/admin/users/{uid}/roles/{urid}/delete(uid=${user.id},urid=${ur.id})}" method="post" style="display:inline;" onsubmit="return confirm('이 역할을 해제하시겠습니까?')">
<button type="submit" class="btn btn-danger" style="padding:3px 10px;font-size:12px;">해제</button>
</form>
</td>
</tr>
</tbody>
</table>
</div>
<p th:if="${userRoles.isEmpty()}" style="color:#888;font-size:13px;">할당된 역할이 없습니다.</p>
</div>
<!-- 로그인 이력 -->
<div style="background:#fff;border-radius:8px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">로그인 이력 (최근 10건)</h3>
<div class="data-table">
<table>
<thead><tr><th>시간</th><th>액션</th><th>IP</th><th>인증방법</th></tr></thead>
<tbody>
<tr th:each="log : ${loginHistory}">
<td th:text="${#temporals.format(log.createdAt, 'yy-MM-dd HH:mm')}"></td>
<td>
<span th:if="${log.action == 'LOGIN_SUCCESS'}" class="badge badge-success">성공</span>
<span th:if="${log.action == 'LOGIN_FAILURE'}" class="badge badge-danger">실패</span>
<span th:if="${log.action == 'LOGOUT'}" class="badge badge-info">로그아웃</span>
</td>
<td th:text="${log.ipAddress}"></td>
<td th:text="${log.authMethod}"></td>
</tr>
<tr th:if="${#lists.isEmpty(loginHistory)}"><td colspan="4" style="text-align:center;color:#888;">이력이 없습니다.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>

파일 보기

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>WBX Admin - Users</title><link rel="stylesheet" th:href="@{/admin/css/admin.css}"></head>
<body>
<div class="admin-layout">
<div th:replace="~{admin/fragments :: sidebar('users')}"></div>
<div class="admin-content">
<div class="page-header"><h1>사용자 관리</h1><p>사용자 추가/수정/삭제 및 역할 할당</p></div>
<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
<div th:if="${error}" class="alert" style="background:#ffebee;color:#c62828;border:1px solid #ffcdd2;padding:12px 16px;border-radius:6px;margin-bottom:16px;font-size:13px;" th:text="${error}"></div>
<!-- 사용자 추가 폼 -->
<div style="background:#fff;border-radius:8px;padding:16px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h3 style="margin-bottom:12px;font-size:15px;color:#1e3c78;">새 사용자 추가</h3>
<form th:action="@{/admin/users/add}" method="post" style="display:flex;gap:8px;align-items:end;flex-wrap:wrap;">
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이메일 *</label>
<input name="email" type="email" required placeholder="user@company.com" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:180px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">사용자명 *</label>
<input name="username" required placeholder="hong" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:120px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">비밀번호 *</label>
<input name="password" type="password" required placeholder="********" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:130px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">이름</label>
<input name="fullName" placeholder="홍길동" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:100px;">
</div>
<div>
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">전화</label>
<input name="phone" placeholder="010-0000-0000" style="padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;width:120px;">
</div>
<div style="display:flex;align-items:center;gap:4px;padding-bottom:2px;">
<input name="isAdmin" type="checkbox" value="true" id="chkAdmin">
<label for="chkAdmin" style="font-size:12px;color:#888;">관리자</label>
</div>
<button type="submit" class="btn btn-primary">추가</button>
</form>
</div>
<!-- 사용자 목록 -->
<div class="data-table">
<table>
<thead>
<tr><th>ID</th><th>이메일</th><th>사용자명</th><th>이름</th><th>관리자</th><th>상태</th><th>잠금</th><th>최종 로그인</th><th>관리</th></tr>
</thead>
<tbody>
<tr th:each="user : ${users}">
<td th:text="${user.id}"></td>
<td th:text="${user.email}"></td>
<td th:text="${user.username}"></td>
<td th:text="${user.fullName}"></td>
<td><span th:if="${user.admin}" class="badge badge-info">SA</span></td>
<td>
<span th:if="${user.active}" class="badge badge-success">활성</span>
<span th:unless="${user.active}" class="badge badge-danger">비활성</span>
</td>
<td><span th:if="${user.locked}" class="badge badge-danger">잠금</span></td>
<td th:text="${user.lastLoginAt != null} ? ${#temporals.format(user.lastLoginAt, 'yy-MM-dd HH:mm')} : '-'"></td>
<td style="white-space:nowrap;">
<a th:href="@{/admin/users/{id}(id=${user.id})}" class="btn btn-outline" style="padding:3px 10px;font-size:12px;">상세</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

이 Diff에서 너무 많은 파일이 변경되어 일부 파일이 표시되지 않습니다 더 보기