feat(signage): GUARDiA 간판 전자간판 중앙관제 초기 푸시 (fresh init)

This commit is contained in:
GUARDiA 2026-07-04 09:42:09 +09:00
commit 314bbd500a
321 changed files with 21062 additions and 0 deletions

84
backend/pom.xml Normal file
View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version>
</parent>
<groupId>com.zioinfo</groupId>
<artifactId>guardia-signage</artifactId>
<version>1.0.0</version>
<name>guardia-signage</name>
<description>GUARDiA 전자간판(e-SignBoard/ESB) 중앙관제 플랫폼 — Spring Boot 3.5 + Java 17 + MyBatis + PostgreSQL</description>
<properties>
<java.version>17</java.version>
<jjwt.version>0.12.6</jjwt.version>
<mybatis.version>3.0.3</mybatis.version>
<postgresql.version>42.7.3</postgresql.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.version}</version>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
<!-- DuckDB (로컬 임베디드 AI 학습/분석 저장소 — /opt/guardia-signage/data/signage_learning.duckdb) -->
<dependency>
<groupId>org.duckdb</groupId>
<artifactId>duckdb_jdbc</artifactId>
<version>1.1.3</version>
</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>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
<!-- Lombok -->
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
<!-- Test -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
<finalName>signage-1.0.0</finalName>
</build>
</project>

View File

@ -0,0 +1,30 @@
package com.zioinfo.signage;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* zioinfo-signage ESL 통합 관리 플랫폼.
*
* <p>레거시 Spring Boot 1.5 / Java 8 ESL 시스템 6개를 Spring Boot 3.5 / Java 17 단일 플랫폼으로 재구현.
* 멀티테넌트(LGINNOTEK·LGIT·EMART·ZIOINFO), 매장/HCore 장치 관리, POS 가격 연동,
* 알람 모니터링, 펌웨어 관리, Ollama 온프레미스 AI 이상 감지.
*
* <p>보안 불변 규칙: 외부 AI API 절대 금지 (Ollama localhost:11434만 허용),
* 자격증명은 AES-256-GCM 암호화 저장, API 응답에서 password_hash 완전 제외.
*
* <p>CRITICAL: @MapperScan(annotationClass = Mapper.class) 패턴 사용 basePackages 아님.
*/
@SpringBootApplication
@MapperScan(annotationClass = Mapper.class)
@EnableScheduling
@EnableAsync
public class SignageApplication {
public static void main(String[] args) {
SpringApplication.run(SignageApplication.class, args);
}
}

View File

@ -0,0 +1,76 @@
package com.zioinfo.signage.admin;
import com.zioinfo.signage.admin.dto.AuditLog;
import com.zioinfo.signage.admin.dto.SignageSetting;
import com.zioinfo.signage.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* GUARDiA SIGNAGE 관리자 API 감사 로그 조회 + 시스템 설정.
*
* <p>RBAC (SecurityConfig requestMatchers 통제):
* <ul>
* <li>GET /api/admin/audit ADMIN, MANAGER</li>
* <li>GET /api/admin/settings ADMIN, MANAGER</li>
* <li>PUT /api/admin/settings/** ADMIN</li>
* </ul>
*
* <p>사용자/테넌트/펌웨어 CRUD 기존 도메인 컨트롤러(UserController/TenantController/FirmwareController)
* 담당한다. 컨트롤러는 admin 보조 영역(감사·설정) 추가한다.
*/
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminController {
private final AuditService auditService;
private final SettingService settingService;
// ===================== 감사 로그 (ADMIN/MANAGER) =====================
@GetMapping("/audit")
public ApiResponse<List<AuditLog>> audit(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) String username,
@RequestParam(required = false) String action,
@RequestParam(required = false) String resource,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to,
@RequestParam(defaultValue = "100") int limit) {
return ApiResponse.ok(auditService.find(tenantCode, username, action, resource, from, to, limit));
}
@GetMapping("/audit/count")
public ApiResponse<Long> auditCount(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) String username,
@RequestParam(required = false) String action,
@RequestParam(required = false) String resource,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
return ApiResponse.ok(auditService.count(tenantCode, username, action, resource, from, to));
}
// ===================== 시스템 설정 =====================
@GetMapping("/settings")
public ApiResponse<List<SignageSetting>> settings(@RequestParam(required = false) String category) {
return ApiResponse.ok(settingService.list(category));
}
@PutMapping("/settings/{key}")
public ApiResponse<SignageSetting> updateSetting(@PathVariable String key,
@RequestBody Map<String, String> body) {
return ApiResponse.ok("설정 변경 완료", settingService.update(key, body.get("value")));
}
}

View File

@ -0,0 +1,99 @@
package com.zioinfo.signage.admin;
import com.zioinfo.signage.admin.dto.AuditLog;
import com.zioinfo.signage.admin.mapper.AuditLogMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.time.LocalDateTime;
import java.util.List;
/**
* 감사 로그 서비스.
*
* <p>ESL 제어 운영(펌웨어 배포·POS 가격 변경·태그 전파) 관리 행위(사용자/테넌트 CRUD·설정 변경)
* {@code signage_audit_log} 기록하고 조회한다.
*
* <p>감사 기록 실패가 본업(펌웨어 배포 ) 막아서는 되므로 insert best-effort(예외 흡수).
*
* <p>보안 불변: 비밀번호/자격증명/PII detail 절대 기록하지 않는다. 호출 측이 /식별자/요약만 넘긴다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuditService {
private final AuditLogMapper mapper;
/** 가장 단순한 형태: 액션/리소스/식별자/요약. 현재 인증 주체로 기록. */
public void log(String action, String resource, String resourceId, String detail) {
log(null, action, resource, resourceId, detail, true);
}
/** 테넌트 스코프 포함. */
public void log(String tenantCode, String action, String resource, String resourceId,
String detail, boolean success) {
try {
AuditLog entry = new AuditLog();
entry.setTenantCode(tenantCode);
entry.setUsername(currentActor());
entry.setAction(action);
entry.setResource(resource);
entry.setResourceId(resourceId);
entry.setDetail(detail);
entry.setSuccess(success);
HttpServletRequest req = currentRequest();
if (req != null) {
entry.setIpAddress(clientIp(req));
String ua = req.getHeader("User-Agent");
entry.setUserAgent(ua != null && ua.length() > 500 ? ua.substring(0, 500) : ua);
}
mapper.insert(entry);
} catch (Exception e) {
// best-effort: 감사 기록 실패가 본업을 막지 않는다.
log.warn("audit log insert 실패 action={} resource={}: {}", action, resource, e.getMessage());
}
}
public List<AuditLog> find(String tenantCode, String username, String action, String resource,
LocalDateTime from, LocalDateTime to, int limit) {
int safeLimit = (limit <= 0 || limit > 1000) ? 100 : limit;
return mapper.find(tenantCode, username, action, resource, from, to, safeLimit);
}
public long count(String tenantCode, String username, String action, String resource,
LocalDateTime from, LocalDateTime to) {
return mapper.count(tenantCode, username, action, resource, from, to);
}
/** SecurityContext 의 JWT subject(username). 없으면 system. */
public static String currentActor() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getName() != null && !auth.getName().isBlank()) {
return auth.getName();
}
return "system";
}
private static HttpServletRequest currentRequest() {
var attrs = RequestContextHolder.getRequestAttributes();
if (attrs instanceof ServletRequestAttributes sra) {
return sra.getRequest();
}
return null;
}
private static String clientIp(HttpServletRequest req) {
String xf = req.getHeader("X-Forwarded-For");
if (xf != null && !xf.isBlank()) {
return xf.split(",")[0].trim();
}
return req.getRemoteAddr();
}
}

View File

@ -0,0 +1,43 @@
package com.zioinfo.signage.admin;
import com.zioinfo.signage.admin.dto.SignageSetting;
import com.zioinfo.signage.admin.mapper.SettingMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 시스템 설정 서비스.
*
* <p>조회는 ADMIN/MANAGER, 변경은 ADMIN (SecurityConfig requestMatchers 통제).
* 변경 감사 로그에 이전이후 요약을 기록한다(값은 민감하지 않은 운영 설정만 다룬다).
*/
@Service
@RequiredArgsConstructor
public class SettingService {
private final SettingMapper mapper;
private final AuditService auditService;
public List<SignageSetting> list(String category) {
return mapper.findAll(category);
}
public SignageSetting update(String key, String value) {
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("ERR-SET-400: key 필수");
}
if (value == null) {
throw new IllegalArgumentException("ERR-SET-400: value 필수");
}
SignageSetting before = mapper.findByKey(key);
String category = before == null ? "GENERAL" : before.getCategory();
String description = before == null ? null : before.getDescription();
mapper.upsert(key, value, category, description);
String prev = before == null ? "(none)" : before.getSettingValue();
auditService.log(null, "SETTING_CHANGE", "SETTING", key,
prev + " -> " + value, true);
return mapper.findByKey(key);
}
}

View File

