feat: UIMS 로그인보조+권한관리(RBAC) 이식

This commit is contained in:
GUARDiA 2026-06-21 21:02:23 +09:00
parent 8e0481e840
commit 337c8beffa
159 changed files with 12119 additions and 48 deletions

View File

@ -1,22 +1,35 @@
package com.zioinfo.esn.auth; package com.zioinfo.esn.auth;
import com.zioinfo.esn.common.ApiResponse; import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.auth.TwoFactorService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.Map;
/**
* ESN 인증 컨트롤러.
* - /login: 2FA off { twofa:"false", token, type }, 2FA on 이면 { twofa:"true", verifyToken, step, maskedEmail }.
* - /verify: (UIWS 2FA 이식) verify-token + 인증코드 access 토큰 발급.
* 기존 클라이언트는 응답에 token 필드가 그대로 존재(2FA off ) 회귀 0.
*/
@RestController @RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthController { public class AuthController {
private final AuthService authService; private final AuthService authService;
private final TwoFactorService twoFactorService;
@PostMapping("/login") @PostMapping("/login")
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) { public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
String token = authService.login(req.username(), req.password()); return ApiResponse.ok(authService.login(req.username(), req.password()));
return ApiResponse.ok(Map.of("token", token, "type", "Bearer")); }
/** UIWS 2FA 이식: 2차 인증 코드 검증 → access 토큰 발급. */
@PostMapping("/verify")
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code()));
} }
@GetMapping("/me") @GetMapping("/me")
@ -32,4 +45,6 @@ public class AuthController {
} }
record LoginRequest(String username, String password) {} record LoginRequest(String username, String password) {}
record VerifyRequest(String verifyToken, String code) {}
} }

View File

@ -0,0 +1,52 @@
package com.zioinfo.esn.auth;
import com.zioinfo.esn.auth.dto.FindIdRequest;
import com.zioinfo.esn.auth.dto.FindIdResponse;
import com.zioinfo.esn.auth.dto.ResetPwRequest;
import com.zioinfo.esn.auth.dto.SignupRequest;
import com.zioinfo.esn.auth.dto.SignupResponse;
import com.zioinfo.esn.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 로그인 보조 기능 3종(UIWS auth 패턴 이식) 회원가입 / 아이디찾기 / 비밀번호 초기화.
*
* <p>base path {@code /api/auth} (SecurityConfig permitAll 로그인 무인증 접근).
* 대상은 ESN 사용자 계정(esn_user).
* <ul>
* <li>회원가입: 승인 대기(approved=false) 상태로 등록 ADMIN 승인 로그인 차단</li>
* <li>아이디찾기: displayName+email 매칭, username 부분 마스킹 반환</li>
* <li>비번초기화: username+email 검증 임시비번 BCrypt 저장 + 메일/로그 발송(응답에 비번 미포함)</li>
* </ul>
* 보안 불변규칙: 임시비번·자격증명·계정 존재여부를 응답/로그 메시지에 노출하지 않는다.
* (기존 2FA 로그인/검증 컨트롤러 AuthController(/api/auth/login,/verify) 별개 회귀 0.)
*/
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthHelperController {
private final AuthHelperService authHelperService;
/** 회원가입(승인 대기 INSERT). */
@PostMapping("/signup")
public ApiResponse<SignupResponse> signup(@RequestBody SignupRequest req) {
return ApiResponse.ok(authHelperService.signup(req));
}
/** 아이디 찾기(이메일+이름 매칭, 마스킹 반환). */
@PostMapping("/find-id")
public ApiResponse<FindIdResponse> findId(@RequestBody FindIdRequest req) {
return ApiResponse.ok(authHelperService.findId(req));
}
/** 비밀번호 초기화(검증 → 임시비번 BCrypt + 메일/로그). */
@PostMapping("/reset-password")
public ApiResponse<SignupResponse> resetPassword(@RequestBody ResetPwRequest req) {
return ApiResponse.ok(authHelperService.resetPassword(req));
}
}

View File

@ -0,0 +1,132 @@
package com.zioinfo.esn.auth;
import com.zioinfo.esn.auth.dto.FindIdRequest;
import com.zioinfo.esn.auth.dto.FindIdResponse;
import com.zioinfo.esn.auth.dto.ResetPwRequest;
import com.zioinfo.esn.auth.dto.SignupRequest;
import com.zioinfo.esn.auth.dto.SignupResponse;
import com.zioinfo.esn.auth.mapper.UserSignupMapper;
import com.zioinfo.esn.uiws.common.mail.MailSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
/**
* 로그인 보조 3종(UIWS auth 패턴 이식) 회원가입 / 아이디찾기 / 비밀번호 초기화.
* 기존 2FA 로그인 서비스(AuthService) 별개 회귀 0.
*
* <p>보안 불변규칙: 임시비번·자격증명·계정 존재여부를 응답/로그 메시지에 노출하지 않는다.
* 메일 발송은 MailSender(미설정 LogMailSender 폴백) 사용 외부 API 호출 없음.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthHelperService {
private final UserSignupMapper userSignupMapper;
private final PasswordEncoder passwordEncoder;
private final MailSender mailSender;
private static final SecureRandom RANDOM = new SecureRandom();
// 혼동 문자(0/O/1/l/I) 제외 임시비번 가독성.
private static final String TMP_PW_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
/**
* 회원가입(승인 대기 INSERT). username/email 중복 차단, 비번 BCrypt, approved=false.
* 보안: 응답에 자격증명·임시정보 미포함, 일반 메시지만.
*/
public SignupResponse signup(SignupRequest req) {
if (req.username() == null || req.username().isBlank()
|| req.password() == null || req.password().length() < 4
|| req.email() == null || req.email().isBlank()) {
return new SignupResponse(false, "아이디·비밀번호(4자 이상)·이메일은 필수입니다.");
}
if (userSignupMapper.countByUsername(req.username()) > 0) {
return new SignupResponse(false, "이미 사용 중인 아이디입니다.");
}
if (userSignupMapper.countByEmail(req.email()) > 0) {
return new SignupResponse(false, "이미 등록된 이메일입니다.");
}
EsnUser u = new EsnUser();
u.setUsername(req.username());
u.setPasswordHash(passwordEncoder.encode(req.password()));
u.setDisplayName(req.displayName() != null && !req.displayName().isBlank()
? req.displayName() : req.username());
u.setEmail(req.email());
// insertSignup: role='USER', approved=false, locked=false, login_fail_count=0 (XML 고정)
userSignupMapper.insertSignup(u);
log.info("[auth-helper] signup pending approval: username={}", req.username());
return new SignupResponse(true, "가입 신청이 접수되었습니다. 관리자 승인 후 로그인할 수 있습니다.");
}
/**
* 아이디 찾기: 표시명+이메일 동시 일치 계정 1건 조회. username 부분 마스킹 반환.
* 미발견 found=false(원문 username 절대 미노출).
*/
public FindIdResponse findId(FindIdRequest req) {
if (req.displayName() == null || req.displayName().isBlank()
|| req.email() == null || req.email().isBlank()) {
return new FindIdResponse(false, "");
}
EsnUser u = userSignupMapper.findByDisplayNameAndEmail(req.displayName(), req.email());
if (u == null) {
return new FindIdResponse(false, "");
}
return new FindIdResponse(true, maskUsername(u.getUsername()));
}
/**
* 비밀번호 초기화: username+email 일치 검증 임시비번 생성·BCrypt 저장·잠금/실패카운트 해제·변경유도.
* 임시비번은 메일(미설정 LogMailSender 로그)로만 전달. API 응답·로그 메시지에 비번 미노출.
* 대상 미존재여도 success=true(계정 열거 방지).
*/
@Transactional
public SignupResponse resetPassword(ResetPwRequest req) {
final String okMsg = "임시 비밀번호를 등록된 이메일로 발송했습니다. 메일을 확인하세요.";
if (req.username() == null || req.username().isBlank()
|| req.email() == null || req.email().isBlank()) {
return new SignupResponse(false, "아이디와 이메일을 모두 입력하세요.");
}
EsnUser u = userSignupMapper.findByUsernameAndEmail(req.username(), req.email());
if (u == null) {
// 존재 여부 누설 방지 동일 성공 메시지 반환(실제 발송 없음).
log.info("[auth-helper] reset-password no match (suppressed): username={}", req.username());
return new SignupResponse(true, okMsg);
}
String tempPw = generateTempPassword();
// applyTempPassword: BCrypt 저장 + pw_change_yn=true + locked=false + login_fail_count=0 (멱등)
userSignupMapper.applyTempPassword(req.username(), passwordEncoder.encode(tempPw));
String subject = "[zioinfo-esn] 임시 비밀번호 안내";
String body = String.format(
"안녕하세요 %s 님,%n임시 비밀번호는 [%s] 입니다.%n로그인 후 즉시 비밀번호를 변경하세요.",
u.getDisplayName() != null ? u.getDisplayName() : u.getUsername(), tempPw);
// 메일 본문에만 임시비번 포함. mailSender 미설정 환경은 LogMailSender 폴백(서버 로그).
mailSender.send(u.getEmail(), subject, body);
log.info("[auth-helper] reset-password issued temp pw (sent via mail/log): username={}", req.username());
return new SignupResponse(true, okMsg);
}
private static String generateTempPassword() {
StringBuilder sb = new StringBuilder(10);
for (int i = 0; i < 10; i++) {
sb.append(TMP_PW_CHARS.charAt(RANDOM.nextInt(TMP_PW_CHARS.length())));
}
return sb.toString();
}
/** username 부분 마스킹: 앞 2자만 노출(예: admin → ad***). 2자 이하는 첫 글자만. */
private static String maskUsername(String username) {
if (username == null || username.isBlank()) {
return "";
}
if (username.length() <= 2) {
return username.charAt(0) + "*";
}
return username.substring(0, 2) + "*".repeat(Math.max(1, username.length() - 2));
}
}

View File

