chore(deploy): MES 클린 재동기화 + Claude provider flip

- workspace→repos 클린 재동기화(auto-sync 발산 해소, 실델타 32파일)
- AI provider flip: db/105_ai_provider_claude.sql 신규(멱등 UPSERT provider=claude)
- application.yml schema-locations 에 105 등재(104 시드 이후 실행)
- 키 미기재(ANTHROPIC_API_KEY env 단일 로드), 실패 시 Ollama 자동 폴백

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GUARDiA 2026-07-11 10:39:23 +09:00
parent a093098657
commit 13ab120c19
32 changed files with 1312 additions and 654 deletions

View File

@ -22,6 +22,7 @@
<springdoc.version>2.6.0</springdoc.version>
<mybatis.version>3.0.3</mybatis.version>
<postgresql.version>42.7.7</postgresql.version>
<duckdb.version>1.1.3</duckdb.version>
</properties>
<dependencies>
@ -42,6 +43,10 @@
<!-- DB Driver -->
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
<!-- DuckDB (로컬 임베디드 AI 학습/추론 저장소 — /opt/guardia-mes/data/mes_learning.duckdb) -->
<!-- 런타임 전용: 코드는 java.sql + 리플렉션 드라이버 로드로 컴파일 의존 0. 미가용 시 학습 저장만 비활성. -->
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>${duckdb.version}</version><scope>runtime</scope></dependency>
<!-- JWT -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
@ -54,6 +59,9 @@
<version>${springdoc.version}</version>
</dependency>
<!-- TOTP (OTP 2차 인증 — RFC 6238, QR 생성). UIWS 좌표 동일. -->
<dependency><groupId>dev.samstevens.totp</groupId><artifactId>totp</artifactId><version>1.7.1</version></dependency>
<!-- Lombok -->
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>

View File