@ -0,0 +1,28 @@
package com.zioinfo.signage.admin.dto;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 감사 로그 (signage_audit_log 테이블 매핑).
*
* <p>ESL 제어 운영(펌웨어 배포·가격 변경·태그 전파) 관리 행위(사용자/테넌트 CRUD·설정 변경)
* 추적한다. 멀티테넌트 스코프(tenantCode) 보존한다.
*
* <p>보안: 비밀번호/자격증명/PII detail 절대 기록하지 않는다(/식별자/요약만).
*/
@Data
public class AuditLog {
private Long id;
private String tenantCode; // 테넌트 스코프 (nullable)
private String username; // 작업 수행자 (JWT subject)
private String action; // USER_CREATE, FIRMWARE_DEPLOY, POS_PRICE_CHANGE, SETTING_CHANGE
private String resource; // 대상 리소스 종류 (USER / FIRMWARE / POS_CVT / TENANT / SETTING ...)
private String resourceId; // 대상 식별자 (id / key )
private String detail; // 부가 요약 (민감정보 제외)
private String ipAddress; // 요청 IP (nullable)
private String userAgent; // User-Agent (nullable)
private Boolean success; // 성공 여부
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.signage.admin.dto;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 시스템 설정 (signage_setting 테이블 매핑).
*
* <p>조회는 ADMIN/MANAGER, 변경은 ADMIN (SecurityConfig requestMatchers 통제).
* 비밀번호/자격증명 같은 민감 설정은 저장하지 않는다(그런 값은 CryptoUtil 암호화 경로 사용).
*/
@Data
public class SignageSetting {
private String settingKey;
private String settingValue;
private String category; // GENERAL / ESL / ALARM / AI ...
private String description;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,33 @@
package com.zioinfo.signage.admin.mapper;
import com.zioinfo.signage.admin.dto.AuditLog;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
/**
* 감사 로그 매퍼. @Mapper 어노테이션 필수
* (SignageApplication @MapperScan(annotationClass = Mapper.class) 패턴).
*/
@Mapper
public interface AuditLogMapper {
int insert(AuditLog log);
List<AuditLog> find(@Param("tenantCode") String tenantCode,
@Param("username") String username,
@Param("action") String action,
@Param("resource") String resource,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("limit") int limit);
long count(@Param("tenantCode") String tenantCode,
@Param("username") String username,
@Param("action") String action,
@Param("resource") String resource,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
}

View File

@ -0,0 +1,25 @@
package com.zioinfo.signage.admin.mapper;
import com.zioinfo.signage.admin.dto.SignageSetting;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 시스템 설정 매퍼. @Mapper 어노테이션 필수
* (SignageApplication @MapperScan(annotationClass = Mapper.class) 패턴).
*/
@Mapper
public interface SettingMapper {
List<SignageSetting> findAll(@Param("category") String category);
SignageSetting findByKey(@Param("settingKey") String settingKey);
/** upsert (ON CONFLICT). description/category 는 신규 INSERT 시에만 사용. */
int upsert(@Param("settingKey") String settingKey,
@Param("settingValue") String settingValue,
@Param("category") String category,
@Param("description") String description);
}

View File

@ -0,0 +1,55 @@
package com.zioinfo.signage.ai.controller;
import com.zioinfo.signage.ai.dto.AiConfigDto;
import com.zioinfo.signage.ai.dto.AiConfigUpdateRequest;
import com.zioinfo.signage.ai.service.AiConfigService;
import com.zioinfo.signage.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
/**
* AI 플랫폼(LLM provider) 설정 관리(ADMIN). [GUARDiA-SIGNAGE]
* SecurityConfig {@code /api/admin/ai-config/** hasRole(ADMIN)} 게이트로 보호된다.
*
* <p>signage_setting(key='ai.*') 저장된 런타임 설정을 조회/갱신/테스트한다. <b>API 키는 환경변수
* (ANTHROPIC_API_KEY)로만 주입</b>되어 응답·로그에 노출되지 않는다(GET {@code claudeKeySet} 불리언만).
* 설정 변경은 재기동 없이 다음 AI 호출부터 반영된다.
*
* <ul>
* <li>GET /api/admin/ai-config 현재 효과 설정( 제외, keySet 불리언만)</li>
* <li>PUT /api/admin/ai-config provider/모델 갱신(화이트리스트 검증)</li>
* <li>POST /api/admin/ai-config/test 저장 설정 기준 선택 provider 연결 테스트(요약 결과만)</li>
* </ul>
*/
@RestController
@RequestMapping("/api/admin/ai-config")
@RequiredArgsConstructor
public class AiConfigController {
private final AiConfigService service;
@GetMapping
public ApiResponse<AiConfigDto> get() {
return ApiResponse.ok(service.getConfig());
}
@PutMapping
public ApiResponse<AiConfigDto> update(@RequestBody AiConfigUpdateRequest req) {
return ApiResponse.ok(service.update(req));
}
@PostMapping("/test")
public ApiResponse<AiConfigService.TestResult> test() {
AiConfigService.TestResult result = service.test();
return result.ok()
? ApiResponse.ok(result)
: new ApiResponse<>(false, result.message(), result, LocalDateTime.now());
}
}

View File

@ -0,0 +1,67 @@
package com.zioinfo.signage.ai.controller;
import com.zioinfo.signage.admin.AuditService;
import com.zioinfo.signage.ai.dto.AiFeedbackRequest;
import com.zioinfo.signage.ai.learning.LocalLearningStore;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.rag.SignageRagClient;
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;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* AI 답변 피드백 수집(👍/👎 + 교정). [GUARDiA-SIGNAGE]
*
* <p>{@code POST /api/ai/feedback} 인증 사용자면 누구나 제출(SecurityConfig POST /api/** 일반 규칙).
* 로컬 DuckDB({@link LocalLearningStore}, 오프라인 AI 분석) 중앙 guardia-rag({@link SignageRagClient#feedback},
* 통합 학습·평가 게이트) <b> </b> 기록한다. 어느 한쪽 실패가 다른 /응답을 막지 않는다(best-effort).
*
* <p><b>보안 불변</b>: 저장 PII 마스킹(LocalLearningStore). 사용자 식별자는 마스킹. 자격증명 미기록.
* 응답은 저장 성공 여부 요약만 반환(원문//스택 미노출).
*/
@RestController
@RequestMapping("/api/ai/feedback")
@RequiredArgsConstructor
public class AiFeedbackController {
private final LocalLearningStore learningStore;
private final SignageRagClient ragClient;
@PostMapping
public ApiResponse<Map<String, Object>> submit(@RequestBody AiFeedbackRequest req) {
String actor = AuditService.currentActor();
String verdict = req.verdict() == null ? "unknown" : req.verdict().trim();
// 1) 로컬 DuckDB 기록(마스킹은 저장 계층에서 수행) best-effort
boolean storedLocal = false;
try {
learningStore.recordFeedback(req.feature(), req.question(), req.answer(),
verdict, req.correction(), actor);
storedLocal = learningStore.isAvailable();
} catch (Exception ignore) {
// no-op 무영향
}
// 2) 중앙 guardia-rag /feedback 전달(테넌트 격리) best-effort
boolean forwarded = false;
try {
Map<String, Object> res = ragClient.feedback(
req.tenantCode(), req.answerId(), req.question(), req.answer(),
verdict, req.correction(), LocalLearningStore.maskUser(actor));
Object stored = res == null ? null : res.get("stored");
forwarded = Boolean.TRUE.equals(stored);
} catch (Exception ignore) {
// no-op 중앙 미가용은 degraded, 로컬 기록으로 무손실
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("storedLocal", storedLocal);
out.put("forwardedCentral", forwarded);
return ApiResponse.ok("피드백이 접수되었습니다.", out);
}
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.signage.ai.dto;
/**
* AI 설정 조회 DTO API 미반환(claudeKeySet 으로 설정 여부만). [GUARDiA-SIGNAGE]
*
* @param provider 효과 provider(ollama/claude/qwen3/deepseek/glm)
* @param ollamaTextModel 효과 Ollama 텍스트 모델(provider 해석 결과)
* @param claudeModel 효과 Claude 모델 ID
* @param claudeKeySet 서버 환경변수 ANTHROPIC_API_KEY 존재 여부(/길이/마스킹 일절 미포함)
* @param aiEnabled 전역 AI 사용 여부(off 규칙 기반 degraded)
*/
public record AiConfigDto(
String provider,
String ollamaTextModel,
String claudeModel,
boolean claudeKeySet,
boolean aiEnabled) {
}

View File

@ -0,0 +1,14 @@
package com.zioinfo.signage.ai.dto;
/**
* AI 설정 저장 요청 화이트리스트 검증. [GUARDiA-SIGNAGE]
*
* @param provider ollama/claude/qwen3/deepseek/glm (필수)
* @param claudeModel Claude 모델 ID(선택, 미제공 기존 유지)
* @param ollamaTextModel Ollama 텍스트 모델(선택, 미제공 기존 유지)
*/
public record AiConfigUpdateRequest(
String provider,
String claudeModel,
String ollamaTextModel) {
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.signage.ai.dto;
/**
* AI 답변 피드백 요청(👍/👎 + 교정). [GUARDiA-SIGNAGE]
*
* <p>로컬 DuckDB(오프라인 분석) + 중앙 guardia-rag {@code /feedback}(통합 학습·평가 게이트) 기록.
*
* @param feature 피드백 대상 기능(alarm-analysis / pos-classify / chat ...)
* @param question 질문/프롬프트(마스킹 저장)
* @param answer AI 답변(마스킹 저장)
* @param verdict up | down (또는 good/bad)
* @param correction 사용자 교정 내용(선택, 마스킹 저장)
* @param answerId 중앙 RAG answer_id(선택 /answer 응답에서 받은 경우)
* @param tenantCode 테넌트 격리 코드(선택)
*/
public record AiFeedbackRequest(
String feature,
String question,
String answer,
String verdict,
String correction,
String answerId,
String tenantCode) {
}

View File

@ -0,0 +1,177 @@
package com.zioinfo.signage.ai.learning;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.regex.Pattern;
/**
* 로컬 임베디드 DuckDB 학습/분석 저장소. [GUARDiA-SIGNAGE]
*
* <p>AI 피드백(👍/👎+교정) 추론 로그(provider/model/latency/degraded) 솔루션별 로컬 DuckDB 파일에
* 격리 저장한다(오프라인 AI 분석용). 중앙 guardia-rag {@code /feedback}(통합 학습·평가 게이트)와는
* <b>병행</b>이며 서로를 대체하지 않는다(피드백은 로컬 + 중앙 기록).
*
* <p><b>파일</b>: {@code guardia.learning.duckdb-path}(기본 {@code /opt/guardia-signage/data/signage_learning.duckdb}).
* 스키마는 부팅 멱등 생성({@code CREATE TABLE/SEQUENCE IF NOT EXISTS}).
*
* <p><b>Fail-Safe</b>: DuckDB 초기화/기록 실패는 <b>본업을 막지 않는다</b>(예외 흡수·no-op).
* 파일 미생성/드라이버 부재/권한 문제 {@code available=false} 조용히 비활성(로그 1줄만).
* DuckDB 프로세스당 단일 R/W 연결이므로 하나의 {@link Connection} 락으로 직렬화한다.
*
* <p><b>보안 불변</b>: 저장 PII(주민번호·카드·전화·이메일) 마스킹. 사용자 식별자는 2자만 남기고 마스킹.
* 자격증명/비밀번호/스택트레이스는 어떤 컬럼에도 기록하지 않는다(호출 측이 요약만 전달).
*/
@Slf4j
@Component
public class LocalLearningStore {
@Value("${guardia.learning.duckdb-path:/opt/guardia-signage/data/signage_learning.duckdb}")
private String dbPath;
@Value("${guardia.rag.solution:signage}")
private String solution;
private volatile boolean available = false;
private Connection conn;
private final Object lock = new Object();
// PII 패턴(마스킹) 최소 집합. 과탐보다 미기록이 안전.
private static final Pattern RRN = Pattern.compile("\\b\\d{6}[- ]?\\d{7}\\b");
private static final Pattern CARD = Pattern.compile("\\b\\d{4}[- ]?\\d{4}[- ]?\\d{4}[- ]?\\d{4}\\b");
private static final Pattern PHONE = Pattern.compile("\\b01[016789][- ]?\\d{3,4}[- ]?\\d{4}\\b");
private static final Pattern EMAIL = Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}");
private static final int MAX_LEN = 4000;
@PostConstruct
void init() {
try {
File f = new File(dbPath);
File dir = f.getParentFile();
if (dir != null && !dir.exists() && !dir.mkdirs()) {
log.warn("DuckDB 디렉터리 생성 불가 — 로컬 학습 저장소 비활성: {}", dir);
return;
}
Class.forName("org.duckdb.DuckDBDriver");
conn = DriverManager.getConnection("jdbc:duckdb:" + dbPath);
try (Statement st = conn.createStatement()) {
st.execute("CREATE SEQUENCE IF NOT EXISTS seq_ai_feedback START 1");
st.execute("CREATE SEQUENCE IF NOT EXISTS seq_ai_infer_log START 1");
st.execute("CREATE TABLE IF NOT EXISTS ai_feedback ("
+ "id BIGINT, ts TIMESTAMP, solution VARCHAR, feature VARCHAR, "
+ "question VARCHAR, answer VARCHAR, verdict VARCHAR, correction VARCHAR, user_masked VARCHAR)");
st.execute("CREATE TABLE IF NOT EXISTS ai_infer_log ("
+ "id BIGINT, ts TIMESTAMP, provider VARCHAR, model VARCHAR, latency_ms BIGINT, degraded BOOLEAN)");
}
available = true;
log.info("로컬 DuckDB 학습 저장소 준비 완료: {}", dbPath);
} catch (Throwable t) {
// 드라이버 부재/권한/파일락 본업 무영향. 조용히 비활성(스택 미노출).
available = false;
log.warn("로컬 DuckDB 학습 저장소 비활성(무영향): {}", t.getClass().getSimpleName());
}
}
public boolean isAvailable() {
return available;
}
/** 피드백 1건 기록(멱등 아님 — append). PII 마스킹 후 저장. 실패는 흡수. */
public void recordFeedback(String feature, String question, String answer,
String verdict, String correction, String user) {
if (!available) {
return;
}
synchronized (lock) {
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO ai_feedback VALUES (nextval('seq_ai_feedback'), ?, ?, ?, ?, ?, ?, ?, ?)")) {
ps.setTimestamp(1, Timestamp.from(Instant.now()));
ps.setString(2, solution);
ps.setString(3, clip(feature));
ps.setString(4, mask(question));
ps.setString(5, mask(answer));
ps.setString(6, clip(verdict));
ps.setString(7, mask(correction));
ps.setString(8, maskUser(user));
ps.executeUpdate();
} catch (Exception e) {
log.warn("DuckDB ai_feedback 기록 실패(무영향): {}", e.getClass().getSimpleName());
}
}
}
/** 추론 로그 1건 기록(provider/model/latency/degraded). 실패는 흡수. */
public void recordInfer(String provider, String model, long latencyMs, boolean degraded) {
if (!available) {
return;
}
synchronized (lock) {
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO ai_infer_log VALUES (nextval('seq_ai_infer_log'), ?, ?, ?, ?, ?)")) {
ps.setTimestamp(1, Timestamp.from(Instant.now()));
ps.setString(2, clip(provider));
ps.setString(3, clip(model));
ps.setLong(4, latencyMs);
ps.setBoolean(5, degraded);
ps.executeUpdate();
} catch (Exception e) {
log.warn("DuckDB ai_infer_log 기록 실패(무영향): {}", e.getClass().getSimpleName());
}
}
}
@PreDestroy
void close() {
synchronized (lock) {
if (conn != null) {
try {
conn.close();
} catch (Exception ignore) {
// no-op
}
}
}
}
// PII 마스킹
static String mask(String s) {
if (s == null || s.isBlank()) {
return s;
}
String out = s;
out = RRN.matcher(out).replaceAll("######-#######");
out = CARD.matcher(out).replaceAll("****-****-****-****");
out = PHONE.matcher(out).replaceAll("***-****-****");
out = EMAIL.matcher(out).replaceAll("***@***");
return clip(out);
}
/** 사용자 식별자 마스킹: 앞 2자만 노출. */
public static String maskUser(String u) {
if (u == null || u.isBlank()) {
return null;
}
String t = u.trim();
if (t.length() <= 2) {
return t.charAt(0) + "*";
}
return t.substring(0, 2) + "***";
}
private static String clip(String s) {
if (s == null) {
return null;
}
return s.length() > MAX_LEN ? s.substring(0, MAX_LEN) : s;
}
}

View File

@ -0,0 +1,254 @@
package com.zioinfo.signage.ai.service;
import com.zioinfo.signage.admin.AuditService;
import com.zioinfo.signage.admin.dto.SignageSetting;
import com.zioinfo.signage.admin.mapper.SettingMapper;
import com.zioinfo.signage.ai.dto.AiConfigDto;
import com.zioinfo.signage.ai.dto.AiConfigUpdateRequest;
import com.zioinfo.signage.common.ai.ClaudeTextClient;
import com.zioinfo.signage.common.ai.TextAiClient.GenResult;
import com.zioinfo.signage.config.OllamaClient;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Set;
/**
* AI provider 런타임 설정(signage_setting, key='ai.*') 단일 출처 서비스. [GUARDiA-SIGNAGE]
*
* <p><b>해상도</b>: DB(signage_setting) 우선 미설정 기본값/env 폴백. 시드(db/94) 미적용/ 비움
* 상태에서도 기존 Ollama 동작이 바이트 동일하게 유지된다(provider 기본 ollama, ollamaTextModel 미설정
* {@code guardia.ollama-text-model} env 그대로).
*
* <p><b>보안</b>: Claude API 키는 서비스가 다루지 않는다. {@code claudeKeySet}
* {@link ClaudeTextClient#isConfigured()}(환경변수 존재 여부) 반환 /길이/마스킹 일절 미포함.
*
* <p><b>프로바이더</b>: claude / qwen3 / deepseek / glm / ollama. Ollama 계열(qwen3·deepseek·glm·ollama)
* 해당 텍스트 모델로 generate. claude Claude실패 Ollama(선택모델) 폴백(라우터).
* glm(glm4:9b) 화이트리스트에 등록되나 서버 RAM(~2.1GB) 초과로 콜드로드 실패 폴백(설정 화면 "RAM 증설 필요" 배지).
*
* <p><b>레퍼런스</b>: GUARDiA OCR {@code ai.service.AiConfigService} 미러(GLM provider 추가·설정저장소 ESN 패턴 치환).
*/
@Service
@RequiredArgsConstructor
public class AiConfigService {
// --- signage_setting (db/94 시드와 일치)
public static final String K_PROVIDER = "ai.provider"; // ENUM: ollama|claude|qwen3|deepseek|glm
public static final String K_CLAUDE_MODEL = "ai.claude.model"; // ENUM: 화이트리스트
public static final String K_OLLAMA_TEXT_MODEL = "ai.ollama.textModel"; // STRING (미설정 env 폴백)
public static final String K_ENABLED = "ai.enabled"; // BOOL (off=규칙기반 degraded)
public static final String CATEGORY_AI = "AI";
public static final String PROVIDER_OLLAMA = "ollama";
public static final String PROVIDER_CLAUDE = "claude";
public static final String PROVIDER_QWEN3 = "qwen3";
public static final String PROVIDER_DEEPSEEK = "deepseek";
public static final String PROVIDER_GLM = "glm";
public static final String DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6";
// Ollama 계열 provider 고정 소형 모델(서버 RAM 제약 준수)
public static final String MODEL_QWEN3 = "qwen3:1.7b";
public static final String MODEL_DEEPSEEK = "deepseek-r1:1.5b";
public static final String MODEL_GLM = "glm4:9b"; // RAM 초과 가능 콜드로드 실패 폴백
/** 허용 provider(임의 문자열 거부). */
public static final Set<String> ALLOWED_PROVIDERS =
Set.of(PROVIDER_OLLAMA, PROVIDER_CLAUDE, PROVIDER_QWEN3, PROVIDER_DEEPSEEK, PROVIDER_GLM);
/** 허용 Claude 모델 ID(임의 문자열 거부). */
public static final Set<String> ALLOWED_CLAUDE_MODELS =
Set.of("claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-8");
/** 허용 Ollama 텍스트 모델(임의 문자열 거부). */
public static final Set<String> ALLOWED_OLLAMA_MODELS =
Set.of("qwen3:1.7b", "deepseek-r1:1.5b", "glm4:9b", "llama3.2:1b");
private final SettingMapper repo;
private final ClaudeTextClient claudeClient;
private final OllamaClient ollamaClient; // 연결 테스트용 텍스트 generate
private final AuditService auditService;
/** 서버 기본 Ollama 텍스트 모델(application.yml guardia.ollama-text-model). */
@Value("${guardia.ollama-text-model:llama3.2:1b}")
private String defaultOllamaTextModel;
// ---------------------------------------------------------------- 효과값(DB 우선 기본)
/** 효과 provider. DB 우선, 미설정/비허용 시 ollama. */
public String provider() {
String v = dbVal(K_PROVIDER);
if (v != null) {
String p = v.trim().toLowerCase();
if (ALLOWED_PROVIDERS.contains(p)) {
return p;
}
}
return PROVIDER_OLLAMA;
}
/** 효과 Claude 모델 ID(DB 우선, 미설정/비허용 시 기본 sonnet-4-6). */
public String claudeModel() {
String v = dbVal(K_CLAUDE_MODEL);
if (v != null) {
String m = v.trim();
if (ALLOWED_CLAUDE_MODELS.contains(m)) {
return m;
}
}
return DEFAULT_CLAUDE_MODEL;
}
/**
* 효과 Ollama 텍스트 모델. provider=qwen3/deepseek/glm 고정 모델, (ollama·claude 폴백)
* DB 설정(화이트리스트) 우선 미설정 env 기본(llama3.2:1b). 라우터의 Ollama 경로·Claude 폴백 공용.
*/
public String ollamaTextModel() {
String p = provider();
if (PROVIDER_QWEN3.equals(p)) {
return MODEL_QWEN3;
}
if (PROVIDER_DEEPSEEK.equals(p)) {
return MODEL_DEEPSEEK;
}
if (PROVIDER_GLM.equals(p)) {
return MODEL_GLM;
}
String v = dbVal(K_OLLAMA_TEXT_MODEL);
if (v != null && ALLOWED_OLLAMA_MODELS.contains(v.trim())) {
return v.trim();
}
return defaultOllamaTextModel;
}
/** 전역 AI 토글(ai.enabled). false 면 모든 AI 결과는 규칙기반(degraded). 기본 true. */
public boolean aiEnabled() {
String v = dbVal(K_ENABLED);
return v == null || !"false".equalsIgnoreCase(v.trim());
}
/** Claude API 키가 환경변수로 설정되어 있는지(여부만). */
public boolean claudeKeySet() {
return claudeClient.isConfigured();
}
/**
* 실제 호출이 Claude 경로로 가야 하는지: provider=claude · 설정됨 · AI 전역 활성.
* false 호출자는 Ollama 경로/폴백을 사용한다( 미설정/실패 자동 폴백 정책 반영).
*/
public boolean isClaudeActive() {
return PROVIDER_CLAUDE.equals(provider()) && claudeKeySet() && aiEnabled();
}
// ---------------------------------------------------------------- ADMIN: 조회 / 갱신
/** 현재 효과 설정 조회. 키 값·길이·마스킹 일절 미포함(claudeKeySet 불리언만). */
public AiConfigDto getConfig() {
return new AiConfigDto(
provider(),
ollamaTextModel(),
claudeModel(),
claudeKeySet(),
aiEnabled());
}
/**
* 설정 갱신(upsert). 화이트리스트 검증: provider{ollama,claude,qwen3,deepseek,glm}, claudeModel허용셋,
* ollamaTextModel(선택)허용셋. 재기동 없이 다음 호출부터 반영.
*/
public AiConfigDto update(AiConfigUpdateRequest req) {
String provider = req.provider() == null ? "" : req.provider().trim().toLowerCase();
if (!ALLOWED_PROVIDERS.contains(provider)) {
throw new IllegalArgumentException("ERR-AI-400: provider 는 ollama/claude/qwen3/deepseek/glm 중 하나여야 합니다.");
}
upsertAudited(K_PROVIDER, provider);
if (req.claudeModel() != null && !req.claudeModel().isBlank()) {
String model = req.claudeModel().trim();
if (!ALLOWED_CLAUDE_MODELS.contains(model)) {
throw new IllegalArgumentException("ERR-AI-400: 허용되지 않은 Claude 모델 ID 입니다.");
}
upsertAudited(K_CLAUDE_MODEL, model);
}
// ollamaTextModel 선택. 제공 시에만 갱신(미제공/공백 = 기존 유지).
if (req.ollamaTextModel() != null && !req.ollamaTextModel().isBlank()) {
String om = req.ollamaTextModel().trim();
if (!ALLOWED_OLLAMA_MODELS.contains(om)) {
throw new IllegalArgumentException("ERR-AI-400: 허용되지 않은 Ollama 텍스트 모델입니다.");
}
upsertAudited(K_OLLAMA_TEXT_MODEL, om);
}
return getConfig();
}
// ---------------------------------------------------------------- ADMIN: 연결 테스트
/**
* 저장된 설정 기준으로 선택 provider 짧은 ping 생성을 요청해 연결을 확인한다.
* 결과 메시지에는 ·스택트레이스·내부 IP 절대 포함하지 않는다(요약만).
*/
public TestResult test() {
if (isClaudeActive()) {
String model = claudeModel();
long start = System.currentTimeMillis();
GenResult r = claudeClient.generate("ping", model, 8);
long ms = System.currentTimeMillis() - start;
if (!r.degraded() && r.text() != null && !r.text().isBlank()) {
return new TestResult(true, false, "정상 · " + model + " · " + fmtMs(ms));
}
return new TestResult(false, true, "Claude 연결 실패 · 모델/네트워크/키 설정을 확인하세요.");
}
// provider=claude 인데 미설정 실제로는 Ollama 폴백 동작 안내(혼선 방지).
if (PROVIDER_CLAUDE.equals(provider()) && !claudeKeySet()) {
return new TestResult(false, true,
"Claude API 키가 설정되지 않아 Ollama(온프레미스)로 폴백 동작합니다. 서버 환경변수 설정 후 다시 시도하세요.");
}
// Ollama 계열 경로(ollama/qwen3/deepseek/glm)
if (!aiEnabled()) {
return new TestResult(false, true, "AI 기능이 비활성 상태입니다. 모든 AI 결과는 규칙 기반(degraded)으로 동작합니다.");
}
String model = ollamaTextModel();
long start = System.currentTimeMillis();
String txt = ollamaClient.generateText("ping", model);
long ms = System.currentTimeMillis() - start;
if (txt != null && !txt.isBlank()) {
return new TestResult(true, false, "정상 · " + model + " · " + fmtMs(ms));
}
if (PROVIDER_GLM.equals(provider())) {
return new TestResult(false, true,
"GLM(glm4:9b) 응답 없음 — 서버 RAM 증설 필요(약 5.5GB). 확보 전까지 소형 모델/폴백을 권장합니다.");
}
return new TestResult(false, true, "Ollama 연결 실패 또는 모델 미가용 — 설정을 확인하세요.");
}
private static String fmtMs(long ms) {
return String.format("%.1fs", ms / 1000.0);
}
/** 연결 테스트 결과(요약만 — 키/스택/IP 미노출). */
public record TestResult(boolean ok, boolean degraded, String message) {
}
// ---------------------------------------------------------------- helpers
/** DB 값(공백/null 은 '미설정'으로 보고 null 반환 → 기본/env 폴백 유도). */
private String dbVal(String key) {
SignageSetting c = repo.findByKey(key);
if (c == null) {
return null;
}
String v = c.getSettingValue();
return (v != null && !v.isBlank()) ? v : null;
}
private void upsertAudited(String key, String val) {
SignageSetting before = repo.findByKey(key);
String prev = before == null ? "(none)" : before.getSettingValue();
if (val.equals(prev)) {
return; // 변경 없음 감사 로그 생략
}
String desc = before == null ? "AI 플랫폼 설정" : before.getDescription();
repo.upsert(key, val, CATEGORY_AI, desc);
auditService.log("AI_CONFIG_CHANGE", "SETTING", key, prev + " -> " + val);
}
}

View File

@ -0,0 +1,76 @@
package com.zioinfo.signage.ai.service;
import com.zioinfo.signage.ai.learning.LocalLearningStore;
import com.zioinfo.signage.common.ai.ClaudeTextClient;
import com.zioinfo.signage.common.ai.TextAiClient;
import com.zioinfo.signage.common.ai.TextAiClient.GenResult;
import com.zioinfo.signage.config.OllamaClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* AI provider 선택 라우팅(런타임). 평문 텍스트 생성 진입점(알람 원인분석·POS 분류 ). [GUARDiA-SIGNAGE]
* {@link AiConfigService#provider()} 읽어 Claude Ollama(qwen3/deepseek/glm/기존소형) 선택한다.
*
* <p><b>선택/폴백 정책</b>:
* <ul>
* <li>provider=claude · 설정됨 · AI 활성 {@link ClaudeTextClient}. 실패(degraded) <b>Ollama 자동 폴백</b>(선택모델).</li>
* <li>provider=qwen3/deepseek/glm/ollama 해당 Ollama 텍스트 모델로 generate.</li>
* <li>provider=claude 인데 미설정 곧장 Ollama(폴백모델).</li>
* </ul>
* 경로 모두 실패 {@code GenResult.degraded=true·text=null} 호출자가 기존 규칙기반 폴백 유지(무회귀).
* 호출은 provider/model/latency/degraded 로컬 DuckDB({@link LocalLearningStore}) best-effort 기록한다.
*
* <p>알람/POS 프롬프트는 {@code controller.AiController} 라우터로 위임한다. 실패 컨트롤러가
* 기존 {@code OllamaClient#chat} 규칙기반 폴백으로 최종 방어(응답 항상 비어있지 않음).
*
* <p><b>레퍼런스</b>: GUARDiA OCR {@code ai.service.AiTextRouter} 미러(GLM 경로·DuckDB 추론로그 추가).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiTextRouter implements TextAiClient {
private final AiConfigService aiConfig;
private final ClaudeTextClient claudeClient;
private final OllamaClient ollamaClient; // 온프레미스 평문 generate
private final LocalLearningStore learningStore;
/**
* 선택된 provider 텍스트를 생성한다. Claude 선택·실패 Ollama 폴백. 예외를 던지지 않는다.
*/
@Override
public GenResult generate(String prompt) {
long start = System.currentTimeMillis();
if (aiConfig.isClaudeActive()) {
String model = aiConfig.claudeModel();
GenResult r = claudeClient.generate(prompt, model);
if (!r.degraded() && r.text() != null && !r.text().isBlank()) {
logInfer(AiConfigService.PROVIDER_CLAUDE, model, start, false);
return r;
}
// claude 실패/빈응답 온프레미스 Ollama 자동 폴백(선택모델).
log.warn("Claude path degraded -> Ollama fallback");
return ollamaGenerate(AiConfigService.PROVIDER_CLAUDE, prompt, aiConfig.ollamaTextModel(), start);
}
// provider = ollama / qwen3 / deepseek / glm 해당 Ollama 모델
return ollamaGenerate(aiConfig.provider(), prompt, aiConfig.ollamaTextModel(), start);
}
/** Ollama 평문 generate 를 GenResult 로 래핑(실패 시 degraded) + 추론 로그. */
private GenResult ollamaGenerate(String provider, String prompt, String model, long start) {
String txt = ollamaClient.generateText(prompt, model);
boolean degraded = (txt == null || txt.isBlank());
logInfer(provider, model, start, degraded);
return degraded ? new GenResult(null, true) : new GenResult(txt, false);
}
private void logInfer(String provider, String model, long start, boolean degraded) {
try {
learningStore.recordInfer(provider, model, System.currentTimeMillis() - start, degraded);
} catch (Exception ignore) {
// best-effort 추론 로그 실패가 본업을 막지 않는다.
}
}
}

View File

@ -0,0 +1,50 @@
package com.zioinfo.signage.auth;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.uiws.auth.TwoFactorService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
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
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final TwoFactorService twoFactorService;
@PostMapping("/login")
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
return ApiResponse.ok(authService.login(req.username(), req.password()));
}
/** 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")
public ApiResponse<Map<String, String>> me(@RequestHeader("Authorization") String header) {
String token = header.replace("Bearer ", "");
return ApiResponse.ok(authService.me(token));
}
@PostMapping("/logout")
public ApiResponse<Void> logout() {
// JWT stateless 클라이언트에서 토큰 삭제
return ApiResponse.ok("로그아웃 성공", null);
}
record LoginRequest(String username, String password) {}
record VerifyRequest(String verifyToken, String code) {}
}

View File

@ -0,0 +1,43 @@
package com.zioinfo.signage.auth;
import com.zioinfo.signage.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종 컨트롤러(공개 SecurityConfig: /api/auth/** permitAll).
* - POST /api/auth/signup : 회원가입(승인대기)
* - POST /api/auth/find-id : 아이디 찾기(이름+이메일)
* - POST /api/auth/reset-password : 비밀번호 초기화(임시비번 메일 발송)
* AuthController(/login,/verify,/me,/logout) 메서드 경로 미충돌 동일 prefix 분리 컨트롤러.
*
* 보안: 아이디찾기/비번초기화는 미존재 계정도 success=true(열거방지).
* 임시비번/자격증명은 응답에 절대 미포함(메일/로그 채널 전용).
*/
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthHelperController {
private final AuthHelperService authHelperService;
@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));
}
@PostMapping("/reset-password")
public ApiResponse<Void> resetPassword(@RequestBody ResetPwRequest req) {
// 미존재해도 항상 success=true (열거방지). 임시비번은 메일/로그로만 전달.
authHelperService.resetPassword(req);
return ApiResponse.ok("임시 비밀번호를 이메일로 발송했습니다.", null);
}
}

View File

@ -0,0 +1,117 @@
package com.zioinfo.signage.auth;
import com.zioinfo.signage.auth.mapper.UserAuthMapper;
import com.zioinfo.signage.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종(회원가입 승인대기 / 아이디찾기 / 비밀번호 초기화).
* 기존 AuthService(2FA 로그인) 손대지 않고 별도 서비스로 분리.
*
* 보안(불변규칙):
* - 임시비밀번호/비밀번호/자격증명은 API 응답·예외 메시지에 절대 미노출. 메일/로그 채널로만 전달.
* - 아이디찾기/비번초기화는 미존재 계정도 success=true(found=false) 계정 열거(enumeration) 방지.
* - 회원가입은 승인대기(is_active=FALSE, approval_status='PENDING') 즉시 활성화 없음.
* - 외부 API 호출 없음(MailSender 폴백은 로그). Ollama 외부 통신 금지 준수.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthHelperService {
private static final SecureRandom RANDOM = new SecureRandom();
private static final String TEMP_PW_ALPHABET =
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
private static final int TEMP_PW_LEN = 12;
private final UserAuthMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final MailSender mailSender;
/** 회원가입 — 승인대기 등록. 아이디 중복 시 거부. */
@Transactional
public SignupResponse signup(SignupRequest req) {
if (req == null || req.username() == null || req.username().isBlank()
|| req.password() == null || req.password().isBlank()) {
throw new RuntimeException("ERR-AUTH-100: 아이디/비밀번호는 필수입니다.");
}
if (userMapper.countByUsername(req.username()) > 0) {
throw new RuntimeException("ERR-AUTH-101: 이미 사용 중인 아이디");
}
String hash = passwordEncoder.encode(req.password());
userMapper.insertPendingUser(
req.tenantCode(), req.username(), hash,
req.name(), req.email(), req.phone());
log.info("[signup] pending user created username={} (approval required)", req.username());
return new SignupResponse(req.username(), "PENDING_APPROVAL");
}
/** 아이디 찾기 — 이름+이메일 매칭. 미존재도 200(found=false), 마스킹 반환. */
public FindIdResponse findId(FindIdRequest req) {
if (req == null || req.name() == null || req.email() == null) {
return new FindIdResponse(false, "");
}
String username = userMapper.findUsernameByNameEmail(req.name(), req.email());
if (username == null || username.isBlank()) {
return new FindIdResponse(false, "");
}
return new FindIdResponse(true, maskUsername(username));
}
/**
* 비밀번호 초기화 아이디+이름+이메일 일치 임시비번 발급(BCrypt 갱신) + 메일 발송.
* 미존재해도 예외 없이 반환(열거방지). 임시비번은 메일/로그로만 전달.
*/
@Transactional
public void resetPassword(ResetPwRequest req) {
if (req == null || req.username() == null || req.name() == null || req.email() == null) {
return; // 열거방지 잘못된 요청도 조용히 성공 처리
}
SignageUser user = userMapper.findByUsernameNameEmail(req.username(), req.name(), req.email());
if (user == null) {
log.info("[reset-pw] no match for username={} (silently success — enumeration guard)",
req.username());
return;
}
String tempPw = generateTempPassword();
userMapper.updatePasswordHash(user.getUsername(), passwordEncoder.encode(tempPw));
String subject = "[GUARDiA SIGNAGE] 임시 비밀번호 발급";
String body = String.format(
"안녕하세요 %s 님,\n임시 비밀번호는 [%s] 입니다.\n로그인 후 즉시 변경해 주세요.",
user.getUsername(), tempPw);
if (user.getEmail() != null && !user.getEmail().isBlank()) {
// 임시비번은 메일 본문에만 API 응답/예외 메시지에는 절대 미포함.
mailSender.send(user.getEmail(), subject, body);
} else {
log.warn("[reset-pw] no email for username={} — temp password issued (log only)",
user.getUsername());
}
}
/** 임시 비밀번호 생성(혼동 문자 제외 영숫자). */
private static String generateTempPassword() {
StringBuilder sb = new StringBuilder(TEMP_PW_LEN);
for (int i = 0; i < TEMP_PW_LEN; i++) {
sb.append(TEMP_PW_ALPHABET.charAt(RANDOM.nextInt(TEMP_PW_ALPHABET.length())));
}
return sb.toString();
}
/** 아이디 마스킹: 앞 2자만 노출 + 나머지 '*'. (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(username.length() - 2);
}
}

View File

@ -0,0 +1,81 @@
package com.zioinfo.signage.auth;
import com.zioinfo.signage.auth.mapper.UserAuthMapper;
import com.zioinfo.signage.uiws.auth.TwoFactorService;
import com.zioinfo.signage.uiws.common.UiwsApiException;
import com.zioinfo.signage.uiws.common.UiwsErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* ESN 인증 서비스.
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 회귀 0).
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 verify-token + 이메일코드 발급.
* 실패 누적 max-login-fail 계정 잠금.
*/
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserAuthMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final TwoFactorService twoFactorService;
/**
* 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) {
SignageUser 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 (!passwordEncoder.matches(password, user.getPasswordHash())) {
// 2FA 활성 실패 누적/잠금. 비활성 기존 동작(메시지만) 유지.
if (twoFactorService.isEnabled()) {
twoFactorService.recordLoginFailure(username);
SignageUser after = userMapper.findByUsername(username);
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
}
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);
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) {
return Map.of(
"username", jwtUtil.getUsername(token),
"role", jwtUtil.getRole(token),
"tenant", jwtUtil.getTenant(token) != null ? jwtUtil.getTenant(token) : ""
);
}
}

View File

@ -0,0 +1,5 @@
package com.zioinfo.signage.auth;
/** 아이디 찾기 요청(이름+이메일 매칭). */
public record FindIdRequest(String name, String email) {
}

View File

@ -0,0 +1,5 @@
package com.zioinfo.signage.auth;
/** 아이디 찾기 응답. found=false 면 maskedUsername="" (열거방지 — 미존재도 200). */
public record FindIdResponse(boolean found, String maskedUsername) {
}

View File

@ -0,0 +1,43 @@
package com.zioinfo.signage.auth;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
// 보안(치명 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 role = jwtUtil.getRole(token);
var auth = new UsernamePasswordAuthenticationToken(
username, null, List.of(new SimpleGrantedAuthority("ROLE_" + role))
);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(req, res);
}
}

View File

@ -0,0 +1,98 @@
package com.zioinfo.signage.auth;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Slf4j
@Component
public class JwtUtil {
@Value("${guardia.jwt.secret:ZioInfoSignageJwtSecret2026VeryLongSecretKeyForHS256AlgorithmAtLeast256Bits}")
private String secret;
@Value("${guardia.jwt.expiration:86400000}")
private long expirationMs;
private SecretKey key() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generate(String username, String role, String tenantCode) {
return Jwts.builder()
.subject(username)
.claim("role", role)
.claim("tenant", tenantCode)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(key())
.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) {
return Jwts.parser().verifyWith(key()).build()
.parseSignedClaims(token).getPayload();
}
public boolean isValid(String token) {
try {
parse(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
log.debug("JWT 검증 실패: {}", e.getMessage());
return false;
}
}
public String getUsername(String token) { return parse(token).getSubject(); }
public String getRole(String token) { return parse(token).get("role", String.class); }
public String getTenant(String token) { return parse(token).get("tenant", String.class); }
}

View File

@ -0,0 +1,5 @@
package com.zioinfo.signage.auth;
/** 비밀번호 초기화 요청(아이디+이름+이메일 매칭). 임시비번은 메일/로그로만 전달. */
public record ResetPwRequest(String username, String name, String email) {
}

View File

@ -0,0 +1,41 @@
package com.zioinfo.signage.auth;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class SignageUser {
private Long id;
private String tenantCode;
private String username;
@JsonIgnore
private String passwordHash; // API 응답 미노출
private String role; // ADMIN, MANAGER, USER
private String email;
private String phone;
private boolean active;
// 로그인 보조 이식 컬럼 (signage_user ALTER, db/93_signage_login_helper.sql)
/** 이름(아이디찾기/비번초기화 매칭 키). */
private String name;
/** 가입 승인 상태(APPROVED / PENDING). 신규 가입은 PENDING(승인대기). */
private String approvalStatus;
private LocalDateTime lastLoginAt;
private LocalDateTime createdAt;
// UIWS 2FA 이식 컬럼 (signage_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;
}

View File

@ -0,0 +1,11 @@
package com.zioinfo.signage.auth;
/** 회원가입 요청(승인대기 INSERT). 비밀번호는 BCrypt 저장, 응답/로그에 평문 노출 금지. */
public record SignupRequest(
String username,
String password,
String name,
String email,
String phone,
String tenantCode) {
}

View File

@ -0,0 +1,5 @@
package com.zioinfo.signage.auth;
/** 회원가입 응답. status="PENDING_APPROVAL" (승인대기). 자격증명 미포함. */
public record SignupResponse(String username, String status) {
}

View File

@ -0,0 +1,57 @@
package com.zioinfo.signage.auth.mapper;
import com.zioinfo.signage.auth.SignageUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
@Mapper
public interface UserAuthMapper {
SignageUser findByUsername(@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);
// 로그인 보조 이식: 회원가입(승인대기) / 아이디찾기 / 비밀번호 초기화
/** 아이디 중복 확인. */
int countByUsername(@Param("username") String username);
/** 회원가입 — 승인대기(is_active=FALSE, approval_status='PENDING') INSERT. */
int insertPendingUser(@Param("tenantCode") String tenantCode,
@Param("username") String username,
@Param("passwordHash") String passwordHash,
@Param("name") String name,
@Param("email") String email,
@Param("phone") String phone);
/** 아이디 찾기 — 이름+이메일 일치 username 1건(없으면 null). */
String findUsernameByNameEmail(@Param("name") String name, @Param("email") String email);
/** 비밀번호 초기화 — 아이디+이름+이메일 모두 일치하는 사용자(없으면 null). */
SignageUser findByUsernameNameEmail(@Param("username") String username,
@Param("name") String name,
@Param("email") String email);
/** 임시 비밀번호(BCrypt) 갱신. */
int updatePasswordHash(@Param("username") String username,
@Param("passwordHash") String passwordHash);
}

View File

@ -0,0 +1,27 @@
package com.zioinfo.signage.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.time.LocalDateTime;
@Getter
@AllArgsConstructor
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
private LocalDateTime timestamp;
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, "OK", data, LocalDateTime.now());
}
public static <T> ApiResponse<T> ok(String message, T data) {
return new ApiResponse<>(true, message, data, LocalDateTime.now());
}
public static <T> ApiResponse<T> fail(String message) {
return new ApiResponse<>(false, message, null, LocalDateTime.now());
}
}