@ -1,12 +1,21 @@
package com.zioinfo.esn.auth; package com.zioinfo.esn.auth;
import com.zioinfo.esn.auth.mapper.UserAuthMapper; import com.zioinfo.esn.auth.mapper.UserAuthMapper;
import com.zioinfo.esn.uiws.auth.TwoFactorService;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Map; import java.util.Map;
/**
* ESN 인증 서비스.
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 회귀 0).
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 verify-token + 이메일코드 발급.
* 실패 누적 max-login-fail 계정 잠금.
*/
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthService { public class AuthService {
@ -14,17 +23,57 @@ public class AuthService {
private final UserAuthMapper userMapper; private final UserAuthMapper userMapper;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final TwoFactorService twoFactorService;
public String login(String username, String password) { /**
* 1차 로그인. 2FA 활성 verify-token + 이메일코드 흐름으로 분기,
* 비활성 기존처럼 access 토큰 즉시 발급.
*
* @return 2FA off: { token, type, twofa:"false" }
* 2FA on : { verifyToken, step:"EMAIL", maskedEmail, twofa:"true" }
*/
public Map<String, String> login(String username, String password) {
EsnUser user = userMapper.findByUsername(username); EsnUser user = userMapper.findByUsername(username);
// 잠금 우선 차단(존재하는 계정에 한해 잠금 메시지 존재 여부 누설 최소화)
if (user != null && twoFactorService.isLocked(user)) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
if (user == null || !user.isActive()) { if (user == null || !user.isActive()) {
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정"); throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
} }
// 회원가입 승인 게이트(UIWS 로그인 보조 이식): 가입 신청자(approved=false) 관리자 승인 로그인 차단.
// 기존 계정은 approved=true(90_uiws_system.sql 멱등 보정) 회귀 없음.
if (Boolean.FALSE.equals(user.getApproved())) {
throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 로그인하세요.");
}
if (!passwordEncoder.matches(password, user.getPasswordHash())) { if (!passwordEncoder.matches(password, user.getPasswordHash())) {
// 2FA 활성 실패 누적/잠금. 비활성 기존 동작(메시지만) 유지.
if (twoFactorService.isEnabled()) {
twoFactorService.recordLoginFailure(username);
EsnUser after = userMapper.findByUsername(username);
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
}
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치"); throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
} }
// 비밀번호 검증 통과
if (twoFactorService.isEnabled()) {
Map<String, String> step1 = twoFactorService.beginTwoFactor(user);
return Map.of(
"twofa", "true",
"verifyToken", step1.get("verifyToken"),
"step", step1.get("step"),
"maskedEmail", step1.getOrDefault("maskedEmail", ""));
}
// 2FA 비활성 기존 단일 로그인 흐름(회귀 0)
userMapper.resetLoginFail(username);
userMapper.updateLastLogin(username); userMapper.updateLastLogin(username);
return jwtUtil.generate(username, user.getRole(), user.getTenantCode()); String token = jwtUtil.generate(username, user.getRole(), user.getTenantCode());
return Map.of("twofa", "false", "token", token, "type", "Bearer");
} }
public Map<String, String> me(String token) { public Map<String, String> me(String token) {

View File

@ -18,4 +18,27 @@ public class EsnUser {
private boolean active; private boolean active;
private LocalDateTime lastLoginAt; private LocalDateTime lastLoginAt;
private LocalDateTime createdAt; private LocalDateTime createdAt;
// UIWS 2FA 이식 컬럼 (esn_user ALTER, db/91_uiws_port.sql)
/** 이메일 인증코드(6자리). 발급 후 verify 단계에서 검증. API 응답에는 절대 미포함. */
@JsonIgnore
private String emailVerifyCode;
/** 인증코드 만료시각. */
@JsonIgnore
private LocalDateTime emailVerifyExpire;
/** 로그인 실패 누적 횟수(기본 0). max-login-fail 도달 시 locked. */
private Integer loginFailCount;
/** 계정 잠금 여부(기본 false). */
private Boolean locked;
/** TOTP 시크릿(UIWS OTP 경로 대비, 현재 이메일 흐름에서는 미사용). */
@JsonIgnore
private String otpSecret;
// UIWS 로그인 보조 이식 컬럼 (esn_user ALTER, db/90_uiws_system.sql)
/** 회원가입 승인 여부(기본 true=기존계정). 신규 가입자는 false → ADMIN 승인 전 로그인 차단. */
private Boolean approved;
/** 표시명(아이디찾기 매칭용). */
private String displayName;
/** 임시비번 발급 후 비밀번호 변경 유도 플래그. */
private Boolean pwChangeYn;
} }

View File

@ -26,7 +26,10 @@ public class JwtFilter extends OncePerRequestFilter {
String header = req.getHeader("Authorization"); String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) { if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7); String token = header.substring(7);
if (jwtUtil.isValid(token)) { // 보안(치명 UIWS 2FA): purpose=2fa verify-token access 토큰이 아니다.
// 동일 서명키라 isValid() 통과하므로 별도 차단하지 않으면 2차 인증 보호 API 접근(2FA 완전 우회) 가능.
// verify-token 인증 컨텍스트를 세우지 않고 무시한다(/api/auth/verify 에서만 사용).
if (jwtUtil.isValid(token) && !jwtUtil.isVerifyToken(token)) {
String username = jwtUtil.getUsername(token); String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token); String role = jwtUtil.getRole(token);
var auth = new UsernamePasswordAuthenticationToken( var auth = new UsernamePasswordAuthenticationToken(

View File

@ -35,6 +35,48 @@ public class JwtUtil {
.compact(); .compact();
} }
/**
* UIWS 2FA 이식: 1차 로그인 통과 발급하는 단기 verify-token.
* purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 보호 API 접근 불가 JwtFilter 에서 차단).
*/
public String generateVerifyToken(String username, long validitySeconds) {
return Jwts.builder()
.subject(username)
.claim("purpose", "2fa")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L))
.signWith(key())
.compact();
}
/** verify-token 검증 후 username 반환. 유효하지 않거나 purpose!=2fa 면 null. */
public String parseVerifyTokenUsername(String token) {
try {
Claims c = parse(token);
if (!"2fa".equals(c.get("purpose", String.class))) {
return null;
}
return c.getSubject();
} catch (JwtException | IllegalArgumentException e) {
log.debug("verify-token 검증 실패: {}", e.getMessage());
return null;
}
}
/**
* 보안(치명): 토큰이 2FA verify-token(purpose=2fa)인지 판별.
* verify-token access 같은 서명키라 isValid() 통과하므로, JwtFilter 별도로
* 걸러내지 않으면 2차 코드 검증 없이 보호 API 접근(2FA 완전 우회) 된다.
* JwtFilter purpose=2fa 토큰을 인증 컨텍스트로 세우지 않는다(/api/auth/verify 에서만 사용).
*/
public boolean isVerifyToken(String token) {
try {
return "2fa".equals(parse(token).get("purpose", String.class));
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public Claims parse(String token) { public Claims parse(String token) {
return Jwts.parser().verifyWith(key()).build() return Jwts.parser().verifyWith(key()).build()
.parseSignedClaims(token).getPayload(); .parseSignedClaims(token).getPayload();

View File

@ -0,0 +1,11 @@
package com.zioinfo.esn.auth.dto;
/**
* 로그인 보조 이식 아이디 찾기 요청 DTO.
* 표시명(displayName) + 이메일(email) 동시 일치하는 ESN 계정을 조회.
* 응답의 username 마스킹하여 반환(자격증명 보호).
*/
public record FindIdRequest(
String displayName,
String email) {
}

View File

@ -0,0 +1,11 @@
package com.zioinfo.esn.auth.dto;
/**
* 로그인 보조 이식 아이디 찾기 응답 DTO.
* found=true 이면 maskedUsername(: ad***) 동봉. 미발견이어도 동일 shape(존재 여부 누설 최소화).
* 원본 username 전체는 절대 노출하지 않는다(부분 마스킹만).
*/
public record FindIdResponse(
boolean found,
String maskedUsername) {
}

View File

@ -0,0 +1,11 @@
package com.zioinfo.esn.auth.dto;
/**
* 로그인 보조 이식 비밀번호 초기화 요청 DTO.
* username + email 동시 일치 검증 임시 비밀번호를 BCrypt 저장.
* 임시 비밀번호는 메일(미설정 LogMailSender 로그)로만 전달 API 응답에 절대 미포함.
*/
public record ResetPwRequest(
String username,
String email) {
}

View File

@ -0,0 +1,13 @@
package com.zioinfo.esn.auth.dto;
/**
* 로그인 보조 이식 회원가입 요청 DTO.
* 대상: ESN 사용자 계정(esn_user). 가입 결과는 승인 대기(approved=false) 상태로 INSERT
* ADMIN 승인 로그인 차단(AuthService.login approved 게이트).
*/
public record SignupRequest(
String username,
String password,
String displayName,
String email) {
}

View File

@ -0,0 +1,11 @@
package com.zioinfo.esn.auth.dto;
/**
* 로그인 보조 이식 회원가입/비밀번호초기화 공통 결과 DTO.
* 항상 일반 메시지만 반환(임시비번·계정 존재여부 민감정보 미포함 자격증명 보호 불변규칙).
* 보안상 비밀번호 초기화는 대상 미존재 시에도 success=true(열거 공격 방지).
*/
public record SignupResponse(
boolean success,
String message) {
}

View File

@ -4,8 +4,29 @@ import com.zioinfo.esn.auth.EsnUser;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
@Mapper @Mapper
public interface UserAuthMapper { public interface UserAuthMapper {
EsnUser findByUsername(@Param("username") String username); EsnUser findByUsername(@Param("username") String username);
int updateLastLogin(@Param("username") String username); int updateLastLogin(@Param("username") String username);
// UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE)
/** 로그인 성공 시 실패 카운트 초기화. */
int resetLoginFail(@Param("username") String username);
/** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */
int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail);
/** 1차 통과 시 이메일 인증코드/만료 저장(verify 단계에서 검증). */
int saveEmailCode(@Param("username") String username,
@Param("code") String code,
@Param("expire") LocalDateTime expire);
/** 2차 검증 성공 시 코드 폐기. */
int clearEmailCode(@Param("username") String username);
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
int unlock(@Param("username") String username);
} }

View File

@ -0,0 +1,35 @@
package com.zioinfo.esn.auth.mapper;
import com.zioinfo.esn.auth.EsnUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 로그인 보조 3종(회원가입·아이디찾기·비번초기화) esn_user 매퍼.
* 기존 2FA 로그인 매퍼(UserAuthMapper) 분리 회귀 0.
* 보안: 임시비번/자격증명은 응답·로그(코드 ) 노출하지 않는다(불변규칙).
*/
@Mapper
public interface UserSignupMapper {
/** 가입 중복 검사: username 존재 여부. */
int countByUsername(@Param("username") String username);
/** 가입 중복 검사: email 존재 여부. */
int countByEmail(@Param("email") String email);
/** 회원가입(승인 대기): role='USER', approved=false, is_active=true 고정 INSERT. */
int insertSignup(EsnUser user);
/** 아이디찾기: 표시명(display_name)+이메일 일치 사용자(존재 시 마스킹 반환). */
EsnUser findByDisplayNameAndEmail(@Param("displayName") String displayName,
@Param("email") String email);
/** 비번초기화: username+email 동시 일치 검증용. */
EsnUser findByUsernameAndEmail(@Param("username") String username,
@Param("email") String email);
/** 비번초기화: 임시비번 적용 + 변경유도 + 잠금/실패카운트 해제(멱등 UPDATE). */
int applyTempPassword(@Param("username") String username,
@Param("passwordHash") String passwordHash);
}

View File

@ -51,7 +51,7 @@ public class OllamaClient {
.uri(URI.create(baseUrl + "/api/chat")) .uri(URI.create(baseUrl + "/api/chat"))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json)) .POST(HttpRequest.BodyPublishers.ofString(json))
.timeout(Duration.ofSeconds(30)) .timeout(Duration.ofSeconds(120))
.build(); .build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) { if (resp.statusCode() == 200) {

View File

@ -36,6 +36,8 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// 인증 불필요 // 인증 불필요
.requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/auth/**").permitAll()
// UIWS 회원가입 화면 공개 조회(부서/거래처) 비인증 접근.
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll() .requestMatchers("/actuator/health").permitAll()
// 정적 리소스 (React SPA) // 정적 리소스 (React SPA)
.requestMatchers(HttpMethod.GET, "/", "/index.html", "/assets/**", "/*.js", "/*.css", "/*.ico").permitAll() .requestMatchers(HttpMethod.GET, "/", "/index.html", "/assets/**", "/*.js", "/*.css", "/*.ico").permitAll()
@ -44,6 +46,10 @@ public class SecurityConfig {
.requestMatchers("/api/tenants/**").hasRole("ADMIN") .requestMatchers("/api/tenants/**").hasRole("ADMIN")
.requestMatchers("/api/users/**").hasAnyRole("ADMIN", "MANAGER") .requestMatchers("/api/users/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers("/api/firmware/**").hasAnyRole("ADMIN", "MANAGER") .requestMatchers("/api/firmware/**").hasAnyRole("ADMIN", "MANAGER")
// UIWS system(권한관리): 조회 인증사용자, 변경(POST/PUT/DELETE) ADMIN/MANAGER.
.requestMatchers(HttpMethod.POST, "/api/system/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.PUT, "/api/system/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.DELETE, "/api/system/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.DELETE, "/api/**").hasAnyRole("ADMIN", "MANAGER") .requestMatchers(HttpMethod.DELETE, "/api/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.POST, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER") .requestMatchers(HttpMethod.POST, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER")
.requestMatchers(HttpMethod.PUT, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER") .requestMatchers(HttpMethod.PUT, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER")

View File

@ -0,0 +1,26 @@
package com.zioinfo.esn.uiws.auth;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Update;
/**
* 2FA 로그인 검증 이력 매퍼 (tb_uiws_login_verify). 발급/검증 감사 기록.
* verify_code 감사 이력에만 남고 API 응답에는 절대 노출하지 않는다.
*/
@Mapper
public interface LoginVerifyMapper {
int insert(UiwsLoginVerify v);
/** 동일 user 의 미검증 EMAIL 이력 중 최신 1건을 검증완료(Y) 처리. */
@Update("""
UPDATE tb_uiws_login_verify
SET verified_yn = 'Y', updated_by = #{userId}, updated_at = now()
WHERE verify_id = (
SELECT verify_id FROM tb_uiws_login_verify
WHERE user_id = #{userId} AND verify_method = 'EMAIL' AND verified_yn = 'N'
ORDER BY verify_id DESC LIMIT 1
)
""")
int markLatestVerified(String userId);
}

View File

@ -0,0 +1,161 @@
package com.zioinfo.esn.uiws.auth;
import com.zioinfo.esn.auth.EsnUser;
import com.zioinfo.esn.auth.JwtUtil;
import com.zioinfo.esn.auth.mapper.UserAuthMapper;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.common.mail.MailSender;
import com.zioinfo.esn.uiws.config.UiwsProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Map;
/**
* UIWS 2FA(이메일 코드) 레이어. ESN 기존 단일 로그인을 보존하면서 2단계 인증을 추가한다.
*
* 흐름:
* 1) 1차 로그인 성공 {@link #beginTwoFactor}: verify-token 발급 + 이메일 인증코드 발송(LogMailSender 폴백) + 감사 기록.
* 2) {@code POST /api/auth/verify}(verifyToken + code) {@link #verify}: 코드 검증 access 발급.
* 3) 로그인 실패 누적 max-login-fail 계정 잠금({@link #recordLoginFailure}).
*
* 보안:
* - 인증코드는 메일/감사 채널로만 전달. API 응답·로그 메시지에 코드/비밀번호/자격증명 절대 미노출(불변규칙).
* - 외부 API 호출 없음(MailSender 폴백은 로그). Ollama 외부 통신 금지 준수.
* - access 토큰은 ESN JwtUtil(username/role/tenantCode 3-클레임) 정책을 그대로 재사용한다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TwoFactorService {
private static final SecureRandom RANDOM = new SecureRandom();
private static final String SYSTEM = "SYSTEM";
private final UserAuthMapper userMapper;
private final LoginVerifyMapper loginVerifyMapper;
private final JwtUtil jwtUtil;
private final MailSender mailSender;
private final UiwsProperties properties;
public boolean isEnabled() {
return properties.getAuth().isTwofaEnabled();
}
/** 잠금 여부(1차 로그인 전 차단용). */
public boolean isLocked(EsnUser user) {
return Boolean.TRUE.equals(user.getLocked());
}
/**
* 1차 로그인 성공 2차 인증 시작: verify-token 발급 + 이메일코드 발송 + 감사 기록 + 실패카운트 초기화.
* @return { verifyToken, step:"EMAIL", maskedEmail }
*/
@Transactional
public Map<String, String> beginTwoFactor(EsnUser user) {
long codeValidity = properties.getAuth().getEmailCodeValiditySeconds();
long tokenValidity = properties.getAuth().getVerifyTokenValiditySeconds();
String code = String.format("%06d", RANDOM.nextInt(1_000_000));
LocalDateTime expire = LocalDateTime.now().plusSeconds(codeValidity);
// user 테이블에 코드/만료 저장(실패카운트 초기화) + 감사 이력 기록
userMapper.saveEmailCode(user.getUsername(), code, expire);
recordVerifyAttempt(user.getUsername(), code, expire);
// 이메일 발송(미설정 환경은 LogMailSender 폴백). 코드는 메일 본문에만.
String subject = "[GUARDiA ESN] 로그인 2차 인증 코드";
String body = String.format(
"안녕하세요 %s 님,\n로그인 2차 인증 코드는 [%s] 입니다.\n유효시간: %d초",
user.getUsername(), code, codeValidity);
if (user.getEmail() != null && !user.getEmail().isBlank()) {
mailSender.send(user.getEmail(), subject, body);
} else {
log.warn("[2FA] no email for user={} — code logged only", user.getUsername());
}
String verifyToken = jwtUtil.generateVerifyToken(user.getUsername(), tokenValidity);
// 응답에는 코드 미포함 verifyToken/step/maskedEmail .
return Map.of(
"verifyToken", verifyToken,
"step", "EMAIL",
"maskedEmail", maskEmail(user.getEmail()));
}
/**
* 2차 검증: verify-token + code 검증 access 발급. 코드 폐기 + 감사 이력 검증완료.
* @return { token, type, username, role, tenant }
*/
@Transactional
public Map<String, String> verify(String verifyToken, String code) {
String username = jwtUtil.parseVerifyTokenUsername(verifyToken);
if (username == null) {
throw new UiwsApiException(UiwsErrorCode.VERIFY_TOKEN_INVALID);
}
EsnUser user = userMapper.findByUsername(username);
if (user == null) {
throw new UiwsApiException(UiwsErrorCode.VERIFY_TOKEN_INVALID);
}
if (Boolean.TRUE.equals(user.getLocked())) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
boolean codeOk = user.getEmailVerifyCode() != null
&& user.getEmailVerifyCode().equals(code)
&& user.getEmailVerifyExpire() != null
&& user.getEmailVerifyExpire().isAfter(LocalDateTime.now());
if (!codeOk) {
throw new UiwsApiException(UiwsErrorCode.VERIFY_CODE_INVALID);
}
// 코드 폐기 + 감사 검증완료 + 마지막 로그인 갱신
userMapper.clearEmailCode(username);
loginVerifyMapper.markLatestVerified(username);
userMapper.updateLastLogin(username);
String access = jwtUtil.generate(user.getUsername(), user.getRole(), user.getTenantCode());
return Map.of(
"token", access,
"type", "Bearer",
"username", user.getUsername(),
"role", user.getRole() == null ? "" : user.getRole(),
"tenant", user.getTenantCode() == null ? "" : user.getTenantCode());
}
/** 로그인 비밀번호 실패 시 누적/잠금 처리. */
@Transactional
public void recordLoginFailure(String username) {
userMapper.incrementLoginFail(username, properties.getAuth().getMaxLoginFail());
}
private void recordVerifyAttempt(String username, String code, LocalDateTime expire) {
UiwsLoginVerify v = new UiwsLoginVerify();
v.setUserId(username);
v.setVerifyMethod("EMAIL");
v.setVerifyCode(code);
v.setExpireAt(expire);
v.setVerifiedYn("N");
v.setCreatedBy(SYSTEM);
v.setCreatedAt(LocalDateTime.now());
loginVerifyMapper.insert(v);
}
/** 이메일 마스킹(자격증명 보호): ab****@domain. */
private static String maskEmail(String email) {
if (email == null || email.isBlank() || !email.contains("@")) {
return "";
}
int at = email.indexOf('@');
String local = email.substring(0, at);
String domain = email.substring(at);
if (local.length() <= 2) {
return local.charAt(0) + "*" + domain;
}
return local.substring(0, 2) + "*".repeat(Math.max(1, local.length() - 2)) + domain;
}
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.esn.uiws.auth;
import lombok.Data;
import java.time.LocalDateTime;
/** 2FA 로그인 검증 이력 (tb_uiws_login_verify). 원본 com.urp.uiws.domain.LoginVerify 이식. */
@Data
public class UiwsLoginVerify {
private Long verifyId;
private String userId; // ERP username 논리참조
private String verifyMethod; // EMAIL | OTP
private String verifyCode;
private LocalDateTime expireAt;
private String verifiedYn; // Y | N
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.esn.uiws.common;
import lombok.Getter;
/**
* UIWS 이식 모듈 업무 예외. RuntimeException 상속하여 ERP 기존 GlobalExceptionHandler
* (RuntimeException 400 + message, 스택트레이스 미노출) 그대로 포착된다.
* 추가 인프라/ 없이 ERP 공통 에러 처리와 정합한다.
*/
@Getter
public class UiwsApiException extends RuntimeException {
private final UiwsErrorCode errorCode;
public UiwsApiException(UiwsErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public UiwsApiException(UiwsErrorCode errorCode, String detail) {
super(detail);
this.errorCode = errorCode;
}
}

View File

@ -0,0 +1,48 @@
package com.zioinfo.esn.uiws.common;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* 현재 인증 사용자 식별자(=ERP username) 추출 헬퍼. 감사 컬럼(created_by/updated_by)
* 소유자(writer_id/owner_id/sender_id) 기록에 사용한다.
*
* ERP JwtFilter 인증 principal username(String) 세팅하므로 값을 그대로 사용한다.
* (UIWS 원본의 CurrentUser/UserPrincipal 패턴을 ERP 인증 모델에 맞게 단순화 이식.)
*/
public final class UiwsCurrentUser {
private static final String SYSTEM = "SYSTEM";
private UiwsCurrentUser() {
}
/** 현재 사용자 ID(username). 미인증/시스템 컨텍스트는 "SYSTEM". */
public static String id() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof String username
&& !"anonymousUser".equals(username)) {
return username;
}
return SYSTEM;
}
/** ADMIN 권한 보유 여부(데이터 가시범위 판정용 — ADMIN=전체). */
public static boolean isAdmin() {
return hasRole("ADMIN");
}
/** MANAGER 이상(MANAGER/ADMIN) 여부 — 업무일지 댓글 권한 등에 사용. */
public static boolean isManagerOrAbove() {
return hasRole("ADMIN") || hasRole("MANAGER") || hasRole("CFO");
}
private static boolean hasRole(String role) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) {
return false;
}
return auth.getAuthorities().stream()
.anyMatch(a -> ("ROLE_" + role).equals(a.getAuthority()));
}
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.esn.uiws.common;
import java.util.List;
/**
* 데이터 가시범위(스코프) UIWS DataScopeService silo 단순화 이식.
* 원본은 부서계층(TB_DEPT) 기반 스코프를 산출하나, ERP 이식본은 코어 dept 테이블을 이식하지 않으므로
* 역할만으로 판정한다:
* - ADMIN 전체(all=true)
* - 본인 데이터만(ownerIds = [본인])
* (부서계층 스코프가 필요해지면 ERP tb_department 연계로 확장 후속 트랙.)
*/
public final class UiwsDataScope {
private UiwsDataScope() {
}
/** all=true 면 전체 조회(ownerIds 무시). ownerIds 는 항상 본인 포함(빈 IN 회피). */
public record Scope(boolean all, List<String> ownerIds) {
}
public static Scope current() {
String me = UiwsCurrentUser.id();
if (UiwsCurrentUser.isAdmin()) {
return new Scope(true, List.of(me));
}
return new Scope(false, List.of(me));
}
}

View File

@ -0,0 +1,60 @@
package com.zioinfo.esn.uiws.common;
import lombok.Getter;
/**
* UIWS 이식 모듈 도메인 오류 코드.
* 원본 com.urp.uiws.common.exception.ErrorCode worklog/schedule/message/auth(2FA) 영역 발췌·이식.
* 메시지는 사용자 노출용 스택트레이스/SQL 민감정보는 절대 포함하지 않는다(보안 불변규칙).
*/
@Getter
public enum UiwsErrorCode {
// 공통
INVALID_REQUEST("ERR-UIWS-400", "요청이 올바르지 않습니다."),
FORBIDDEN("ERR-UIWS-403", "접근 권한이 없습니다."),
NOT_FOUND("ERR-UIWS-404", "대상을 찾을 수 없습니다."),
// worklog
WORKLOG_NOT_FOUND("ERR-UIWS-WL-404", "업무일지를 찾을 수 없습니다."),
WORKLOG_TIME_OVERLAP("ERR-UIWS-WL-409", "동일 일지 내 시간대가 중복됩니다."),
WORKLOG_TIME_INVALID("ERR-UIWS-WL-422", "근무 시작/종료 시간이 올바르지 않습니다."),
// schedule
SCHEDULE_NOT_FOUND("ERR-UIWS-SC-404", "일정을 찾을 수 없습니다."),
SCHEDULE_DT_INVALID("ERR-UIWS-SC-422", "일정 시작/종료 일시가 올바르지 않습니다."),
DIARY_NOT_FOUND("ERR-UIWS-DI-404", "일지를 찾을 수 없습니다."),
ATTACH_NOT_FOUND("ERR-UIWS-AT-404", "첨부파일을 찾을 수 없습니다."),
ATTACH_REF_TYPE_INVALID("ERR-UIWS-AT-422", "첨부 대상 유형은 SCHEDULE 또는 DIARY 여야 합니다."),
FILE_EMPTY("ERR-UIWS-FILE-400", "업로드할 파일이 비어 있습니다."),
FILE_STORAGE_ERROR("ERR-UIWS-FILE-500", "파일 저장 중 오류가 발생했습니다."),
// message
MESSAGE_NOT_FOUND("ERR-UIWS-MSG-404", "쪽지를 찾을 수 없습니다."),
MESSAGE_RCV_TYPE_INVALID("ERR-UIWS-MSG-422", "수신구분은 RECV(수신) 또는 REF(참조) 여야 합니다."),
// auth (2FA)
VERIFY_TOKEN_INVALID("ERR-UIWS-2FA-401", "2차 검증 토큰이 유효하지 않거나 만료되었습니다."),
VERIFY_CODE_INVALID("ERR-UIWS-2FA-401C", "인증 코드가 올바르지 않거나 만료되었습니다."),
ACCOUNT_LOCKED("ERR-UIWS-2FA-LOCK", "로그인 실패 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의하세요."),
// system(권한관리) UIWS system 모듈 이식
DUPLICATE_KEY("ERR-UIWS-SYS-409D", "이미 존재하는 항목입니다."),
RESOURCE_IN_USE("ERR-UIWS-SYS-409U", "참조 중인 항목이 있어 처리할 수 없습니다."),
ROLE_NOT_FOUND("ERR-UIWS-SYS-RL404", "권한을 찾을 수 없습니다."),
USER_NOT_FOUND("ERR-UIWS-SYS-US404", "사용자를 찾을 수 없습니다."),
USER_ID_DUPLICATED("ERR-UIWS-SYS-US409", "이미 사용 중인 사용자 ID입니다."),
DEPT_NOT_FOUND("ERR-UIWS-SYS-DP404", "부서를 찾을 수 없습니다."),
COMPANY_NOT_FOUND("ERR-UIWS-SYS-CO404", "거래처를 찾을 수 없습니다."),
MENU_NOT_FOUND("ERR-UIWS-SYS-MN404", "메뉴를 찾을 수 없습니다."),
PROGRAM_NOT_FOUND("ERR-UIWS-SYS-PG404", "프로그램을 찾을 수 없습니다."),
CODE_GRP_NOT_FOUND("ERR-UIWS-SYS-CG404", "코드그룹을 찾을 수 없습니다.");
private final String code;
private final String message;
UiwsErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.esn.uiws.common.mail;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/**
* 로컬/개발/미설정용 메일 발송 폴백. 실제 전송 없이 로그로만 기록한다(외부 호출 0).
* esn.uiws.mail.mode=log (기본) 활성. SMTP 운영 mode=smtp 별도 구현 활성화.
*
* 보안: 본문에 인증코드/임시비밀번호가 포함되므로 로그 레벨은 운영에서 조정.
* (인증코드는 API 응답으로는 절대 반환하지 않는다 메일/로그 채널로만 전달.)
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "esn.uiws.mail.mode", havingValue = "log", matchIfMissing = true)
public class LogMailSender implements MailSender {
@Override
public void send(String to, String subject, String body) {
log.info("[UIWS-MAIL:LOG] to={} subject={}\n{}", to, subject, body);
}
}

View File

@ -0,0 +1,10 @@
package com.zioinfo.esn.uiws.common.mail;
/**
* 메일 발송 추상화(UIWS 이식). 로컬/미설정 환경은 LogMailSender(로그만), 운영은 SMTP 구현으로 교체.
* 외부 API 호출은 하지 않는다(보안 불변규칙 Ollama 외부 호출 금지).
*/
public interface MailSender {
void send(String to, String subject, String body);
}

View File

@ -0,0 +1,41 @@
package com.zioinfo.esn.uiws.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* UIWS 이식 모듈 설정. application.yml esn.uiws.* 바인딩.
* 계획서 2FA 설정키(verify-token-validity / max-login-fail / email-code-validity) +
* 첨부 업로드 디렉터리. 모두 안전 기본값 보유(미설정이어도 동작).
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "esn.uiws")
public class UiwsProperties {
private final Auth auth = new Auth();
private final Upload upload = new Upload();
@Getter
@Setter
public static class Auth {
/** 2FA 레이어 on/off. 기본 on(켜짐). off 시 기존 단일 JWT 로그인 흐름 유지(회귀 0). */
private boolean twofaEnabled = true;
/** 1차 통과 후 verify-token 유효시간(초). 계획서 300(5분). */
private long verifyTokenValiditySeconds = 300;
/** 이메일 인증코드 유효시간(초). 계획서 300(5분). */
private long emailCodeValiditySeconds = 300;
/** 로그인 실패 누적 N회 시 계정 잠금. 계획서 5. */
private int maxLoginFail = 5;
}
@Getter
@Setter
public static class Upload {
/** 첨부파일 저장 루트. 기본 ./uploads/uiws. */
private String uploadDir = "./uploads/uiws";
}
}

View File

@ -0,0 +1,75 @@
package com.zioinfo.esn.uiws.message.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.message.dto.MessageDtos.*;
import com.zioinfo.esn.uiws.message.service.MessageService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
/**
* UIWS 이식 쪽지(모듈 03). 인증 필수(/api/messages). 원본 prefix·엔드포인트 보존.
* Spring Data Pageable 의존 회피: page/size 단순 파라미터로 이식(ERP는 spring-data-web 미사용).
*/
@RestController
@RequestMapping("/api/messages")
@RequiredArgsConstructor
public class MessageController {
private final MessageService messageService;
@PostMapping
public ApiResponse<MessageSendResponse> send(@Valid @RequestBody MessageSendDto dto) {
return ApiResponse.ok(messageService.send(dto));
}
@GetMapping("/sent")
public ApiResponse<MessageService.SentPage> sent(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String titleKeyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(messageService.listSent(fromDate, toDate, titleKeyword, page, size));
}
@GetMapping("/sent/{id}")
public ApiResponse<SentMessageDetailDto> sentDetail(@PathVariable("id") Long id) {
return ApiResponse.ok(messageService.sentDetail(id));
}
@DeleteMapping("/sent")
public ApiResponse<Void> deleteSent(@Valid @RequestBody MessageIdsRequest req) {
messageService.deleteSent(req.ids());
return ApiResponse.ok(null);
}
@GetMapping("/unread-count")
public ApiResponse<Long> unreadCount() {
return ApiResponse.ok(messageService.unreadCount());
}
@GetMapping("/received")
public ApiResponse<MessageService.ReceivedPage> received(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String titleKeyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(messageService.listReceived(fromDate, toDate, titleKeyword, page, size));
}
@GetMapping("/received/{id}")
public ApiResponse<ReceivedMessageDetailDto> receivedDetail(@PathVariable("id") Long id) {
return ApiResponse.ok(messageService.receivedDetail(id));
}
@DeleteMapping("/received")
public ApiResponse<Void> deleteReceived(@Valid @RequestBody MessageIdsRequest req) {
messageService.deleteReceived(req.ids());
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,100 @@
package com.zioinfo.esn.uiws.message.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
/**
* 쪽지(모듈 03) 요청/응답 DTO 모음. 원본 com.urp.uiws.message.dto.* 이식(필드·shape 동일).
* 프론트 경계면 계약 유지: API 응답 필드명 그대로 보존.
*/
public final class MessageDtos {
private MessageDtos() {
}
/** 전송/답장 요청. */
public record MessageSendDto(
@NotBlank String title,
@NotBlank String content,
Long refWorklogId,
Long replyToId,
@Valid @NotEmpty(message = "수신자는 1명 이상이어야 합니다.") List<MessageReceiverDto> receivers
) {
}
/** 수신자 1건. rcvType: RECV(수신) | REF(참조). */
public record MessageReceiverDto(
@NotBlank String receiverId,
@NotBlank String rcvType
) {
}
/** 전송 응답. */
public record MessageSendResponse(Long messageId) {
}
/** 보낸쪽지함 행. */
public record SentMessageDto(
Long messageId,
String title,
String receiverSummary,
long openCount,
long totalCount,
String sentAt
) {
}
/** 보낸쪽지 상세(수신자 개봉현황). */
public record SentMessageDetailDto(
Long messageId,
String title,
String content,
Long refWorklogId,
String sentAt,
List<ReceiverStatusDto> receivers,
long totalCount,
long openCount,
long unopenCount
) {
}
/** 수신자별 개봉현황. */
public record ReceiverStatusDto(
String receiverNm,
String rcvType,
String readYn,
String readAt
) {
}
/** 받은쪽지함 행. */
public record ReceivedMessageDto(
Long messageId,
String title,
String senderNm,
String sentAt,
String readYn
) {
}
/** 받은쪽지 상세(조회 시 개봉처리). */
public record ReceivedMessageDetailDto(
Long messageId,
String title,
String content,
String senderNm,
String sentAt,
String receivedAt,
Long refWorklogId
) {
}
/** 다중삭제 본문. */
public record MessageIdsRequest(
@NotEmpty(message = "ids는 필수입니다.") List<Long> ids
) {
}
}

View File

@ -0,0 +1,78 @@
package com.zioinfo.esn.uiws.message.mapper;
import com.zioinfo.esn.uiws.message.model.UiwsMessage;
import com.zioinfo.esn.uiws.message.model.UiwsMessageRcv;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 쪽지 MyBatis 매퍼. 원본 JPA MessageRepository/MessageRcvRepository MyBatis 변환 이식.
* 사용자명은 esn_user(username 라벨) 조인으로 라벨화(코어 user 재사용, 별도 user 테이블 미이식).
*/
@Mapper
public interface MessageMapper {
// message 헤더
int insertMessage(UiwsMessage m);
UiwsMessage findMessageById(@Param("messageId") Long messageId);
boolean existsMessageById(@Param("messageId") Long messageId);
/** 보낸쪽지 페이징(SENDER_DEL_YN='N' 제외). */
List<UiwsMessage> searchSent(@Param("senderId") String senderId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("titleKeyword") String titleKeyword,
@Param("limit") int limit,
@Param("offset") int offset);
long countSent(@Param("senderId") String senderId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("titleKeyword") String titleKeyword);
/** 보낸쪽지 다중 소프트삭제(본인 것만). */
int softDeleteSent(@Param("senderId") String senderId,
@Param("ids") List<Long> ids,
@Param("actor") String actor);
// 수신자
int insertRcv(UiwsMessageRcv rcv);
List<UiwsMessageRcv> findRcvByMessageId(@Param("messageId") Long messageId);
List<UiwsMessageRcv> findRcvByMessageIds(@Param("ids") List<Long> ids);
UiwsMessageRcv findRcvByMessageAndReceiver(@Param("messageId") Long messageId,
@Param("receiverId") String receiverId);
long countUnread(@Param("receiverId") String receiverId);
/** 받은쪽지 개봉처리(READ_YN='Y', READ_AT). */
int markRead(@Param("rcvId") Long rcvId, @Param("actor") String actor, @Param("readAt") LocalDateTime readAt);
int softDeleteReceived(@Param("receiverId") String receiverId,
@Param("ids") List<Long> ids,
@Param("actor") String actor);
/** 받은쪽지 목록(조인: message + rcv). 행: messageId,title,senderId,sentAt,readYn. */
List<Map<String, Object>> searchReceived(@Param("receiverId") String receiverId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("titleKeyword") String titleKeyword,
@Param("limit") int limit,
@Param("offset") int offset);
long countReceived(@Param("receiverId") String receiverId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("titleKeyword") String titleKeyword);
// 사용자명 라벨(코어 user 재사용)
List<Map<String, String>> findUserNames(@Param("ids") List<String> ids);
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.esn.uiws.message.model;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 쪽지 헤더 (tb_uiws_message). 원본 com.urp.uiws.domain.Message 이식.
* MyBatis map-underscore-to-camel-case 컬럼필드 자동 매핑.
*/
@Data
public class UiwsMessage {
private Long messageId;
private String senderId;
private String title;
private String content;
private Long refWorklogId;
private Long replyToId;
private LocalDateTime sentAt;
private String senderDelYn;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.esn.uiws.message.model;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 쪽지 수신자 (tb_uiws_message_rcv). 원본 com.urp.uiws.domain.MessageRcv 이식.
*/
@Data
public class UiwsMessageRcv {
private Long rcvId;
private Long messageId;
private String receiverId;
private String rcvType; // RECV | REF
private String readYn; // Y | N
private LocalDateTime readAt;
private String receiverDelYn; // Y | N
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,271 @@
package com.zioinfo.esn.uiws.message.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.message.dto.MessageDtos.*;
import com.zioinfo.esn.uiws.message.mapper.MessageMapper;
import com.zioinfo.esn.uiws.message.model.UiwsMessage;
import com.zioinfo.esn.uiws.message.model.UiwsMessageRcv;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 쪽지(모듈 03) 서비스 원본 com.urp.uiws.message.service.MessageService MyBatis 변환 이식.
* 로직(전송/답장, 보낸함, 개봉현황, 받은함, 개봉처리, 다중삭제, 미열람 배지) 동등하게 보존한다.
* 기본 조회기간 = 최근 1주일.
*/
@Service
@RequiredArgsConstructor
public class MessageService {
private static final Set<String> VALID_RCV_TYPES = Set.of("RECV", "REF");
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
private final MessageMapper mapper;
// ------------------------------------------------------------------ 전송/답장
@Transactional
public MessageSendResponse send(MessageSendDto dto) {
String actor = UiwsCurrentUser.id();
LocalDateTime now = LocalDateTime.now();
if (dto.replyToId() != null && !mapper.existsMessageById(dto.replyToId())) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND, "답장 원본 쪽지를 찾을 수 없습니다.");
}
UiwsMessage m = new UiwsMessage();
m.setSenderId(actor);
m.setTitle(dto.title());
m.setContent(dto.content());
m.setRefWorklogId(dto.refWorklogId());
m.setReplyToId(dto.replyToId());
m.setSentAt(now);
m.setSenderDelYn("N");
m.setCreatedBy(actor);
m.setCreatedAt(now);
mapper.insertMessage(m); // useGeneratedKeys m.messageId
// 수신자: 동일 수신자 중복 제거(UNIQUE(message_id,receiver_id) 보호). 등장 rcvType 채택.
Set<String> seen = new LinkedHashSet<>();
for (MessageReceiverDto r : dto.receivers()) {
String receiverId = (r.receiverId() == null) ? null : r.receiverId().trim();
String rcvType = (r.rcvType() == null) ? null : r.rcvType().trim().toUpperCase();
if (receiverId == null || receiverId.isBlank()) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "수신자 ID가 비어 있습니다.");
}
if (!VALID_RCV_TYPES.contains(rcvType)) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_RCV_TYPE_INVALID);
}
if (!seen.add(receiverId)) {
continue;
}
UiwsMessageRcv rcv = new UiwsMessageRcv();
rcv.setMessageId(m.getMessageId());
rcv.setReceiverId(receiverId);
rcv.setRcvType(rcvType);
rcv.setReadYn("N");
rcv.setReceiverDelYn("N");
rcv.setCreatedBy(actor);
rcv.setCreatedAt(now);
mapper.insertRcv(rcv);
}
return new MessageSendResponse(m.getMessageId());
}
// ------------------------------------------------------------------ 보낸쪽지함
@Transactional(readOnly = true)
public SentPage listSent(LocalDate fromDate, LocalDate toDate, String titleKeyword, int page, int size) {
LocalDateTime[] range = range(fromDate, toDate);
String me = UiwsCurrentUser.id();
String kw = blankToNull(titleKeyword);
long total = mapper.countSent(me, range[0], range[1], kw);
List<UiwsMessage> rows = mapper.searchSent(me, range[0], range[1], kw, size, page * size);
List<Long> ids = rows.stream().map(UiwsMessage::getMessageId).toList();
List<UiwsMessageRcv> rcvs = ids.isEmpty() ? List.of() : mapper.findRcvByMessageIds(ids);
Map<Long, List<UiwsMessageRcv>> byMsg = rcvs.stream().collect(Collectors.groupingBy(UiwsMessageRcv::getMessageId));
Map<String, String> names = userNames(rcvs.stream().map(UiwsMessageRcv::getReceiverId).toList());
List<SentMessageDto> content = rows.stream().map(m -> {
List<UiwsMessageRcv> list = byMsg.getOrDefault(m.getMessageId(), List.of());
long openCount = list.stream().filter(r -> "Y".equals(r.getReadYn())).count();
return new SentMessageDto(m.getMessageId(), m.getTitle(),
receiverSummary(list, names), openCount, list.size(), fmt(m.getSentAt()));
}).toList();
return new SentPage(content, total, page, size, totalPages(total, size));
}
// ------------------------------------------------------------------ 보낸쪽지 상세
@Transactional(readOnly = true)
public SentMessageDetailDto sentDetail(Long messageId) {
String me = UiwsCurrentUser.id();
UiwsMessage m = mapper.findMessageById(messageId);
if (m == null) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND);
}
if (!me.equals(m.getSenderId())) {
throw new UiwsApiException(UiwsErrorCode.FORBIDDEN, "본인이 보낸 쪽지만 조회할 수 있습니다.");
}
List<UiwsMessageRcv> list = mapper.findRcvByMessageId(messageId);
Map<String, String> names = userNames(list.stream().map(UiwsMessageRcv::getReceiverId).toList());
List<ReceiverStatusDto> receivers = list.stream()
.map(r -> new ReceiverStatusDto(
names.getOrDefault(r.getReceiverId(), r.getReceiverId()),
r.getRcvType(), r.getReadYn(), fmt(r.getReadAt())))
.toList();
long total = receivers.size();
long open = list.stream().filter(r -> "Y".equals(r.getReadYn())).count();
return new SentMessageDetailDto(m.getMessageId(), m.getTitle(), m.getContent(), m.getRefWorklogId(),
fmt(m.getSentAt()), receivers, total, open, total - open);
}
// ------------------------------------------------------------------ 보낸쪽지 다중삭제
@Transactional
public void deleteSent(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return;
}
String me = UiwsCurrentUser.id();
mapper.softDeleteSent(me, ids, me);
}
// ------------------------------------------------------------------ 미열람 (배지)
@Transactional(readOnly = true)
public long unreadCount() {
return mapper.countUnread(UiwsCurrentUser.id());
}
// ------------------------------------------------------------------ 받은쪽지함
@Transactional(readOnly = true)
public ReceivedPage listReceived(LocalDate fromDate, LocalDate toDate, String titleKeyword, int page, int size) {
LocalDateTime[] range = range(fromDate, toDate);
String me = UiwsCurrentUser.id();
String kw = blankToNull(titleKeyword);
long total = mapper.countReceived(me, range[0], range[1], kw);
List<Map<String, Object>> rows = mapper.searchReceived(me, range[0], range[1], kw, size, page * size);
Map<String, String> names = userNames(rows.stream().map(r -> str(r.get("senderId"))).toList());
List<ReceivedMessageDto> content = rows.stream()
.map(r -> new ReceivedMessageDto(
toLongObj(r.get("messageId")),
str(r.get("title")),
names.getOrDefault(str(r.get("senderId")), str(r.get("senderId"))),
fmt(toDt(r.get("sentAt"))),
str(r.get("readYn"))))
.toList();
return new ReceivedPage(content, total, page, size, totalPages(total, size));
}
// ------------------------------------------------------------------ 받은쪽지 상세(개봉처리)
@Transactional
public ReceivedMessageDetailDto receivedDetail(Long messageId) {
String me = UiwsCurrentUser.id();
UiwsMessageRcv rcv = mapper.findRcvByMessageAndReceiver(messageId, me);
if (rcv == null || "Y".equals(rcv.getReceiverDelYn())) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND, "받은 쪽지를 찾을 수 없습니다.");
}
UiwsMessage m = mapper.findMessageById(messageId);
if (m == null) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND);
}
LocalDateTime readAt = rcv.getReadAt();
if (!"Y".equals(rcv.getReadYn())) {
readAt = LocalDateTime.now();
mapper.markRead(rcv.getRcvId(), me, readAt);
}
String senderNm = userNames(List.of(m.getSenderId())).getOrDefault(m.getSenderId(), m.getSenderId());
return new ReceivedMessageDetailDto(m.getMessageId(), m.getTitle(), m.getContent(),
senderNm, fmt(m.getSentAt()), fmt(readAt), m.getRefWorklogId());
}
// ------------------------------------------------------------------ 받은쪽지 다중삭제
@Transactional
public void deleteReceived(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return;
}
String me = UiwsCurrentUser.id();
mapper.softDeleteReceived(me, ids, me);
}
// ================================================================== helpers
private LocalDateTime[] range(LocalDate fromDate, LocalDate toDate) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusWeeks(1);
return new LocalDateTime[]{from.atStartOfDay(), to.atTime(LocalTime.MAX)};
}
private String receiverSummary(List<UiwsMessageRcv> list, Map<String, String> names) {
if (list.isEmpty()) {
return "";
}
String first = names.getOrDefault(list.get(0).getReceiverId(), list.get(0).getReceiverId());
return list.size() == 1 ? first : first + "" + (list.size() - 1) + "";
}
private Map<String, String> userNames(List<String> userIds) {
List<String> ids = userIds.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
if (ids.isEmpty()) {
return Map.of();
}
return mapper.findUserNames(ids).stream()
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
}
private static int totalPages(long total, int size) {
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private static String str(Object o) {
return o == null ? "" : o.toString();
}
private static Long toLongObj(Object o) {
if (o == null) {
return null;
}
return (o instanceof Number n) ? n.longValue() : Long.parseLong(o.toString());
}
private static LocalDateTime toDt(Object o) {
if (o == null) {
return null;
}
if (o instanceof LocalDateTime dt) {
return dt;
}
if (o instanceof java.sql.Timestamp ts) {
return ts.toLocalDateTime();
}
return null;
}
private static String fmt(LocalDateTime dt) {
return dt != null ? dt.format(DT) : null;
}
// 페이지 응답(ERP ApiResponse 안에 그대로 직렬화 content/totalElements/page/size/totalPages 보존)
public record SentPage(List<SentMessageDto> content, long totalElements, int page, int size, int totalPages) {
}
public record ReceivedPage(List<ReceivedMessageDto> content, long totalElements, int page, int size, int totalPages) {
}
}

