feat(signage): GUARDiA 간판 전자간판 중앙관제 초기 푸시 (fresh init)
This commit is contained in:
commit
314bbd500a
84
backend/pom.xml
Normal file
84
backend/pom.xml
Normal 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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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")));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 — 추론 로그 실패가 본업을 막지 않는다.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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) : ""
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package com.zioinfo.signage.auth;
|
||||
|
||||
/** 아이디 찾기 요청(이름+이메일 매칭). */
|
||||
public record FindIdRequest(String name, String email) {
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package com.zioinfo.signage.auth;
|
||||
|
||||
/** 아이디 찾기 응답. found=false 면 maskedUsername="" (열거방지 — 미존재도 200). */
|
||||
public record FindIdResponse(boolean found, String maskedUsername) {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
98
backend/src/main/java/com/zioinfo/signage/auth/JwtUtil.java
Normal file
98
backend/src/main/java/com/zioinfo/signage/auth/JwtUtil.java
Normal 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); }
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package com.zioinfo.signage.auth;
|
||||
|
||||
/** 비밀번호 초기화 요청(아이디+이름+이메일 매칭). 임시비번은 메일/로그로만 전달. */
|
||||
public record ResetPwRequest(String username, String name, String email) {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package com.zioinfo.signage.auth;
|
||||
|
||||
/** 회원가입 응답. status="PENDING_APPROVAL" (승인대기). 자격증명 미포함. */
|
||||
public record SignupResponse(String username, String status) {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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("서버 처리 중 오류가 발생했습니다."));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 분석 결과: 현재 시스템 상태를 검토하고 운영 매뉴얼을 참조하세요.";
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 라우팅(Claude↔Ollama), 실패 시 규칙기반 폴백.
|
||||
*/
|
||||
@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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 라우터(Claude↔Ollama) 경유 → 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
254
backend/src/main/java/com/zioinfo/signage/rag/RagAiService.java
Normal file
254
backend/src/main/java/com/zioinfo/signage/rag/RagAiService.java
Normal 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); }
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user