View File

@ -0,0 +1,64 @@
package com.zioinfo.signage.common;
import com.zioinfo.signage.uiws.common.UiwsApiException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 전역 예외 처리(보안 불변규칙: 스택트레이스/SQL/자격증명 미노출).
*
* <p>UIWS 이식 모듈 + 2FA 레이어 도입과 함께 추가. 기존 ESN 컨트롤러도 동일 정책으로 보호한다
* (예외 메시지는 사용자 노출용 요약만 내부 원인은 서버 로그에만 남긴다).
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/** UIWS 이식/2FA 도메인 예외 → 400 + 안전한 사용자 메시지. */
@ExceptionHandler(UiwsApiException.class)
public ResponseEntity<ApiResponse<Void>> handleUiws(UiwsApiException e) {
log.debug("[UIWS] {}: {}", e.getErrorCode().getCode(), e.getMessage());
return ResponseEntity.badRequest().body(ApiResponse.fail(e.getMessage()));
}
/** 요청 검증 실패 → 400 + 필드 요약(스택트레이스 없음). */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(fe -> fe.getDefaultMessage())
.orElse("요청 값이 올바르지 않습니다.");
return ResponseEntity.badRequest().body(ApiResponse.fail(msg));
}
/** 권한 거부 → 403 (RBAC 가드). */
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleDenied(AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.fail("접근 권한이 없습니다."));
}
/**
* 모든 예외 400 + 요약 메시지(기존 ESN RuntimeException("ERR-AUTH-...") 흐름 보존).
* 스택트레이스/원인은 서버 로그에만 기록하고 응답에는 노출하지 않는다(보안 불변규칙).
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiResponse<Void>> handleRuntime(RuntimeException e, HttpServletRequest req) {
log.warn("[ERR] {} {} — {}", req.getMethod(), req.getRequestURI(), e.getMessage());
String msg = e.getMessage() != null ? e.getMessage() : "요청 처리 중 오류가 발생했습니다.";
return ResponseEntity.badRequest().body(ApiResponse.fail(msg));
}
/** 그 외 체크 예외/오류 → 500 + 요약(상세 미노출). */
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleOther(Exception e, HttpServletRequest req) {
log.error("[ERR-500] {} {}", req.getMethod(), req.getRequestURI(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail("서버 처리 중 오류가 발생했습니다."));
}
}