View File

@ -0,0 +1,68 @@
package com.zioinfo.esn.uiws.schedule.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.AttachmentDto;
import com.zioinfo.esn.uiws.schedule.service.AttachmentService;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
/**
* UIWS 이식 첨부파일(모듈 02, 폴리모픽 SCHEDULE/DIARY). 인증 필수(/api/attachments).
*/
@RestController
@RequestMapping("/api/attachments")
@RequiredArgsConstructor
public class AttachmentController {
private final AttachmentService attachmentService;
@PostMapping
public ApiResponse<AttachmentDto> upload(
@RequestParam("refType") String refType,
@RequestParam("refId") Long refId,
@RequestParam("file") MultipartFile file) {
return ApiResponse.ok(attachmentService.upload(refType, refId, file));
}
@GetMapping("/{id}/download")
public ResponseEntity<Resource> download(@PathVariable("id") Long id) {
AttachmentService.DownloadFile f = attachmentService.download(id);
Resource resource = new FileSystemResource(f.path());
String contentType;
try {
contentType = Files.probeContentType(f.path());
} catch (IOException e) {
contentType = null;
}
ContentDisposition cd = ContentDisposition.attachment()
.filename(f.fileNm(), StandardCharsets.UTF_8)
.build();
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(cd);
long len = f.path().toFile().length();
if (len > 0) {
headers.setContentLength(len);
}
return ResponseEntity.ok()
.headers(headers)
.contentType(contentType != null ? MediaType.parseMediaType(contentType) : MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
attachmentService.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,52 @@
package com.zioinfo.esn.uiws.schedule.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.esn.uiws.schedule.service.DiaryService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
/**
* UIWS 이식 일지(모듈 02). 인증 필수(/api/diaries).
*/
@RestController
@RequestMapping("/api/diaries")
@RequiredArgsConstructor
public class DiaryController {
private final DiaryService diaryService;
@GetMapping
public ApiResponse<DiaryPage> list(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(diaryService.list(fromDate, toDate, page, size));
}
@PostMapping
public ApiResponse<DiaryDetailDto> create(@Valid @RequestBody DiarySaveDto dto) {
return ApiResponse.ok(diaryService.create(dto));
}
@GetMapping("/{id}")
public ApiResponse<DiaryDetailDto> detail(@PathVariable("id") Long id) {
return ApiResponse.ok(diaryService.detail(id));
}
@PutMapping("/{id}")
public ApiResponse<DiaryDetailDto> update(@PathVariable("id") Long id, @Valid @RequestBody DiarySaveDto dto) {
return ApiResponse.ok(diaryService.update(id, dto));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
diaryService.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,68 @@
package com.zioinfo.esn.uiws.schedule.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.esn.uiws.schedule.service.ScheduleService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
/**
* UIWS 이식 일정(모듈 02). 인증 필수(/api/schedules). 원본 엔드포인트 보존.
*/
@RestController
@RequestMapping("/api/schedules")
@RequiredArgsConstructor
public class ScheduleController {
private final ScheduleService scheduleService;
@GetMapping
public ApiResponse<List<ScheduleDto>> calendar(
@RequestParam(defaultValue = "PERSONAL") String type,
@RequestParam(defaultValue = "month") String view,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate baseDate,
@RequestParam(required = false) String deptId) {
return ApiResponse.ok(scheduleService.calendar(type, view, baseDate, deptId));
}
@GetMapping("/all")
public ApiResponse<SchedulePage> all(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String type,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(scheduleService.all(fromDate, toDate, type, page, size));
}
@GetMapping("/search")
public ApiResponse<List<ScheduleDto>> search(@RequestParam(required = false) String keyword) {
return ApiResponse.ok(scheduleService.search(keyword));
}
@PostMapping
public ApiResponse<ScheduleDto> create(@Valid @RequestBody ScheduleSaveDto dto) {
return ApiResponse.ok(scheduleService.create(dto));
}
@GetMapping("/{id}")
public ApiResponse<ScheduleDetailDto> detail(@PathVariable("id") Long id) {
return ApiResponse.ok(scheduleService.detail(id));
}
@PutMapping("/{id}")
public ApiResponse<ScheduleDetailDto> update(@PathVariable("id") Long id, @Valid @RequestBody ScheduleSaveDto dto) {
return ApiResponse.ok(scheduleService.update(id, dto));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
scheduleService.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,109 @@
package com.zioinfo.esn.uiws.schedule.dto;
import com.zioinfo.esn.uiws.schedule.model.UiwsAttach;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.util.List;
/**
* 일정·일지·첨부(모듈 02) DTO. 원본 com.urp.uiws.schedule.dto.* 이식(shape 동일).
*/
public final class ScheduleDtos {
private ScheduleDtos() {
}
public record ScheduleDto(
Long scheduleId,
String scheType,
String title,
String scheGubunCd,
String importanceCd,
String startDt,
String endDt,
String ownerId,
String deptId
) {
}
public record ScheduleDetailDto(
Long scheduleId,
String scheType,
String title,
String scheGubunCd,
String importanceCd,
String startDt,
String endDt,
String ownerId,
String deptId,
String content,
String chargerId,
String chargerNm,
List<AttachmentDto> attachments
) {
}
public record ScheduleSaveDto(
@NotBlank @Pattern(regexp = "PERSONAL|DEPT") String scheType,
@NotBlank String title,
String scheGubunCd,
String importanceCd,
@NotNull String startDt,
@NotNull String endDt,
String content,
String deptId,
String chargerId,
List<Long> attachmentIds
) {
}
public record DiaryDto(
Long diaryId,
String title,
String writerNm,
String diaryDate,
Long scheduleId
) {
}
public record DiaryDetailDto(
Long diaryId,
String title,
String writerNm,
String diaryDate,
Long scheduleId,
String content,
List<AttachmentDto> attachments
) {
}
public record DiarySaveDto(
@NotBlank String title,
String content,
Long scheduleId,
String diaryDate,
List<Long> attachmentIds
) {
}
public record AttachmentDto(
Long attachId,
String refType,
Long refId,
String fileNm,
Long fileSize
) {
public static AttachmentDto from(UiwsAttach a) {
return new AttachmentDto(a.getAttachId(), a.getRefType(), a.getRefId(), a.getFileNm(), a.getFileSize());
}
}
/** 페이지 래퍼(content/totalElements/page/size/totalPages). */
public record DiaryPage(List<DiaryDto> content, long totalElements, int page, int size, int totalPages) {
}
public record SchedulePage(List<ScheduleDto> content, long totalElements, int page, int size, int totalPages) {
}
}

View File

@ -0,0 +1,93 @@
package com.zioinfo.esn.uiws.schedule.mapper;
import com.zioinfo.esn.uiws.schedule.model.UiwsAttach;
import com.zioinfo.esn.uiws.schedule.model.UiwsDiary;
import com.zioinfo.esn.uiws.schedule.model.UiwsSchedule;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 일정·일지·첨부 MyBatis 매퍼. 원본 JPA ScheduleRepository/DiaryRepository/AttachRepository 변환 이식.
* 데이터 가시범위: scopeAll(true=ADMIN 전체) OR owner IN (ownerIds). ownerIds 항상 본인 포함( IN 회피).
*/
@Mapper
public interface ScheduleMapper {
// schedule
int insertSchedule(UiwsSchedule s);
int updateSchedule(UiwsSchedule s);
UiwsSchedule findScheduleById(@Param("scheduleId") Long scheduleId);
boolean existsScheduleById(@Param("scheduleId") Long scheduleId);
int deleteScheduleById(@Param("scheduleId") Long scheduleId);
/** 달력: 기간 겹침([from,to) 배타 상한) + type + 개인소유/부서필터. */
List<UiwsSchedule> findInRange(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("scheType") String scheType,
@Param("ownerId") String ownerId,
@Param("deptId") String deptId);
List<UiwsSchedule> searchAll(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("scheType") String scheType,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds,
@Param("limit") int limit,
@Param("offset") int offset);
long countAll(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("scheType") String scheType,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
List<UiwsSchedule> searchPopup(@Param("keyword") String keyword,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
// diary
int insertDiary(UiwsDiary d);
int updateDiary(UiwsDiary d);
UiwsDiary findDiaryById(@Param("diaryId") Long diaryId);
boolean existsDiaryById(@Param("diaryId") Long diaryId);
int deleteDiaryById(@Param("diaryId") Long diaryId);
List<UiwsDiary> searchDiary(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("limit") int limit,
@Param("offset") int offset);
long countDiary(@Param("from") LocalDate from, @Param("to") LocalDate to);
// attach
int insertAttach(UiwsAttach a);
UiwsAttach findAttachById(@Param("attachId") Long attachId);
int updateAttachRef(@Param("attachId") Long attachId,
@Param("refType") String refType,
@Param("refId") Long refId,
@Param("actor") String actor);
List<UiwsAttach> findAttachByRef(@Param("refType") String refType, @Param("refId") Long refId);
int deleteAttachById(@Param("attachId") Long attachId);
int deleteAttachByRef(@Param("refType") String refType, @Param("refId") Long refId);
// 사용자명 라벨(코어 user 재사용)
List<Map<String, String>> findUserNames(@Param("ids") List<String> ids);
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.esn.uiws.schedule.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 첨부파일 폴리모픽 (tb_uiws_attach, ref_type=SCHEDULE|DIARY). 원본 com.urp.uiws.domain.Attach 이식. */
@Data
public class UiwsAttach {
private Long attachId;
private String refType;
private Long refId;
private String fileNm;
private String filePath;
private Long fileSize;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.esn.uiws.schedule.model;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 일지 (tb_uiws_diary). 원본 com.urp.uiws.domain.Diary 이식. */
@Data
public class UiwsDiary {
private Long diaryId;
private String title;
private String content;
private Long scheduleId;
private String writerId;
private LocalDate diaryDate;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.esn.uiws.schedule.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 일정 (tb_uiws_schedule). 원본 com.urp.uiws.domain.Schedule 이식. */
@Data
public class UiwsSchedule {
private Long scheduleId;
private String scheType; // PERSONAL | DEPT
private String title;
private String scheGubunCd;
private String importanceCd;
private LocalDateTime startDt;
private LocalDateTime endDt;
private String content;
private String ownerId;
private String deptId;
private String chargerId;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,121 @@
package com.zioinfo.esn.uiws.schedule.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.AttachmentDto;
import com.zioinfo.esn.uiws.schedule.mapper.ScheduleMapper;
import com.zioinfo.esn.uiws.schedule.model.UiwsAttach;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
/**
* 첨부파일(폴리모픽 SCHEDULE/DIARY) 업로드·삭제·연결. 원본 com.urp.uiws.schedule.service.AttachmentService 이식.
*/
@Service
@RequiredArgsConstructor
public class AttachmentService {
public static final String REF_SCHEDULE = "SCHEDULE";
public static final String REF_DIARY = "DIARY";
private static final Set<String> VALID_REF_TYPES = Set.of(REF_SCHEDULE, REF_DIARY);
private final ScheduleMapper mapper;
private final FileStorageService fileStorageService;
@Transactional
public AttachmentDto upload(String refType, Long refId, MultipartFile file) {
String type = normalizeRefType(refType);
if (refId == null) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "refId 는 필수입니다.");
}
FileStorageService.StoredFile stored = fileStorageService.store(file);
String actor = UiwsCurrentUser.id();
UiwsAttach a = new UiwsAttach();
a.setRefType(type);
a.setRefId(refId);
a.setFileNm(stored.originalName());
a.setFilePath(stored.relativePath());
a.setFileSize(stored.size());
a.setCreatedBy(actor);
a.setCreatedAt(LocalDateTime.now());
mapper.insertAttach(a);
return AttachmentDto.from(a);
}
public record DownloadFile(String fileNm, Path path) {
}
@Transactional(readOnly = true)
public DownloadFile download(Long attachId) {
UiwsAttach a = mapper.findAttachById(attachId);
if (a == null) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND);
}
Path path = fileStorageService.resolve(a.getFilePath());
if (!Files.exists(path)) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND, "파일이 존재하지 않습니다.");
}
return new DownloadFile(a.getFileNm(), path);
}
@Transactional
public void delete(Long attachId) {
UiwsAttach a = mapper.findAttachById(attachId);
if (a == null) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND);
}
fileStorageService.delete(a.getFilePath());
mapper.deleteAttachById(attachId);
}
/** 일정/일지 저장 시 attachmentIds 의 첨부들을 해당 대상으로 귀속(확정). */
@Transactional
public void link(String refType, Long refId, List<Long> attachmentIds) {
String type = normalizeRefType(refType);
if (attachmentIds == null || attachmentIds.isEmpty()) {
return;
}
String actor = UiwsCurrentUser.id();
for (Long id : attachmentIds) {
if (id == null) {
continue;
}
mapper.updateAttachRef(id, type, refId, actor);
}
}
@Transactional(readOnly = true)
public List<AttachmentDto> list(String refType, Long refId) {
return mapper.findAttachByRef(normalizeRefType(refType), refId)
.stream().map(AttachmentDto::from).toList();
}
/** 대상 삭제 시 귀속 첨부 일괄 제거(물리파일 포함). */
@Transactional
public void deleteByRef(String refType, Long refId) {
String type = normalizeRefType(refType);
List<UiwsAttach> rows = mapper.findAttachByRef(type, refId);
for (UiwsAttach a : rows) {
fileStorageService.delete(a.getFilePath());
}
mapper.deleteAttachByRef(type, refId);
}
private String normalizeRefType(String refType) {
String type = refType == null ? "" : refType.trim().toUpperCase();
if (!VALID_REF_TYPES.contains(type)) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_REF_TYPE_INVALID);
}
return type;
}
}

View File

@ -0,0 +1,133 @@
package com.zioinfo.esn.uiws.schedule.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.esn.uiws.schedule.mapper.ScheduleMapper;
import com.zioinfo.esn.uiws.schedule.model.UiwsDiary;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 일지(모듈 02) 서비스 원본 com.urp.uiws.schedule.service.DiaryService MyBatis 변환 이식.
* 목록(페이징)·CRUD·첨부 연계.
*/
@Service
@RequiredArgsConstructor
public class DiaryService {
private final ScheduleMapper mapper;
private final AttachmentService attachmentService;
@Transactional(readOnly = true)
public DiaryPage list(LocalDate fromDate, LocalDate toDate, int page, int size) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusMonths(1);
long total = mapper.countDiary(from, to);
List<UiwsDiary> rows = mapper.searchDiary(from, to, size, page * size);
Map<String, String> names = userNames(rows.stream().map(UiwsDiary::getWriterId).toList());
List<DiaryDto> content = rows.stream()
.map(d -> new DiaryDto(d.getDiaryId(), d.getTitle(),
names.getOrDefault(d.getWriterId(), d.getWriterId()),
d.getDiaryDate() != null ? d.getDiaryDate().toString() : null,
d.getScheduleId()))
.toList();
return new DiaryPage(content, total, page, size, totalPages(total, size));
}
@Transactional
public DiaryDetailDto create(DiarySaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsDiary d = new UiwsDiary();
applySave(d, dto);
d.setWriterId(actor);
d.setCreatedBy(actor);
d.setCreatedAt(LocalDateTime.now());
mapper.insertDiary(d);
attachmentService.link(AttachmentService.REF_DIARY, d.getDiaryId(), dto.attachmentIds());
return toDetail(d);
}
@Transactional(readOnly = true)
public DiaryDetailDto detail(Long id) {
return toDetail(find(id));
}
@Transactional
public DiaryDetailDto update(Long id, DiarySaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsDiary d = find(id);
applySave(d, dto);
d.setUpdatedBy(actor);
d.setUpdatedAt(LocalDateTime.now());
mapper.updateDiary(d);
attachmentService.link(AttachmentService.REF_DIARY, d.getDiaryId(), dto.attachmentIds());
return toDetail(d);
}
@Transactional
public void delete(Long id) {
if (!mapper.existsDiaryById(id)) {
throw new UiwsApiException(UiwsErrorCode.DIARY_NOT_FOUND);
}
attachmentService.deleteByRef(AttachmentService.REF_DIARY, id);
mapper.deleteDiaryById(id);
}
// ================================================================== helpers
private void applySave(UiwsDiary d, DiarySaveDto dto) {
d.setTitle(dto.title());
d.setContent(dto.content());
d.setScheduleId(dto.scheduleId());
d.setDiaryDate(parseDateNullable(dto.diaryDate()));
}
private DiaryDetailDto toDetail(UiwsDiary d) {
String writerNm = userNames(List.of(d.getWriterId())).getOrDefault(d.getWriterId(), d.getWriterId());
List<AttachmentDto> attachments = attachmentService.list(AttachmentService.REF_DIARY, d.getDiaryId());
return new DiaryDetailDto(d.getDiaryId(), d.getTitle(), writerNm,
d.getDiaryDate() != null ? d.getDiaryDate().toString() : null,
d.getScheduleId(), d.getContent(), attachments);
}
private UiwsDiary find(Long id) {
UiwsDiary d = mapper.findDiaryById(id);
if (d == null) {
throw new UiwsApiException(UiwsErrorCode.DIARY_NOT_FOUND);
}
return d;
}
private Map<String, String> userNames(List<String> ids) {
List<String> clean = ids.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
if (clean.isEmpty()) {
return Map.of();
}
return mapper.findUserNames(clean).stream()
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
}
private static int totalPages(long total, int size) {
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
}
private static LocalDate parseDateNullable(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return LocalDate.parse(s);
} catch (Exception e) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "날짜 형식이 올바르지 않습니다(yyyy-MM-dd).");
}
}
}