@ -56,6 +56,12 @@ public class AdminController {
return ApiResponse.ok(userService.resetPassword(id, req.password()));
}
/** 관리자 OTP 초기화(SUPERADMIN). 대상 OTP 시크릿 폐기 → 다음 로그인 재등록. 시크릿 미노출·감사 기록. */
@PostMapping("/users/{id}/otp-reset")
public ApiResponse<UserDto> resetOtp(@PathVariable Long id) {
return ApiResponse.ok(userService.resetOtp(id));
}
@DeleteMapping("/users/{id}")
public ApiResponse<Void> deleteUser(@PathVariable Long id, Authentication auth) {
String currentUsername = auth != null ? auth.getName() : null;

View File

@ -85,6 +85,17 @@ public class AdminUserService {
return UserDto.from(user);
}
/**
* 관리자 OTP 초기화(SUPERADMIN). 대상 사용자의 OTP 시크릿을 폐기하고 등록을 해제한다.
* 사용자는 다음 로그인 OTP_SETUP(재등록) 플로우를 탄다. 시크릿은 응답/로그에 노출하지 않는다.
*/
public UserDto resetOtp(Long id) {
MesUser user = require(id);
mapper.clearOtp(id);
auditService.log("USER_OTP_RESET", user.getUsername(), "OTP 초기화(다음 로그인 재등록)");
return UserDto.from(user);
}
public void delete(Long id, String currentUsername) {
MesUser user = require(id);
if (user.getUsername().equals(currentUsername)) {

View File

@ -22,6 +22,9 @@ public interface AdminUserMapper {
int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash);
/** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false(다음 로그인 시 재등록 유도). 시크릿 미노출. */
int clearOtp(@Param("id") Long id);
int deleteById(@Param("id") Long id);
/** 활성 SUPERADMIN 계정 수 (마지막 관리자 삭제/강등 방지용). */

View File

@ -1,5 +1,7 @@
package com.zioinfo.mes.ai;
import com.zioinfo.mes.ai.service.AiTextRouter;
import com.zioinfo.mes.common.ai.TextAiClient.GenResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -31,11 +33,19 @@ import java.util.Map;
public class AiService {
private final OllamaClient ollama;
/** provider 라우팅(Claude→Ollama 폴백) + 로컬 학습 로그 경유. 기존 OllamaClient 직접 호출을 대체. */
private final AiTextRouter aiTextRouter;
public boolean ollamaAvailable() {
return ollama.available();
}
/** 라우터 경유 텍스트 생성 — degraded 면 null(호출부가 Java 폴백 수행). */
private String routeGenerate(String prompt) {
GenResult r = aiTextRouter.generate(prompt);
return (r != null && !r.degraded()) ? r.text() : null;
}
// 1. 불량 원인 분석 (공정/설비/자재 상관)
public Map<String, Object> defectRootCause(String defectCode, List<Map<String, Object>> context) {
Map<String, Object> result = new LinkedHashMap<>();
@ -44,11 +54,11 @@ public class AiService {
"당신은 제조 품질 엔지니어입니다. 불량코드 '%s' 와 관련 공정/설비/자재 데이터: %s. "
+ "가장 가능성 높은 추정 원인 1가지와 권고 조치를 'CAUSE: ...\\nACTION: ...' 형식 한국어로 출력.",
defectCode, ctx);
String out = ollama.generate(prompt);
String out = routeGenerate(prompt);
if (out != null && !out.isBlank()) {
result.put("cause", firstNonBlank(extractLine(out, "CAUSE:"), out));
result.put("action", extractLine(out, "ACTION:"));
result.put("source", "ollama");
result.put("source", "ai");
return result;
}
// Java 폴백: 불량코드 분류 규칙
@ -156,12 +166,12 @@ public class AiService {
Map<String, Object> filter = new LinkedHashMap<>();
if (naturalQuery == null || naturalQuery.isBlank()) return filter;
String prompt = "다음 질의에서 검색 필터를 'STATUS:..\\nITEM:..\\nDATE:..' 형식으로만 추출:\\n" + naturalQuery;
String out = ollama.generate(prompt);
String out = routeGenerate(prompt);
if (out != null && !out.isBlank()) {
putIf(filter, "status", extractLine(out, "STATUS:"));
putIf(filter, "item", extractLine(out, "ITEM:"));
putIf(filter, "date", extractLine(out, "DATE:"));
if (!filter.isEmpty()) { filter.put("source", "ollama"); return filter; }
if (!filter.isEmpty()) { filter.put("source", "ai"); return filter; }
}
// Java 폴백: 키워드 매칭
String q = naturalQuery.toLowerCase();

View File

@ -34,17 +34,26 @@ public class OllamaClient {
this.visionModel = visionModel;
}
/** 프롬프트로 텍스트 생성. 실패 시 빈 문자열 반환(예외 없음). */
@SuppressWarnings("unchecked")
/** 프롬프트로 텍스트 생성(기본 텍스트 모델). 실패 시 빈 문자열 반환(예외 없음). */
public String generate(String prompt) {
return generate(prompt, textModel);
}
/**
* 지정 모델로 텍스트 생성(AiTextRouter provider 소형 모델 선택 경유). 실패 문자열.
* 기존 {@link #generate(String)} 계약 보존 오버로드만 추가.
*/
@SuppressWarnings("unchecked")
public String generate(String prompt, String model) {
String useModel = (model == null || model.isBlank()) ? textModel : model.trim();
try {
Map<String, Object> body = Map.of("model", textModel, "prompt", prompt, "stream", false);
Map<String, Object> body = Map.of("model", useModel, "prompt", prompt, "stream", false);
Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
.post().uri("/api/generate")
.bodyValue(body)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(30))
.timeout(Duration.ofSeconds(120))
.map(m -> (Map<String, Object>) m)
.block();
if (res == null) return "";
@ -70,7 +79,7 @@ public class OllamaClient {
.bodyValue(body)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(45))
.timeout(Duration.ofSeconds(120))
.map(m -> (Map<String, Object>) m)
.block();
if (res == null) return "";

View File

@ -1,22 +1,52 @@
package com.zioinfo.mes.auth;
import com.zioinfo.mes.auth.dto.ChangePasswordRequest;
import com.zioinfo.mes.auth.dto.OtpConfirmRequest;
import com.zioinfo.mes.auth.dto.OtpSetupResponse;
import com.zioinfo.mes.auth.dto.OtpVerifyRequest;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.uiws.auth.OtpAuthService;
import com.zioinfo.mes.uiws.auth.TwoFactorService;
import com.zioinfo.mes.uiws.common.UiwsApiException;
import com.zioinfo.mes.uiws.common.UiwsErrorCode;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* MES 인증 컨트롤러.
* - /login: 2FA off { token, type, twofa:"false" }, 2FA on 이면 { twofa:"true", verifyToken, verifyMethod|step, ... }.
* - /verify: (UIWS 2FA 이식) verify-token + 이메일코드 access/refresh 발급.
* - /verify-otp: (TOTP 이식) verify-token + 6자리 access/refresh 발급(최초 로그인이면 등록 확정).
* 기존 클라이언트(2FA off) 응답 형태 token 보존 회귀 0.
*/
@RestController
@RequestMapping("/api/mes/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final TwoFactorService twoFactorService;
private final OtpAuthService otpAuthService;
private final JwtUtil jwtUtil;
@PostMapping("/login")
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
String token = authService.login(req.username(), req.password());
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
return ApiResponse.ok(authService.login(req.username(), req.password()));
}
/** UIWS 2FA(이메일코드) 이식: 2차 인증 코드 검증 → access/refresh 발급. */
@PostMapping("/verify")
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code()));
}
/** TOTP 이식: 로그인 2단계 6자리 검증 → access/refresh 발급(최초 로그인이면 등록 확정). */
@PostMapping("/verify-otp")
public ApiResponse<Map<String, String>> verifyOtp(@Valid @RequestBody OtpVerifyRequest req) {
return ApiResponse.ok(otpAuthService.verifyOtp(req.verifyToken(), req.code()));
}
@GetMapping("/me")
@ -25,5 +55,50 @@ public class AuthController {
return ApiResponse.ok(authService.me(token));
}
// 마이페이지: OTP 등록/재설정/해제 + 비밀번호 변경 (본인, access 토큰 필요)
/** 마이페이지 OTP 등록/재설정 시작 → { secret, otpAuthUri, qrImage }(이 응답에서만 시크릿/QR 노출). */
@PostMapping("/otp/setup")
public ApiResponse<OtpSetupResponse> otpSetup(@RequestHeader("Authorization") String header) {
return ApiResponse.ok(otpAuthService.setup(requireUser(header)));
}
/** 마이페이지 OTP 확인·활성화(앱 6자리 코드). */
@PostMapping("/otp/confirm")
public ApiResponse<Map<String, String>> otpConfirm(@RequestHeader("Authorization") String header,
@Valid @RequestBody OtpConfirmRequest req) {
otpAuthService.confirm(requireUser(header), req.code());
return ApiResponse.ok(Map.of("result", "ok"));
}
/** 마이페이지 OTP 해제. */
@PostMapping("/otp/disable")
public ApiResponse<Map<String, String>> otpDisable(@RequestHeader("Authorization") String header) {
otpAuthService.disable(requireUser(header));
return ApiResponse.ok(Map.of("result", "ok"));
}
/** 마이페이지 비밀번호 변경(현재 비번 검증 + BCrypt). 새 비번은 응답/로그 미포함. */
@PostMapping("/change-password")
public ApiResponse<Map<String, String>> changePassword(@RequestHeader("Authorization") String header,
@Valid @RequestBody ChangePasswordRequest req) {
authService.changePassword(requireUser(header), req);
return ApiResponse.ok(Map.of("result", "ok"));
}
/**
* Authorization 헤더에서 인증 사용자를 해석한다. verify-token(2fa 단계용) 거부.
* (/api/mes/auth/** permitAll 이라 필터 인증이 없으므로 여기서 access 토큰을 명시 검증.)
*/
private String requireUser(String header) {
String token = header == null ? "" : header.replace("Bearer ", "").trim();
if (token.isEmpty() || jwtUtil.isVerifyToken(token) || !jwtUtil.isValid(token)) {
throw new UiwsApiException(UiwsErrorCode.UNAUTHORIZED);
}
return jwtUtil.getUsername(token);
}
record LoginRequest(String username, String password) {}
record VerifyRequest(String verifyToken, String code) {}
}

View File

@ -1,30 +1,103 @@
package com.zioinfo.mes.auth;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.auth.dto.AuthHelperResult;
import com.zioinfo.mes.auth.dto.ChangePasswordRequest;
import com.zioinfo.mes.auth.dto.FindIdRequest;
import com.zioinfo.mes.auth.dto.FindIdResponse;
import com.zioinfo.mes.auth.dto.ResetPasswordRequest;
import com.zioinfo.mes.auth.dto.SignupRequest;
import com.zioinfo.mes.auth.mapper.UserMapper;
import com.zioinfo.mes.uiws.auth.OtpAuthService;
import com.zioinfo.mes.uiws.auth.TwoFactorService;
import com.zioinfo.mes.uiws.common.UiwsApiException;
import com.zioinfo.mes.uiws.common.UiwsErrorCode;
import com.zioinfo.mes.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;
import java.util.HashMap;
import java.util.Map;
/**
* MES 인증 서비스.
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 회귀 0).
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 verify-token + 이메일코드 발급.
* 실패 누적 max-login-fail 계정 잠금.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private static final SecureRandom RANDOM = new SecureRandom();
/** 임시 비밀번호 문자셋(혼동 문자 0/O/1/l/I 제외). */
private static final String TMP_PW_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789@#$%";
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final TwoFactorService twoFactorService;
private final OtpAuthService otpAuthService;
private final MailSender mailSender;
private final AuditService auditService;
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) {
MesUser user = userMapper.findByUsername(username);
// 잠금 우선 차단(존재하는 계정에 한해 잠금 메시지 존재 여부 누설 최소화)
if (user != null && twoFactorService.isLocked(user)) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
if (user == null || !user.isActive()) {
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
}
// 회원가입 승인 게이트(로그인 보조 이식): 미승인 계정은 비번 일치 전에 차단.
if (Boolean.FALSE.equals(user.getApproved())) {
throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 이용 가능합니다.");
}
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
// 2FA(OTP 또는 이메일) 활성 실패 누적/잠금. 비활성 기존 동작(메시지만) 유지.
if (otpAuthService.isEnabled() || twoFactorService.isEnabled()) {
twoFactorService.recordLoginFailure(username);
MesUser after = userMapper.findByUsername(username);
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
}
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
}
return jwtUtil.generate(username, user.getRole());
// 비밀번호 검증 통과 2단계 우선순위: OTP > 이메일코드 > 단일 로그인
if (otpAuthService.isEnabled()) {
// { twofa:true, verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }
return otpAuthService.beginOtp(user);
}
if (twoFactorService.isEnabled()) {
Map<String, String> step1 = twoFactorService.beginTwoFactor(user);
// verifyToken/step/maskedEmail + twofa 플래그
return Map.of(
"twofa", "true",
"verifyToken", step1.get("verifyToken"),
"step", step1.get("step"),
"maskedEmail", step1.getOrDefault("maskedEmail", ""));
}
// 2FA 비활성 기존 단일 로그인 흐름(회귀 0)
userMapper.resetLoginFail(username);
String token = jwtUtil.generate(username, user.getRole());
return Map.of("twofa", "false", "token", token, "type", "Bearer");
}
public Map<String, Object> me(String token) {
@ -37,4 +110,121 @@ public class AuthService {
m.put("displayName", u != null ? u.getDisplayName() : username);
return m;
}
// 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화
/**
* 운영자 회원가입(승인 대기). username/email 중복 검사 approved=false·role=VIEWER INSERT.
* 비밀번호는 BCrypt 저장. 활성/승인 전까지 로그인 차단(AuthService.login 승인 게이트).
*/
@Transactional
public AuthHelperResult 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 AuthHelperResult(false, "아이디·비밀번호(4자 이상)·이메일은 필수입니다.");
}
if (userMapper.countByUsername(req.username()) > 0) {
return new AuthHelperResult(false, "이미 사용 중인 아이디입니다.");
}
if (userMapper.countByEmail(req.email()) > 0) {
return new AuthHelperResult(false, "이미 등록된 이메일입니다.");
}
MesUser u = new MesUser();
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());
userMapper.signup(u);
log.info("[auth-helper] signup pending approval: username={}", req.username());
return new AuthHelperResult(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, "");
}
MesUser u = userMapper.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 AuthHelperResult resetPassword(ResetPasswordRequest req) {
final String okMsg = "임시 비밀번호를 등록된 이메일로 발송했습니다. 메일을 확인하세요.";
if (req.username() == null || req.username().isBlank()
|| req.email() == null || req.email().isBlank()) {
return new AuthHelperResult(false, "아이디와 이메일을 모두 입력하세요.");
}
MesUser u = userMapper.findByUsernameAndEmail(req.username(), req.email());
if (u == null) {
// 존재 여부 누설 방지 동일 성공 메시지 반환(실제 발송 없음).
log.info("[auth-helper] reset-password no match (suppressed): username={}", req.username());
return new AuthHelperResult(true, okMsg);
}
String tempPw = generateTempPassword();
userMapper.updatePasswordHash(req.username(), passwordEncoder.encode(tempPw));
String subject = "[GUARDiA MES] 임시 비밀번호 안내";
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 AuthHelperResult(true, okMsg);
}
/**
* 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIWS changePassword 미러.
* 현재 비번 불일치 PASSWORD_MISMATCH, 기존과 동일 PASSWORD_SAME_AS_OLD.
* 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙).
*/
@Transactional
public void changePassword(String username, ChangePasswordRequest req) {
MesUser user = userMapper.findByUsername(username);
if (user == null) {
throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
}
if (!passwordEncoder.matches(req.currentPassword(), user.getPasswordHash())) {
throw new UiwsApiException(UiwsErrorCode.PASSWORD_MISMATCH);
}
if (passwordEncoder.matches(req.newPassword(), user.getPasswordHash())) {
throw new UiwsApiException(UiwsErrorCode.PASSWORD_SAME_AS_OLD);
}
userMapper.updatePasswordHash(username, passwordEncoder.encode(req.newPassword()));
auditService.log(username, "PASSWORD_CHANGE", username, "본인 비밀번호 변경");
}
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

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

View File