View File

@ -0,0 +1,171 @@
package com.zioinfo.signage.common.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zioinfo.signage.common.ai.TextAiClient.GenResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* 외부 Claude(Anthropic) Messages API 텍스트 생성 클라이언트. [ISSUER=GUARDiA-SIGNAGE]
*
* <p><b>외부 호출 예외 허용</b>: 클라이언트는 소유자 승인(폐쇄망 아님) 따라 {@code api.anthropic.com}
* 호출이 허용된 유일한 외부 경로다. 외부 API 호출은 여전히 금지(Ollama localhost 전용).
*
* <p><b>API 보안(최우선)</b>: 키는 환경변수 {@code ANTHROPIC_API_KEY} 에서만 로드하며
* ({@code @Value("${ANTHROPIC_API_KEY:}")}), DB·코드·커밋·로그·응답·예외 메시지 어디에도 기록하지 않는다.
* 키는 오직 HTTP 헤더 {@code x-api-key} 로만 전달된다. 로그는 상태코드/예외 클래스명만 남긴다(본문· 미기록).
*
* <p><b>실패 처리</b>: 미설정/타임아웃/비200/예외 {@code GenResult.degraded=true·text=null} 반환
* (예외를 던지지 않음 호출자는 Ollama 폴백). 신규 라이브러리 0 JDK {@link HttpClient} 사용.
*
* <p>모델은 인자로 받는다(호출자가 화이트리스트 검증된 모델 ID 전달). 인터페이스 {@link TextAiClient}
* {@code generate(String)} 기본 모델로 위임한다(주로 라우터가 모델을 명시 호출).
*
* <p><b>레퍼런스</b>: GUARDiA OCR {@code com.zioinfo.ocr.common.ai.ClaudeTextClient} 미러(패키지·ISSUER 치환).
*/
@Slf4j
@Service
public class ClaudeTextClient implements TextAiClient {
/** Anthropic Messages API 엔드포인트(외부 호출 예외 허용 단일 경로). */
private static final String API_URL = "https://api.anthropic.com/v1/messages";
/** Anthropic 버전 헤더(고정). */
private static final String ANTHROPIC_VERSION = "2023-06-01";
/** 기본 모델(키 검증된 화이트리스트의 기본값). */
static final String DEFAULT_MODEL = "claude-sonnet-4-6";
/** 일반 생성 max_tokens. */
private static final int DEFAULT_MAX_TOKENS = 1024;
/** 콜드 응답 대비 요청 타임아웃(초). Ollama timeout 과 동급. */
private static final int TIMEOUT_SEC = 120;
private static final int CONNECT_TIMEOUT_SEC = 15;
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 환경변수 전용 주입. 미설정 시 빈 문자열(=미구성). 값은 절대 외부 노출/로그 금지. */
@Value("${ANTHROPIC_API_KEY:}")
private String apiKey;
/** API 키가 환경변수로 설정되어 있는지(여부만 — 값/길이/마스킹 일절 미노출). */
public boolean isConfigured() {
return apiKey != null && !apiKey.isBlank();
}
/** {@link TextAiClient} 계약: 기본 모델로 생성. */
@Override
public GenResult generate(String prompt) {
return generate(prompt, DEFAULT_MODEL);
}
/** 지정 모델로 생성(라우터가 화이트리스트 검증된 모델 ID 전달). */
public GenResult generate(String prompt, String model) {
return generate(prompt, model, DEFAULT_MAX_TOKENS);
}
/**
* Anthropic Messages API 호출. 실패 {@code degraded=true·text=null}.
* ·요청본문·응답본문은 로그에 남기지 않는다(상태코드/예외 클래스명만).
*/
public GenResult generate(String prompt, String model, int maxTokens) {
if (!isConfigured()) {
return new GenResult(null, true);
}
if (prompt == null || prompt.isBlank()) {
return new GenResult(null, true);
}
String useModel = (model == null || model.isBlank()) ? DEFAULT_MODEL : model.trim();
int tokens = maxTokens > 0 ? maxTokens : DEFAULT_MAX_TOKENS;
try {
String payload = "{"
+ "\"model\":" + str(useModel) + ","
+ "\"max_tokens\":" + tokens + ","
+ "\"messages\":[{\"role\":\"user\",\"content\":" + str(prompt) + "}]"
+ "}";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SEC))
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.timeout(Duration.ofSeconds(TIMEOUT_SEC))
.header("content-type", "application/json")
.header("x-api-key", apiKey) // 키는 헤더로만 로그/응답 미노출
.header("anthropic-version", ANTHROPIC_VERSION)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
log.warn("Claude API status {} -> degraded fallback", resp.statusCode()); // 본문 미기록
return new GenResult(null, true);
}
String text = extractText(resp.body());
if (text == null || text.isBlank()) {
return new GenResult(null, true);
}
return new GenResult(text.trim(), false);
} catch (Exception e) {
log.warn("Claude API call failed -> degraded fallback: {}", e.getClass().getSimpleName()); // 메시지· 미기록
return new GenResult(null, true);
}
}
/** Messages API 응답에서 content[].text(type=text) 추출·연결. */
private static String extractText(String body) {
if (body == null || body.isBlank()) {
return null;
}
try {
JsonNode root = MAPPER.readTree(body);
JsonNode content = root.get("content");
if (content == null || !content.isArray()) {
return null;
}
StringBuilder sb = new StringBuilder();
for (JsonNode block : content) {
JsonNode type = block.get("type");
if (type != null && "text".equals(type.asText())) {
JsonNode t = block.get("text");
if (t != null && !t.isNull()) {
sb.append(t.asText());
}
}
}
String out = sb.toString();
return out.isBlank() ? null : out;
} catch (Exception e) {
return null;
}
}
/** JSON 문자열 이스케이프(프롬프트 직렬화 안전). */
private static String str(String s) {
if (s == null) {
return "\"\"";
}
StringBuilder sb = new StringBuilder("\"");
for (char c : s.toCharArray()) {
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> {
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
}
return sb.append("\"").toString();
}
}

View File

@ -0,0 +1,27 @@
package com.zioinfo.signage.common.ai;
/**
* 텍스트 생성 공용 인터페이스 (AI provider 추상화). [GUARDiA-SIGNAGE]
*
* <p>온프레미스 Ollama({@code config.OllamaClient#generateText}) 외부 Claude({@link ClaudeTextClient})
* 동일 계약으로 다루기 위한 얇은 추상화. 구현체는 어떤 사유로든(비활성/실패/타임아웃/키미설정) 실패
* {@link GenResult#degraded()}=true · {@link GenResult#text()}=null 반환하고, 호출자가 폴백을 책임진다.
*
* <p>provider 선택/폴백 라우팅은 {@code ai.service.AiTextRouter} 담당한다( 인터페이스를 구현).
*
* <p><b>레퍼런스</b>: GUARDiA OCR {@code com.zioinfo.ocr.common.ai.TextAiClient} 미러(패키지·ISSUER 치환).
*/
public interface TextAiClient {
/**
* 프롬프트로 텍스트를 생성한다. 실패 {@code degraded=true·text=null}(예외를 던지지 않음).
*
* @param prompt 한국어 지시문(컨텍스트 포함)
* @return 생성 결과(텍스트 또는 degraded 폴백 신호)
*/
GenResult generate(String prompt);
/** 생성 결과: 텍스트(폴백 시 null) + degraded(폴백 여부). */
record GenResult(String text, boolean degraded) {
}
}

View File