View File

@ -0,0 +1,93 @@
package com.zioinfo.esn.uiws.schedule.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.config.UiwsProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
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.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
* 첨부파일 로컬 스토리지(UIWS 이식). 경로순회 방지(파일명 정규화 + UUID 저장명), 일자별 디렉터리 분리.
* 저장 결과로 상대경로(file_path) 반환한다.
*/
@Service
@RequiredArgsConstructor
public class FileStorageService {
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyy/MM/dd");
private final UiwsProperties properties;
public record StoredFile(String originalName, String relativePath, long size) {
}
public StoredFile store(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new UiwsApiException(UiwsErrorCode.FILE_EMPTY);
}
String original = StringUtils.cleanPath(
file.getOriginalFilename() != null ? file.getOriginalFilename() : "file");
original = original.replace("\\", "_").replace("/", "_");
if (original.contains("..")) {
original = original.replace("..", "_");
}
String ext = "";
int dot = original.lastIndexOf('.');
if (dot >= 0) {
ext = original.substring(dot);
}
String subDir = LocalDate.now().format(DAY);
String storedName = UUID.randomUUID().toString().replace("-", "") + ext;
String relativePath = subDir + "/" + storedName;
try {
Path root = Paths.get(properties.getUpload().getUploadDir()).toAbsolutePath().normalize();
Path target = root.resolve(relativePath).normalize();
if (!target.startsWith(root)) {
throw new UiwsApiException(UiwsErrorCode.FILE_STORAGE_ERROR, "허용되지 않은 저장 경로입니다.");
}
Files.createDirectories(target.getParent());
file.transferTo(target.toFile());
} catch (IOException e) {
throw new UiwsApiException(UiwsErrorCode.FILE_STORAGE_ERROR);
}
return new StoredFile(original, relativePath, file.getSize());
}
public Path resolve(String relativePath) {
if (relativePath == null || relativePath.isBlank()) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND);
}
Path root = Paths.get(properties.getUpload().getUploadDir()).toAbsolutePath().normalize();
Path target = root.resolve(relativePath).normalize();
if (!target.startsWith(root)) {
throw new UiwsApiException(UiwsErrorCode.FILE_STORAGE_ERROR, "허용되지 않은 경로입니다.");
}
return target;
}
public void delete(String relativePath) {
if (relativePath == null || relativePath.isBlank()) {
return;
}
try {
Path root = Paths.get(properties.getUpload().getUploadDir()).toAbsolutePath().normalize();
Path target = root.resolve(relativePath).normalize();
if (target.startsWith(root)) {
Files.deleteIfExists(target);
}
} catch (IOException ignore) {
// 물리파일 삭제 실패는 무시(메타 일관성 우선)
}
}
}