@ -34,6 +34,46 @@ public class JwtUtil {
.compact();
}
/**
* UIWS 2FA 이식: 1차 로그인 통과 발급하는 단기 verify-token.
* purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 API 접근 불가).
*/
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)인지 판별.
* JwtFilter access 토큰만 인증 컨텍스트로 인정하도록 verify-token 걸러내는 사용.
*/
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) {
return Jwts.parser().verifyWith(key()).build()
.parseSignedClaims(token).getPayload();

View File

@ -17,4 +17,24 @@ public class MesUser {
private String role;
private boolean active;
private LocalDateTime createdAt;
// UIWS 2FA 이식 (mes_user ALTER, db/91_uiws_port.sql)
/** 2FA 발송 대상 이메일. mes_user 원본 미보유 → 91_uiws_port.sql ALTER 로 추가. */
private String email;
/** 이메일 인증코드(6자리). 발급 후 verify 단계에서 검증. API 응답에는 절대 미포함. */
private String emailVerifyCode;
/** 인증코드 만료시각. */
private LocalDateTime emailVerifyExpire;
/** 로그인 실패 누적 횟수(기본 0). max-login-fail 도달 시 locked. */
private Integer loginFailCount;
/** 계정 잠금 여부(기본 false). */
private Boolean locked;
/** TOTP 시크릿(보류/확정 공용). API 응답·로그 미노출. */
private String otpSecret;
/** OTP 등록 확정 여부(최초 로그인 verify 성공 시 true). db/93_auth_otp.sql ALTER. */
private Boolean otpEnabled;
// 로그인 보조 이식(회원가입 승인 게이트) mes_user.approved
/** 회원가입 승인 여부(기본 true). 신규 가입자는 false → SUPERADMIN 승인 전 로그인 차단. */
private Boolean approved;
}

View File

@ -3,11 +3,89 @@ package com.zioinfo.mes.auth.mapper;
import com.zioinfo.mes.auth.MesUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
@Mapper
public interface UserMapper {
// findByUsername / insert mapper/UserMapper.xml 정의(2FA 컬럼 포함 resultMap).
// 어노테이션 중복 정의 "statement already contains value" 크래시 XML 유지.
MesUser findByUsername(@Param("username") String username);
int insert(MesUser user);
// UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE)
/** 로그인 성공 시 실패 카운트 초기화. */
@Update("UPDATE mes_user SET login_fail_count = 0 WHERE username = #{username}")
int resetLoginFail(@Param("username") String username);
/** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */
@Update("""
UPDATE mes_user
SET login_fail_count = COALESCE(login_fail_count, 0) + 1,
locked = (COALESCE(login_fail_count, 0) + 1 >= #{maxFail})
WHERE username = #{username}
""")
int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail);
/** 1차 통과 시 이메일 인증코드/만료 저장(verify 단계에서 검증). */
@Update("""
UPDATE mes_user
SET email_verify_code = #{code}, email_verify_expire = #{expire}, login_fail_count = 0
WHERE username = #{username}
""")
int saveEmailCode(@Param("username") String username,
@Param("code") String code,
@Param("expire") LocalDateTime expire);
/** 2차 검증 성공 시 코드 폐기. */
@Update("UPDATE mes_user SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}")
int clearEmailCode(@Param("username") String username);
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
@Update("UPDATE mes_user SET locked = false, login_fail_count = 0 WHERE username = #{username}")
int unlock(@Param("username") String username);
// TOTP(OTP 2차 인증) 이식 (멱등 UPDATE)
/** 보류 시크릿 저장(등록 확정 전). otp_enabled 은 건드리지 않는다(verify/confirm 에서 확정). */
@Update("UPDATE mes_user SET otp_secret = #{secret} WHERE username = #{username}")
int updateOtpSecret(@Param("username") String username, @Param("secret") String secret);
/** 등록 확정: otp_enabled=true (시크릿은 유지). */
@Update("UPDATE mes_user SET otp_enabled = true WHERE username = #{username}")
int enableOtp(@Param("username") String username);
/** 해제/초기화: 시크릿 폐기 + otp_enabled=false. (마이페이지 해제) */
@Update("UPDATE mes_user SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}")
int disableOtp(@Param("username") String username);
// 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 (XML 정의)
/** username 존재 여부(회원가입 중복 검사). */
int countByUsername(@Param("username") String username);
/** email 존재 여부(회원가입 중복 검사 — 운영자 계정 한정). */
int countByEmail(@Param("email") String email);
/**
* 회원가입(승인 대기). approved=false·is_active=true·role=VIEWER 고정.
* 관리자 화면에서 활성/승인 전까지 로그인 차단.
*/
int signup(MesUser user);
/** 아이디찾기: 표시명(display_name)+이메일 일치 운영자 1건. */
MesUser findByDisplayNameAndEmail(@Param("displayName") String displayName,
@Param("email") String email);
/** 비밀번호 초기화 대상 검증: username+email 동시 일치 운영자 1건. */
MesUser findByUsernameAndEmail(@Param("username") String username,
@Param("email") String email);
/** 임시 비밀번호 적용 + 잠금/실패카운트 해제(초기화 시). */
int updatePasswordHash(@Param("username") String username,
@Param("passwordHash") String passwordHash);
}

View File