@ -0,0 +1,68 @@
package com.zioinfo.signage.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
/**
* AES-256-GCM 자격증명 암호화 유틸리티.
* 보안 불변 규칙: 서버 자격증명은 암호화 DB에만 저장, API 응답에 절대 노출 금지.
*/
@Slf4j
@Component
public class CryptoUtil {
private static final String ALGO = "AES/GCM/NoPadding";
private static final int GCM_IV_LEN = 12;
private static final int GCM_TAG_LEN = 128;
// 운영 환경변수로 주입 권장
private static final String KEY_HEX = "7a696f696e666f7369676e6167656b6579323032367365637265746b65797878";
private SecretKey secretKey() {
byte[] key = new byte[32];
byte[] src = KEY_HEX.getBytes(StandardCharsets.UTF_8);
System.arraycopy(src, 0, key, 0, Math.min(src.length, 32));
return new SecretKeySpec(key, "AES");
}
public String encrypt(String plain) {
if (plain == null) return null;
try {
byte[] iv = new byte[GCM_IV_LEN];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance(ALGO);
cipher.init(Cipher.ENCRYPT_MODE, secretKey(), new GCMParameterSpec(GCM_TAG_LEN, iv));
byte[] enc = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
byte[] result = new byte[iv.length + enc.length];
System.arraycopy(iv, 0, result, 0, iv.length);
System.arraycopy(enc, 0, result, iv.length, enc.length);
return Base64.getEncoder().encodeToString(result);
} catch (Exception e) {
log.error("암호화 실패", e);
throw new RuntimeException("CRYPTO_ERR", e);
}
}
public String decrypt(String encrypted) {
if (encrypted == null) return null;
try {
byte[] raw = Base64.getDecoder().decode(encrypted);
byte[] iv = new byte[GCM_IV_LEN];
System.arraycopy(raw, 0, iv, 0, GCM_IV_LEN);
Cipher cipher = Cipher.getInstance(ALGO);
cipher.init(Cipher.DECRYPT_MODE, secretKey(), new GCMParameterSpec(GCM_TAG_LEN, iv));
byte[] dec = cipher.doFinal(raw, GCM_IV_LEN, raw.length - GCM_IV_LEN);
return new String(dec, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("복호화 실패", e);
throw new RuntimeException("CRYPTO_ERR", e);
}
}
}

View File

@ -0,0 +1,119 @@
package com.zioinfo.signage.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
/**
* Ollama 온프레미스 AI 클라이언트.
* 보안 불변 규칙: 외부 AI API 절대 금지 Ollama localhost:11434만 허용.
* 연결 실패 폴백 응답 반환(예외 미전파).
*/
@Slf4j
@Component
public class OllamaClient {
@Value("${guardia.ollama-url:http://localhost:11434}")
private String baseUrl;
@Value("${guardia.ollama-text-model:llama3}")
private String model;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private final ObjectMapper mapper = new ObjectMapper();
public String chat(String prompt) {
return chat(prompt, "ESN AI 이상 분석 전문가 역할입니다.");
}
public String chat(String prompt, String systemPrompt) {
try {
var body = Map.of(
"model", model,
"messages", new Object[]{
Map.of("role", "system", "content", systemPrompt),
Map.of("role", "user", "content", prompt)
},
"stream", false
);
String json = mapper.writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/api/chat"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.timeout(Duration.ofSeconds(120))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
var res = mapper.readTree(resp.body());
return res.path("message").path("content").asText("분석 결과를 생성했습니다.");
}
return fallback(prompt);
} catch (Exception e) {
log.warn("Ollama 연결 실패 — 폴백 응답 반환: {}", e.getMessage());
return fallback(prompt);
}
}
/**
* 평문 텍스트 생성(모델 지정). AiTextRouter/AiConfigService 연결 테스트 진입점.
*
* <p>{@link #chat(String, String)} 달리 <b>실패 규칙기반 폴백 문자열을 만들지 않고 {@code null}</b>
* 반환한다(라우터가 degraded 감지해 다음 단계 폴백을 수행하게 하기 위함).
*
* @param prompt 사용자 프롬프트(시스템 지시 포함 가능)
* @param modelOverride 사용할 Ollama 모델(null/blank 이면 서버 기본 {@code guardia.ollama-text-model})
* @return 생성 텍스트(성공) 또는 {@code null}(비200/타임아웃/예외/빈응답)
*/
public String generateText(String prompt, String modelOverride) {
if (prompt == null || prompt.isBlank()) {
return null;
}
String useModel = (modelOverride == null || modelOverride.isBlank()) ? model : modelOverride;
try {
var body = Map.of(
"model", useModel,
"messages", new Object[]{ Map.of("role", "user", "content", prompt) },
"stream", false
);
String json = mapper.writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/api/chat"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.timeout(Duration.ofSeconds(120))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
var res = mapper.readTree(resp.body());
String text = res.path("message").path("content").asText("");
return (text == null || text.isBlank()) ? null : text.trim();
}
log.warn("Ollama generateText status {} — null 반환(라우터 폴백)", resp.statusCode());
return null;
} catch (Exception e) {
log.warn("Ollama generateText 실패 — null 반환(라우터 폴백): {}", e.getClass().getSimpleName());
return null;
}
}
private String fallback(String prompt) {
if (prompt.toLowerCase().contains("alarm") || prompt.contains("알람")) {
return "알람 분석: 장치 연결 상태를 확인하고 네트워크 환경을 점검하세요. 지속 발생 시 현장 엔지니어 파견이 필요합니다.";
}
if (prompt.toLowerCase().contains("pos") || prompt.contains("가격")) {
return "POS 데이터 분류: 정상 가격 변환 데이터입니다. 이상 감지된 항목은 수동 검토가 필요합니다.";
}
return "AI 분석 결과: 현재 시스템 상태를 검토하고 운영 매뉴얼을 참조하세요.";
}
}

View File

@ -0,0 +1,80 @@
package com.zioinfo.signage.config;
import com.zioinfo.signage.auth.JwtFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Spring Security 6 JWT 무상태 인증 + 멀티테넌트 RBAC.
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> {})
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// 인증 불필요
.requestMatchers("/api/auth/**").permitAll()
// UIWS system 이식: 공통 룩업(부서/거래처 트리 비민감) 공개 조회
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
// 정적 리소스 (React SPA)
.requestMatchers(HttpMethod.GET, "/", "/index.html", "/assets/**", "/*.js", "/*.css", "/*.ico").permitAll()
// 관리자 전용
.requestMatchers("/api/tenants/**").hasRole("ADMIN")
.requestMatchers("/api/users/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers("/api/firmware/**").hasAnyRole("ADMIN", "MANAGER")
// UIWS system(권한관리: 사용자/역할/메뉴/부서/거래처/코드/프로그램) 관리자/매니저 전용
// (일반 /api/** 메서드 규칙보다 먼저 평가되도록 상단 배치)
.requestMatchers("/api/system/**").hasAnyRole("ADMIN", "MANAGER")
// admin 보조: 감사로그 조회·설정 조회 = ADMIN/MANAGER, 설정 변경 = ADMIN
// (아래 일반 PUT /api/** 규칙보다 먼저 평가되도록 상단 배치)
.requestMatchers(HttpMethod.PUT, "/api/admin/settings/**").hasRole("ADMIN")
// AI 플랫폼(LLM provider) 설정 조회/갱신/테스트 모두 ADMIN 전용 (상단 배치로 우선 평가)
.requestMatchers("/api/admin/ai-config/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "MANAGER")
// AI 기법 토글 변경 = MANAGER+ (조회/실행은 일반 인증 사용자) 상단 배치로 우선 평가
.requestMatchers(HttpMethod.PUT, "/api/ai/rag/toggles/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.DELETE, "/api/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.POST, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER")
.requestMatchers(HttpMethod.PUT, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER")
// GET은 인증 사용자 허용
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.signage.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class WebConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}

View File

@ -0,0 +1,44 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.AdScheduleVo;
import com.zioinfo.signage.service.AdScheduleService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/ad-schedules")
@RequiredArgsConstructor
public class AdScheduleController {
private final AdScheduleService service;
@GetMapping
public ApiResponse<List<AdScheduleVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(tenantCode, status, keyword));
}
@GetMapping("/{id}")
public ApiResponse<AdScheduleVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<AdScheduleVo> create(@RequestBody AdScheduleVo v) {
return ApiResponse.ok("광고 스케줄 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<AdScheduleVo> update(@PathVariable Long id, @RequestBody AdScheduleVo v) {
return ApiResponse.ok("광고 스케줄 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("광고 스케줄 삭제 완료", null);
}
}

View File

@ -0,0 +1,110 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.ai.service.AiTextRouter;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.common.ai.TextAiClient.GenResult;
import com.zioinfo.signage.config.OllamaClient;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* SIGNAGE 텍스트 AI 진입점 간판 장애 원인분석 · 간판 문구 생성 · 광고 편성 추천.
*
* <p><b>provider 라우팅</b>: {@link AiTextRouter} 경유해 Claude(외부, 설정 )Ollama(온프레미스,
* qwen3/deepseek/glm/기존소형) 런타임 선택한다. 라우터가 degraded 반환하면( 미설정· provider 실패)
* 기존 {@link OllamaClient#chat} 규칙기반 폴백으로 최종 방어 응답은 항상 비어있지 않다(무회귀).
*
* <p>보안 불변: 외부 AI Claude(소유자 승인 예외) 단일 경로. 외부 호출 금지. /스택 미노출.
*/
@RestController
@RequestMapping("/api/ai")
@RequiredArgsConstructor
public class AiController {
private final AiTextRouter aiRouter;
private final OllamaClient ollama;
/**
* 간판/게이트웨이 장애 원인분석 provider 라우팅(ClaudeOllama), 실패 규칙기반 폴백.
*/
@PostMapping("/analyze-alarm")
public ApiResponse<Map<String, String>> analyzeAlarm(@RequestBody Map<String, Object> body) {
String alarmType = (String) body.getOrDefault("alarmType", "UNKNOWN");
String message = (String) body.getOrDefault("message", "");
String severity = (String) body.getOrDefault("severity", "LOW");
String system = "당신은 전자간판(e-SignBoard) 중앙관제 전문 AI 엔지니어입니다. 게이트웨이·ESB 태그·디스플레이 장애를 분석하고 실용적 조치를 제안하세요.";
String prompt = String.format(
"전자간판 장애 분석:\n유형: %s\n심각도: %s\n메시지: %s\n\n" +
"원인 분석 및 조치 방안을 3줄 이내로 간결하게 제시하세요.",
alarmType, severity, message
);
String result = routeOrFallback(system, prompt);
return ApiResponse.ok(Map.of("analysis", result, "alarmType", alarmType, "severity", severity));
}
/**
* 간판 문구 생성 상호/업종/ 기반 간판 문구 후보(provider 라우팅, 실패 규칙기반 폴백).
*/
@PostMapping("/generate-sign-text")
public ApiResponse<Map<String, String>> generateSignText(@RequestBody Map<String, Object> body) {
String bizName = (String) body.getOrDefault("bizName", "");
String industry = (String) body.getOrDefault("industry", "");
String tone = (String) body.getOrDefault("tone", "심플하고 고급스럽게");
String system = "당신은 전자간판 카피라이터 AI 입니다. 도시 미관과 조화되는 간결한 간판 문구를 제안합니다.";
String prompt = String.format(
"간판 문구 생성:\n상호: %s\n업종: %s\n톤: %s\n\n" +
"표시하기 좋은 간판 문구 후보 3개를 짧게 제시하세요.",
bizName, industry, tone
);
String result = routeOrFallback(system, prompt);
return ApiResponse.ok(Map.of("bizName", bizName, "suggestions", result));
}
/**
* 광고 편성 추천 매장/시간대/업종 기반 주기 광고 편성 추천(provider 라우팅, 실패 규칙기반 폴백).
*/
@PostMapping("/recommend-ad")
public ApiResponse<Map<String, String>> recommendAd(@RequestBody Map<String, Object> body) {
String storeCode = String.valueOf(body.getOrDefault("storeCode", ""));
String context = (String) body.getOrDefault("context", "");
String system = "당신은 전자간판 광고 편성 AI 입니다. 매장 특성과 시간대를 고려해 광고 편성을 추천합니다.";
String prompt = String.format(
"광고 편성 추천:\n매장코드: %s\n상황/특성: %s\n\n" +
"시간대별 광고 편성 전략을 3줄 이내로 제시하세요.",
storeCode, context
);
String result = routeOrFallback(system, prompt);
return ApiResponse.ok(Map.of("storeCode", storeCode, "recommendation", result));
}
/**
* 일반 AI 채팅 전자간판 운영 관련 질문(provider 라우팅, 실패 규칙기반 폴백).
*/
@PostMapping("/chat")
public ApiResponse<Map<String, String>> chat(@RequestBody Map<String, String> body) {
String message = body.getOrDefault("message", "");
String system = "전자간판(e-SignBoard) 중앙관제 플랫폼 운영 전문가입니다.";
String result = routeOrFallback(system, message);
return ApiResponse.ok(Map.of("response", result));
}
/**
* 라우터(ClaudeOllama) 경유 degraded 기존 OllamaClient.chat 규칙기반 폴백.
* 시스템 지시는 프롬프트 앞에 병합(단일 user 메시지 계약). 응답은 항상 비어있지 않다.
*/
private String routeOrFallback(String system, String prompt) {
String merged = (system == null || system.isBlank()) ? prompt : system + "\n\n" + prompt;
GenResult r = aiRouter.generate(merged);
if (!r.degraded() && r.text() != null && !r.text().isBlank()) {
return r.text();
}
return ollama.chat(prompt, system);
}
}

View File

@ -0,0 +1,56 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.AlarmVo;
import com.zioinfo.signage.service.AlarmService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/alarms")
@RequiredArgsConstructor
public class AlarmController {
private final AlarmService service;
@GetMapping
public ApiResponse<List<AlarmVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String severity,
@RequestParam(required = false) String status) {
return ApiResponse.ok(service.list(tenantCode, storeId, severity, status));
}
@GetMapping("/{id}")
public ApiResponse<AlarmVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<AlarmVo> create(@RequestBody AlarmVo v) {
return ApiResponse.ok("알람 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<AlarmVo> update(@PathVariable Long id, @RequestBody AlarmVo v) {
return ApiResponse.ok("알람 수정 완료", service.update(id, v));
}
@PutMapping("/{id}/resolve")
public ApiResponse<Void> resolve(@PathVariable Long id,
@RequestBody Map<String, String> body,
Authentication auth) {
String resolvedBy = auth != null ? auth.getName() : "system";
service.resolve(id, resolvedBy, body.getOrDefault("resolution", ""));
return ApiResponse.ok("알람 해결 완료", null);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("알람 삭제 완료", null);
}
}

View File

@ -0,0 +1,44 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.BackupVo;
import com.zioinfo.signage.service.BackupService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/backups")
@RequiredArgsConstructor
public class BackupController {
private final BackupService service;
@GetMapping
public ApiResponse<List<BackupVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(tenantCode, status, keyword));
}
@GetMapping("/{id}")
public ApiResponse<BackupVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<BackupVo> create(@RequestBody BackupVo v) {
return ApiResponse.ok("백업/복구 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<BackupVo> update(@PathVariable Long id, @RequestBody BackupVo v) {
return ApiResponse.ok("백업/복구 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("백업/복구 삭제 완료", null);
}
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.DashboardVo;
import com.zioinfo.signage.mapper.DashboardMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/dashboard")
@RequiredArgsConstructor
public class DashboardController {
private final DashboardMapper mapper;
@GetMapping
public ApiResponse<DashboardVo> summary(@RequestParam(required = false) String tenantCode) {
return ApiResponse.ok(mapper.getSummary(tenantCode));
}
}

View File

@ -0,0 +1,55 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.admin.AuditService;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.FirmwareVo;
import com.zioinfo.signage.service.FirmwareService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/firmware")
@RequiredArgsConstructor
public class FirmwareController {
private final FirmwareService service;
private final AuditService audit;
@GetMapping
public ApiResponse<List<FirmwareVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) String deviceType) {
return ApiResponse.ok(service.list(tenantCode, deviceType));
}
@GetMapping("/{id}")
public ApiResponse<FirmwareVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<FirmwareVo> create(@RequestBody FirmwareVo v) {
FirmwareVo created = service.create(v);
// ESL 펌웨어 배포 추적 가치 높음
audit.log(created.getTenantCode(), "FIRMWARE_DEPLOY", "FIRMWARE", String.valueOf(created.getId()),
"deviceType=" + created.getDeviceType() + ", version=" + created.getFirmwareVersion()
+ ", latest=" + created.isLatest(), true);
return ApiResponse.ok("펌웨어 등록 완료", created);
}
@PutMapping("/{id}")
public ApiResponse<FirmwareVo> update(@PathVariable Long id, @RequestBody FirmwareVo v) {
FirmwareVo updated = service.update(id, v);
audit.log(updated.getTenantCode(), "FIRMWARE_UPDATE", "FIRMWARE", String.valueOf(id),
"deviceType=" + updated.getDeviceType() + ", version=" + updated.getFirmwareVersion()
+ ", latest=" + updated.isLatest(), true);
return ApiResponse.ok("펌웨어 수정 완료", updated);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
audit.log(null, "FIRMWARE_DELETE", "FIRMWARE", String.valueOf(id), "펌웨어 삭제", true);
return ApiResponse.ok("펌웨어 삭제 완료", null);
}
}