View File

@ -0,0 +1,216 @@
package com.zioinfo.esn.uiws.schedule.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
import com.zioinfo.esn.uiws.common.UiwsDataScope;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.esn.uiws.schedule.mapper.ScheduleMapper;
import com.zioinfo.esn.uiws.schedule.model.UiwsSchedule;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 일정(모듈 02) 서비스 원본 com.urp.uiws.schedule.service.ScheduleService MyBatis 변환 이식.
* 달력(month/week/day)·전체목록·검색팝업·CRUD. 첨부 연계(attachmentIds).
*/
@Service
@RequiredArgsConstructor
public class ScheduleService {
private static final String TYPE_PERSONAL = "PERSONAL";
private static final String TYPE_DEPT = "DEPT";
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
private final ScheduleMapper mapper;
private final AttachmentService attachmentService;
@Transactional(readOnly = true)
public List<ScheduleDto> calendar(String type, String view, LocalDate baseDate, String deptId) {
String t = normalizeType(type);
LocalDate base = (baseDate != null) ? baseDate : LocalDate.now();
LocalDate[] range = computeRange(view, base);
LocalDateTime from = range[0].atStartOfDay();
LocalDateTime to = range[1].atStartOfDay(); // 배타 상한
String ownerId = TYPE_PERSONAL.equals(t) ? UiwsCurrentUser.id() : null;
String deptFilter = TYPE_DEPT.equals(t) ? blankToNull(deptId) : null;
return mapper.findInRange(from, to, t, ownerId, deptFilter).stream().map(this::toDto).toList();
}
@Transactional(readOnly = true)
public SchedulePage all(LocalDate fromDate, LocalDate toDate, String type, int page, int size) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusMonths(1);
String t = (type == null || type.isBlank()) ? null : normalizeType(type);
UiwsDataScope.Scope scope = UiwsDataScope.current();
LocalDateTime f = from.atStartOfDay();
LocalDateTime tt = to.plusDays(1).atStartOfDay();
long total = mapper.countAll(f, tt, t, scope.all(), scope.ownerIds());
List<ScheduleDto> content = mapper.searchAll(f, tt, t, scope.all(), scope.ownerIds(), size, page * size)
.stream().map(this::toDto).toList();
return new SchedulePage(content, total, page, size, totalPages(total, size));
}
@Transactional(readOnly = true)
public List<ScheduleDto> search(String keyword) {
UiwsDataScope.Scope scope = UiwsDataScope.current();
return mapper.searchPopup(blankToNull(keyword), scope.all(), scope.ownerIds())
.stream().map(this::toDto).toList();
}
@Transactional
public ScheduleDto create(ScheduleSaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsSchedule s = new UiwsSchedule();
applySave(s, dto);
s.setOwnerId(actor);
s.setCreatedBy(actor);
s.setCreatedAt(LocalDateTime.now());
mapper.insertSchedule(s);
attachmentService.link(AttachmentService.REF_SCHEDULE, s.getScheduleId(), dto.attachmentIds());
return toDto(s);
}
@Transactional(readOnly = true)
public ScheduleDetailDto detail(Long id) {
return toDetail(find(id));
}
@Transactional
public ScheduleDetailDto update(Long id, ScheduleSaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsSchedule s = find(id);
applySave(s, dto);
s.setUpdatedBy(actor);
s.setUpdatedAt(LocalDateTime.now());
mapper.updateSchedule(s);
attachmentService.link(AttachmentService.REF_SCHEDULE, s.getScheduleId(), dto.attachmentIds());
return toDetail(s);
}
@Transactional
public void delete(Long id) {
if (!mapper.existsScheduleById(id)) {
throw new UiwsApiException(UiwsErrorCode.SCHEDULE_NOT_FOUND);
}
attachmentService.deleteByRef(AttachmentService.REF_SCHEDULE, id);
mapper.deleteScheduleById(id);
}
// ================================================================== helpers
private void applySave(UiwsSchedule s, ScheduleSaveDto dto) {
LocalDateTime start = parseDateTime(dto.startDt());
LocalDateTime end = parseDateTime(dto.endDt());
if (end.isBefore(start)) {
throw new UiwsApiException(UiwsErrorCode.SCHEDULE_DT_INVALID, "종료 일시가 시작 일시보다 빠릅니다.");
}
s.setScheType(normalizeType(dto.scheType()));
s.setTitle(dto.title());
s.setScheGubunCd(blankToNull(dto.scheGubunCd()));
s.setImportanceCd(blankToNull(dto.importanceCd()));
s.setStartDt(start);
s.setEndDt(end);
s.setContent(dto.content());
s.setDeptId(blankToNull(dto.deptId()));
s.setChargerId(blankToNull(dto.chargerId()));
}
private ScheduleDto toDto(UiwsSchedule s) {
return new ScheduleDto(s.getScheduleId(), s.getScheType(), s.getTitle(),
s.getScheGubunCd(), s.getImportanceCd(), fmt(s.getStartDt()), fmt(s.getEndDt()),
s.getOwnerId(), s.getDeptId());
}
private ScheduleDetailDto toDetail(UiwsSchedule s) {
String chargerNm = null;
if (s.getChargerId() != null) {
chargerNm = userNames(List.of(s.getChargerId())).getOrDefault(s.getChargerId(), s.getChargerId());
}
List<AttachmentDto> attachments = attachmentService.list(AttachmentService.REF_SCHEDULE, s.getScheduleId());
return new ScheduleDetailDto(s.getScheduleId(), s.getScheType(), s.getTitle(),
s.getScheGubunCd(), s.getImportanceCd(), fmt(s.getStartDt()), fmt(s.getEndDt()),
s.getOwnerId(), s.getDeptId(), s.getContent(), s.getChargerId(), chargerNm, attachments);
}
private UiwsSchedule find(Long id) {
UiwsSchedule s = mapper.findScheduleById(id);
if (s == null) {
throw new UiwsApiException(UiwsErrorCode.SCHEDULE_NOT_FOUND);
}
return s;
}
private Map<String, String> userNames(List<String> ids) {
List<String> clean = ids.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
if (clean.isEmpty()) {
return Map.of();
}
return mapper.findUserNames(clean).stream()
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
}
private LocalDate[] computeRange(String view, LocalDate base) {
String v = (view == null || view.isBlank()) ? "month" : view.trim().toLowerCase();
return switch (v) {
case "day" -> new LocalDate[]{base, base.plusDays(1)};
case "week" -> {
LocalDate start = base.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
yield new LocalDate[]{start, start.plusWeeks(1)};
}
case "month" -> {
LocalDate start = base.withDayOfMonth(1);
yield new LocalDate[]{start, start.plusMonths(1)};
}
default -> throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST,
"view 는 month/week/day 중 하나여야 합니다.");
};
}
private String normalizeType(String type) {
String t = (type == null) ? "" : type.trim().toUpperCase();
if (!TYPE_PERSONAL.equals(t) && !TYPE_DEPT.equals(t)) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "type 은 PERSONAL/DEPT 중 하나여야 합니다.");
}
return t;
}
private static int totalPages(long total, int size) {
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private static String fmt(LocalDateTime dt) {
return dt != null ? dt.format(DT) : null;
}
private static LocalDateTime parseDateTime(String s) {
if (s == null || s.isBlank()) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "일시 값이 비어 있습니다.");
}
try {
if (s.length() <= 10) {
return LocalDate.parse(s).atStartOfDay();
}
return LocalDateTime.parse(s);
} catch (Exception e) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST,
"일시 형식이 올바르지 않습니다(yyyy-MM-ddTHH:mm:ss).");
}
}
}