@ -1,6 +1,7 @@
package com.zioinfo.mes.common;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ -39,6 +40,14 @@ public class GlobalExceptionHandler {
return ApiResponse.fail(e.getMessage());
}
@ExceptionHandler(DataAccessException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleDataAccess(DataAccessException e) {
// 보안 불변 규칙: SQL/테이블/쿼리/스택 상세 절대 미노출 내부 로그만 남기고 일반 메시지 반환
log.error("DB 오류", e);
return ApiResponse.fail("ERR-MES-DB: 데이터 처리 중 오류가 발생했습니다");
}
@ExceptionHandler(RuntimeException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleRuntime(RuntimeException e) {

View File

@ -43,9 +43,12 @@ public class SecurityConfig {
.cors(cors -> {})
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// 로그인 + 로그인 보조 3종(signup/find-id/reset-password) + 2FA verify 모두 /api/mes/auth/** 하위 permitAll
.requestMatchers("/api/mes/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/mes/docs/**", "/api/mes/swagger/**").permitAll()
// UIWS system 이식: 무인증 공개 조회(회사/부서 룩업) 회원가입 화면 등에서 사용
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
// 정적 프론트 번들
.requestMatchers("/", "/index.html", "/assets/**", "/favicon.ico").permitAll()
@ -57,6 +60,9 @@ public class SecurityConfig {
.requestMatchers("/api/admin/audit").hasAnyRole("SUPERADMIN", "MANAGER")
.requestMatchers("/api/admin/**").hasRole("SUPERADMIN")
// RAG 기법 토글 변경 MANAGER 이상(운영자 전용). 분석 트리거(POST) 아래 WORKER+ 규칙 적용.
.requestMatchers(HttpMethod.PUT, "/api/mes/rag/toggles/**").hasAnyRole("SUPERADMIN", "MANAGER")
// 변경(실적·검사·입출고·재고이동) WORKER 이상 (기준정보/마감/승인은 서비스에서 MANAGER+ 가드)
.requestMatchers(HttpMethod.POST, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER")
.requestMatchers(HttpMethod.PUT, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER")
@ -65,6 +71,9 @@ public class SecurityConfig {
// 조회 인증 사용자 전체(Viewer+)
.requestMatchers(HttpMethod.GET, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER", "VIEWER")
// UIWS system(권한관리) 이식 사용자/역할/메뉴/부서/거래처/코드 관리는 SUPERADMIN 전용(RBAC 게이트)
.requestMatchers("/api/system/**").hasRole("SUPERADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

View File

@ -15,8 +15,12 @@ spring:
minimum-idle: 1
sql:
init:
mode: ${SQL_INIT_MODE:never}
schema-locations: classpath:db/schema.sql
# UIWS 이식: 91_uiws_port.sql(tb_uiws_* + mes_user 2FA ALTER, 전부 멱등)만 부팅 시 적용.
# 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 시드 충돌 회피).
mode: ${SQL_INIT_MODE:always}
# 91=업무/2FA(tb_uiws_* + mes_user ALTER), 92=권한관리 system, 104=AI 플랫폼 설정 시드(멱등), 105=AI provider claude flip(멱등 UPSERT, 104 이후). 전부 멱등.
schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/93_auth_otp.sql,classpath:db/104_seed_ai_config.sql,classpath:db/105_ai_provider_claude.sql
continue-on-error: true
servlet:
multipart:
max-file-size: 20MB
@ -34,15 +38,39 @@ springdoc:
swagger-ui:
path: /api/mes/swagger
# ── UIWS 이식 모듈 설정 (worklog/schedule/message/stats + 2FA 레이어) ──────────
mes:
uiws:
auth:
twofa-enabled: ${UIWS_2FA:true} # off=기존 단일로그인 회귀 0
verify-token-validity-seconds: 300 # 1차 통과 후 verify-token 5분
email-code-validity-seconds: 300 # 이메일 인증코드 5분
max-login-fail: 5 # 실패 5회 누적 시 계정 잠금
mail:
mode: ${UIWS_MAIL_MODE:log} # LogMailSender 폴백(외부 API 0). 운영 smtp 시 별도 빈
upload:
upload-dir: ${UIWS_UPLOAD_DIR:./uploads/uiws}
# ── AI 로컬 학습 저장소(임베디드 DuckDB) — 피드백/추론 로그. 미가용 시 조용히 비활성(AI 기능 무영향) ──
ai:
duckdb-path: ${MES_DUCKDB_PATH:/opt/guardia-mes/data/mes_learning.duckdb}
guardia:
erp-url: ${ERP_URL:http://localhost:8003}
itsm-url: ${ITSM_URL:http://localhost:9001}
ocr-url: ${OCR_URL:http://localhost:8005}
bi-url: ${BI_URL:http://localhost:8006}
# 중앙 guardia-rag (최신 AI 기법: 하이브리드/그래프/리랭크 검색·에이전틱 tool-use·구조화·스트리밍)
# 보안 불변: 온프레미스 루프백 전용. 미가용/타임아웃 시 MES 결정론 로컬 폴백(degraded:true).
rag:
base-url: ${RAG_URL:http://127.0.0.1:8020}
timeout-ms: ${RAG_TIMEOUT_MS:120000}
enabled: ${RAG_ENABLED:true}
solution: mes
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지.
# (예외: Claude 는 소유자 승인으로 api.anthropic.com 단일 경로 허용 — 키는 ANTHROPIC_API_KEY env 로만 주입)
ollama-url: ${OLLAMA_URL:http://localhost:11434}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3}
ollama-vision-model: ${OLLAMA_VISION_MODEL:llava}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
ollama-vision-model: ${OLLAMA_VISION_MODEL:moondream}
crypto:
secret: ${MES_CRYPTO_SECRET:guardia-mes-aes-256-gcm-master-key-2026-zioinfo}
jwt:

View File

@ -0,0 +1,30 @@
-- =====================================================================
-- GUARDiA MES — 105. AI provider flip: ollama → claude (멱등 UPSERT)
-- 대상 DB : mes_db (테이블 mes_setting, PK=key)
-- 적용 : application.yml spring.sql.init.schema-locations 에 104 시드 '이후' 등재.
-- mode:always + continue-on-error 재실행 안전.
-- 설명 : 104_seed 는 ai.provider=ollama 를 ON CONFLICT DO NOTHING 으로 시드하므로,
-- 이미 시드된 운영 DB 에서는 provider 가 ollama 로 고정된다.
-- 본 파일이 104 '이후' 실행되어 provider 를 claude 로 flip(UPSERT)한다.
-- 행 부재 시 INSERT, 존재 시 UPDATE → 신규/기존 DB 모두 결정적으로 claude.
-- 실효 : AiConfigService.isClaudeActive() = provider=claude · ANTHROPIC_API_KEY(env) SET · ai.enabled.
-- 키 미설정/Claude 실패 시 AiTextRouter 가 Ollama 자동 폴백(무중단·무회귀).
-- 보안 : 키/시크릿/IP/비밀번호 일절 미포함. 키는 서버 env(ANTHROPIC_API_KEY)로만 주입.
-- 멱등 : INSERT ... ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value — 재실행 안전.
-- =====================================================================
SET client_encoding = 'UTF8';
-- mes_setting 이 (104 미적용 환경에서도) 존재하도록 멱등 보장.
CREATE TABLE IF NOT EXISTS mes_setting (
key VARCHAR(200) PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT NOW()
);
-- provider 를 claude 로 flip (신규=INSERT, 기존=UPDATE). 결정적 멱등.
INSERT INTO mes_setting (key, value, updated_at) VALUES
('ai.provider', 'claude', NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW();
-- end 105_ai_provider_claude.sql

View File

@ -41,6 +41,10 @@
UPDATE mes_user SET password_hash = #{passwordHash} WHERE id = #{id}
</update>
<update id="clearOtp">
UPDATE mes_user SET otp_secret = NULL, otp_enabled = false WHERE id = #{id}
</update>
<delete id="deleteById">
DELETE FROM mes_user WHERE id = #{id}
</delete>

View File

@ -8,7 +8,7 @@
COALESCE(SUM(good_qty),0) AS total_good,
COALESCE(SUM(defect_qty),0) AS total_defect,
ROUND( (COALESCE(SUM(defect_qty),0)::numeric
/ NULLIF(SUM(good_qty)+SUM(defect_qty),0) * 100), 2) AS defect_rate,
/ NULLIF(SUM(good_qty)+SUM(defect_qty),0) * 100)::numeric, 2) AS defect_rate,
(SELECT COUNT(*) FROM mes_workorder
WHERE status IN ('DONE','CLOSED')
AND updated_at &gt;= CURRENT_DATE - (#{days} || ' days')::interval) AS completed_workorders

View File

@ -11,10 +11,21 @@
<result property="role" column="role"/>
<result property="active" column="is_active"/>
<result property="createdAt" column="created_at"/>
<!-- UIWS 2FA 이식 컬럼 (mes_user ALTER, db/91_uiws_port.sql) -->
<result property="email" column="email"/>
<result property="emailVerifyCode" column="email_verify_code"/>
<result property="emailVerifyExpire" column="email_verify_expire"/>
<result property="loginFailCount" column="login_fail_count"/>
<result property="locked" column="locked"/>
<result property="otpSecret" column="otp_secret"/>
<result property="otpEnabled" column="otp_enabled"/>
<!-- 로그인 보조 이식: 회원가입 승인 게이트 -->
<result property="approved" column="approved"/>
</resultMap>
<select id="findByUsername" resultMap="userMap">
SELECT id, username, password_hash, display_name, role, is_active, created_at
SELECT id, username, password_hash, display_name, role, is_active, created_at,
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
FROM mes_user
WHERE username = #{username}
</select>
@ -25,4 +36,48 @@
VALUES (#{username}, #{passwordHash}, #{displayName}, #{role}, #{active})
</insert>
<!-- 2FA 실패카운트/잠금/인증코드 갱신은 UserMapper.java 의 @Update 어노테이션에 정의(중복 금지). -->
<!-- ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 ─────────────── -->
<select id="countByUsername" resultType="int">
SELECT COUNT(*) FROM mes_user WHERE username = #{username}
</select>
<select id="countByEmail" resultType="int">
SELECT COUNT(*) FROM mes_user WHERE email = #{email}
</select>
<!-- 회원가입(승인 대기): role=VIEWER·is_active=true·approved=false·실패카운트0·미잠금 고정 -->
<insert id="signup" parameterType="com.zioinfo.mes.auth.MesUser"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO mes_user (username, password_hash, display_name, role, email,
is_active, approved, login_fail_count, locked)
VALUES (#{username}, #{passwordHash}, #{displayName}, 'VIEWER', #{email},
true, false, 0, false)
</insert>
<select id="findByDisplayNameAndEmail" resultMap="userMap">
SELECT id, username, password_hash, display_name, role, is_active, created_at,
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
FROM mes_user
WHERE display_name = #{displayName} AND email = #{email}
ORDER BY id
LIMIT 1
</select>
<select id="findByUsernameAndEmail" resultMap="userMap">
SELECT id, username, password_hash, display_name, role, is_active, created_at,
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
FROM mes_user
WHERE username = #{username} AND email = #{email}
</select>
<!-- 임시비번 적용 + 잠금/실패카운트 해제(초기화 시) -->
<update id="updatePasswordHash">
UPDATE mes_user
SET password_hash = #{passwordHash}, locked = false, login_fail_count = 0
WHERE username = #{username}
</update>
</mapper>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -3,9 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>GUARDiA MES — AI 제조실행시스템</title>
<script type="module" crossorigin src="/assets/index-BLHiicbF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwkEm3xg.css">
<script type="module" crossorigin src="/assets/index-CesirnPf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bw9GVsq6.css">
</head>
<body>
<div id="root"></div>

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>GUARDiA MES — AI 제조실행시스템</title>
</head>
<body>

View File

@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import Layout from './components/Layout'
import ProtectedRoute from './components/ProtectedRoute'
import Login from './pages/Login'
import MyPage from './pages/MyPage'
import Dashboard from './pages/Dashboard'
// MES
import Jobs from './pages/Jobs'
@ -35,9 +36,24 @@ import Warehouses from './pages/Warehouses'
// 공통
import Analytics from './pages/Analytics'
import AiTools from './pages/AiTools'
import RagSettings from './pages/RagSettings'
import AiPlatformSettings from './pages/AiPlatformSettings'
import UserManagement from './pages/UserManagement'
import AuditLog from './pages/AuditLog'
import SystemSettings from './pages/SystemSettings'
import MobileApp from './pages/MobileApp'
// 업무 (UIWS 이식) — 업무일지/일정/쪽지/업무통계
import UiwsWorklog from './pages/uiws/WorklogList'
import UiwsSchedule from './pages/uiws/ScheduleCalendar'
import UiwsMessage from './pages/uiws/MessageBox'
import UiwsStats from './pages/uiws/StatsPivot'
// 시스템관리(권한) (UIWS 이식) — 역할/역할-메뉴권한/공통코드/메뉴/부서/거래처
import SysRoles from './pages/uiws/system/RoleManagement'
import SysRoleMenu from './pages/uiws/system/RoleMenuPermission'
import SysCodes from './pages/uiws/system/CodeManagement'
import SysMenus from './pages/uiws/system/MenuManagement'
import SysDepts from './pages/uiws/system/DeptManagement'
import SysCompanies from './pages/uiws/system/CompanyManagement'
const qc = new QueryClient()
@ -49,6 +65,7 @@ export default function App() {
<Route path="/login" element={<Login />} />
<Route element={<Layout />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/mypage" element={<MyPage />} />
{/* MES */}
<Route path="/jobs" element={<Jobs />} />
<Route path="/progress" element={<Progress />} />
@ -82,6 +99,36 @@ export default function App() {
{/* 공통 */}
<Route path="/analytics" element={<Analytics />} />
<Route path="/ai-tools" element={<AiTools />} />
<Route path="/ai-techniques" element={
<ProtectedRoute min="MANAGER"><RagSettings /></ProtectedRoute>
} />
<Route path="/admin/ai-platform" element={
<ProtectedRoute min="SUPERADMIN"><AiPlatformSettings /></ProtectedRoute>
} />
{/* 업무 (UIWS 이식) — 기존 라우트 보존, 추가만 */}
<Route path="/uiws/worklog" element={<UiwsWorklog />} />
<Route path="/uiws/schedule" element={<UiwsSchedule />} />
<Route path="/uiws/message" element={<UiwsMessage />} />
<Route path="/uiws/stats" element={<UiwsStats />} />
{/* 시스템관리(권한) (UIWS 이식) — SUPERADMIN 전용, 기존 라우트 보존·추가만 */}
<Route path="/uiws/system/roles" element={
<ProtectedRoute min="SUPERADMIN"><SysRoles /></ProtectedRoute>
} />
<Route path="/uiws/system/role-menus" element={
<ProtectedRoute min="SUPERADMIN"><SysRoleMenu /></ProtectedRoute>
} />
<Route path="/uiws/system/codes" element={
<ProtectedRoute min="SUPERADMIN"><SysCodes /></ProtectedRoute>
} />
<Route path="/uiws/system/menus" element={
<ProtectedRoute min="SUPERADMIN"><SysMenus /></ProtectedRoute>
} />
<Route path="/uiws/system/depts" element={
<ProtectedRoute min="SUPERADMIN"><SysDepts /></ProtectedRoute>
} />
<Route path="/uiws/system/companies" element={
<ProtectedRoute min="SUPERADMIN"><SysCompanies /></ProtectedRoute>
} />
{/* 관리자 */}
<Route path="/admin/users" element={
<ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute>
@ -92,6 +139,7 @@ export default function App() {
<Route path="/admin/settings" element={
<ProtectedRoute min="MANAGER"><SystemSettings /></ProtectedRoute>
} />
<Route path="/admin/mobile-app" element={<MobileApp />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>

View File

@ -35,6 +35,15 @@ export const login = (username: string, password: string) =>
api.post('/api/mes/auth/login', { username, password })
export const getMe = () => api.get('/api/mes/auth/me')
// ── 2FA / OTP / 계정 보안 (access 토큰 필요, prefix /api/mes/auth) ──────
// 마이페이지 OTP 등록/재설정/해제 + 비밀번호 변경.
// 보안 불변: setup 응답(secret/qrImage)은 화면 표시용만 — 로그/저장 금지.
export const otpSetup = () => api.post('/api/mes/auth/otp/setup')
export const otpConfirm = (code: string) => api.post('/api/mes/auth/otp/confirm', { code })
export const otpDisable = () => api.post('/api/mes/auth/otp/disable')
export const changePassword = (currentPassword: string, newPassword: string) =>
api.post('/api/mes/auth/change-password', { currentPassword, newPassword })
// ── Dashboard ─────────────────────────────────────────────────────────
export const getDashboardSummary = () => unwrap(api.get('/api/mes/dashboard/summary'))
export const getWorkorderStatus = () => unwrap(api.get('/api/mes/dashboard/workorder-status'))
@ -300,6 +309,22 @@ export const aiInspectionJudge = (d: object) => unwrap(api.post('/api/mes/ai/ins
export const aiSafetyStock = (d: object) => unwrap(api.post('/api/mes/ai/safety-stock', d))
export const aiScheduleOptimize = (d: object) => unwrap(api.post('/api/mes/ai/schedule-optimize', d))
// ── RAG 최신 AI 기법 (중앙 guardia-rag 경유, 별개 레이어 — 항상 200, 폴백 degraded) ──
// 불량 RCA + SPC 이상감지: SPC 수치는 결정론, 원인 서술은 /rag/agent(+structured)
export const ragDefectAnalysis = (d: object) => unwrap(api.post('/api/mes/rag/defect-analysis', d))
// 설비 예지보전 + 수요/생산 예측: 수치는 결정론 베이스라인, 해석은 /rag/agent
export const ragPredictAnalysis = (d: object) => unwrap(api.post('/api/mes/rag/predict-analysis', d))
// 👍/👎 피드백 (solution=mes 격리)
export const ragFeedback = (d: object) => unwrap(api.post('/api/mes/rag/feedback', d))
// 기법 토글 스냅샷 / 변경(MANAGER+)
export const getRagToggles = () => unwrap(api.get('/api/mes/rag/toggles'))
export const updateRagToggle = (key: string, value: any) =>
unwrap(api.put(`/api/mes/rag/toggles/${key}`, { value }))
// ── AI 피드백 (로컬 DuckDB 학습저장소 + 중앙 rag(8020) 전달, 인증 사용자 전체) ──
export const postAiFeedback = (d: { feature: string; question?: string; answer?: string; verdict: 'up' | 'down'; correction?: string }) =>
unwrap(api.post('/api/ai/feedback', d))
// ── Admin (SUPERADMIN / 감사로그·설정 GET 은 MANAGER+) ────────────────
export const getUsers = () => api.get('/api/admin/users')
export const createUser = (data: { username: string; password: string; displayName?: string; role?: string }) =>
@ -308,6 +333,8 @@ export const updateUserRole = (id: number, role: string) => api.put(`/api/admin/
export const updateUserActive = (id: number, active: boolean) => api.put(`/api/admin/users/${id}/active`, { active })
export const resetPassword = (id: number, password: string) => api.put(`/api/admin/users/${id}/password`, { password })
export const deleteUser = (id: number) => api.delete(`/api/admin/users/${id}`)
// 관리자 OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 시 재등록). 시크릿 미조회.
export const adminOtpReset = (id: number) => api.post(`/api/admin/users/${id}/otp-reset`)
export const getAuditLogs = (action = '', actor = '', limit = 200) =>
api.get(`/api/admin/audit${qs({ action, actor, limit })}`)
export const getSettings = () => api.get('/api/admin/settings')

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, Link } from 'react-router-dom'
import { LogOut, UserCircle } from 'lucide-react'
import { getAiStatus } from '../api/client'
@ -30,10 +30,10 @@ export default function Header() {
<span className={`w-1.5 h-1.5 rounded-full ${ollama ? 'bg-accent' : 'bg-slate-500'}`} />
AI {ollama === null ? '확인 중' : ollama ? '온라인' : '폴백'}
</span>
<span className="flex items-center gap-1.5 text-sm text-slate-300">
<Link to="/mypage" className="flex items-center gap-1.5 text-sm text-slate-300 hover:text-brand" title="마이페이지">
<UserCircle size={18} /> {user}
{role && <span className="text-[11px] text-slate-500">({role})</span>}
</span>
</Link>
<button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand">
<LogOut size={16} />
</button>

View File

@ -5,9 +5,11 @@ import {
Gauge, Network, CalendarRange, PackagePlus, Truck, Boxes, ClipboardCheck, Barcode,
ScanSearch, Ruler, AlertTriangle, ListChecks, LineChart, FileBadge, Package, ListTree,
Route as RouteIcon, Building2, Warehouse, BarChart3, Sparkles, ShieldCheck, ScrollText,
Settings, Cpu,
Settings, Cpu, NotebookPen, CalendarDays, Mail, PieChart, Smartphone,
KeyRound, ShieldHalf, ListTodo, Menu as MenuIcon, Users2, Contact,
} from 'lucide-react'
import { currentRole, hasRole } from './rbac'
import ThemeToggle from './uiws/ThemeToggle'
interface Link { to: string; label: string; icon: any; min?: string }
@ -48,13 +50,35 @@ const masterLinks: Link[] = [
]
const commonLinks: Link[] = [
{ to: '/analytics', label: '분석 / KPI', icon: BarChart3 },
{ to: '/ai-tools', label: 'AI 도구', icon: Sparkles },
{ to: '/ai-tools', label: 'WISE AI', icon: Sparkles },
{ to: '/ai-techniques', label: 'AI 기법 설정', icon: Cpu, min: 'MANAGER' },
]
// 업무 (UIWS 이식) — 업무일지/일정/쪽지/업무통계
const uiwsLinks: Link[] = [
{ to: '/uiws/worklog', label: '업무일지', icon: NotebookPen },
{ to: '/uiws/schedule', label: '일정', icon: CalendarDays },
{ to: '/uiws/message', label: '쪽지', icon: Mail },
{ to: '/uiws/stats', label: '업무통계', icon: PieChart },
]
// 시스템관리(권한) (UIWS 이식) — SUPERADMIN 전용
const sysLinks: Link[] = [
{ to: '/uiws/system/roles', label: '권한 관리', icon: KeyRound, min: 'SUPERADMIN' },
{ to: '/uiws/system/role-menus', label: '역할-메뉴 권한', icon: ShieldHalf, min: 'SUPERADMIN' },
{ to: '/uiws/system/codes', label: '공통코드', icon: ListTodo, min: 'SUPERADMIN' },
{ to: '/uiws/system/menus', label: '메뉴 관리', icon: MenuIcon, min: 'SUPERADMIN' },
{ to: '/uiws/system/depts', label: '부서 관리', icon: Users2, min: 'SUPERADMIN' },
{ to: '/uiws/system/companies', label: '거래처 관리', icon: Contact, min: 'SUPERADMIN' },
]
const adminLinks: Link[] = [
{ to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' },
{ to: '/admin/ai-platform', label: 'AI 플랫폼 설정', icon: Cpu, min: 'SUPERADMIN' },
{ to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'MANAGER' },
{ to: '/admin/settings', label: '시스템 설정', icon: Settings, min: 'MANAGER' },
]
// 통합 메신저 앱(읽기전용 QR 설치) — 인증 사용자 전체
const mobileLinks: Link[] = [
{ to: '/admin/mobile-app', label: '모바일 앱 설치', icon: Smartphone },
]
const linkClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 px-5 py-1.5 text-sm transition-colors ${
@ -93,6 +117,7 @@ export default function Sidebar() {
return () => { window.clearInterval(t); window.removeEventListener('storage', sync) }
}, [])
const visibleSys = sysLinks.filter(l => hasRole(l.min || 'VIEWER', role))
const visibleAdmin = adminLinks.filter(l => hasRole(l.min || 'VIEWER', role))
return (
@ -111,13 +136,25 @@ export default function Sidebar() {
<Section title="QMS 품질관리" links={qmsLinks} role={role} />
<Section title="기준정보" links={masterLinks} role={role} />
<Section title="공통" links={commonLinks} role={role} />
<Section title="업무 (UIWS)" links={uiwsLinks} role={role} />
<div className="border-t border-edge mt-3">
<Section title="모바일" links={mobileLinks} role={role} />
</div>
{visibleSys.length > 0 && (
<div className="border-t border-edge mt-3">
<Section title="시스템관리(권한)" links={visibleSys} role={role} />
</div>
)}
{visibleAdmin.length > 0 && (
<div className="border-t border-edge mt-3">
<Section title="관리자" links={visibleAdmin} role={role} />
</div>
)}
</nav>
<div className="p-4 text-[11px] text-slate-500 border-t border-edge">
<div className="p-3 border-t border-edge">
<ThemeToggle />
</div>
<div className="px-4 pb-4 text-[11px] text-slate-500">
Ollama · WMS·MES·QMS
</div>
</aside>

View File

@ -2,9 +2,13 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
import './theme/theme.css'
import { ThemeProvider } from './theme/ThemeContext'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode>
)

View File

@ -1,13 +1,117 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import {
Sparkles, AlertTriangle, TrendingUp, Wrench, LineChart, Search, Boxes, CalendarClock,
Cpu, ThumbsUp, ThumbsDown, Quote,
} from 'lucide-react'
import { Card, Btn, Field } from '../components/ui'
import {
getAiStatus, aiDefectRootCause, aiForecast, aiPredictiveMaintenance,
aiSpcAnomaly, aiParseQuery, aiSafetyStock, aiScheduleOptimize,
ragDefectAnalysis, ragPredictAnalysis, ragFeedback, postAiFeedback,
} from '../api/client'
/** 적용된 기법 배지 — 응답의 applied metadata 를 시각화(토글 effect 관측). */
function AppliedBadge({ data }: { data: any }) {
const a = data?.applied
if (!a) return null
const chips: string[] = [`mode:${a.retrievalMode}`, `기법:${a.technique}`]
if (a.rerank) chips.push('rerank')
if (a.graphrag) chips.push('graphrag')
if (a.hybrid) chips.push('hybrid')
if (a.toolUse) chips.push(`agent(${a.maxSteps})`)
if (a.structured) chips.push('structured')
if (a.stream) chips.push('stream')
if (data?.degraded) chips.push('⚠ degraded(폴백)')
return (
<div className="mt-2 flex flex-wrap gap-1">
{chips.map((c, i) => (
<span key={i} className={`text-[10px] px-1.5 py-0.5 rounded border ${c.startsWith('⚠') ? 'bg-amber-500/15 text-amber-300 border-amber-500/30' : 'bg-brand/10 text-brand border-brand/30'}`}>{c}</span>
))}
</div>
)
}
/** 👍/👎 피드백 (중앙 /rag/feedback, solution=mes 격리). answerId 가 있을 때만 노출. */
function FeedbackBar({ answerId, query }: { answerId?: string; query?: string }) {
const [done, setDone] = useState('')
if (!answerId) return null
const send = async (verdict: 'up' | 'down') => {
try { await ragFeedback({ answerId, query, verdict }); setDone(verdict === 'up' ? '👍 반영됨' : '👎 반영됨') } catch { setDone('전송 실패') }
}
return (
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
<span> ?</span>
<button onClick={() => send('up')} className="p-1 rounded hover:bg-brand/10 hover:text-brand"><ThumbsUp size={13} /></button>
<button onClick={() => send('down')} className="p-1 rounded hover:bg-rose-500/10 hover:text-rose-300"><ThumbsDown size={13} /></button>
{done && <span className="text-brand">{done}</span>}
</div>
)
}
/** 👍/👎 로컬 학습 피드백 (POST /api/ai/feedback → 로컬 DuckDB + 중앙 rag). 결과가 있을 때만 노출. */
function LocalFeedbackBar({ feature, question, answer }: { feature: string; question?: string; answer?: any }) {
const [done, setDone] = useState('')
if (answer == null) return null
const ans = typeof answer === 'string' ? answer : JSON.stringify(answer)
const send = async (verdict: 'up' | 'down') => {
try { await postAiFeedback({ feature, question, answer: ans, verdict }); setDone(verdict === 'up' ? '👍 반영됨' : '👎 반영됨') }
catch { setDone('전송 실패') }
}
return (
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
<span> ?</span>
<button onClick={() => send('up')} className="p-1 rounded hover:bg-brand/10 hover:text-brand"><ThumbsUp size={13} /></button>
<button onClick={() => send('down')} className="p-1 rounded hover:bg-rose-500/10 hover:text-rose-300"><ThumbsDown size={13} /></button>
{done && <span className="text-brand">{done}</span>}
</div>
)
}
/** 인용 라벨 — 문서명·위치·근거지지도. citation/source 객체 형태 방어적 처리. */
function citeLabel(c: any): string {
if (c == null) return '문서'
if (typeof c === 'string') return c
const src = c.source || c.document || c.doc || c.title || c.chunk_id || c.id || '문서'
const loc = c.page != null ? ` p.${c.page}` : (c.location ? ` ${c.location}` : '')
const sup = c.support != null ? ` · ${Math.round(Number(c.support) * 100)}%` : ''
return `${src}${loc}${sup}`
}
/**
* WISE UX (sources/citations) + (abstained) .
* node = defect / interpretation . · .
*/
function WiseEvidence({ node }: { node: any }) {
if (node == null) return null
const citations: any[] = Array.isArray(node.citations) ? node.citations : []
const sources: any[] = Array.isArray(node.sources) ? node.sources : []
const items = citations.length ? citations : sources
const abstained = node.abstained === true
if (!abstained && items.length === 0) return null
return (
<div className="mt-3">
{abstained && (
<div className="mb-2 flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-300">
<AlertTriangle size={13} /> ( · )
</div>
)}
{items.length > 0 ? (
<>
<div className="text-[11px] text-slate-400 mb-1.5 flex items-center gap-1"><Quote size={12} /> ({items.length})</div>
<div className="flex flex-wrap gap-1.5">
{items.map((c, i) => (
<span key={i} className="text-[10px] px-2 py-1 rounded-md bg-card border border-edge text-slate-300">{citeLabel(c)}</span>
))}
</div>
</>
) : (
<div className="text-[11px] text-slate-500"> </div>
)}
</div>
)
}
function Output({ data }: { data: any }) {
if (data == null) return null
if (typeof data === 'string') return <pre className="mt-3 text-xs text-slate-300 bg-ink border border-edge rounded p-3 whitespace-pre-wrap max-h-56 overflow-auto">{data}</pre>
@ -22,8 +126,8 @@ export default function AiTools() {
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold flex items-center gap-2"><Sparkles className="text-accent" size={20} /> AI </h1>
<p className="text-sm text-slate-400 mt-0.5"> · · · SPC이상 · · · (Ollama + Java )</p>
<h1 className="text-xl font-bold flex items-center gap-2"><Sparkles className="text-accent" size={20} /> WISE AI</h1>
<p className="text-sm text-slate-400 mt-0.5">Enterprise AI for Trusted Knowledge · ··( guardia-rag) + ···SPC이상 (Ollama + Java )</p>
</div>
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border ${ollama ? 'text-accent border-accent/30 bg-accent/10' : 'text-slate-400 border-edge bg-card'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${ollama ? 'bg-accent' : 'bg-slate-500'}`} />
@ -31,6 +135,17 @@ export default function AiTools() {
</span>
</div>
{/* ── 최신 기법 (중앙 guardia-rag 경유) — 대표 2개 기능 ───────────── */}
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold flex items-center gap-2 text-brand"><Cpu size={15} /> WISE AI · (RAG · · )</h2>
<Link to="/ai-techniques" className="text-xs text-slate-400 hover:text-brand underline"> </Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 mb-8">
<RagDefectTool /><RagPredictTool />
</div>
{/* ── 기존 AI 도구 (불변, Ollama + Java 폴백) ───────────────────── */}
<h2 className="text-sm font-semibold text-slate-300 mb-3"> AI </h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<DefectTool /><ForecastTool /><MaintenanceTool /><SpcTool />
<QueryTool /><SafetyStockTool /><ScheduleTool />
@ -39,6 +154,87 @@ export default function AiTools() {
)
}
/** 대표 1: 불량 원인분석 + SPC 이상감지 (SPC 수치=결정론, 원인서술=/rag/agent+structured). */
function RagDefectTool() {
const [code, setCode] = useState(''); const [ctx, setCtx] = useState('')
const [values, setValues] = useState(''); const [ucl, setUcl] = useState(''); const [lcl, setLcl] = useState(''); const [cl, setCl] = useState('')
const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
const run = async () => {
setBusy(true)
try {
const vals = values.split(',').map(s => s.trim()).filter(Boolean).map(Number)
const req: any = { defectCode: code, context: ctx ? [{ note: ctx }] : [] }
if (vals.length) { req.values = vals; req.ucl = Number(ucl) || 0; req.lcl = Number(lcl) || 0; req.cl = Number(cl) || 0 }
setOut(await ragDefectAnalysis(req))
} finally { setBusy(false) }
}
return (
<Card title="불량 원인분석 + SPC 이상감지 (RAG)" icon={<AlertTriangle size={15} />}>
<Field label="불량 코드"><input className="inp" value={code} onChange={e => setCode(e.target.value)} placeholder="예: D-DIM-01" /></Field>
<Field label="컨텍스트(공정/설비/자재)"><textarea className="inp" value={ctx} onChange={e => setCtx(e.target.value)} /></Field>
<Field label="SPC 표본값(쉼표구분, 선택)"><input className="inp" value={values} onChange={e => setValues(e.target.value)} placeholder="예: 10.1, 10.3, 9.8, 10.5" /></Field>
<div className="grid grid-cols-3 gap-2">
<Field label="UCL"><input className="inp" value={ucl} onChange={e => setUcl(e.target.value)} /></Field>
<Field label="LCL"><input className="inp" value={lcl} onChange={e => setLcl(e.target.value)} /></Field>
<Field label="CL"><input className="inp" value={cl} onChange={e => setCl(e.target.value)} /></Field>
</div>
<Btn onClick={run} disabled={busy} size="sm"><Cpu size={13} /> {busy ? '분석 중…' : 'RCA + SPC 분석'}</Btn>
<AppliedBadge data={out} />
<Output data={out} />
<WiseEvidence node={out?.defect} />
<FeedbackBar answerId={out?.defect?.answerId} query={code} />
</Card>
)
}
/** 대표 2: 설비 예지보전 / 수요·생산 예측 (수치=결정론 베이스라인, 해석=/rag/agent). */
function RagPredictTool() {
const [kind, setKind] = useState<'pdm' | 'forecast'>('forecast')
const [eq, setEq] = useState(''); const [avail, setAvail] = useState(''); const [dt, setDt] = useState(''); const [mtbf, setMtbf] = useState('')
const [series, setSeries] = useState(''); const [horizon, setHorizon] = useState(7)
const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
const run = async () => {
setBusy(true)
try {
const req: any = { kind }
if (kind === 'pdm') { req.equipmentCode = eq; req.availability = Number(avail) || 0; req.downtimeCount = Number(dt) || 0; req.mtbfHours = Number(mtbf) || 0 }
else { req.series = series.split(',').map(s => s.trim()).filter(Boolean).map(Number); req.horizon = horizon }
setOut(await ragPredictAnalysis(req))
} finally { setBusy(false) }
}
return (
<Card title="예지보전 / 수요·생산 예측 (RAG)" icon={<TrendingUp size={15} />}>
<div className="flex gap-2 mb-2">
{(['forecast', 'pdm'] as const).map(k => (
<button key={k} onClick={() => setKind(k)} className={`px-3 py-1.5 text-xs rounded-lg border ${kind === k ? 'bg-brand text-ink border-brand' : 'bg-card border-edge text-slate-300'}`}>
{k === 'forecast' ? '수요/생산 예측' : '설비 예지보전'}
</button>
))}
</div>
{kind === 'pdm' ? (
<>
<Field label="설비 코드"><input className="inp" value={eq} onChange={e => setEq(e.target.value)} placeholder="예: EQ-CNC-01" /></Field>
<div className="grid grid-cols-3 gap-2">
<Field label="가동률(0~1)"><input className="inp" value={avail} onChange={e => setAvail(e.target.value)} /></Field>
<Field label="비가동 횟수"><input className="inp" value={dt} onChange={e => setDt(e.target.value)} /></Field>
<Field label="MTBF(시간)"><input className="inp" value={mtbf} onChange={e => setMtbf(e.target.value)} /></Field>
</div>
</>
) : (
<>
<Field label="시계열(쉼표구분)"><input className="inp" value={series} onChange={e => setSeries(e.target.value)} placeholder="예: 100, 120, 95, 130" /></Field>
<Field label="예측 기간(일)"><input type="number" className="inp" value={horizon} onChange={e => setHorizon(Number(e.target.value))} /></Field>
</>
)}
<Btn onClick={run} disabled={busy} size="sm"><Cpu size={13} /> {busy ? '분석 중…' : '예측 + 해석'}</Btn>
<AppliedBadge data={out} />
<Output data={out} />
<WiseEvidence node={out?.interpretation} />
<FeedbackBar answerId={out?.interpretation?.answerId} query={kind} />
</Card>
)
}
function DefectTool() {
const [code, setCode] = useState(''); const [ctx, setCtx] = useState(''); const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
const run = async () => { setBusy(true); try { setOut(await aiDefectRootCause({ defectCode: code, context: ctx })) } finally { setBusy(false) } }
@ -48,6 +244,7 @@ function DefectTool() {
<Field label="컨텍스트(공정/설비/자재)"><textarea className="inp" value={ctx} onChange={e => setCtx(e.target.value)} /></Field>
<Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '분석 중…' : '원인 분석'}</Btn>
<Output data={out} />
<LocalFeedbackBar feature="defect-root-cause" question={code} answer={out} />
</Card>
)
}
@ -99,6 +296,7 @@ function QueryTool() {
<Field label="자연어 질의"><input className="inp" value={q} onChange={e => setQ(e.target.value)} placeholder="예: 지난주 지연된 작업지시" onKeyDown={e => e.key === 'Enter' && run()} /></Field>
<Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '해석 중…' : '질의 해석'}</Btn>
<Output data={out} />
<LocalFeedbackBar feature="parse-query" question={q} answer={out} />
</Card>
)
}

View File

@ -1,21 +1,83 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Cpu } from 'lucide-react'
import { Cpu, ShieldCheck } from 'lucide-react'
import { login, getMe } from '../api/client'
import { verify2fa, verifyOtp, signup, findId, resetPassword } from '../api/uiws'
type HelperMode = 'signup' | 'find-id' | 'reset-password'
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
/**
* GUARDiA MES UIWS 2FA + Authenticator(OTP) .
* 1 login twofa="true" verifyMethod 2 (verifyToken ):
* · OTP : Authenticator 6 /api/mes/auth/verify-otp
* · OTP_SETUP : QR(qrImage)+(secret) 6 /verify-otp ( )
* · EMAIL(): 6 /api/mes/auth/verify ()
* twofa!="true"(off) ( 0). ·릿·QR / .
*/
export default function Login() {
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const [step, setStep] = useState<'login' | 'verify'>('login')
const [verifyToken, setVerifyToken] = useState('')
const [verifyMethod, setVerifyMethod] = useState<VerifyMethod>('EMAIL')
const [maskedEmail, setMaskedEmail] = useState('')
const [qrImage, setQrImage] = useState('') // OTP_SETUP 등록 순간만 존재
const [secret, setSecret] = useState('') // OTP_SETUP 등록 순간만 존재
const [code, setCode] = useState('')
const nav = useNavigate()
const submit = async (e: React.FormEvent) => {
const isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
const isSetup = verifyMethod === 'OTP_SETUP'
// ── 로그인 보조 3종(회원가입/아이디찾기/비밀번호초기화) 모달 상태 ──────────────
const [helper, setHelper] = useState<HelperMode | null>(null)
const [hForm, setHForm] = useState({ username: '', password: '', displayName: '', email: '' })
const [hBusy, setHBusy] = useState(false)
const [hMsg, setHMsg] = useState('') // 결과 안내(성공/실패 공통)
const [hErr, setHErr] = useState(false) // 메시지 톤(에러 여부)
const openHelper = (mode: HelperMode) => {
setHelper(mode); setHForm({ username: '', password: '', displayName: '', email: '' })
setHMsg(''); setHErr(false)
}
const closeHelper = () => { setHelper(null); setHMsg(''); setHErr(false) }
// 보조 기능 제출 — 응답 봉투 {success,message,data}. 임시비번/존재여부는 서버가 메시지로만 안내.
const submitHelper = async (e: React.FormEvent) => {
e.preventDefault()
setErr(''); setBusy(true)
setHBusy(true); setHMsg(''); setHErr(false)
try {
const res = await login(username, password)
const token = res.data?.data?.token
let res
if (helper === 'signup') {
res = await signup({ username: hForm.username.trim(), password: hForm.password,
displayName: hForm.displayName.trim() || undefined, email: hForm.email.trim() })
const d = res.data?.data
setHErr(!d?.success); setHMsg(d?.message || '처리되었습니다.')
} else if (helper === 'find-id') {
res = await findId({ displayName: hForm.displayName.trim(), email: hForm.email.trim() })
const d = res.data?.data
if (d?.found) { setHErr(false); setHMsg(`회원님의 아이디는 [${d.maskedUsername}] 입니다.`) }
else { setHErr(true); setHMsg('일치하는 계정을 찾을 수 없습니다. 이름과 이메일을 확인하세요.') }
} else if (helper === 'reset-password') {
res = await resetPassword({ username: hForm.username.trim(), email: hForm.email.trim() })
const d = res.data?.data
setHErr(!d?.success); setHMsg(d?.message || '요청이 처리되었습니다.')
}
} catch {
setHErr(true); setHMsg('요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도하세요.')
} finally {
setHBusy(false)
}
}
const helperTitle = helper === 'signup' ? '회원가입(승인 대기)'
: helper === 'find-id' ? '아이디 찾기' : '비밀번호 초기화'
// access 토큰 저장 + getMe 로 role/username 보관(Sidebar RBAC 가드 의존) → 대시보드 이동
const finish = async (token?: string) => {
if (!token) throw new Error('no token')
localStorage.setItem('mes_token', token)
try {
@ -25,6 +87,26 @@ export default function Login() {
if (d.username) localStorage.setItem('mes_user', d.username)
} catch { /* noop */ }
nav('/dashboard')
}
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setErr(''); setBusy(true)
try {
const res = await login(username, password)
const data = res.data?.data || {}
if (data.twofa === 'true') {
// 2단계: verify-token 보관 후 verifyMethod 로 분기(OTP/OTP_SETUP/EMAIL)
setVerifyToken(data.verifyToken)
setVerifyMethod((data.verifyMethod as VerifyMethod) || 'EMAIL')
setMaskedEmail(data.maskedEmail || '')
setQrImage(data.qrImage || '')
setSecret(data.secret || '')
setCode('')
setStep('verify')
return
}
await finish(data.token)
} catch {
setErr('로그인 실패 — 아이디/비밀번호를 확인하세요.')
} finally {
@ -32,26 +114,168 @@ export default function Login() {
}
}
const submitCode = async (e: React.FormEvent) => {
e.preventDefault()
setErr(''); setBusy(true)
try {
// OTP·OTP_SETUP 은 /verify-otp, 이메일은 /verify(하위호환)
const res = isOtp
? await verifyOtp(verifyToken, code.trim())
: await verify2fa(verifyToken, code.trim())
await finish(res.data?.data?.token)
} catch {
setErr('인증 코드가 올바르지 않거나 만료되었습니다.')
} finally {
setBusy(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-ink">
<form onSubmit={submit} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
<form onSubmit={step === 'login' ? submit : submitCode}
className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
<div className="flex items-center gap-2 justify-center mb-6">
<Cpu className="text-brand" size={32} />
<span className="text-xl font-bold">GUARDiA MES</span>
</div>
<p className="text-center text-sm text-slate-400 mb-6">AI · WMS · MES · QMS</p>
{step === 'login' ? (
<>
<label className="block text-xs text-slate-400 mb-1"></label>
<input value={username} onChange={e => setUsername(e.target.value)}
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
<label className="block text-xs text-slate-400 mb-1"></label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
</>
) : (
<>
<div className="flex items-center gap-2 justify-center mb-3">
<ShieldCheck size={18} className="text-brand" />
<span className="text-sm font-semibold">2 </span>
</div>
{isOtp ? (
isSetup ? (
<>
<p className="text-center text-xs text-slate-400 mb-3">
. QR을 Authenticator (Google·Microsoft)
6 .
</p>
{qrImage && (
<div className="flex justify-center mb-3">
<img src={qrImage} alt="OTP QR" width={180} height={180}
style={{ background: '#fff', padding: 8, borderRadius: 8 }} />
</div>
)}
{secret && (
<div className="mb-4">
<div className="text-[11px] text-slate-500 mb-1">QR </div>
<code className="block text-xs text-accent bg-ink border border-edge rounded-md px-2 py-1.5 break-all select-all">
{secret}
</code>
</div>
)}
</>
) : (
<p className="text-center text-xs text-slate-400 mb-4">
Authenticator 6 .
</p>
)
) : (
<p className="text-center text-xs text-slate-400 mb-4">
6 {maskedEmail ? ` (${maskedEmail})` : ''}.
</p>
)}
<label className="block text-xs text-slate-400 mb-1"> </label>
<input value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
inputMode="numeric" autoComplete="one-time-code" maxLength={6} autoFocus
placeholder="000000"
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none tracking-[0.4em] text-center" />
</>
)}
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
<button disabled={busy} className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
{busy ? '로그인 중…' : '로그인'}
<button disabled={busy || (step === 'verify' && code.length !== 6)}
className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
{busy ? '처리 중…' : step === 'login' ? '로그인' : isSetup ? '등록하고 로그인' : '인증 확인'}
</button>
<p className="text-center text-[11px] text-slate-500 mt-4">admin / manager / worker · admin123</p>
{step === 'verify' && (
<button type="button"
onClick={() => { setStep('login'); setCode(''); setErr(''); setQrImage(''); setSecret('') }}
className="w-full mt-2 py-2 rounded-lg text-xs text-slate-400 hover:text-slate-200">
</button>
)}
{step === 'login' && (
<>
{/* 로그인 보조 3종 링크 (UIWS auth 패턴 이식) */}
<div className="flex items-center justify-center gap-2 text-[11px] text-slate-400 mt-4">
<button type="button" onClick={() => openHelper('signup')} className="hover:text-brand"></button>
<span className="text-slate-600">|</span>
<button type="button" onClick={() => openHelper('find-id')} className="hover:text-brand"> </button>
<span className="text-slate-600">|</span>
<button type="button" onClick={() => openHelper('reset-password')} className="hover:text-brand"> </button>
</div>
<p className="text-center text-[11px] text-slate-500 mt-3">admin / manager / worker · admin123</p>
</>
)}
</form>
{/* 로그인 보조 모달 (MES 다크 테마 토큰 재사용 — 하드코딩 색상 없음) */}
{helper && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={closeHelper}>
<form onClick={e => e.stopPropagation()} onSubmit={submitHelper}
className="w-[360px] bg-panel border border-edge rounded-2xl p-7">
<div className="flex items-center justify-between mb-5">
<span className="text-base font-bold">{helperTitle}</span>
<button type="button" onClick={closeHelper}
className="text-slate-400 hover:text-slate-200 text-lg leading-none">×</button>
</div>
{/* 회원가입: 아이디·비번·이름·이메일 / 아이디찾기: 이름·이메일 / 비번초기화: 아이디·이메일 */}
{(helper === 'signup' || helper === 'reset-password') && (
<>
<label className="block text-xs text-slate-400 mb-1"></label>
<input value={hForm.username} onChange={e => setHForm({ ...hForm, username: e.target.value })}
className="w-full mb-3 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
</>
)}
{helper === 'signup' && (
<>
<label className="block text-xs text-slate-400 mb-1"> (4 )</label>
<input type="password" value={hForm.password} onChange={e => setHForm({ ...hForm, password: e.target.value })}
className="w-full mb-3 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
</>
)}
{(helper === 'signup' || helper === 'find-id') && (
<>
<label className="block text-xs text-slate-400 mb-1"></label>
<input value={hForm.displayName} onChange={e => setHForm({ ...hForm, displayName: e.target.value })}
className="w-full mb-3 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
</>
)}
<label className="block text-xs text-slate-400 mb-1"></label>
<input type="email" value={hForm.email} onChange={e => setHForm({ ...hForm, email: e.target.value })}
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
{hMsg && (
<p className={`text-xs mb-3 ${hErr ? 'text-rose-400' : 'text-emerald-400'}`}>{hMsg}</p>
)}
<button disabled={hBusy}
className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
{hBusy ? '처리 중…' : (helper === 'signup' ? '가입 신청' : helper === 'find-id' ? '아이디 찾기' : '초기화 요청')}
</button>
{helper === 'signup' && (
<p className="text-[11px] text-slate-500 mt-3"> .</p>
)}
</form>
</div>
)}
</div>
)
}

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { Plus, Trash2, KeyRound, Power, ShieldCheck } from 'lucide-react'
import { Plus, Trash2, KeyRound, Power, ShieldCheck, RefreshCw } from 'lucide-react'
import { MES_ROLES } from '../components/rbac'
import {
getUsers, createUser, updateUserRole, updateUserActive, resetPassword, deleteUser,
adminOtpReset,
} from '../api/client'
interface User { id: number; username: string; displayName?: string; role: string; active: boolean; createdAt: string | null }
@ -36,6 +37,10 @@ export default function UserManagement() {
const toggleActive = (u: User) => wrap(() => updateUserActive(u.id, !u.active))
const doReset = (u: User) => { const pw = window.prompt(`'${u.username}' 의 새 비밀번호`); if (pw) wrap(() => resetPassword(u.id, pw)) }
const remove = (u: User) => { if (window.confirm(`'${u.username}' 삭제?`)) wrap(() => deleteUser(u.id)) }
const otpReset = (u: User) => {
if (!window.confirm(`'${u.username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`)) return
wrap(() => adminOtpReset(u.id))
}
return (
<div>
@ -81,6 +86,7 @@ export default function UserManagement() {
<td className="px-4"><div className="flex items-center justify-end gap-3">
<button onClick={() => toggleActive(u)} title={u.active ? '비활성화' : '활성화'} className="text-slate-400 hover:text-brand"><Power size={16} /></button>
<button onClick={() => doReset(u)} title="비밀번호 재설정" className="text-slate-400 hover:text-brand"><KeyRound size={16} /></button>
<button onClick={() => otpReset(u)} title="OTP 초기화" className="text-slate-400 hover:text-brand"><RefreshCw size={16} /></button>
<button onClick={() => remove(u)} title="삭제" className="text-rose-400 hover:text-rose-300"><Trash2 size={16} /></button>
</div></td>
</tr>