View File

@ -0,0 +1,52 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.GatewayVo;
import com.zioinfo.signage.service.GatewayService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/gateways")
@RequiredArgsConstructor
public class GatewayController {
private final GatewayService service;
@GetMapping
public ApiResponse<List<GatewayVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String deviceType,
@RequestParam(required = false) String status) {
return ApiResponse.ok(service.list(tenantCode, storeId, deviceType, status));
}
@GetMapping("/{id}")
public ApiResponse<GatewayVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<GatewayVo> create(@RequestBody GatewayVo v) {
return ApiResponse.ok("장치 등록 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<GatewayVo> update(@PathVariable Long id, @RequestBody GatewayVo v) {
return ApiResponse.ok("장치 수정 완료", service.update(id, v));
}
@PutMapping("/{id}/status")
public ApiResponse<Void> updateStatus(@PathVariable Long id, @RequestBody Map<String, String> body) {
service.updateStatus(id, body.getOrDefault("status", "OFFLINE"));
return ApiResponse.ok("상태 업데이트 완료", null);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("장치 삭제 완료", null);
}
}

View File

@ -0,0 +1,56 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.admin.AuditService;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.PosCvtVo;
import com.zioinfo.signage.service.PosCvtService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/pos-cvt")
@RequiredArgsConstructor
public class PosCvtController {
private final PosCvtService service;
private final AuditService audit;
@GetMapping
public ApiResponse<List<PosCvtVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(tenantCode, storeId, status, keyword));
}
@GetMapping("/{id}")
public ApiResponse<PosCvtVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PutMapping("/{id}/process")
public ApiResponse<Integer> process(@PathVariable Long id) {
PosCvtVo pos = service.get(id);
int queued = service.process(id);
// ESL 가격 변경 전파 추적 가치 높음 (가격은 영업정보이나 민감개인정보 아님)
String detail = pos == null ? ("queued=" + queued)
: ("product=" + pos.getProductCode() + ", price=" + pos.getPrice() + ", queued=" + queued);
audit.log(pos != null ? pos.getTenantCode() : null, "POS_PRICE_CHANGE", "POS_CVT",
String.valueOf(id), detail, true);
return ApiResponse.ok("처리 완료 (태그 큐 " + queued + "건)", queued);
}
@PutMapping("/{id}/ignore")
public ApiResponse<Void> ignore(@PathVariable Long id) {
service.ignore(id);
return ApiResponse.ok("무시 처리 완료", null);
}
@PutMapping("/{id}/error")
public ApiResponse<Void> error(@PathVariable Long id, @RequestBody Map<String, String> body) {
service.markError(id, body.getOrDefault("message", "처리 오류"));
return ApiResponse.ok("오류 처리 완료", null);
}
}

View File

@ -0,0 +1,44 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.ProductVo;
import com.zioinfo.signage.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService service;
@GetMapping
public ApiResponse<List<ProductVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(tenantCode, storeId, keyword));
}
@GetMapping("/{id}")
public ApiResponse<ProductVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<ProductVo> create(@RequestBody ProductVo v) {
return ApiResponse.ok("상품 등록 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<ProductVo> update(@PathVariable Long id, @RequestBody ProductVo v) {
return ApiResponse.ok("상품 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("상품 삭제 완료", null);
}
}

View File

@ -0,0 +1,43 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.RegionVo;
import com.zioinfo.signage.service.RegionService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/regions")
@RequiredArgsConstructor
public class RegionController {
private final RegionService service;
@GetMapping
public ApiResponse<List<RegionVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(tenantCode, keyword));
}
@GetMapping("/{id}")
public ApiResponse<RegionVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<RegionVo> create(@RequestBody RegionVo v) {
return ApiResponse.ok("지역 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<RegionVo> update(@PathVariable Long id, @RequestBody RegionVo v) {
return ApiResponse.ok("지역 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("지역 삭제 완료", null);
}
}

View File

@ -0,0 +1,57 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.SignBoardVo;
import com.zioinfo.signage.service.SignBoardService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* e-paper ESL 태그 선반 위치 상품 바인딩 관리
* 핵심 플로우: 태그 스캔 상품 선택 위치 지정 bind() 가격 변경 자동
*/
@RestController
@RequestMapping("/api/signboards")
@RequiredArgsConstructor
public class SignBoardController {
private final SignBoardService service;
@GetMapping
public ApiResponse<List<SignBoardVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String bindStatus,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(tenantCode, storeId, bindStatus, keyword));
}
@GetMapping("/{id}")
public ApiResponse<SignBoardVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@GetMapping("/device/{deviceId}")
public ApiResponse<SignBoardVo> getByDevice(@PathVariable String deviceId) {
return ApiResponse.ok(service.getByDeviceId(deviceId));
}
/** 태그에 상품+위치 바인딩 */
@PostMapping
public ApiResponse<SignBoardVo> bind(@RequestBody SignBoardVo vo) {
return ApiResponse.ok(service.bind(vo));
}
@PutMapping("/{id}")
public ApiResponse<SignBoardVo> update(@PathVariable Long id, @RequestBody SignBoardVo vo) {
vo.setId(id);
return ApiResponse.ok(service.bind(vo));
}
/** 태그 언바인딩 (선반에서 태그 제거 시) */
@PutMapping("/{id}/unbind")
public ApiResponse<Void> unbind(@PathVariable Long id) {
service.unbind(id);
return ApiResponse.ok("언바인딩 완료", null);
}
}

View File

@ -0,0 +1,70 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.SignDeployVo;
import com.zioinfo.signage.service.SignDeployService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/sign-deploys")
@RequiredArgsConstructor
public class SignDeployController {
private final SignDeployService service;
/**
* 매장(간판) 단말 30초 폴링 {header:{token}, body:{type,sub_type,code,path,md5,file_name,file_size}}.
* 사업계획서 슬라이드20 REST 계약. body=null 이면 변경 없음.
*/
@PostMapping("/poll")
public ApiResponse<Map<String, Object>> poll(@RequestBody Map<String, Object> req) {
Object codeVal = req.get("code");
if (codeVal == null) codeVal = req.get("storeCode");
String storeCode = codeVal == null ? "" : String.valueOf(codeVal);
String token = "";
Object h = req.get("header");
if (h instanceof Map) {
Object t = ((Map<?, ?>) h).get("token");
if (t != null) token = String.valueOf(t);
}
return ApiResponse.ok(service.poll(storeCode, token));
}
/** 단말의 수신·적용 완료 통지. */
@PostMapping("/{id}/confirm")
public ApiResponse<Void> confirm(@PathVariable Long id) {
service.confirm(id);
return ApiResponse.ok("배포 적용 확인 완료", null);
}
@GetMapping
public ApiResponse<List<SignDeployVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(tenantCode, status, keyword));
}
@GetMapping("/{id}")
public ApiResponse<SignDeployVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<SignDeployVo> create(@RequestBody SignDeployVo v) {
return ApiResponse.ok("간판정보 배포 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<SignDeployVo> update(@PathVariable Long id, @RequestBody SignDeployVo v) {
return ApiResponse.ok("간판정보 배포 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("간판정보 배포 삭제 완료", null);
}
}

View File

@ -0,0 +1,44 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.SignImageVo;
import com.zioinfo.signage.service.SignImageService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/sign-images")
@RequiredArgsConstructor
public class SignImageController {
private final SignImageService service;
@GetMapping
public ApiResponse<List<SignImageVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(tenantCode, status, keyword));
}
@GetMapping("/{id}")
public ApiResponse<SignImageVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<SignImageVo> create(@RequestBody SignImageVo v) {
return ApiResponse.ok("간판 이미지 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<SignImageVo> update(@PathVariable Long id, @RequestBody SignImageVo v) {
return ApiResponse.ok("간판 이미지 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("간판 이미지 삭제 완료", null);
}
}

View File

@ -0,0 +1,44 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.StoreVo;
import com.zioinfo.signage.service.StoreService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/stores")
@RequiredArgsConstructor
public class StoreController {
private final StoreService service;
@GetMapping
public ApiResponse<List<StoreVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Long storeGroupId,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(tenantCode, storeGroupId, keyword));
}
@GetMapping("/{id}")
public ApiResponse<StoreVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<StoreVo> create(@RequestBody StoreVo v) {
return ApiResponse.ok("매장 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<StoreVo> update(@PathVariable Long id, @RequestBody StoreVo v) {
return ApiResponse.ok("매장 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("매장 삭제 완료", null);
}
}

View File

@ -0,0 +1,41 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.StoreGroupVo;
import com.zioinfo.signage.service.StoreGroupService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/store-groups")
@RequiredArgsConstructor
public class StoreGroupController {
private final StoreGroupService service;
@GetMapping
public ApiResponse<List<StoreGroupVo>> list(@RequestParam(required = false) String tenantCode) {
return ApiResponse.ok(service.list(tenantCode));
}
@GetMapping("/{id}")
public ApiResponse<StoreGroupVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<StoreGroupVo> create(@RequestBody StoreGroupVo v) {
return ApiResponse.ok("매장그룹 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<StoreGroupVo> update(@PathVariable Long id, @RequestBody StoreGroupVo v) {
return ApiResponse.ok("매장그룹 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("매장그룹 삭제 완료", null);
}
}

View File

@ -0,0 +1,43 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.TemplateVo;
import com.zioinfo.signage.service.TemplateService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/templates")
@RequiredArgsConstructor
public class TemplateController {
private final TemplateService service;
@GetMapping
public ApiResponse<List<TemplateVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) String templateType) {
return ApiResponse.ok(service.list(tenantCode, templateType));
}
@GetMapping("/{id}")
public ApiResponse<TemplateVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<TemplateVo> create(@RequestBody TemplateVo v) {
return ApiResponse.ok("템플릿 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<TemplateVo> update(@PathVariable Long id, @RequestBody TemplateVo v) {
return ApiResponse.ok("템플릿 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("템플릿 삭제 완료", null);
}
}

View File

@ -0,0 +1,43 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.TemplateGroupVo;
import com.zioinfo.signage.service.TemplateGroupService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/template-groups")
@RequiredArgsConstructor
public class TemplateGroupController {
private final TemplateGroupService service;
@GetMapping
public ApiResponse<List<TemplateGroupVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(tenantCode, keyword));
}
@GetMapping("/{id}")
public ApiResponse<TemplateGroupVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<TemplateGroupVo> create(@RequestBody TemplateGroupVo v) {
return ApiResponse.ok("템플릿 그룹 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<TemplateGroupVo> update(@PathVariable Long id, @RequestBody TemplateGroupVo v) {
return ApiResponse.ok("템플릿 그룹 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("템플릿 그룹 삭제 완료", null);
}
}

View File

@ -0,0 +1,50 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.admin.AuditService;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.TenantVo;
import com.zioinfo.signage.service.TenantService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/tenants")
@RequiredArgsConstructor
public class TenantController {
private final TenantService service;
private final AuditService audit;
@GetMapping
public ApiResponse<List<TenantVo>> list() {
return ApiResponse.ok(service.list());
}
@GetMapping("/{id}")
public ApiResponse<TenantVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<TenantVo> create(@RequestBody TenantVo v) {
TenantVo created = service.create(v);
audit.log(created.getTenantCode(), "TENANT_CREATE", "TENANT", String.valueOf(created.getId()),
"name=" + created.getTenantName(), true);
return ApiResponse.ok("테넌트 생성 완료", created);
}
@PutMapping("/{id}")
public ApiResponse<TenantVo> update(@PathVariable Long id, @RequestBody TenantVo v) {
TenantVo updated = service.update(id, v);
audit.log(updated.getTenantCode(), "TENANT_UPDATE", "TENANT", String.valueOf(id),
"name=" + updated.getTenantName(), true);
return ApiResponse.ok("테넌트 수정 완료", updated);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
audit.log(null, "TENANT_DELETE", "TENANT", String.valueOf(id), "테넌트 삭제", true);
return ApiResponse.ok("테넌트 삭제 완료", null);
}
}

View File

@ -0,0 +1,75 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.UpdateQueueVo;
import com.zioinfo.signage.service.UpdateQueueService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* e-paper 태그 가격 업데이트 전파
* Gateway가 API를 주기적으로 폴링 태그에 RF 전송 confirm 호출
*
* Gateway 연동 흐름:
* GET /api/update-queue/poll?tenantCode=EMART&limit=50 PENDING 목록 수신
* PUT /api/update-queue/{id}/sent RF 전송 완료
* PUT /api/update-queue/confirm?deviceId=ESL-001 태그 ACK 수신
*/
@RestController
@RequestMapping("/api/update-queue")
@RequiredArgsConstructor
public class UpdateQueueController {
private final UpdateQueueService service;
@GetMapping
public ApiResponse<List<UpdateQueueVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String status) {
return ApiResponse.ok(service.list(tenantCode, storeId, status));
}
@GetMapping("/{id}")
public ApiResponse<UpdateQueueVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
/** Gateway 폴링 엔드포인트: PENDING → SENDING 전환 후 반환 */
@GetMapping("/poll")
public ApiResponse<List<UpdateQueueVo>> poll(
@RequestParam(required = false) String tenantCode,
@RequestParam(defaultValue = "50") int limit) {
return ApiResponse.ok(service.pollPending(tenantCode, limit));
}
/** Gateway가 RF 전송 완료 후 호출 */
@PutMapping("/{id}/sent")
public ApiResponse<Void> sent(@PathVariable Long id) {
service.markSent(id);
return ApiResponse.ok("전송 완료", null);
}
/** 태그로부터 확인 응답 수신 (device_id 기반 일괄 확인) */
@PutMapping("/confirm")
public ApiResponse<Void> confirm(@RequestParam String deviceId) {
service.confirm(deviceId);
return ApiResponse.ok("확인 완료", null);
}
/** 전송 실패 처리 */
@PutMapping("/{id}/failed")
public ApiResponse<Void> failed(@PathVariable Long id,
@RequestBody Map<String, String> body) {
service.markFailed(id, body.getOrDefault("message", "전송 실패"));
return ApiResponse.ok("실패 처리 완료", null);
}
/** 큐 항목 취소 */
@PutMapping("/{id}/cancel")
public ApiResponse<Void> cancel(@PathVariable Long id) {
service.cancel(id);
return ApiResponse.ok("취소 완료", null);
}
}

View File

@ -0,0 +1,79 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.admin.AuditService;
import com.zioinfo.signage.auth.SignageUser;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService service;
private final AuditService audit;
@GetMapping
public ApiResponse<List<SignageUser>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) String role) {
return ApiResponse.ok(service.list(tenantCode, role));
}
@GetMapping("/{id}")
public ApiResponse<SignageUser> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<SignageUser> create(@RequestBody Map<String, Object> body) {
SignageUser user = new SignageUser();
user.setUsername((String) body.get("username"));
user.setRole((String) body.getOrDefault("role", "USER"));
user.setTenantCode((String) body.get("tenantCode"));
user.setEmail((String) body.get("email"));
user.setPhone((String) body.get("phone"));
user.setActive(true);
String rawPassword = (String) body.getOrDefault("password", "changeme123");
SignageUser created = service.create(user, rawPassword);
// 보안: 비밀번호는 감사 detail 절대 기록하지 않는다(username/role/tenant 요약만).
audit.log(created.getTenantCode(), "USER_CREATE", "USER", String.valueOf(created.getId()),
"username=" + created.getUsername() + ", role=" + created.getRole(), true);
return ApiResponse.ok("사용자 생성 완료", created);
}
@PutMapping("/{id}")
public ApiResponse<SignageUser> update(@PathVariable Long id, @RequestBody SignageUser v) {
SignageUser updated = service.update(id, v);
audit.log(updated.getTenantCode(), "USER_UPDATE", "USER", String.valueOf(id),
"role=" + updated.getRole() + ", active=" + updated.isActive(), true);
return ApiResponse.ok("사용자 수정 완료", updated);
}
@PutMapping("/{id}/password")
public ApiResponse<Void> changePassword(@PathVariable Long id, @RequestBody Map<String, String> body) {
service.changePassword(id, body.getOrDefault("password", "changeme123"));
// 보안: 비밀번호 값은 기록하지 않는다(대상 id ).
audit.log(null, "USER_PASSWORD_RESET", "USER", String.valueOf(id), "비밀번호 초기화", true);
return ApiResponse.ok("비밀번호 변경 완료", null);
}
@PutMapping("/{id}/active")
public ApiResponse<SignageUser> setActive(@PathVariable Long id, @RequestBody Map<String, Object> body) {
boolean active = Boolean.TRUE.equals(body.get("active"));
SignageUser u = service.setActive(id, active);
audit.log(u != null ? u.getTenantCode() : null, "USER_ACTIVE_TOGGLE", "USER",
String.valueOf(id), "active=" + active, true);
return ApiResponse.ok(active ? "활성화 완료" : "비활성화 완료", u);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
audit.log(null, "USER_DELETE", "USER", String.valueOf(id), "사용자 삭제", true);
return ApiResponse.ok("사용자 삭제 완료", null);
}
}