View File

@ -0,0 +1,43 @@
package com.zioinfo.esn.uiws.stats.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.stats.dto.StatsDtos.PivotResponse;
import com.zioinfo.esn.uiws.stats.service.StatsService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
/**
* UIWS 이식 업무통계(모듈 04, 동적 컬럼 피벗). 인증 필수(/api/stats).
* prefix /api/stats ERP 기존 라우트와 미충돌(확인). 원본 엔드포인트 보존.
*/
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
public class StatsController {
private final StatsService statsService;
@GetMapping("/personal-work")
public ApiResponse<PivotResponse> personalWork(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String deptId,
@RequestParam(required = false) String userId,
@RequestParam(required = false) Boolean showStatus,
@RequestParam(required = false) Boolean showType) {
return ApiResponse.ok(statsService.personalWork(fromDate, toDate, deptId, userId, showStatus, showType));
}
@GetMapping("/company-work")
public ApiResponse<PivotResponse> companyWork(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String companyId,
@RequestParam(required = false) String userId,
@RequestParam(required = false) Boolean showType) {
return ApiResponse.ok(statsService.companyWork(fromDate, toDate, companyId, userId, showType));
}
}

View File

@ -0,0 +1,25 @@
package com.zioinfo.esn.uiws.stats.dto;
import java.util.List;
import java.util.Map;
/**
* 근무현황 통계(모듈 04) 동적 컬럼 피벗 DTO. 원본 com.urp.uiws.stats.dto.* 이식.
*/
public final class StatsDtos {
private StatsDtos() {
}
/** 피벗 열 메타. 고정열 예 {key:"worker",label:"근무자"}; 동적열 예 {key:"company_본사",label:"본사"}. */
public record PivotColumn(String key, String label) {
}
/** 동적 컬럼 피벗 응답. { fixedColumns, dynamicColumns, rows } */
public record PivotResponse(
List<PivotColumn> fixedColumns,
List<PivotColumn> dynamicColumns,
List<Map<String, Object>> rows
) {
}
}

View File

@ -0,0 +1,36 @@
package com.zioinfo.esn.uiws.stats.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* 근무현황 피벗 집계 매퍼. 원본 StatsRepository(JdbcTemplate 네이티브) MyBatis 변환 이식.
*
* silo 정합(코어 TB_USER/TB_COMPANY/TB_CODE 미이식):
* - 근무자 라벨: esn_user (writer_id=username 라벨). 미존재 writer_id 노출(COALESCE).
* - 근무처(company) 라벨: company_id 직접 사용(거래처 테이블 미이식).
* - 근무상태/근무유형 라벨: 코드값 직접 사용(공통코드 미이식).
* 결과는 long-form(그룹키 + cnt). 서비스가 동적 컬럼으로 피벗한다.
*/
@Mapper
public interface StatsMapper {
/** 개인별: 행=근무자×근무상태×근무유형, 동적열 차원=근무처. 컬럼: worker, work_status, work_type, dyn, cnt. */
List<Map<String, Object>> personalWork(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("userId") String userId,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
/** 업체별: 행=근무처×근무유형, 동적열 차원=근무자. 컬럼: company, work_type, dyn, cnt. */
List<Map<String, Object>> companyWork(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("companyId") String companyId,
@Param("userId") String userId,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
}

View File

@ -0,0 +1,165 @@
package com.zioinfo.esn.uiws.stats.service;
import com.zioinfo.esn.uiws.common.UiwsDataScope;
import com.zioinfo.esn.uiws.stats.dto.StatsDtos.PivotColumn;
import com.zioinfo.esn.uiws.stats.dto.StatsDtos.PivotResponse;
import com.zioinfo.esn.uiws.stats.mapper.StatsMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 근무현황 통계(모듈 04) 동적 컬럼 피벗 서비스. 원본 com.urp.uiws.stats.service.StatsService 이식.
* long-form 집계를 wide-form 으로 피벗. showStatus/showType 토글. 기본 조회기간 = 최근 1주일.
* 데이터 스코프(ADMIN=전체/ =본인) UiwsDataScope silo 판정.
*/
@Service
@RequiredArgsConstructor
public class StatsService {
/** 행 식별 키 구분자(데이터에 등장하지 않는 제어문자). */
private static final char SEP = '';
private final StatsMapper mapper;
@Transactional(readOnly = true)
public PivotResponse personalWork(LocalDate fromDate, LocalDate toDate,
String deptId, String userId,
Boolean showStatus, Boolean showType) {
LocalDate[] range = range(fromDate, toDate);
UiwsDataScope.Scope scope = UiwsDataScope.current();
// deptId 코어 dept 미이식으로 silo 에서 무시(파라미터 호환만 유지).
List<Map<String, Object>> agg = mapper.personalWork(
range[0], range[1], emptyToNull(userId), scope.all(), scope.ownerIds());
boolean status = !Boolean.FALSE.equals(showStatus);
boolean type = !Boolean.FALSE.equals(showType);
List<PivotColumn> fixed = new ArrayList<>();
fixed.add(new PivotColumn("worker", "근무자"));
if (status) {
fixed.add(new PivotColumn("workStatus", "근무상태"));
}
if (type) {
fixed.add(new PivotColumn("workType", "근무유형"));
}
return pivot(agg, fixed, "company",
row -> rowKey(row, status, type),
row -> {
Map<String, Object> base = new LinkedHashMap<>();
base.put("worker", str(row.get("worker")));
if (status) {
base.put("workStatus", str(row.get("work_status")));
}
if (type) {
base.put("workType", str(row.get("work_type")));
}
return base;
});
}
@Transactional(readOnly = true)
public PivotResponse companyWork(LocalDate fromDate, LocalDate toDate,
String companyId, String userId,
Boolean showType) {
LocalDate[] range = range(fromDate, toDate);
UiwsDataScope.Scope scope = UiwsDataScope.current();
List<Map<String, Object>> agg = mapper.companyWork(
range[0], range[1], emptyToNull(companyId), emptyToNull(userId), scope.all(), scope.ownerIds());
boolean type = !Boolean.FALSE.equals(showType);
List<PivotColumn> fixed = new ArrayList<>();
fixed.add(new PivotColumn("company", "근무처"));
if (type) {
fixed.add(new PivotColumn("workType", "근무유형"));
}
return pivot(agg, fixed, "worker",
row -> str(row.get("company")) + SEP + (type ? str(row.get("work_type")) : ""),
row -> {
Map<String, Object> base = new LinkedHashMap<>();
base.put("company", str(row.get("company")));
if (type) {
base.put("workType", str(row.get("work_type")));
}
return base;
});
}
// ================================================================== 피벗 공통
private interface RowKeyFn {
String key(Map<String, Object> row);
}
private interface BaseFn {
Map<String, Object> base(Map<String, Object> row);
}
private PivotResponse pivot(List<Map<String, Object>> agg, List<PivotColumn> fixed,
String dynPrefix, RowKeyFn keyFn, BaseFn baseFn) {
Map<String, PivotColumn> dynCols = new LinkedHashMap<>();
Map<String, Map<String, Object>> rowMap = new LinkedHashMap<>();
for (Map<String, Object> r : agg) {
String dynLabel = str(r.get("dyn"));
String dynKey = dynPrefix + "_" + dynLabel;
dynCols.putIfAbsent(dynKey, new PivotColumn(dynKey, dynLabel));
String rk = keyFn.key(r);
Map<String, Object> row = rowMap.computeIfAbsent(rk, k -> baseFn.base(r));
long cnt = toLong(r.get("cnt"));
row.merge(dynKey, cnt, (a, b) -> toLong(a) + toLong(b));
}
List<PivotColumn> dynamic = new ArrayList<>(dynCols.values());
List<Map<String, Object>> rows = new ArrayList<>();
for (Map<String, Object> row : rowMap.values()) {
for (PivotColumn dc : dynamic) {
row.putIfAbsent(dc.key(), 0L);
}
rows.add(row);
}
return new PivotResponse(fixed, dynamic, rows);
}
private String rowKey(Map<String, Object> row, boolean status, boolean type) {
StringBuilder sb = new StringBuilder(str(row.get("worker")));
if (status) {
sb.append(SEP).append(str(row.get("work_status")));
}
if (type) {
sb.append(SEP).append(str(row.get("work_type")));
}
return sb.toString();
}
private LocalDate[] range(LocalDate fromDate, LocalDate toDate) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusWeeks(1);
return new LocalDate[]{from, to};
}
private static String emptyToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private static String str(Object o) {
return o == null ? "" : o.toString();
}
private static long toLong(Object o) {
if (o == null) {
return 0L;
}
return (o instanceof Number n) ? n.longValue() : Long.parseLong(o.toString());
}
}

View File

@ -0,0 +1,58 @@
package com.zioinfo.esn.uiws.system.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.system.dto.CodeGrpDetailDto;
import com.zioinfo.esn.uiws.system.dto.CodeGrpDto;
import com.zioinfo.esn.uiws.system.dto.CodeGrpSaveDto;
import com.zioinfo.esn.uiws.system.dto.CodeValueDto;
import com.zioinfo.esn.uiws.system.dto.PageResponse;
import com.zioinfo.esn.uiws.system.service.CodeService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 2.7 코드(codes) — 그룹목록/상세/CRUD/드롭다운값. base: /api/system/codes */
@RestController
@RequestMapping("/api/system/codes")
@RequiredArgsConstructor
public class CodeController {
private final CodeService codeService;
@GetMapping
public ApiResponse<PageResponse<CodeGrpDto>> listGroups(
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(codeService.listGroups(keyword, page, size));
}
/** 드롭다운 공용 코드값 조회(정적 경로 우선). */
@GetMapping("/group/{grpCd}/values")
public ApiResponse<List<CodeValueDto>> getValues(@PathVariable("grpCd") String grpCd) {
return ApiResponse.ok(codeService.getValues(grpCd));
}
@GetMapping("/{grpCd}")
public ApiResponse<CodeGrpDetailDto> getGroup(@PathVariable("grpCd") String grpCd) {
return ApiResponse.ok(codeService.getGroup(grpCd));
}
@PostMapping
public ApiResponse<CodeGrpDetailDto> create(@Valid @RequestBody CodeGrpSaveDto dto) {
return ApiResponse.ok(codeService.create(dto));
}
@PutMapping("/{grpCd}")
public ApiResponse<CodeGrpDetailDto> update(@PathVariable("grpCd") String grpCd, @Valid @RequestBody CodeGrpSaveDto dto) {
return ApiResponse.ok(codeService.update(grpCd, dto));
}
@DeleteMapping("/{grpCd}")
public ApiResponse<Void> delete(@PathVariable("grpCd") String grpCd) {
codeService.delete(grpCd);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,56 @@
package com.zioinfo.esn.uiws.system.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.system.dto.CompanyDto;
import com.zioinfo.esn.uiws.system.dto.CompanySaveDto;
import com.zioinfo.esn.uiws.system.dto.IdsRequest;
import com.zioinfo.esn.uiws.system.dto.PageResponse;
import com.zioinfo.esn.uiws.system.service.CompanyService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 2.4 거래처(companies) — 목록/검색팝업/CRUD/다중삭제. base: /api/system/companies */
@RestController
@RequestMapping("/api/system/companies")
@RequiredArgsConstructor
public class CompanyController {
private final CompanyService companyService;
@GetMapping
public ApiResponse<PageResponse<CompanyDto>> list(
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(companyService.list(keyword, page, size));
}
@GetMapping("/search")
public ApiResponse<List<CompanyDto>> search(@RequestParam(required = false) String keyword) {
return ApiResponse.ok(companyService.searchPopup(keyword));
}
@PostMapping
public ApiResponse<CompanyDto> create(@Valid @RequestBody CompanySaveDto dto) {
return ApiResponse.ok(companyService.create(dto));
}
@GetMapping("/{id}")
public ApiResponse<CompanyDto> get(@PathVariable("id") String id) {
return ApiResponse.ok(companyService.get(id));
}
@PutMapping("/{id}")
public ApiResponse<CompanyDto> update(@PathVariable("id") String id, @Valid @RequestBody CompanySaveDto dto) {
return ApiResponse.ok(companyService.update(id, dto));
}
@DeleteMapping
public ApiResponse<Void> delete(@Valid @RequestBody IdsRequest req) {
companyService.delete(req.ids());
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,61 @@
package com.zioinfo.esn.uiws.system.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.system.dto.DeptDto;
import com.zioinfo.esn.uiws.system.dto.DeptSaveDto;
import com.zioinfo.esn.uiws.system.dto.DeptTreeDto;
import com.zioinfo.esn.uiws.system.dto.PageResponse;
import com.zioinfo.esn.uiws.system.service.DeptService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 2.3 부서(depts) — 목록/검색팝업/트리/CRUD. base: /api/system/depts */
@RestController
@RequestMapping("/api/system/depts")
@RequiredArgsConstructor
public class DeptController {
private final DeptService deptService;
@GetMapping
public ApiResponse<PageResponse<DeptDto>> list(
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(deptService.list(keyword, page, size));
}
@GetMapping("/search")
public ApiResponse<List<DeptDto>> search(@RequestParam(required = false) String keyword) {
return ApiResponse.ok(deptService.searchPopup(keyword));
}
@GetMapping("/tree")
public ApiResponse<List<DeptTreeDto>> tree() {
return ApiResponse.ok(deptService.tree());
}
@PostMapping
public ApiResponse<DeptDto> create(@Valid @RequestBody DeptSaveDto dto) {
return ApiResponse.ok(deptService.create(dto));
}
@GetMapping("/{id}")
public ApiResponse<DeptDto> get(@PathVariable("id") String id) {
return ApiResponse.ok(deptService.get(id));
}
@PutMapping("/{id}")
public ApiResponse<DeptDto> update(@PathVariable("id") String id, @Valid @RequestBody DeptSaveDto dto) {
return ApiResponse.ok(deptService.update(id, dto));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") String id) {
deptService.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,39 @@
package com.zioinfo.esn.uiws.system.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.system.dto.DeptUserRoleDto;
import com.zioinfo.esn.uiws.system.dto.PageResponse;
import com.zioinfo.esn.uiws.system.dto.RoleIdsRequest;
import com.zioinfo.esn.uiws.system.service.DeptRoleService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/** 2.2 부서권한(dept-role) — 부서 사용자+권한 조회 / 부여 / 삭제. base: /api/system/depts/{deptId}/roles */
@RestController
@RequestMapping("/api/system/depts/{deptId}/roles")
@RequiredArgsConstructor
public class DeptRoleController {
private final DeptRoleService deptRoleService;
@GetMapping
public ApiResponse<PageResponse<DeptUserRoleDto>> list(
@PathVariable("deptId") String deptId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(deptRoleService.listDeptUsers(deptId, page, size));
}
@PostMapping
public ApiResponse<Void> grant(@PathVariable("deptId") String deptId, @Valid @RequestBody RoleIdsRequest req) {
deptRoleService.grant(deptId, req.roleIds());
return ApiResponse.ok(null);
}
@DeleteMapping
public ApiResponse<Void> revoke(@PathVariable("deptId") String deptId, @Valid @RequestBody RoleIdsRequest req) {
deptRoleService.revoke(deptId, req.roleIds());
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,55 @@
package com.zioinfo.esn.uiws.system.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.system.dto.IdsRequest;
import com.zioinfo.esn.uiws.system.dto.MenuDto;
import com.zioinfo.esn.uiws.system.dto.MenuSaveDto;
import com.zioinfo.esn.uiws.system.dto.PageResponse;
import com.zioinfo.esn.uiws.system.service.MenuService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 2.9 메뉴(menus) — 트리목록/검색/CRUD/다중삭제. base: /api/system/menus */
@RestController
@RequestMapping("/api/system/menus")
@RequiredArgsConstructor
public class MenuController {
private final MenuService menuService;
@GetMapping
public ApiResponse<PageResponse<MenuDto>> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "100") int size) {
return ApiResponse.ok(menuService.list(page, size));
}
@GetMapping("/search")
public ApiResponse<List<MenuDto>> search(@RequestParam(required = false) String keyword) {
return ApiResponse.ok(menuService.searchPopup(keyword));
}
@PostMapping
public ApiResponse<MenuDto> create(@Valid @RequestBody MenuSaveDto dto) {
return ApiResponse.ok(menuService.create(dto));
}
@GetMapping("/{id}")
public ApiResponse<MenuDto> get(@PathVariable("id") String id) {
return ApiResponse.ok(menuService.get(id));
}
@PutMapping("/{id}")
public ApiResponse<MenuDto> update(@PathVariable("id") String id, @Valid @RequestBody MenuSaveDto dto) {
return ApiResponse.ok(menuService.update(id, dto));
}
@DeleteMapping
public ApiResponse<Void> delete(@Valid @RequestBody IdsRequest req) {
menuService.delete(req.ids());
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,57 @@
package com.zioinfo.esn.uiws.system.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.system.dto.IdsRequest;
import com.zioinfo.esn.uiws.system.dto.PageResponse;
import com.zioinfo.esn.uiws.system.dto.ProgramDto;
import com.zioinfo.esn.uiws.system.dto.ProgramSaveDto;
import com.zioinfo.esn.uiws.system.service.ProgramService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 2.8 프로그램(programs) — 목록/검색/CRUD/다중삭제. base: /api/system/programs */
@RestController
@RequestMapping("/api/system/programs")
@RequiredArgsConstructor
public class ProgramController {
private final ProgramService programService;
@GetMapping
public ApiResponse<PageResponse<ProgramDto>> list(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String programType,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(programService.list(keyword, programType, page, size));
}
@GetMapping("/search")
public ApiResponse<List<ProgramDto>> search(@RequestParam(required = false) String keyword) {
return ApiResponse.ok(programService.searchPopup(keyword));
}
@PostMapping
public ApiResponse<ProgramDto> create(@Valid @RequestBody ProgramSaveDto dto) {
return ApiResponse.ok(programService.create(dto));
}
@GetMapping("/{id}")
public ApiResponse<ProgramDto> get(@PathVariable("id") String id) {
return ApiResponse.ok(programService.get(id));
}
@PutMapping("/{id}")
public ApiResponse<ProgramDto> update(@PathVariable("id") String id, @Valid @RequestBody ProgramSaveDto dto) {
return ApiResponse.ok(programService.update(id, dto));
}
@DeleteMapping
public ApiResponse<Void> delete(@Valid @RequestBody IdsRequest req) {
programService.delete(req.ids());
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.esn.uiws.system.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.system.dto.PublicCompanyDto;
import com.zioinfo.esn.uiws.system.dto.PublicDeptDto;
import com.zioinfo.esn.uiws.system.service.PublicLookupService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 가입 화면(비인증) 공개 조회. base: /api/public/{depts,companies} */
@RestController
@RequestMapping("/api/public")
@RequiredArgsConstructor
public class PublicLookupController {
private final PublicLookupService publicLookupService;
@GetMapping("/depts")
public ApiResponse<List<PublicDeptDto>> depts() {
return ApiResponse.ok(publicLookupService.depts());
}
@GetMapping("/companies")
public ApiResponse<List<PublicCompanyDto>> companies() {
return ApiResponse.ok(publicLookupService.companies());
}
}

View File

@ -0,0 +1,49 @@
package com.zioinfo.esn.uiws.system.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.system.dto.IdsRequest;
import com.zioinfo.esn.uiws.system.dto.PageResponse;
import com.zioinfo.esn.uiws.system.dto.RoleDto;
import com.zioinfo.esn.uiws.system.dto.RoleSaveDto;
import com.zioinfo.esn.uiws.system.service.RoleService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/** 2.1 권한(roles) — 목록/등록/상세/수정/다중삭제. base: /api/system/roles */
@RestController
@RequestMapping("/api/system/roles")
@RequiredArgsConstructor
public class RoleController {
private final RoleService roleService;
@GetMapping
public ApiResponse<PageResponse<RoleDto>> list(
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(roleService.list(keyword, page, size));
}
@PostMapping
public ApiResponse<RoleDto> create(@Valid @RequestBody RoleSaveDto dto) {
return ApiResponse.ok(roleService.create(dto));
}
@GetMapping("/{id}")
public ApiResponse<RoleDto> get(@PathVariable("id") String id) {
return ApiResponse.ok(roleService.get(id));
}
@PutMapping("/{id}")
public ApiResponse<RoleDto> update(@PathVariable("id") String id, @Valid @RequestBody RoleSaveDto dto) {
return ApiResponse.ok(roleService.update(id, dto));
}
@DeleteMapping
public ApiResponse<Void> delete(@Valid @RequestBody IdsRequest req) {
roleService.delete(req.ids());
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,39 @@
package com.zioinfo.esn.uiws.system.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.system.dto.PageResponse;
import com.zioinfo.esn.uiws.system.dto.RoleDto;
import com.zioinfo.esn.uiws.system.dto.RoleMenuDto;
import com.zioinfo.esn.uiws.system.dto.RoleMenuSaveRequest;
import com.zioinfo.esn.uiws.system.service.RoleMenuService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 2.5 메뉴생성(role-menus) — 권한목록 / 권한별 메뉴매핑 조회·저장. */
@RestController
@RequiredArgsConstructor
public class RoleMenuController {
private final RoleMenuService roleMenuService;
@GetMapping("/api/system/role-menus")
public ApiResponse<PageResponse<RoleDto>> listRoles(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(roleMenuService.listRoles(page, size));
}
@GetMapping("/api/system/roles/{roleId}/menus")
public ApiResponse<List<RoleMenuDto>> getRoleMenus(@PathVariable("roleId") String roleId) {
return ApiResponse.ok(roleMenuService.getRoleMenus(roleId));
}
@PutMapping("/api/system/roles/{roleId}/menus")
public ApiResponse<Void> saveRoleMenus(@PathVariable("roleId") String roleId, @Valid @RequestBody RoleMenuSaveRequest req) {
roleMenuService.saveRoleMenus(roleId, req.menus());
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,89 @@
package com.zioinfo.esn.uiws.system.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.system.dto.CheckIdResponse;
import com.zioinfo.esn.uiws.system.dto.IdsRequest;
import com.zioinfo.esn.uiws.system.dto.PageResponse;
import com.zioinfo.esn.uiws.system.dto.UserDto;
import com.zioinfo.esn.uiws.system.dto.UserSaveDto;
import com.zioinfo.esn.uiws.system.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 2.6 사용자(users) — 목록/검색/중복ID확인/CRUD/다중삭제/비번초기화/잠금해제/승인. base: /api/system/users */
@RestController
@RequestMapping("/api/system/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public ApiResponse<PageResponse<UserDto>> list(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String deptId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(userService.list(keyword, deptId, page, size));
}
@GetMapping("/search")
public ApiResponse<List<UserDto>> search(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String deptId) {
return ApiResponse.ok(userService.searchPopup(keyword, deptId));
}
@GetMapping("/check-id")
public ApiResponse<CheckIdResponse> checkId(@RequestParam String userId) {
return ApiResponse.ok(userService.checkId(userId));
}
@PostMapping
public ApiResponse<UserDto> create(@Valid @RequestBody UserSaveDto dto) {
return ApiResponse.ok(userService.create(dto));
}
@GetMapping("/{id}")
public ApiResponse<UserDto> get(@PathVariable("id") String id) {
return ApiResponse.ok(userService.get(id));
}
@PutMapping("/{id}")
public ApiResponse<UserDto> update(@PathVariable("id") String id, @Valid @RequestBody UserSaveDto dto) {
return ApiResponse.ok(userService.update(id, dto));
}
@DeleteMapping
public ApiResponse<Void> delete(@Valid @RequestBody IdsRequest req) {
userService.delete(req.ids());
return ApiResponse.ok(null);
}
@PostMapping("/{id}/reset-pw")
public ApiResponse<Void> resetPassword(@PathVariable("id") String id) {
userService.resetPassword(id);
return ApiResponse.ok(null);
}
@PostMapping("/{id}/unlock")
public ApiResponse<Void> unlock(@PathVariable("id") String id) {
userService.unlock(id);
return ApiResponse.ok(null);
}
@PostMapping("/{id}/approve")
public ApiResponse<Void> approve(@PathVariable("id") String id) {
userService.approve(id);
return ApiResponse.ok(null);
}
@PostMapping("/{id}/revoke-approval")
public ApiResponse<Void> revokeApproval(@PathVariable("id") String id) {
userService.revokeApproval(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,4 @@
package com.zioinfo.esn.uiws.system.dto;
/** { available: boolean } */
public record CheckIdResponse(boolean available) {}

View File

@ -0,0 +1,6 @@
package com.zioinfo.esn.uiws.system.dto;
import java.util.List;
/** { grpCd, grpNm, useYn, values: CodeValueDto[] } */
public record CodeGrpDetailDto(String grpCd, String grpNm, String useYn, List<CodeValueDto> values) {}

View File

@ -0,0 +1,4 @@
package com.zioinfo.esn.uiws.system.dto;
/** { grpCd, grpNm, useYn } */
public record CodeGrpDto(String grpCd, String grpNm, String useYn) {}

View File

@ -0,0 +1,11 @@
package com.zioinfo.esn.uiws.system.dto;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
/** { grpCd, grpNm, useYn, values: CodeValueDto[] } */
public record CodeGrpSaveDto(
@NotBlank(message = "grpCd는 필수입니다.") String grpCd,
@NotBlank(message = "grpNm은 필수입니다.") String grpNm,
@NotBlank(message = "useYn은 필수입니다.") String useYn,
List<CodeValueDto> values) {}

View File

@ -0,0 +1,4 @@
package com.zioinfo.esn.uiws.system.dto;
/** { grpCd, codeVal, codeNm, sortOrd, useYn } */
public record CodeValueDto(String grpCd, String codeVal, String codeNm, Integer sortOrd, String useYn) {}

View File

@ -0,0 +1,4 @@
package com.zioinfo.esn.uiws.system.dto;
/** { companyId, companyNm, bizNo, useYn } */
public record CompanyDto(String companyId, String companyNm, String bizNo, String useYn) {}

View File

@ -0,0 +1,10 @@
package com.zioinfo.esn.uiws.system.dto;
import jakarta.validation.constraints.NotBlank;
/** { companyId, companyNm, bizNo, useYn } */
public record CompanySaveDto(
String companyId,
@NotBlank(message = "companyNm은 필수입니다.") String companyNm,
String bizNo,
@NotBlank(message = "useYn은 필수입니다.") String useYn) {}

View File

@ -0,0 +1,4 @@
package com.zioinfo.esn.uiws.system.dto;
/** { deptId, deptNm, parentDeptId, sortOrd, useYn } */
public record DeptDto(String deptId, String deptNm, String parentDeptId, Integer sortOrd, String useYn) {}

View File

@ -0,0 +1,11 @@
package com.zioinfo.esn.uiws.system.dto;
import jakarta.validation.constraints.NotBlank;
/** { deptId, deptNm, parentDeptId, sortOrd, useYn } */
public record DeptSaveDto(
String deptId,
@NotBlank(message = "deptNm은 필수입니다.") String deptNm,
String parentDeptId,
Integer sortOrd,
@NotBlank(message = "useYn은 필수입니다.") String useYn) {}

View File

@ -0,0 +1,8 @@
package com.zioinfo.esn.uiws.system.dto;
import java.util.List;
/** 부서 계층 트리 노드. children 은 sortOrd→deptId 순. */
public record DeptTreeDto(
String deptId, String deptNm, String parentDeptId,
Integer sortOrd, String useYn, List<DeptTreeDto> children) {}

View File

@ -0,0 +1,6 @@
package com.zioinfo.esn.uiws.system.dto;
import java.util.List;
/** { userId, userNm, roleIds } — 부서 사용자별 부여 권한. */
public record DeptUserRoleDto(String userId, String userNm, List<String> roleIds) {}

View File

@ -0,0 +1,7 @@
package com.zioinfo.esn.uiws.system.dto;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
/** 다중삭제 공통 본문: { ids: string[] } */
public record IdsRequest(@NotEmpty(message = "ids는 필수입니다.") List<String> ids) {}

View File

@ -0,0 +1,6 @@
package com.zioinfo.esn.uiws.system.dto;
/** { menuId, menuNm, parentMenuId, programId, menuUrl, sortOrd, useYn } */
public record MenuDto(
String menuId, String menuNm, String parentMenuId, String programId,
String menuUrl, Integer sortOrd, String useYn) {}

View File

@ -0,0 +1,10 @@
package com.zioinfo.esn.uiws.system.dto;
import jakarta.validation.constraints.NotBlank;
/** { menuId, menuNm, parentMenuId, programId, menuUrl, sortOrd, useYn } */
public record MenuSaveDto(
String menuId,
@NotBlank(message = "menuNm은 필수입니다.") String menuNm,
String parentMenuId, String programId, String menuUrl, Integer sortOrd,
@NotBlank(message = "useYn은 필수입니다.") String useYn) {}

View File

@ -0,0 +1,18 @@
package com.zioinfo.esn.uiws.system.dto;
import java.util.List;
/**
* UIWS system 이식용 페이지 응답 봉투. 원본 com.urp.uiws.common.response.PageResponse 대체.
* MyBatis 기반(Spring Data Page 부재)이므로 content + total + page + size 직접 담는다.
*/
public record PageResponse<T>(
List<T> content,
long total,
int page,
int size
) {
public static <T> PageResponse<T> of(List<T> content, long total, int page, int size) {
return new PageResponse<>(content, total, page, size);
}
}

View File

@ -0,0 +1,6 @@
package com.zioinfo.esn.uiws.system.dto;
/** { programId, programNm, programType, programUrl, category, useYn } */
public record ProgramDto(
String programId, String programNm, String programType,
String programUrl, String category, String useYn) {}

View File

@ -0,0 +1,11 @@
package com.zioinfo.esn.uiws.system.dto;
import jakarta.validation.constraints.NotBlank;
/** { programId, programNm, programType, programUrl, category, useYn } */
public record ProgramSaveDto(
@NotBlank(message = "programId는 필수입니다.") String programId,
@NotBlank(message = "programNm은 필수입니다.") String programNm,
@NotBlank(message = "programType은 필수입니다.") String programType,
String programUrl, String category,
@NotBlank(message = "useYn은 필수입니다.") String useYn) {}

View File

@ -0,0 +1,4 @@
package com.zioinfo.esn.uiws.system.dto;
/** 가입 화면 공개 조회용 거래처: { companyId, companyNm } */
public record PublicCompanyDto(String companyId, String companyNm) {}

View File

@ -0,0 +1,4 @@
package com.zioinfo.esn.uiws.system.dto;
/** 가입 화면 공개 조회용 부서: { deptId, deptNm } */
public record PublicDeptDto(String deptId, String deptNm) {}

View File

@ -0,0 +1,4 @@
package com.zioinfo.esn.uiws.system.dto;
/** { roleId, roleNm, roleDesc, useYn } */
public record RoleDto(String roleId, String roleNm, String roleDesc, String useYn) {}

View File

@ -0,0 +1,7 @@
package com.zioinfo.esn.uiws.system.dto;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
/** 부서권한 부여/삭제 본문: { roleIds: string[] } */
public record RoleIdsRequest(@NotEmpty(message = "roleIds는 필수입니다.") List<String> roleIds) {}

View File

@ -0,0 +1,4 @@
package com.zioinfo.esn.uiws.system.dto;
/** { menuId, menuNm, readYn, writeYn } */
public record RoleMenuDto(String menuId, String menuNm, String readYn, String writeYn) {}

View File

@ -0,0 +1,6 @@
package com.zioinfo.esn.uiws.system.dto;
import java.util.List;
/** 권한별 메뉴 매핑 저장 본문: { menus: RoleMenuDto[] } */
public record RoleMenuSaveRequest(List<RoleMenuDto> menus) {}

View File

@ -0,0 +1,10 @@
package com.zioinfo.esn.uiws.system.dto;
import jakarta.validation.constraints.NotBlank;
/** { roleId, roleNm, roleDesc, useYn } */
public record RoleSaveDto(
String roleId,
@NotBlank(message = "roleNm은 필수입니다.") String roleNm,
String roleDesc,
@NotBlank(message = "useYn은 필수입니다.") String useYn) {}

View File

@ -0,0 +1,7 @@
package com.zioinfo.esn.uiws.system.dto;
/** 사용자 조회 DTO(비밀번호 제외 — 절대 노출 금지). */
public record UserDto(
String userId, String userNm, String email, String gradeCd,
String deptId, String deptNm, String companyId, String companyNm,
String roleCd, String naverworksId, String lockYn, String useYn, String approvalYn) {}

View File

@ -0,0 +1,13 @@
package com.zioinfo.esn.uiws.system.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
/** roleCd = USER/MANAGER/ADMIN (null → USER). 시스템관리(ADMIN 전용)에서만 설정. */
public record UserSaveDto(
@NotBlank(message = "userId는 필수입니다.") String userId,
@NotBlank(message = "userNm은 필수입니다.") String userNm,
String password,
@NotBlank(message = "email은 필수입니다.") @Email(message = "email 형식이 올바르지 않습니다.") String email,
String gradeCd, String deptId, String companyId, String roleCd, String naverworksId,
@NotBlank(message = "useYn은 필수입니다.") String useYn) {}

View File

@ -0,0 +1,23 @@
package com.zioinfo.esn.uiws.system.mapper;
import com.zioinfo.esn.uiws.system.model.*;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 공통코드 그룹/값(tb_uiws_code_grp, tb_uiws_code) 매퍼. 원본 CodeGrpRepository/CodeRepository 변환. */
@Mapper
public interface CodeMapper {
List<SysCodeGrp> searchGroups(@Param("keyword") String keyword,
@Param("offset") int offset, @Param("size") int size);
long countGroups(@Param("keyword") String keyword);
SysCodeGrp findGroupById(@Param("grpCd") String grpCd);
boolean groupExists(@Param("grpCd") String grpCd);
int insertGroup(SysCodeGrp grp);
int updateGroup(SysCodeGrp grp);
int deleteGroup(@Param("grpCd") String grpCd);
List<SysCode> findValuesByGrp(@Param("grpCd") String grpCd);
List<SysCode> findActiveValuesByGrp(@Param("grpCd") String grpCd);
int insertValue(SysCode code);
int deleteValuesByGrp(@Param("grpCd") String grpCd);
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.esn.uiws.system.mapper;
import com.zioinfo.esn.uiws.system.model.*;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 거래처(tb_uiws_company) 매퍼. 원본 CompanyRepository 변환. */
@Mapper
public interface CompanyMapper {
List<SysCompany> search(@Param("keyword") String keyword,
@Param("offset") int offset, @Param("size") int size);
long countSearch(@Param("keyword") String keyword);
List<SysCompany> searchActive(@Param("keyword") String keyword);
List<SysCompany> findActiveOrdered();
List<SysCompany> findAll();
SysCompany findById(@Param("companyId") String companyId);
boolean existsById(@Param("companyId") String companyId);
int insert(SysCompany company);
int update(SysCompany company);
int deleteById(@Param("companyId") String companyId);
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.esn.uiws.system.mapper;
import com.zioinfo.esn.uiws.system.model.*;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 부서(tb_uiws_dept) 매퍼. 원본 DeptRepository 변환. */
@Mapper
public interface DeptMapper {
List<SysDept> search(@Param("keyword") String keyword,
@Param("offset") int offset, @Param("size") int size);
long countSearch(@Param("keyword") String keyword);
List<SysDept> searchActive(@Param("keyword") String keyword);
List<SysDept> findActiveOrdered();
List<SysDept> findAll();
SysDept findById(@Param("deptId") String deptId);
boolean existsById(@Param("deptId") String deptId);
boolean existsByParentDeptId(@Param("parentDeptId") String parentDeptId);
int insert(SysDept dept);
int update(SysDept dept);
int deleteById(@Param("deptId") String deptId);
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.esn.uiws.system.mapper;
import com.zioinfo.esn.uiws.system.model.*;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 부서-권한 매핑(tb_uiws_dept_role) 매퍼. 원본 DeptRoleRepository 변환. */
@Mapper
public interface DeptRoleMapper {
List<String> findRoleIdsByDeptId(@Param("deptId") String deptId);
boolean exists(@Param("deptId") String deptId, @Param("roleId") String roleId);
boolean existsByRoleId(@Param("roleId") String roleId);
int insert(@Param("deptId") String deptId, @Param("roleId") String roleId, @Param("actor") String actor);
int delete(@Param("deptId") String deptId, @Param("roleId") String roleId);
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.esn.uiws.system.mapper;
import com.zioinfo.esn.uiws.system.model.*;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 메뉴(tb_uiws_menu) 매퍼. 원본 SysMenuRepository 변환. */
@Mapper
public interface MenuMapper {
List<SysMenu> searchAll(@Param("offset") int offset, @Param("size") int size);
long countAll();
List<SysMenu> search(@Param("keyword") String keyword);
List<SysMenu> findAllOrdered();
SysMenu findById(@Param("menuId") String menuId);
boolean existsById(@Param("menuId") String menuId);
boolean existsByParentMenuId(@Param("parentMenuId") String parentMenuId);
boolean existsByProgramId(@Param("programId") String programId);
int insert(SysMenu menu);
int update(SysMenu menu);
int deleteById(@Param("menuId") String menuId);
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.esn.uiws.system.mapper;
import com.zioinfo.esn.uiws.system.model.*;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 프로그램(tb_uiws_program) 매퍼. 원본 ProgramRepository 변환. */
@Mapper
public interface ProgramMapper {
List<SysProgram> search(@Param("keyword") String keyword, @Param("programType") String programType,
@Param("offset") int offset, @Param("size") int size);
long countSearch(@Param("keyword") String keyword, @Param("programType") String programType);
List<SysProgram> searchActive(@Param("keyword") String keyword);
SysProgram findById(@Param("programId") String programId);
boolean existsById(@Param("programId") String programId);
int insert(SysProgram program);
int update(SysProgram program);
int deleteById(@Param("programId") String programId);
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.esn.uiws.system.mapper;
import com.zioinfo.esn.uiws.system.model.*;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 권한(tb_uiws_role) 매퍼. 원본 RoleRepository 변환. */
@Mapper
public interface RoleMapper {
List<SysRole> search(@Param("keyword") String keyword,
@Param("offset") int offset, @Param("size") int size);
long countSearch(@Param("keyword") String keyword);
SysRole findById(@Param("roleId") String roleId);
boolean existsById(@Param("roleId") String roleId);
int insert(SysRole role);
int update(SysRole role);
int deleteById(@Param("roleId") String roleId);
}

View File

@ -0,0 +1,15 @@
package com.zioinfo.esn.uiws.system.mapper;
import com.zioinfo.esn.uiws.system.model.*;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 권한-메뉴 매핑(tb_uiws_role_menu) 매퍼. 원본 RoleMenuRepository 변환. */
@Mapper
public interface RoleMenuMapper {
List<SysRoleMenu> findByRoleId(@Param("roleId") String roleId);
boolean existsByMenuId(@Param("menuId") String menuId);
int insert(SysRoleMenu rm);
int deleteByRoleId(@Param("roleId") String roleId);
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.esn.uiws.system.mapper;
import com.zioinfo.esn.uiws.system.model.*;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 업무 사용자(tb_uiws_sys_user) 매퍼. 원본 SysUserRepository 변환. */
@Mapper
public interface SysUserMapper {
List<SysUser> search(@Param("keyword") String keyword, @Param("deptId") String deptId,
@Param("offset") int offset, @Param("size") int size);
long countSearch(@Param("keyword") String keyword, @Param("deptId") String deptId);
List<SysUser> searchActive(@Param("keyword") String keyword, @Param("deptId") String deptId);
List<SysUser> findByDeptId(@Param("deptId") String deptId);
SysUser findById(@Param("userId") String userId);
boolean existsById(@Param("userId") String userId);
int insert(SysUser user);
int update(SysUser user);
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.esn.uiws.system.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 공통코드 값 (tb_uiws_code, 복합 PK grp_cd+code_val). 원본 com.urp.uiws.domain.Code 이식. */
@Data
public class SysCode {
private String grpCd;
private String codeVal;
private String codeNm;
private Integer sortOrd;
private String useYn;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.esn.uiws.system.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 공통코드 그룹 (tb_uiws_code_grp). 원본 com.urp.uiws.domain.CodeGrp 이식. */
@Data
public class SysCodeGrp {
private String grpCd;
private String grpNm;
private String useYn;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.esn.uiws.system.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 거래처(=근무처) (tb_uiws_company). 원본 com.urp.uiws.domain.Company 이식. */
@Data
public class SysCompany {
private String companyId;
private String companyNm;
private String bizNo;
private String useYn;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.esn.uiws.system.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 부서 (tb_uiws_dept). 원본 com.urp.uiws.domain.Dept 이식. */
@Data
public class SysDept {
private String deptId;
private String deptNm;
private String parentDeptId;
private Integer sortOrd;
private String useYn;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.esn.uiws.system.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 메뉴 (tb_uiws_menu, 2-depth 자기참조). 원본 com.urp.uiws.domain.Menu 이식. */
@Data
public class SysMenu {
private String menuId;
private String menuNm;
private String parentMenuId;
private String programId;
private String menuUrl;
private Integer sortOrd;
private String useYn;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.esn.uiws.system.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 프로그램(화면) (tb_uiws_program). 원본 com.urp.uiws.domain.Program 이식. */
@Data
public class SysProgram {
private String programId;
private String programNm;
private String programType; // FORM | POPUP
private String programUrl;
private String category;
private String useYn;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.esn.uiws.system.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 권한(역할) (tb_uiws_role). 원본 com.urp.uiws.domain.Role 이식. */
@Data
public class SysRole {
private String roleId;
private String roleNm;
private String roleDesc;
private String useYn;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

Some files were not shown because too many files have changed in this diff Show More