View File

@ -0,0 +1,45 @@
package com.zioinfo.signage.controller;
import com.zioinfo.signage.common.ApiResponse;
import com.zioinfo.signage.domain.WorkHistoryVo;
import com.zioinfo.signage.service.WorkService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/works")
@RequiredArgsConstructor
public class WorkController {
private final WorkService service;
@GetMapping
public ApiResponse<List<WorkHistoryVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String workType) {
return ApiResponse.ok(service.list(tenantCode, storeId, status, workType));
}
@GetMapping("/{id}")
public ApiResponse<WorkHistoryVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<WorkHistoryVo> create(@RequestBody WorkHistoryVo v) {
return ApiResponse.ok("작업 등록 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<WorkHistoryVo> update(@PathVariable Long id, @RequestBody WorkHistoryVo v) {
return ApiResponse.ok("작업 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("작업 삭제 완료", null);
}
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class AdScheduleVo {
private Long id;
private String tenantCode;
private Long storeId;
private String storeCode;
private String adName;
private String mediaType;
private String mediaPath;
private String templateCode;
private LocalDateTime startAt;
private LocalDateTime endAt;
private String dailyStart;
private String dailyEnd;
private Integer priority;
private String status;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class AlarmVo {
private Long id;
private String tenantCode;
private Long storeId;
private String storeName;
private String alarmType; // DEVICE, NETWORK, BATTERY, FIRMWARE, SYSTEM
private String alarmCode;
private String severity; // CRITICAL, HIGH, MEDIUM, LOW
private String message;
private String status; // OPEN, ACKNOWLEDGED, RESOLVED
private String resolvedBy;
private String resolution;
private LocalDateTime resolvedAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class BackupVo {
private Long id;
private String tenantCode;
private String storeCode;
private String backupType;
private String fileName;
private Long fileSize;
private String md5;
private String filePath;
private String status;
private LocalDateTime backupAt;
private LocalDateTime restoredAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.signage.domain;
import lombok.Data;
@Data
public class DashboardVo {
private long totalStores;
private long activeStores;
private long totalAlarms;
private long unresolvedAlarms;
private long criticalAlarms;
private long totalDevices;
private long onlineDevices;
private long offlineDevices;
private long totalWorkToday;
private long completedWorkToday;
private long pendingPosCvt;
private long processedPosCvtToday;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class FirmwareVo {
private Long id;
private String tenantCode;
private String firmwareVersion;
private String deviceType; // GATEWAY, HUB, ESL_DEVICE
private String fileName;
private String filePath;
private Long fileSize;
private String checksum;
private String description;
private boolean latest;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class GatewayVo {
private Long id;
private String tenantCode;
private Long storeId;
private String storeName;
private String deviceType; // GATEWAY, HUB, ESB_TAG
private String gwType; // GEN1, GEN2, GEN2_PLUS_EMS (게이트웨이 세대)
private String deviceId;
private String ipAddress;
private String macAddress;
private String firmwareVersion;
private String status; // ONLINE, OFFLINE, ERROR, UPDATING
private Integer batteryLevel;
private String signalStrength;
private LocalDateTime lastSeenAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class PosCvtVo {
private Long id;
private String tenantCode;
private Long storeId;
private String storeName;
private String posCode;
private String productCode;
private String productName;
private BigDecimal price;
private BigDecimal salePrice;
private String currency;
private String status; // PENDING, PROCESSED, ERROR, IGNORED
private String errorMessage;
private LocalDateTime processedAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class ProductVo {
private Long id;
private String tenantCode;
private Long storeId;
private String storeName;
private String productCode;
private String productName;
private String category;
private BigDecimal price;
private BigDecimal salePrice;
private String currency;
private boolean active;
private LocalDateTime updatedAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class RegionVo {
private Long id;
private String tenantCode;
private String regionCode;
private String regionName;
private String dcName;
private String description;
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,27 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class SignBoardVo {
private Long id;
private String tenantCode;
private Long storeId;
private String storeName;
private String deviceId; // ESL_DEVICE의 device_id
private String productCode;
private String productName;
private String locationCode; // 선반 위치 (: A01-03)
private String templateCode;
private String displayData; // 현재 화면 렌더링 데이터 (JSON)
private BigDecimal lastPrice;
private LocalDateTime lastUpdatedAt;
private String bindStatus; // ACTIVE, UNBOUND, ERROR
// HCore 장치 정보 (join)
private String deviceStatus; // ONLINE, OFFLINE
private Integer batteryLevel;
private String signalStrength;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class SignDeployVo {
private Long id;
private String tenantCode;
private String storeCode;
private String deployType;
private String subType;
private String code;
private String fileName;
private Long fileSize;
private String md5;
private String filePath;
private String status;
private Integer pollIntervalSec;
private LocalDateTime lastPolledAt;
private LocalDateTime deployedAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class SignImageVo {
private Long id;
private String tenantCode;
private Long storeId;
private String storeCode;
private String imageCode;
private String bizName;
private String phone;
private String message;
private String templateCode;
private Integer width;
private Integer height;
private String imagePath;
private String status;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,15 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class StoreGroupVo {
private Long id;
private String tenantCode;
private String groupCode;
private String groupName;
private String description;
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class StoreVo {
private Long id;
private String tenantCode;
private String storeCode;
private String storeName;
private String address;
private String phone;
private Long storeGroupId;
private String storeGroupName;
private String managerName;
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,15 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class TemplateGroupVo {
private Long id;
private String tenantCode;
private String groupCode;
private String groupName;
private String description;
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class TemplateVo {
private Long id;
private String tenantCode;
private String templateCode;
private String templateName;
private String templateType; // PRICE, INFO, PROMO, CUSTOM
private Integer width;
private Integer height;
private String description;
private String layoutJson; // JSON 레이아웃 정의
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,14 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class TenantVo {
private Long id;
private String tenantCode;
private String tenantName;
private String description;
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,31 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class UpdateQueueVo {
private Long id;
private String tenantCode;
private Long storeId;
private String storeName;
private Long bindingId;
private String deviceId;
private String locationCode;
private String productCode;
private String productName;
private BigDecimal newPrice;
private BigDecimal newSalePrice;
private String templateCode;
private String renderData;
private Integer priority; // 1(긴급)~10(일반)
private String status; // PENDING, SENDING, SENT, CONFIRMED, FAILED, CANCELLED
private Integer retryCount;
private Integer maxRetry;
private String errorMessage;
private Long posCvtId;
private LocalDateTime sentAt;
private LocalDateTime confirmedAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.signage.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class WorkHistoryVo {
private Long id;
private String tenantCode;
private Long storeId;
private String storeName;
private String workType; // INSTALL, MAINTENANCE, FIRMWARE_UPDATE, REPLACEMENT, INSPECTION
private String workContent;
private String workerName;
private String status; // SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED
private String remarks;
private LocalDateTime startedAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.AdScheduleVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AdScheduleMapper {
List<AdScheduleVo> findAll(@Param("tenantCode") String tenantCode,
@Param("status") String status,
@Param("keyword") String keyword);
AdScheduleVo findById(@Param("id") Long id);
int insert(AdScheduleVo vo);
int update(AdScheduleVo vo);
int delete(@Param("id") Long id);
long countAll(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.AlarmVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AlarmMapper {
List<AlarmVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeId") Long storeId,
@Param("severity") String severity,
@Param("status") String status);
AlarmVo findById(@Param("id") Long id);
int insert(AlarmVo alarm);
int update(AlarmVo alarm);
int resolve(@Param("id") Long id, @Param("resolvedBy") String resolvedBy,
@Param("resolution") String resolution);
int delete(@Param("id") Long id);
long countUnresolved(@Param("tenantCode") String tenantCode);
long countCritical(@Param("tenantCode") String tenantCode);
long countAll(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.BackupVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface BackupMapper {
List<BackupVo> findAll(@Param("tenantCode") String tenantCode,
@Param("status") String status,
@Param("keyword") String keyword);
BackupVo findById(@Param("id") Long id);
int insert(BackupVo vo);
int update(BackupVo vo);
int delete(@Param("id") Long id);
long countAll(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,10 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.DashboardVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface DashboardMapper {
DashboardVo getSummary(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.FirmwareVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface FirmwareMapper {
List<FirmwareVo> findAll(@Param("tenantCode") String tenantCode,
@Param("deviceType") String deviceType);
FirmwareVo findById(@Param("id") Long id);
FirmwareVo findLatest(@Param("deviceType") String deviceType,
@Param("tenantCode") String tenantCode);
int insert(FirmwareVo firmware);
int update(FirmwareVo firmware);
int clearLatest(@Param("deviceType") String deviceType, @Param("tenantCode") String tenantCode);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.GatewayVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface GatewayMapper {
List<GatewayVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeId") Long storeId,
@Param("deviceType") String deviceType,
@Param("status") String status);
GatewayVo findById(@Param("id") Long id);
GatewayVo findByDeviceId(@Param("deviceId") String deviceId);
int insert(GatewayVo hcore);
int update(GatewayVo hcore);
int updateStatus(@Param("id") Long id, @Param("status") String status);
int delete(@Param("id") Long id);
long countOnline(@Param("tenantCode") String tenantCode);
long countOffline(@Param("tenantCode") String tenantCode);
long countAll(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.PosCvtVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface PosCvtMapper {
List<PosCvtVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeId") Long storeId,
@Param("status") String status,
@Param("keyword") String keyword);
PosCvtVo findById(@Param("id") Long id);
int insert(PosCvtVo posCvt);
int updateStatus(@Param("id") Long id, @Param("status") String status,
@Param("errorMessage") String errorMessage);
long countPending(@Param("tenantCode") String tenantCode);
long countProcessedToday(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.ProductVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ProductMapper {
List<ProductVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeId") Long storeId,
@Param("keyword") String keyword);
ProductVo findById(@Param("id") Long id);
ProductVo findByCode(@Param("productCode") String productCode,
@Param("storeId") Long storeId);
int insert(ProductVo product);
int update(ProductVo product);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.RegionVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface RegionMapper {
List<RegionVo> findAll(@Param("tenantCode") String tenantCode,
@Param("keyword") String keyword);
RegionVo findById(@Param("id") Long id);
int insert(RegionVo vo);
int update(RegionVo vo);
int delete(@Param("id") Long id);
long countAll(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,25 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.SignBoardVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface SignBoardMapper {
List<SignBoardVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeId") Long storeId,
@Param("bindStatus") String bindStatus,
@Param("keyword") String keyword);
SignBoardVo findById(@Param("id") Long id);
SignBoardVo findByDeviceId(@Param("deviceId") String deviceId);
List<SignBoardVo> findByProductCode(@Param("tenantCode") String tenantCode,
@Param("storeId") Long storeId,
@Param("productCode") String productCode);
int insert(SignBoardVo vo);
int update(SignBoardVo vo);
int unbind(@Param("id") Long id);
int delete(@Param("id") Long id);
long countAll(@Param("tenantCode") String tenantCode);
long countActive(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.SignDeployVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface SignDeployMapper {
List<SignDeployVo> findAll(@Param("tenantCode") String tenantCode,
@Param("status") String status,
@Param("keyword") String keyword);
SignDeployVo findById(@Param("id") Long id);
/** 매장코드 최신 PENDING/SENT 배포 1건 (30초 폴링 대상). */
SignDeployVo findLatestForStore(@Param("storeCode") String storeCode);
/** 폴링 확인 시각 갱신(status 미변경). */
int markPolled(@Param("id") Long id);
/** 배포 적용 완료 처리(status=APPLIED, deployed_at). */
int markApplied(@Param("id") Long id);
int insert(SignDeployVo vo);
int update(SignDeployVo vo);
int delete(@Param("id") Long id);
long countAll(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.SignImageVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface SignImageMapper {
List<SignImageVo> findAll(@Param("tenantCode") String tenantCode,
@Param("status") String status,
@Param("keyword") String keyword);
SignImageVo findById(@Param("id") Long id);
int insert(SignImageVo vo);
int update(SignImageVo vo);
int delete(@Param("id") Long id);
long countAll(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,15 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.StoreGroupVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface StoreGroupMapper {
List<StoreGroupVo> findAll(@Param("tenantCode") String tenantCode);
StoreGroupVo findById(@Param("id") Long id);
int insert(StoreGroupVo group);
int update(StoreGroupVo group);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.StoreVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface StoreMapper {
List<StoreVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeGroupId") Long storeGroupId,
@Param("keyword") String keyword);
StoreVo findById(@Param("id") Long id);
StoreVo findByCode(@Param("storeCode") String storeCode, @Param("tenantCode") String tenantCode);
int insert(StoreVo store);
int update(StoreVo store);
int delete(@Param("id") Long id);
long countActive(@Param("tenantCode") String tenantCode);
long countAll(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.TemplateGroupVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface TemplateGroupMapper {
List<TemplateGroupVo> findAll(@Param("tenantCode") String tenantCode,
@Param("keyword") String keyword);
TemplateGroupVo findById(@Param("id") Long id);
int insert(TemplateGroupVo vo);
int update(TemplateGroupVo vo);
int delete(@Param("id") Long id);
long countAll(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.TemplateVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface TemplateMapper {
List<TemplateVo> findAll(@Param("tenantCode") String tenantCode,
@Param("templateType") String templateType);
TemplateVo findById(@Param("id") Long id);
int insert(TemplateVo template);
int update(TemplateVo template);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.TenantVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface TenantMapper {
List<TenantVo> findAll();
TenantVo findById(@Param("id") Long id);
TenantVo findByCode(@Param("tenantCode") String tenantCode);
int insert(TenantVo tenant);
int update(TenantVo tenant);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.UpdateQueueVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface UpdateQueueMapper {
List<UpdateQueueVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeId") Long storeId,
@Param("status") String status);
List<UpdateQueueVo> findPending(@Param("tenantCode") String tenantCode,
@Param("limit") int limit);
UpdateQueueVo findById(@Param("id") Long id);
int insert(UpdateQueueVo vo);
int updateStatus(@Param("id") Long id, @Param("status") String status,
@Param("errorMessage") String errorMessage);
int confirmByDeviceId(@Param("deviceId") String deviceId);
int incrementRetry(@Param("id") Long id);
long countPending(@Param("tenantCode") String tenantCode);
long countFailed(@Param("tenantCode") String tenantCode);
long countByStatus(@Param("tenantCode") String tenantCode, @Param("status") String status);
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.auth.SignageUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface UserMapper {
List<SignageUser> findAll(@Param("tenantCode") String tenantCode,
@Param("role") String role);
SignageUser findById(@Param("id") Long id);
SignageUser findByUsername(@Param("username") String username);
int insert(SignageUser user);
int update(SignageUser user);
int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.signage.mapper;
import com.zioinfo.signage.domain.WorkHistoryVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface WorkMapper {
List<WorkHistoryVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeId") Long storeId,
@Param("status") String status,
@Param("workType") String workType);
WorkHistoryVo findById(@Param("id") Long id);
int insert(WorkHistoryVo work);
int update(WorkHistoryVo work);
int delete(@Param("id") Long id);
long countToday(@Param("tenantCode") String tenantCode);
long countCompletedToday(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,254 @@
package com.zioinfo.signage.rag;
import com.zioinfo.signage.config.OllamaClient;
import com.zioinfo.signage.rag.RagToggleService.RagToggles;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* ESN 대표 AI 기능 RAG 배선 레이어(순증·비파괴, 테넌트 격리).
*
* <p>기존 {@link OllamaClient}(폴백 내장) {@code AiController} <b>불변</b>. 서비스는
* 중앙 guardia-rag 경유하는 <b>별개 레이어</b>, 대표 기능을 전환한다:
* <ol>
* <li><b>알람 원인분석·우선순위</b> {@code /rag/agent}(tool-use 다단계 진단)
* + GraphRAG(retrieval_mode=graph 의존성 추적, graphrag 토글 on ) + {@code /rag/structured}
* (우선순위 결정론 JSON). 토글 off/미가용 기존 {@link OllamaClient#chat} 무손실 폴백.</li>
* <li><b>POS 데이터 자동 분류</b> {@code /rag/structured}(format:json 결정론).
* confidence 임계 미만이면 {@code needsReview:true}. 미가용 OllamaClient 폴백.</li>
* </ol>
*
* <p>모든 응답에 적용 metadata(retrievalMode·rerank·structured·toolUse·degraded) 표기해
* 토글 effect 관측 가능하게 한다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RagAiService {
private final SignageRagClient rag;
private final RagToggleService toggles;
private final SignageToolRegistry tools;
private final OllamaClient ollama; // 기존 폴백 재사용(불변)
/** 알람 우선순위·근본원인 결정론 JSON 스키마. */
private static final Map<String, Object> ALARM_SCHEMA = Map.of(
"type", "object",
"properties", Map.of(
"rootCauseHypothesis", Map.of("type", "string"),
"priority", Map.of("type", "string"), // P1~P4
"groupKey", Map.of("type", "string"),
"affectedDeviceCount", Map.of("type", "integer"),
"recommendedActions", Map.of("type", "array", "items", Map.of("type", "string")),
"confidence", Map.of("type", "number")
),
"required", List.of("priority", "rootCauseHypothesis")
);
/** POS 분류 결정론 JSON 스키마. */
private static final Map<String, Object> POS_SCHEMA = Map.of(
"type", "object",
"properties", Map.of(
"classification", Map.of("type", "string"), // NORMAL|ABNORMAL
"category", Map.of("type", "string"), // PRICE_OK|PRICE_MISMATCH|MISSING|OUTLIER ...
"reason", Map.of("type", "string"),
"confidence", Map.of("type", "number")
),
"required", List.of("classification", "confidence")
);
// ESL/HCore 알람 원인분석·우선순위
@SuppressWarnings("unchecked")
public Map<String, Object> analyzeAlarm(String tenantCode, Map<String, Object> req, String actor) {
RagToggles t = toggles.current(tenantCode);
String alarmType = str(req.get("alarmType"));
String severity = str(req.getOrDefault("severity", "LOW"));
String message = str(req.get("message"));
String storeCode = str(req.get("storeCode"));
String query = String.format(
"ESL/HCore 알람의 근본원인과 우선순위를 진단하라. 유형=%s, 심각도=%s, 매장=%s, 메시지=%s. "
+ "허브 다운이 하위 단말 다중 알람을 유발하는 연쇄(cascading) 가능성을 의존성 그래프로 검토하고, "
+ "같은 근본원인의 파생 알람은 하나로 그룹핑하라.",
alarmType, severity, storeCode, message);
Map<String, Object> out = new LinkedHashMap<>();
if (!t.ragEnabled || !rag.available()) {
return alarmFallback(out, t, req);
}
// tool-use 토글 on /rag/agent 다단계 진단, off graph/answer 단발
Map<String, Object> evidence;
if (t.toolUse) {
evidence = rag.agent(tenantCode, query, tools.alarmDiagnosisTools(),
toggles.effectiveMode(t), t.agentMaxSteps, t.generationModel);
} else {
evidence = rag.answer(tenantCode, query, toggles.effectiveMode(t),
t.rerank, t.topK, t.generationModel, true);
}
if (bool(evidence.get("degraded"))) {
return alarmFallback(out, t, req);
}
// 우선순위·그룹핑은 결정론 JSON(/structured)으로 수렴 파싱 실패 차단
String evText = str(evidence.getOrDefault("answer", ""));
Map<String, Object> sres = rag.structured(tenantCode,
query + "\n근거:\n" + evText + "\n위 근거로 priority(P1~P4)·rootCause·group·조치를 JSON 으로만 산출.",
ALARM_SCHEMA, t.generationModel, t.temperature);
Map<String, Object> data = sres.get("data") instanceof Map ? (Map<String, Object>) sres.get("data") : Map.of();
if (bool(sres.get("degraded")) || data.isEmpty()) {
return alarmFallback(out, t, req);
}
out.put("rootCauseHypothesis", data.get("rootCauseHypothesis"));
out.put("priority", data.getOrDefault("priority", "P3"));
out.put("groupKey", data.get("groupKey"));
out.put("affectedDeviceCount", data.get("affectedDeviceCount"));
out.put("recommendedActions", data.get("recommendedActions"));
out.put("confidence", data.get("confidence"));
out.put("citations", evidence.get("citations"));
out.put("answerId", evidence.get("answer_id"));
out.put("engine", t.toolUse ? "RAG_AGENT" : "RAG_GRAPH");
out.put("degraded", false);
out.putAll(meta(t, t.toolUse ? "agent" : "answer", true));
return out;
}
private Map<String, Object> alarmFallback(Map<String, Object> out, RagToggles t, Map<String, Object> req) {
// 비파괴 폴백: 기존 OllamaClient /모델 + 규칙 기반 우선순위
String prompt = String.format("ESL 알람 분석: 유형=%s 심각도=%s 메시지=%s. 원인·조치 3줄.",
str(req.get("alarmType")), str(req.getOrDefault("severity", "LOW")), str(req.get("message")));
String analysis = ollama.chat(prompt, "ESL 시스템 전문 AI 엔지니어. 실용 조치 제안.");
out.put("rootCauseHypothesis", analysis);
out.put("priority", rulePriority(str(req.getOrDefault("severity", "LOW"))));
out.put("recommendedActions", List.of("디바이스 연결 상태 확인", "허브 통신 점검", "지속 시 현장 엔지니어 파견"));
out.put("engine", "OLLAMA_FALLBACK");
out.put("degraded", true);
out.put("degraded_reason", t.ragEnabled ? "rag_unavailable" : "rag_disabled");
out.putAll(meta(t, "fallback", false));
return out;
}
private String rulePriority(String severity) {
switch (severity == null ? "" : severity.toUpperCase()) {
case "CRITICAL": return "P1";
case "HIGH": return "P2";
case "MEDIUM": return "P3";
default: return "P4";
}
}
// POS 데이터 자동 분류 (결정론)
@SuppressWarnings("unchecked")
public Map<String, Object> classifyPos(String tenantCode, Map<String, Object> req, String actor) {
RagToggles t = toggles.current(tenantCode);
String productCode = str(req.get("productCode"));
String price = str(req.getOrDefault("price", "0"));
String salePrice = str(req.getOrDefault("salePrice", "0"));
Map<String, Object> out = new LinkedHashMap<>();
if (!t.ragEnabled || !rag.available() || !t.structured) {
return posFallback(out, t, req);
}
String prompt = String.format(
"POS 가격 데이터를 NORMAL/ABNORMAL 로 분류하라. 상품코드=%s, 정가=%s, 판매가=%s. "
+ "판매가>정가, 음수, 비현실적 값, 누락을 ABNORMAL 로 판정하고 category·reason·confidence(0~1)를 JSON 으로만.",
productCode, price, salePrice);
Map<String, Object> sres = rag.structured(tenantCode, prompt, POS_SCHEMA, t.generationModel, t.temperature);
Map<String, Object> data = sres.get("data") instanceof Map ? (Map<String, Object>) sres.get("data") : Map.of();
if (bool(sres.get("degraded")) || data.isEmpty() || data.get("classification") == null) {
return posFallback(out, t, req);
}
double conf = num(data.get("confidence"), 0.0);
boolean needsReview = conf < t.faithfulnessThreshold;
out.put("classification", String.valueOf(data.get("classification")).toUpperCase());
out.put("category", data.get("category"));
out.put("reason", data.get("reason"));
out.put("confidence", conf);
out.put("needsReview", needsReview); // 임계 미만 수동 검토
out.put("productCode", productCode);
out.put("engine", "RAG_STRUCTURED");
out.put("degraded", false);
out.putAll(meta(t, "structured", true));
return out;
}
private Map<String, Object> posFallback(Map<String, Object> out, RagToggles t, Map<String, Object> req) {
String price = str(req.getOrDefault("price", "0"));
String salePrice = str(req.getOrDefault("salePrice", "0"));
// 규칙 우선(결정론) 판매가>정가 또는 음수면 ABNORMAL
String cls = "NORMAL";
String reason = "규칙 기반: 가격 범위 정상";
try {
double p = Double.parseDouble(price), s = Double.parseDouble(salePrice);
if (p < 0 || s < 0 || s > p) { cls = "ABNORMAL"; reason = "규칙 기반: 음수 또는 판매가>정가"; }
} catch (Exception ignore) {
// 숫자 파싱 실패 모델 보조
String r = ollama.chat("POS 가격 검증: 정가=" + price + " 판매가=" + salePrice, "POS 데이터 품질 관리 AI.");
cls = r.toUpperCase().contains("ABNORMAL") ? "ABNORMAL" : "NORMAL";
reason = "모델 폴백 판정";
}
out.put("classification", cls);
out.put("reason", reason);
out.put("confidence", 0.0);
out.put("needsReview", true); // 폴백은 항상 검토 권고
out.put("productCode", str(req.get("productCode")));
out.put("engine", "RULE_FALLBACK");
out.put("degraded", true);
out.put("degraded_reason", t.ragEnabled ? "rag_unavailable" : "rag_disabled");
out.putAll(meta(t, "fallback", false));
return out;
}
// 피드백 / 토글
public Map<String, Object> feedback(String tenantCode, Map<String, Object> req, String actor) {
String userRef = "u_" + Integer.toHexString((actor == null ? "anon" : actor).hashCode());
return rag.feedback(tenantCode,
str0(req.get("answerId")), str0(req.get("query")), str0(req.get("answer")),
str(req.getOrDefault("verdict", "down")), str0(req.get("correction")), userRef);
}
public Map<String, Object> currentToggles(String tenantCode) {
Map<String, Object> m = toggles.current(tenantCode).toMap();
m.put("ragAvailable", rag.available());
return m;
}
public Map<String, Object> updateToggle(String tenantCode, String key, String value) {
Map<String, Object> m = toggles.update(tenantCode, key, value).toMap();
m.put("ragAvailable", rag.available());
return m;
}
// helpers
private Map<String, Object> meta(RagToggles t, String technique, boolean structured) {
Map<String, Object> m = new LinkedHashMap<>();
Map<String, Object> applied = new LinkedHashMap<>();
applied.put("tenantCode", t.tenantCode);
applied.put("retrievalMode", toggles.effectiveMode(t));
applied.put("rerank", t.rerank);
applied.put("graphrag", t.graphrag);
applied.put("toolUse", t.toolUse);
applied.put("structured", structured);
applied.put("technique", technique);
m.put("applied", applied);
return m;
}
private boolean bool(Object o) { return Boolean.TRUE.equals(o) || "true".equalsIgnoreCase(String.valueOf(o)); }
private double num(Object o, double def) {
try { return Double.parseDouble(String.valueOf(o)); } catch (Exception e) { return def; }
}
private String str(Object o) { return o == null ? "" : String.valueOf(o); }
private String str0(Object o) { return o == null ? null : String.valueOf(o); }
}

View File

@ -0,0 +1,98 @@
package com.zioinfo.signage.rag;
import com.zioinfo.signage.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* ESN 최신 AI 기법(중앙 guardia-rag) 배선 API 별개 레이어, 테넌트 격리.
*
* <p>기존 {@code /api/ai/*}(AiController) <b>불변</b>. 컨트롤러는 중앙 경유 신규 경로만 추가한다.
*
* <ul>
* <li>POST /api/ai/rag/analyze-alarm ESL/HCore 알람 원인분석·우선순위(/agent+graph+structured)</li>
* <li>POST /api/ai/rag/classify-pos POS 데이터 자동 분류(/structured, 임계 미만 needsReview)</li>
* <li>POST /api/ai/rag/feedback 👍/👎 피드백(/rag/feedback, solution=esn 격리)</li>
* <li>GET /api/ai/rag/toggles 현재 테넌트 기법 토글 스냅샷</li>
* <li>PUT /api/ai/rag/toggles/{key} 테넌트 토글 변경(MANAGER+, signage_rag_setting 격리)</li>
* </ul>
*
* <p>테넌트는 {@code X-Tenant-Code} 헤더 또는 {@code tenantCode} 바디 필드로 식별(없으면 기본).
*/
@RestController
@RequestMapping("/api/ai/rag")
@RequiredArgsConstructor
public class RagController {
private final RagAiService service;
@PostMapping("/analyze-alarm")
public ApiResponse<Map<String, Object>> analyzeAlarm(
@RequestBody Map<String, Object> req,
@RequestHeader(value = "X-Tenant-Code", required = false) String tenantHeader,
Authentication auth) {
return ApiResponse.ok(service.analyzeAlarm(tenant(tenantHeader, req), req, name(auth)));
}
@PostMapping("/classify-pos")
public ApiResponse<Map<String, Object>> classifyPos(
@RequestBody Map<String, Object> req,
@RequestHeader(value = "X-Tenant-Code", required = false) String tenantHeader,
Authentication auth) {
return ApiResponse.ok(service.classifyPos(tenant(tenantHeader, req), req, name(auth)));
}
@PostMapping("/feedback")
public ApiResponse<Map<String, Object>> feedback(
@RequestBody Map<String, Object> req,
@RequestHeader(value = "X-Tenant-Code", required = false) String tenantHeader,
Authentication auth) {
return ApiResponse.ok(service.feedback(tenant(tenantHeader, req), req, name(auth)));
}
@GetMapping("/toggles")
public ApiResponse<Map<String, Object>> toggles(
@RequestHeader(value = "X-Tenant-Code", required = false) String tenantHeader,
@RequestParam(value = "tenantCode", required = false) String tenantParam) {
String tc = tenantHeader != null ? tenantHeader : tenantParam;
return ApiResponse.ok(service.currentToggles(tc));
}
/**
* 토글 변경 rag.* 화이트리스트 키만, 테넌트 격리. RBAC(MANAGER+) SecurityConfig 통제.
*/
@PutMapping("/toggles/{key}")
public ApiResponse<Map<String, Object>> updateToggle(
@PathVariable String key,
@RequestBody Map<String, Object> req,
@RequestHeader(value = "X-Tenant-Code", required = false) String tenantHeader) {
if (key == null || !key.startsWith("rag.")) {
return ApiResponse.fail("ERR-RAG-400: rag.* 키만 변경 가능");
}
String tc = tenantHeader != null ? tenantHeader : str(req.get("tenantCode"));
Object v = req.get("value");
try {
return ApiResponse.ok(service.updateToggle(tc, key, v == null ? "" : String.valueOf(v)));
} catch (IllegalArgumentException e) {
return ApiResponse.fail(e.getMessage());
}
}
// helpers
private String tenant(String header, Map<String, Object> req) {
if (header != null && !header.isBlank()) return header;
Object t = req == null ? null : req.get("tenantCode");
return t == null ? null : String.valueOf(t);
}
private String name(Authentication auth) {
return auth == null ? "anon" : auth.getName();
}
private String str(Object o) {
return o == null ? null : String.valueOf(o);
}
}

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