feat: GUARDiA Mall v1.0 — 옴니채널 꽃집 e-커머스 (백엔드 8011 + 프론트 + 모바일 고객/관리자앱) + CI/CD
This commit is contained in:
commit
b519efe629
59
Jenkinsfile
vendored
Normal file
59
Jenkinsfile
vendored
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
// GUARDiA Mall — CI/CD Pipeline (Jenkins, 보조)
|
||||||
|
// 주 배포는 Gitea webhook → deploy_server.py(9999). Jenkins는 검증/롤백 백업 경로.
|
||||||
|
// 저장소 구조: backend/, frontend/ 가 루트에 위치 (단일 jar — frontend가 backend static으로 번들됨)
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
environment {
|
||||||
|
MALL_HOME = '/opt/guardia-mall'
|
||||||
|
JAVA_HOME = '/usr/lib/jvm/java-21-openjdk-amd64' // 서버 JDK21 (Java17 타겟 호환 빌드)
|
||||||
|
PATH = "${JAVA_HOME}/bin:${env.PATH}"
|
||||||
|
}
|
||||||
|
stages {
|
||||||
|
stage('Checkout') { steps { checkout scm } }
|
||||||
|
|
||||||
|
// frontend 먼저 빌드 → vite outDir(../backend/src/main/resources/static)에 산출 → jar에 번들
|
||||||
|
stage('Frontend Build') {
|
||||||
|
steps {
|
||||||
|
dir('frontend') {
|
||||||
|
sh 'npm ci --silent 2>/dev/null || npm install --silent'
|
||||||
|
sh 'npm run build'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Backend Build & Test') {
|
||||||
|
steps {
|
||||||
|
dir('backend') {
|
||||||
|
sh 'mvn clean package -q' // 테스트 포함(CryptoUtil 게이트웨이 어댑터)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
sudo systemctl stop guardia-mall 2>/dev/null || true
|
||||||
|
sudo mkdir -p ${MALL_HOME}
|
||||||
|
sudo cp backend/target/guardia-mall-*.jar ${MALL_HOME}/app.jar
|
||||||
|
sudo systemctl start guardia-mall
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Health Check') {
|
||||||
|
steps {
|
||||||
|
retry(5) {
|
||||||
|
sleep 8
|
||||||
|
sh 'curl -sf http://localhost:8011/actuator/health | grep -q "UP"'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
success { echo 'GUARDiA Mall 배포 성공 (포트 8011)' }
|
||||||
|
failure {
|
||||||
|
sh 'sudo systemctl stop guardia-mall 2>/dev/null || true'
|
||||||
|
echo 'GUARDiA Mall 배포 실패 — 서비스 중지(롤백)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
backend/pom.xml
Normal file
44
backend/pom.xml
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<?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.11</version>
|
||||||
|
</parent>
|
||||||
|
<groupId>com.zioinfo</groupId>
|
||||||
|
<artifactId>guardia-mall</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>GUARDiA Mall</name>
|
||||||
|
<description>AI 기반 쇼핑몰 커머스 플랫폼 — Ollama 온프레미스 + GUARDiA ITSM/ERP/CRM/OCR/BI 연동</description>
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<jjwt.version>0.12.6</jjwt.version>
|
||||||
|
<springdoc.version>2.6.0</springdoc.version>
|
||||||
|
<mybatis.version>3.0.3</mybatis.version>
|
||||||
|
<postgresql.version>42.7.7</postgresql.version>
|
||||||
|
</properties>
|
||||||
|
<dependencies>
|
||||||
|
<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>
|
||||||
|
<dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>${mybatis.version}</version></dependency>
|
||||||
|
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
|
||||||
|
<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>
|
||||||
|
<dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>${springdoc.version}</version></dependency>
|
||||||
|
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
|
||||||
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency>
|
||||||
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency>
|
||||||
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||||
|
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-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></build>
|
||||||
|
</project>
|
||||||
22
backend/src/main/java/com/zioinfo/mall/MallApplication.java
Normal file
22
backend/src/main/java/com/zioinfo/mall/MallApplication.java
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package com.zioinfo.mall;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUARDiA Mall — AI 기반 쇼핑몰 커머스 플랫폼.
|
||||||
|
*
|
||||||
|
* <p>쇼핑몰 코어(상품·카탈로그·장바구니·주문·결제·배송·재고·프로모션·리뷰·정산·찜·CS)와
|
||||||
|
* GUARDiA 기능 연계(CRM 회원·ERP 재고/정산·OCR 상품등록·BI 매출분석·ITSM CS연계),
|
||||||
|
* Ollama 온프레미스 AI(상품추천·리뷰요약·자연어검색·CS자동응답)를 제공한다.
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
|
@EnableAsync
|
||||||
|
public class MallApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(MallApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package com.zioinfo.mall.admin;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.admin.dto.AuditLog;
|
||||||
|
import com.zioinfo.mall.admin.dto.MallSetting;
|
||||||
|
import com.zioinfo.mall.admin.dto.UserDto;
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUARDiA Mall 관리자 API — /api/admin.
|
||||||
|
*
|
||||||
|
* <p>RBAC(SecurityConfig requestMatchers 로 통제):
|
||||||
|
* <ul>
|
||||||
|
* <li>/api/admin/users/** — ADMIN</li>
|
||||||
|
* <li>/api/admin/audit — ADMIN, MANAGER</li>
|
||||||
|
* <li>/api/admin/settings — GET: ADMIN/MANAGER, PUT: ADMIN</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminController {
|
||||||
|
|
||||||
|
private final AdminUserService userService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
private final SettingService settingService;
|
||||||
|
|
||||||
|
// ===================== 1. 사용자 관리 (ADMIN) =====================
|
||||||
|
|
||||||
|
@GetMapping("/users")
|
||||||
|
public ApiResponse<List<UserDto>> listUsers() {
|
||||||
|
return ApiResponse.ok(userService.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users")
|
||||||
|
public ApiResponse<UserDto> createUser(@RequestBody CreateUserRequest req) {
|
||||||
|
return ApiResponse.ok(userService.create(req.username(), req.password(), req.role(), req.displayName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/users/{id}/role")
|
||||||
|
public ApiResponse<UserDto> updateRole(@PathVariable Long id, @RequestBody RoleRequest req) {
|
||||||
|
return ApiResponse.ok(userService.updateRole(id, req.role()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/users/{id}/active")
|
||||||
|
public ApiResponse<UserDto> updateActive(@PathVariable Long id, @RequestBody ActiveRequest req) {
|
||||||
|
return ApiResponse.ok(userService.updateActive(id, req.active()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/users/{id}/password")
|
||||||
|
public ApiResponse<UserDto> resetPassword(@PathVariable Long id, @RequestBody PasswordRequest req) {
|
||||||
|
return ApiResponse.ok(userService.resetPassword(id, req.password()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/users/{id}")
|
||||||
|
public ApiResponse<Void> deleteUser(@PathVariable Long id, Authentication auth) {
|
||||||
|
String currentUsername = auth != null ? auth.getName() : null;
|
||||||
|
userService.delete(id, currentUsername);
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 2. 감사 로그 (ADMIN/MANAGER) =====================
|
||||||
|
|
||||||
|
@GetMapping("/audit")
|
||||||
|
public ApiResponse<List<AuditLog>> audit(
|
||||||
|
@RequestParam(value = "action", required = false) String action,
|
||||||
|
@RequestParam(value = "actor", required = false) String actor,
|
||||||
|
@RequestParam(value = "limit", defaultValue = "100") int limit) {
|
||||||
|
return ApiResponse.ok(auditService.find(action, actor, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 3. 시스템 설정 =====================
|
||||||
|
|
||||||
|
@GetMapping("/settings")
|
||||||
|
public ApiResponse<List<MallSetting>> settings() {
|
||||||
|
return ApiResponse.ok(settingService.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/settings/{key}")
|
||||||
|
public ApiResponse<MallSetting> updateSetting(@PathVariable String key,
|
||||||
|
@RequestBody SettingRequest req) {
|
||||||
|
return ApiResponse.ok(settingService.update(key, req.value()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 요청 DTO =====================
|
||||||
|
|
||||||
|
record CreateUserRequest(String username, String password, String role, String displayName) {}
|
||||||
|
record RoleRequest(String role) {}
|
||||||
|
record ActiveRequest(boolean active) {}
|
||||||
|
record PasswordRequest(String password) {}
|
||||||
|
record SettingRequest(String value) {}
|
||||||
|
}
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
package com.zioinfo.mall.admin;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.admin.dto.UserDto;
|
||||||
|
import com.zioinfo.mall.admin.mapper.AdminUserMapper;
|
||||||
|
import com.zioinfo.mall.auth.MallUser;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 사용자 관리 서비스 (ADMIN 전용).
|
||||||
|
*
|
||||||
|
* <p>보안: 응답은 항상 {@link UserDto} 로 변환하여 password_hash 노출을 차단한다.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminUserService {
|
||||||
|
|
||||||
|
private static final Set<String> VALID_ROLES = Set.of("ADMIN", "MANAGER", "USER");
|
||||||
|
|
||||||
|
private final AdminUserMapper mapper;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
public List<UserDto> list() {
|
||||||
|
return mapper.findAll().stream().map(UserDto::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserDto create(String username, String password, String role, String displayName) {
|
||||||
|
if (username == null || username.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("ERR-USR-400: username 필수");
|
||||||
|
}
|
||||||
|
if (password == null || password.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("ERR-USR-400: password 필수");
|
||||||
|
}
|
||||||
|
String resolvedRole = normalizeRole(role);
|
||||||
|
if (mapper.countByUsername(username) > 0) {
|
||||||
|
throw new RuntimeException("ERR-USR-409: 이미 존재하는 username");
|
||||||
|
}
|
||||||
|
MallUser user = new MallUser();
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(password));
|
||||||
|
user.setRole(resolvedRole);
|
||||||
|
user.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName);
|
||||||
|
user.setActive(true);
|
||||||
|
mapper.insert(user);
|
||||||
|
auditService.log("USER_CREATE", username, "role=" + resolvedRole);
|
||||||
|
return UserDto.from(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserDto updateRole(Long id, String role) {
|
||||||
|
MallUser user = require(id);
|
||||||
|
String newRole = normalizeRole(role);
|
||||||
|
if ("ADMIN".equals(user.getRole()) && !"ADMIN".equals(newRole)
|
||||||
|
&& user.isActive() && mapper.countAdmins() <= 1) {
|
||||||
|
throw new RuntimeException("ERR-USR-423: 마지막 ADMIN 계정의 역할은 변경할 수 없습니다");
|
||||||
|
}
|
||||||
|
mapper.updateRole(id, newRole);
|
||||||
|
auditService.log("USER_ROLE_CHANGE", user.getUsername(), user.getRole() + " -> " + newRole);
|
||||||
|
return UserDto.from(require(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserDto updateActive(Long id, boolean active) {
|
||||||
|
MallUser user = require(id);
|
||||||
|
if (!active && "ADMIN".equals(user.getRole()) && user.isActive() && mapper.countAdmins() <= 1) {
|
||||||
|
throw new RuntimeException("ERR-USR-423: 마지막 ADMIN 계정은 비활성화할 수 없습니다");
|
||||||
|
}
|
||||||
|
mapper.updateActive(id, active);
|
||||||
|
auditService.log("USER_ACTIVE_TOGGLE", user.getUsername(), "active=" + active);
|
||||||
|
return UserDto.from(require(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserDto resetPassword(Long id, String newPassword) {
|
||||||
|
if (newPassword == null || newPassword.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("ERR-USR-400: password 필수");
|
||||||
|
}
|
||||||
|
MallUser user = require(id);
|
||||||
|
mapper.updatePassword(id, passwordEncoder.encode(newPassword));
|
||||||
|
auditService.log("USER_PASSWORD_RESET", user.getUsername(), "password reset");
|
||||||
|
return UserDto.from(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(Long id, String currentUsername) {
|
||||||
|
MallUser user = require(id);
|
||||||
|
if (user.getUsername().equals(currentUsername)) {
|
||||||
|
throw new RuntimeException("ERR-USR-423: 자기 자신은 삭제할 수 없습니다");
|
||||||
|
}
|
||||||
|
if ("ADMIN".equals(user.getRole()) && user.isActive() && mapper.countAdmins() <= 1) {
|
||||||
|
throw new RuntimeException("ERR-USR-423: 마지막 ADMIN 계정은 삭제할 수 없습니다");
|
||||||
|
}
|
||||||
|
mapper.deleteById(id);
|
||||||
|
auditService.log("USER_DELETE", user.getUsername(), "role=" + user.getRole());
|
||||||
|
}
|
||||||
|
|
||||||
|
private MallUser require(Long id) {
|
||||||
|
MallUser user = mapper.findById(id);
|
||||||
|
if (user == null) {
|
||||||
|
throw new RuntimeException("ERR-USR-404: 사용자를 찾을 수 없습니다");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRole(String role) {
|
||||||
|
if (role == null || role.isBlank()) {
|
||||||
|
return "USER";
|
||||||
|
}
|
||||||
|
String upper = role.trim().toUpperCase();
|
||||||
|
if (!VALID_ROLES.contains(upper)) {
|
||||||
|
throw new IllegalArgumentException("ERR-USR-400: 유효하지 않은 role (ADMIN/MANAGER/USER)");
|
||||||
|
}
|
||||||
|
return upper;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package com.zioinfo.mall.admin;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.admin.dto.AuditLog;
|
||||||
|
import com.zioinfo.mall.admin.mapper.AuditLogMapper;
|
||||||
|
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 java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 감사 로그 서비스.
|
||||||
|
*
|
||||||
|
* <p>관리자/주문/결제 등 주요 작업 시 호출하여 mall_audit_log 에 기록한다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuditService {
|
||||||
|
|
||||||
|
private final AuditLogMapper mapper;
|
||||||
|
|
||||||
|
public void log(String action, String target, String detail) {
|
||||||
|
log(currentActor(), action, target, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void log(String actor, String action, String target, String detail) {
|
||||||
|
AuditLog entry = new AuditLog();
|
||||||
|
entry.setActor(actor);
|
||||||
|
entry.setAction(action);
|
||||||
|
entry.setTarget(target);
|
||||||
|
entry.setDetail(detail);
|
||||||
|
try {
|
||||||
|
mapper.insert(entry);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("감사 로그 기록 실패(무시): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AuditLog> find(String action, String actor, int limit) {
|
||||||
|
int safeLimit = (limit <= 0 || limit > 1000) ? 100 : limit;
|
||||||
|
return mapper.find(action, actor, safeLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String currentActor() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth != null && auth.getName() != null && !auth.getName().isBlank()) {
|
||||||
|
return auth.getName();
|
||||||
|
}
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package com.zioinfo.mall.admin;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.admin.dto.MallSetting;
|
||||||
|
import com.zioinfo.mall.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<MallSetting> list() {
|
||||||
|
return mapper.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public MallSetting 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 필수");
|
||||||
|
}
|
||||||
|
MallSetting before = mapper.findByKey(key);
|
||||||
|
mapper.upsert(key, value);
|
||||||
|
String prev = before == null ? "(none)" : before.getValue();
|
||||||
|
auditService.log("SETTING_CHANGE", key, prev + " -> " + value);
|
||||||
|
return mapper.findByKey(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.zioinfo.mall.admin.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 감사 로그 (mall_audit_log 테이블 매핑). */
|
||||||
|
@Data
|
||||||
|
public class AuditLog {
|
||||||
|
private Long id;
|
||||||
|
private String actor;
|
||||||
|
private String action;
|
||||||
|
private String target;
|
||||||
|
private String detail;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.zioinfo.mall.admin.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 시스템 설정 (mall_setting 테이블 매핑). */
|
||||||
|
@Data
|
||||||
|
public class MallSetting {
|
||||||
|
private String key;
|
||||||
|
private String value;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.zioinfo.mall.admin.dto;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.auth.MallUser;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 응답 DTO.
|
||||||
|
*
|
||||||
|
* <p>보안 불변 규칙: password_hash 는 어떤 응답에도 포함하지 않는다.
|
||||||
|
*/
|
||||||
|
public record UserDto(
|
||||||
|
Long id,
|
||||||
|
String username,
|
||||||
|
String role,
|
||||||
|
String displayName,
|
||||||
|
boolean active,
|
||||||
|
LocalDateTime createdAt
|
||||||
|
) {
|
||||||
|
public static UserDto from(MallUser u) {
|
||||||
|
return new UserDto(u.getId(), u.getUsername(), u.getRole(), u.getDisplayName(), u.isActive(), u.getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package com.zioinfo.mall.admin.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.auth.MallUser;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 관리자 사용자 관리 매퍼. mall_account 테이블 사용. */
|
||||||
|
@Mapper
|
||||||
|
public interface AdminUserMapper {
|
||||||
|
|
||||||
|
List<MallUser> findAll();
|
||||||
|
|
||||||
|
MallUser findById(@Param("id") Long id);
|
||||||
|
|
||||||
|
int insert(MallUser user);
|
||||||
|
|
||||||
|
int updateRole(@Param("id") Long id, @Param("role") String role);
|
||||||
|
|
||||||
|
int updateActive(@Param("id") Long id, @Param("active") boolean active);
|
||||||
|
|
||||||
|
int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash);
|
||||||
|
|
||||||
|
int deleteById(@Param("id") Long id);
|
||||||
|
|
||||||
|
int countAdmins();
|
||||||
|
|
||||||
|
int countByUsername(@Param("username") String username);
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.zioinfo.mall.admin.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.admin.dto.AuditLog;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface AuditLogMapper {
|
||||||
|
|
||||||
|
int insert(AuditLog log);
|
||||||
|
|
||||||
|
List<AuditLog> find(@Param("action") String action,
|
||||||
|
@Param("actor") String actor,
|
||||||
|
@Param("limit") int limit);
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.zioinfo.mall.admin.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.admin.dto.MallSetting;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface SettingMapper {
|
||||||
|
|
||||||
|
List<MallSetting> findAll();
|
||||||
|
|
||||||
|
MallSetting findByKey(@Param("key") String key);
|
||||||
|
|
||||||
|
int upsert(@Param("key") String key, @Param("value") String value);
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
package com.zioinfo.mall.ai;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.analytics.mapper.AnalyticsMapper;
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import com.zioinfo.mall.product.MallProduct;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUARDiA Mall AI API — /api/mall/ai. Ollama 온프레미스 + Java 폴백.
|
||||||
|
* 고객용(추천·리뷰요약·자연어검색·카드메시지)과 운영용(당일소진·수요예측·이양추천) 분리.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/ai")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MallAiController {
|
||||||
|
|
||||||
|
private final MallAiService aiService;
|
||||||
|
private final AnalyticsMapper analyticsMapper;
|
||||||
|
|
||||||
|
/** 1. 상품 추천 — 공개(스토어프론트). */
|
||||||
|
@GetMapping("/recommend")
|
||||||
|
public ApiResponse<List<MallProduct>> recommend(@RequestParam(required = false) String occasion,
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(defaultValue = "6") int limit) {
|
||||||
|
return ApiResponse.ok(aiService.recommend(occasion, keyword, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2. 리뷰 AI 요약 — 공개. */
|
||||||
|
@GetMapping("/review-summary/{productId}")
|
||||||
|
public ApiResponse<Map<String, Object>> reviewSummary(@PathVariable Long productId) {
|
||||||
|
return ApiResponse.ok(aiService.summarizeReviews(productId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 3. 자연어 상품 검색 — 공개. */
|
||||||
|
@PostMapping("/nl-search")
|
||||||
|
public ApiResponse<Map<String, Object>> nlSearch(@RequestBody Map<String, String> req) {
|
||||||
|
return ApiResponse.ok(aiService.nlSearch(req.getOrDefault("query", "")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4. CS 자동응답 초안 — 인증 고객. */
|
||||||
|
@PostMapping("/cs-reply")
|
||||||
|
public ApiResponse<Map<String, Object>> csReply(@RequestBody Map<String, String> req) {
|
||||||
|
String reply = aiService.csAutoReply(req.getOrDefault("subject", ""), req.getOrDefault("content", ""));
|
||||||
|
return ApiResponse.ok(Map.of("reply", reply));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 7. AI 카드 메시지 — 인증 고객. */
|
||||||
|
@PostMapping("/card-message")
|
||||||
|
public ApiResponse<Map<String, Object>> cardMessage(@RequestBody Map<String, String> req) {
|
||||||
|
List<String> msgs = aiService.cardMessages(req.get("occasion"), req.get("tone"), req.get("recipient"));
|
||||||
|
return ApiResponse.ok(Map.of("messages", msgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 5. 당일 재고 소진 추천 — MANAGER+. */
|
||||||
|
@GetMapping("/daily-clearance/{storeId}")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<Map<String, Object>> dailyClearance(@PathVariable Long storeId) {
|
||||||
|
return ApiResponse.ok(aiService.dailyClearanceBouquet(storeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 6. 피크시즌 수요 예측 — MANAGER+. */
|
||||||
|
@GetMapping("/demand-forecast")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<Map<String, Object>> demandForecast(@RequestParam(defaultValue = "valentine") String season,
|
||||||
|
@RequestParam(defaultValue = "14") int days) {
|
||||||
|
return ApiResponse.ok(aiService.demandForecast(season, analyticsMapper.salesByStore(days)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 8. 매장간 재고 이양 추천 — MANAGER+. */
|
||||||
|
@PostMapping("/transfer-recommend")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public ApiResponse<List<Map<String, Object>>> transferRecommend(@RequestBody Map<String, Object> req) {
|
||||||
|
List<Object> raw = (List<Object>) req.getOrDefault("storeIds", List.of());
|
||||||
|
List<Long> storeIds = raw.stream().map(o -> Long.valueOf(String.valueOf(o))).toList();
|
||||||
|
return ApiResponse.ok(aiService.transferRecommendations(storeIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
302
backend/src/main/java/com/zioinfo/mall/ai/MallAiService.java
Normal file
302
backend/src/main/java/com/zioinfo/mall/ai/MallAiService.java
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
package com.zioinfo.mall.ai;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.inventory.mapper.StoreInventoryMapper;
|
||||||
|
import com.zioinfo.mall.product.MallProduct;
|
||||||
|
import com.zioinfo.mall.product.mapper.ProductMapper;
|
||||||
|
import com.zioinfo.mall.review.mapper.ReviewMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUARDiA Mall AI 서비스 — Ollama(localhost) 적극 활용 + 전부 Java 폴백.
|
||||||
|
*
|
||||||
|
* <p>보안 불변: 외부 AI API 금지. OllamaClient는 localhost:11434만 호출하고
|
||||||
|
* 빈 응답 시 결정론적 Java 폴백으로 동작한다(서비스 중단 없음).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MallAiService {
|
||||||
|
|
||||||
|
private final OllamaClient ollama;
|
||||||
|
private final ProductMapper productMapper;
|
||||||
|
private final ReviewMapper reviewMapper;
|
||||||
|
private final StoreInventoryMapper storeInventoryMapper;
|
||||||
|
|
||||||
|
/** 1. 상품 추천 — 행사/키워드 기반. AI 실패 시 인기/평점 폴백. */
|
||||||
|
public List<MallProduct> recommend(String occasion, String keyword, int limit) {
|
||||||
|
List<MallProduct> pool = productMapper.search(null, keyword, "ON_SALE", occasion, null, null, "sales", 30, 0);
|
||||||
|
if (pool.isEmpty()) {
|
||||||
|
pool = productMapper.search(null, null, "ON_SALE", null, null, null, "rating", 30, 0);
|
||||||
|
}
|
||||||
|
String names = joinNames(pool);
|
||||||
|
String prompt = "You are a florist recommender. From this catalog: [" + names + "]. "
|
||||||
|
+ "Recommend up to " + limit + " bouquets for occasion='" + (occasion == null ? "any" : occasion)
|
||||||
|
+ "' keyword='" + (keyword == null ? "" : keyword) + "'. Reply ONLY product names comma-separated.";
|
||||||
|
String ai = ollama.generate(prompt);
|
||||||
|
if (ai != null && !ai.isBlank()) {
|
||||||
|
List<MallProduct> ordered = reorderByAi(pool, ai);
|
||||||
|
if (!ordered.isEmpty()) return ordered.subList(0, Math.min(limit, ordered.size()));
|
||||||
|
}
|
||||||
|
return pool.subList(0, Math.min(limit, pool.size())); // Java 폴백: 판매순
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2. 리뷰 AI 요약. 폴백: 평균 평점 + 최근 키워드 요약. */
|
||||||
|
public Map<String, Object> summarizeReviews(Long productId) {
|
||||||
|
List<String> contents = reviewMapper.recentContents(productId, 20);
|
||||||
|
Map<String, Object> stats = reviewMapper.stats(productId);
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("stats", stats);
|
||||||
|
if (contents.isEmpty()) {
|
||||||
|
out.put("summary", "No reviews yet.");
|
||||||
|
out.put("source", "fallback");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
String prompt = "Summarize these flower bouquet reviews in 2 concise sentences (pros/cons):\n"
|
||||||
|
+ String.join("\n", contents);
|
||||||
|
String ai = ollama.generate(prompt);
|
||||||
|
if (ai != null && !ai.isBlank()) {
|
||||||
|
out.put("summary", ai);
|
||||||
|
out.put("source", "ollama");
|
||||||
|
} else {
|
||||||
|
out.put("summary", "Customers mention: " + topKeywords(contents) + ". Avg rating "
|
||||||
|
+ stats.getOrDefault("avg", "N/A") + "/5.");
|
||||||
|
out.put("source", "fallback");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 3. 자연어 상품 검색 — "$50 이하 기념일 꽃" → 필터 추출. 폴백: 정규식 파싱. */
|
||||||
|
public Map<String, Object> nlSearch(String query) {
|
||||||
|
BigDecimal maxPrice = parsePrice(query);
|
||||||
|
String occasion = parseOccasion(query);
|
||||||
|
String flowerType = parseFlower(query);
|
||||||
|
List<MallProduct> items = productMapper.search(null, null, "ON_SALE", occasion, flowerType, maxPrice, "sales", 20, 0);
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("query", query);
|
||||||
|
out.put("parsed", Map.of("maxPrice", maxPrice == null ? "" : maxPrice,
|
||||||
|
"occasion", occasion == null ? "" : occasion, "flowerType", flowerType == null ? "" : flowerType));
|
||||||
|
out.put("items", items);
|
||||||
|
out.put("source", "fallback-nlp");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4. CS 자동응답 초안. 폴백: 카테고리 템플릿. */
|
||||||
|
public String csAutoReply(String subject, String content) {
|
||||||
|
String prompt = "You are a polite flower-shop customer support agent. Write a short helpful reply (<=4 sentences) to:\n"
|
||||||
|
+ "Subject: " + subject + "\nMessage: " + content;
|
||||||
|
String ai = ollama.generate(prompt);
|
||||||
|
if (ai != null && !ai.isBlank()) return ai;
|
||||||
|
return "Thank you for reaching out about \"" + subject + "\". We're sorry for any inconvenience. "
|
||||||
|
+ "Our team is reviewing your request and will follow up shortly. "
|
||||||
|
+ "For urgent delivery issues, please reply with your order number.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5. 당일 재고 소진 추천 — 매장 잔여 재고로 AI 부케 구성(Farmgirl 'Daily Standard').
|
||||||
|
* 폴백: 잔여 수량 많은 상위 상품을 묶음 제안.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> dailyClearanceBouquet(Long storeId) {
|
||||||
|
List<com.zioinfo.mall.inventory.MallStoreInventory> inv = storeInventoryMapper.findByStore(storeId);
|
||||||
|
inv.sort((a, b) -> Integer.compare(b.getOnHand() == null ? 0 : b.getOnHand(),
|
||||||
|
a.getOnHand() == null ? 0 : a.getOnHand()));
|
||||||
|
List<String> surplus = new ArrayList<>();
|
||||||
|
for (com.zioinfo.mall.inventory.MallStoreInventory si : inv) {
|
||||||
|
if (si.getOnHand() != null && si.getOnHand() > 0 && Boolean.TRUE.equals(si.getAvailable())) {
|
||||||
|
surplus.add(si.getProductName() + " (x" + si.getOnHand() + ")");
|
||||||
|
}
|
||||||
|
if (surplus.size() >= 6) break;
|
||||||
|
}
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("storeId", storeId);
|
||||||
|
out.put("surplus", surplus);
|
||||||
|
if (surplus.isEmpty()) {
|
||||||
|
out.put("bouquet", "No surplus stock to compose today.");
|
||||||
|
out.put("source", "fallback");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
String prompt = "Compose a creative 'Daily Standard' bouquet name and 1-line description using surplus flowers: ["
|
||||||
|
+ String.join(", ", surplus) + "]. Reply as: NAME | DESCRIPTION.";
|
||||||
|
String ai = ollama.generate(prompt);
|
||||||
|
if (ai != null && !ai.isBlank()) {
|
||||||
|
out.put("bouquet", ai);
|
||||||
|
out.put("source", "ollama");
|
||||||
|
} else {
|
||||||
|
out.put("bouquet", "Today's Designer's Choice | A fresh seasonal mix featuring "
|
||||||
|
+ surplus.get(0).replaceAll("\\s*\\(.*\\)", "") + " and more.");
|
||||||
|
out.put("source", "fallback");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 6. 피크시즌 수요 예측 — 매장별 용량 계획(밸런타인/어머니날).
|
||||||
|
* 폴백: 최근 일평균 주문 × 시즌 배수 vs 용량 비교.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> demandForecast(String season, List<Map<String, Object>> storeSales) {
|
||||||
|
double multiplier = "valentine".equalsIgnoreCase(season) ? 3.5
|
||||||
|
: "mother".equalsIgnoreCase(season) ? 3.0 : 1.5;
|
||||||
|
List<Map<String, Object>> forecast = new ArrayList<>();
|
||||||
|
for (Map<String, Object> s : storeSales) {
|
||||||
|
Object oc = s.get("orderCount");
|
||||||
|
int recent = oc == null ? 0 : ((Number) oc).intValue();
|
||||||
|
int predicted = (int) Math.round(recent * multiplier);
|
||||||
|
Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("storeId", s.get("storeId"));
|
||||||
|
row.put("storeName", s.get("storeName"));
|
||||||
|
row.put("recentOrders", recent);
|
||||||
|
row.put("predictedOrders", predicted);
|
||||||
|
row.put("multiplier", multiplier);
|
||||||
|
forecast.add(row);
|
||||||
|
}
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("season", season);
|
||||||
|
out.put("forecast", forecast);
|
||||||
|
out.put("source", "fallback-heuristic");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 7. AI 카드 메시지 작성 도우미. 폴백: 행사별 템플릿. */
|
||||||
|
public List<String> cardMessages(String occasion, String tone, String recipient) {
|
||||||
|
String prompt = "Write 3 short flower-card messages for occasion='" + occasion
|
||||||
|
+ "' tone='" + (tone == null ? "warm" : tone) + "' recipient='" + (recipient == null ? "" : recipient)
|
||||||
|
+ "'. One per line, no numbering.";
|
||||||
|
String ai = ollama.generate(prompt);
|
||||||
|
if (ai != null && !ai.isBlank()) {
|
||||||
|
List<String> lines = new ArrayList<>();
|
||||||
|
for (String l : ai.split("\n")) {
|
||||||
|
String t = l.replaceAll("^[0-9.\\-)\\s]+", "").trim();
|
||||||
|
if (!t.isEmpty()) lines.add(t);
|
||||||
|
}
|
||||||
|
if (!lines.isEmpty()) return lines;
|
||||||
|
}
|
||||||
|
return fallbackCards(occasion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 8. 매장간 재고 이양 추천 — 잉여/부족 매칭.
|
||||||
|
* 폴백: 상품별로 재고 많은 매장(공급) → 적은/품절 매장(수요) 매칭.
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> transferRecommendations(List<Long> storeIds) {
|
||||||
|
// 상품별 매장 재고 집계
|
||||||
|
Map<Long, List<com.zioinfo.mall.inventory.MallStoreInventory>> byProduct = new HashMap<>();
|
||||||
|
for (Long sid : storeIds) {
|
||||||
|
for (com.zioinfo.mall.inventory.MallStoreInventory si : storeInventoryMapper.findByStore(sid)) {
|
||||||
|
byProduct.computeIfAbsent(si.getProductId(), k -> new ArrayList<>()).add(si);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> recs = new ArrayList<>();
|
||||||
|
for (Map.Entry<Long, List<com.zioinfo.mall.inventory.MallStoreInventory>> e : byProduct.entrySet()) {
|
||||||
|
List<com.zioinfo.mall.inventory.MallStoreInventory> list = e.getValue();
|
||||||
|
com.zioinfo.mall.inventory.MallStoreInventory max = null, min = null;
|
||||||
|
for (com.zioinfo.mall.inventory.MallStoreInventory si : list) {
|
||||||
|
int oh = si.getOnHand() == null ? 0 : si.getOnHand();
|
||||||
|
if (max == null || oh > (max.getOnHand() == null ? 0 : max.getOnHand())) max = si;
|
||||||
|
if (min == null || oh < (min.getOnHand() == null ? 0 : min.getOnHand())) min = si;
|
||||||
|
}
|
||||||
|
if (max != null && min != null && !Objects.equals(max.getStoreId(), min.getStoreId())) {
|
||||||
|
int surplus = (max.getOnHand() == null ? 0 : max.getOnHand());
|
||||||
|
int shortage = (min.getOnHand() == null ? 0 : min.getOnHand());
|
||||||
|
if (surplus - shortage >= 10) {
|
||||||
|
int qty = (surplus - shortage) / 2;
|
||||||
|
Map<String, Object> r = new LinkedHashMap<>();
|
||||||
|
r.put("productId", e.getKey());
|
||||||
|
r.put("productName", max.getProductName());
|
||||||
|
r.put("fromStoreId", max.getStoreId());
|
||||||
|
r.put("toStoreId", min.getStoreId());
|
||||||
|
r.put("suggestedQty", qty);
|
||||||
|
r.put("reason", "Surplus " + surplus + " vs shortage " + shortage);
|
||||||
|
recs.add(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return recs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- helpers
|
||||||
|
private String joinNames(List<MallProduct> ps) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (MallProduct p : ps) {
|
||||||
|
if (sb.length() > 0) sb.append(", ");
|
||||||
|
sb.append(p.getName());
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MallProduct> reorderByAi(List<MallProduct> pool, String ai) {
|
||||||
|
String lower = ai.toLowerCase();
|
||||||
|
List<MallProduct> ordered = new ArrayList<>();
|
||||||
|
for (MallProduct p : pool) {
|
||||||
|
if (p.getName() != null && lower.contains(p.getName().toLowerCase())) ordered.add(p);
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal parsePrice(String q) {
|
||||||
|
if (q == null) return null;
|
||||||
|
java.util.regex.Matcher m = java.util.regex.Pattern.compile("\\$?\\s*(\\d+(?:\\.\\d+)?)").matcher(q);
|
||||||
|
BigDecimal found = null;
|
||||||
|
if ((q.contains("under") || q.contains("이하") || q.contains("below") || q.contains("$")) && m.find()) {
|
||||||
|
found = new BigDecimal(m.group(1));
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String parseOccasion(String q) {
|
||||||
|
if (q == null) return null;
|
||||||
|
String l = q.toLowerCase();
|
||||||
|
if (l.contains("anniversary") || l.contains("기념일")) return "ANNIVERSARY";
|
||||||
|
if (l.contains("birthday") || l.contains("생일")) return "BIRTHDAY";
|
||||||
|
if (l.contains("sympathy") || l.contains("조의")) return "SYMPATHY";
|
||||||
|
if (l.contains("romance") || l.contains("love") || l.contains("사랑")) return "ROMANCE";
|
||||||
|
if (l.contains("get well") || l.contains("쾌유")) return "GETWELL";
|
||||||
|
if (l.contains("congrat") || l.contains("축하")) return "CONGRATS";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String parseFlower(String q) {
|
||||||
|
if (q == null) return null;
|
||||||
|
String l = q.toLowerCase();
|
||||||
|
if (l.contains("rose") || l.contains("장미")) return "ROSES";
|
||||||
|
if (l.contains("tulip") || l.contains("튤립")) return "TULIPS";
|
||||||
|
if (l.contains("lily") || l.contains("백합")) return "LILIES";
|
||||||
|
if (l.contains("sunflower") || l.contains("해바라기")) return "SUNFLOWERS";
|
||||||
|
if (l.contains("hydrangea") || l.contains("수국")) return "HYDRANGEAS";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String topKeywords(List<String> contents) {
|
||||||
|
Map<String, Integer> freq = new HashMap<>();
|
||||||
|
for (String c : contents) {
|
||||||
|
for (String w : c.toLowerCase().split("\\W+")) {
|
||||||
|
if (w.length() >= 5) freq.merge(w, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return freq.entrySet().stream()
|
||||||
|
.sorted((a, b) -> b.getValue() - a.getValue())
|
||||||
|
.limit(3).map(Map.Entry::getKey)
|
||||||
|
.reduce((a, b) -> a + ", " + b).orElse("quality, freshness");
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> fallbackCards(String occasion) {
|
||||||
|
String o = occasion == null ? "" : occasion.toUpperCase();
|
||||||
|
return switch (o) {
|
||||||
|
case "BIRTHDAY" -> List.of("Happy Birthday! Wishing you a day as bright as these blooms.",
|
||||||
|
"Another year more wonderful — enjoy every petal!",
|
||||||
|
"Hope your special day blossoms with joy.");
|
||||||
|
case "ANNIVERSARY" -> List.of("Happy Anniversary — here's to many more years in bloom.",
|
||||||
|
"Celebrating your love today and always.",
|
||||||
|
"Forever and always, with these flowers.");
|
||||||
|
case "SYMPATHY" -> List.of("With heartfelt sympathy in your time of loss.",
|
||||||
|
"Thinking of you with deepest condolences.",
|
||||||
|
"May these flowers bring a moment of peace.");
|
||||||
|
default -> List.of("Just because you deserve something beautiful.",
|
||||||
|
"Sending smiles your way with these blooms.",
|
||||||
|
"A little flower to brighten your day.");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
50
backend/src/main/java/com/zioinfo/mall/ai/OllamaClient.java
Normal file
50
backend/src/main/java/com/zioinfo/mall/ai/OllamaClient.java
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package com.zioinfo.mall.ai;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ollama 온프레미스 LLM 클라이언트 (Mall AI 전용).
|
||||||
|
*
|
||||||
|
* <p>보안 불변 규칙: localhost Ollama만 호출. 외부 AI API 절대 금지.
|
||||||
|
* 장애/오프라인 시 예외 없이 빈 문자열 반환(서비스가 Java 폴백 수행).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class OllamaClient {
|
||||||
|
|
||||||
|
private final WebClient.Builder builder;
|
||||||
|
private final String ollamaUrl;
|
||||||
|
private final String model;
|
||||||
|
|
||||||
|
public OllamaClient(WebClient.Builder builder,
|
||||||
|
@Value("${guardia.ollama-url:http://localhost:11434}") String ollamaUrl,
|
||||||
|
@Value("${guardia.ollama-text-model:llama3}") String model) {
|
||||||
|
this.builder = builder;
|
||||||
|
this.ollamaUrl = ollamaUrl;
|
||||||
|
this.model = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public String generate(String prompt) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> body = Map.of("model", model, "prompt", prompt, "stream", false);
|
||||||
|
Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
|
||||||
|
.post().uri("/api/generate").bodyValue(body)
|
||||||
|
.retrieve().bodyToMono(Map.class)
|
||||||
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
.map(m -> (Map<String, Object>) m).block();
|
||||||
|
if (res == null) return "";
|
||||||
|
Object r = res.get("response");
|
||||||
|
return r == null ? "" : String.valueOf(r).trim();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getMessage());
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
package com.zioinfo.mall.analytics;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.analytics.mapper.AnalyticsMapper;
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분석 API — /api/mall/analytics. MANAGER+.
|
||||||
|
* Super Admin 대시보드(매장별 매출·순위·대량주문) + BI 매출 피드.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/analytics")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public class AnalyticsController {
|
||||||
|
|
||||||
|
private final AnalyticsMapper mapper;
|
||||||
|
|
||||||
|
/** Super Admin 실시간 현황판 — 오늘 총매출 + 매장별 순위 + 대량주문. */
|
||||||
|
@GetMapping("/dashboard")
|
||||||
|
public ApiResponse<Map<String, Object>> dashboard(
|
||||||
|
@RequestParam(defaultValue = "1") int days,
|
||||||
|
@RequestParam(defaultValue = "500") BigDecimal bigOrderThreshold) {
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("today", mapper.todayTotals());
|
||||||
|
out.put("storeRanking", mapper.salesByStore(days));
|
||||||
|
out.put("topProducts", mapper.topProducts(10));
|
||||||
|
out.put("bigOrders", mapper.bigOrders(bigOrderThreshold, 20));
|
||||||
|
return ApiResponse.ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/store-sales")
|
||||||
|
public ApiResponse<List<Map<String, Object>>> storeSales(@RequestParam(defaultValue = "7") int days) {
|
||||||
|
return ApiResponse.ok(mapper.salesByStore(days));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/trend")
|
||||||
|
public ApiResponse<List<Map<String, Object>>> trend(@RequestParam(defaultValue = "14") int days) {
|
||||||
|
return ApiResponse.ok(mapper.dailyTrend(days));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/top-products")
|
||||||
|
public ApiResponse<List<Map<String, Object>>> topProducts(@RequestParam(defaultValue = "10") int limit) {
|
||||||
|
return ApiResponse.ok(mapper.topProducts(limit));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.zioinfo.mall.analytics.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface AnalyticsMapper {
|
||||||
|
/** 오늘 전체 매출 합계. */
|
||||||
|
Map<String, Object> todayTotals();
|
||||||
|
/** 매장별 매출 순위(오늘). */
|
||||||
|
List<Map<String, Object>> salesByStore(@Param("days") int days);
|
||||||
|
/** 인기 상품 TOP. */
|
||||||
|
List<Map<String, Object>> topProducts(@Param("limit") int limit);
|
||||||
|
/** 대량 주문(웨딩/이벤트, 임계 금액 이상) 목록. */
|
||||||
|
List<Map<String, Object>> bigOrders(@Param("minAmount") java.math.BigDecimal minAmount, @Param("limit") int limit);
|
||||||
|
/** 일자별 매출 추이. */
|
||||||
|
List<Map<String, Object>> dailyTrend(@Param("days") int days);
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package com.zioinfo.mall.auth;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/auth")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
|
||||||
|
String token = authService.login(req.username(), req.password());
|
||||||
|
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ApiResponse<Map<String, String>> register(@RequestBody RegisterRequest req) {
|
||||||
|
String token = authService.register(req.username(), req.password(), req.displayName());
|
||||||
|
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ApiResponse<Map<String, String>> me(@RequestHeader("Authorization") String header) {
|
||||||
|
String token = header.replace("Bearer ", "");
|
||||||
|
return ApiResponse.ok(authService.me(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
record LoginRequest(String username, String password) {}
|
||||||
|
record RegisterRequest(String username, String password, String displayName) {}
|
||||||
|
}
|
||||||
52
backend/src/main/java/com/zioinfo/mall/auth/AuthService.java
Normal file
52
backend/src/main/java/com/zioinfo/mall/auth/AuthService.java
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package com.zioinfo.mall.auth;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.auth.mapper.UserMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
|
||||||
|
public String login(String username, String password) {
|
||||||
|
MallUser user = userMapper.findByUsername(username);
|
||||||
|
if (user == null || !user.isActive()) {
|
||||||
|
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
|
||||||
|
}
|
||||||
|
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||||
|
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
|
||||||
|
}
|
||||||
|
return jwtUtil.generate(username, user.getRole());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 고객 셀프 회원가입 — 항상 USER 역할로 생성. */
|
||||||
|
public String register(String username, String password, String displayName) {
|
||||||
|
if (username == null || username.isBlank() || password == null || password.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("ERR-AUTH-400: username/password 필수");
|
||||||
|
}
|
||||||
|
if (userMapper.countByUsername(username) > 0) {
|
||||||
|
throw new RuntimeException("ERR-AUTH-409: 이미 존재하는 아이디");
|
||||||
|
}
|
||||||
|
MallUser user = new MallUser();
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(password));
|
||||||
|
user.setRole("USER");
|
||||||
|
user.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName);
|
||||||
|
user.setActive(true);
|
||||||
|
userMapper.insert(user);
|
||||||
|
return jwtUtil.generate(username, "USER");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> me(String token) {
|
||||||
|
String username = jwtUtil.getUsername(token);
|
||||||
|
String role = jwtUtil.getRole(token);
|
||||||
|
return Map.of("username", username, "role", role);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/src/main/java/com/zioinfo/mall/auth/JwtFilter.java
Normal file
40
backend/src/main/java/com/zioinfo/mall/auth/JwtFilter.java
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package com.zioinfo.mall.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);
|
||||||
|
if (jwtUtil.isValid(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
backend/src/main/java/com/zioinfo/mall/auth/JwtUtil.java
Normal file
59
backend/src/main/java/com/zioinfo/mall/auth/JwtUtil.java
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package com.zioinfo.mall.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:guardia-mall-jwt-secret-2026-minimum-256bit-key-zioinfo}")
|
||||||
|
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) {
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject(username)
|
||||||
|
.claim("role", role)
|
||||||
|
.issuedAt(new Date())
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + expirationMs))
|
||||||
|
.signWith(key())
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/src/main/java/com/zioinfo/mall/auth/MallUser.java
Normal file
16
backend/src/main/java/com/zioinfo/mall/auth/MallUser.java
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package com.zioinfo.mall.auth;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 계정 (mall_account). 역할: ADMIN/MANAGER/USER(고객). */
|
||||||
|
@Data
|
||||||
|
public class MallUser {
|
||||||
|
private Long id;
|
||||||
|
private String username;
|
||||||
|
private String passwordHash;
|
||||||
|
private String role;
|
||||||
|
private String displayName;
|
||||||
|
private boolean active;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package com.zioinfo.mall.auth.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.auth.MallUser;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UserMapper {
|
||||||
|
|
||||||
|
MallUser findByUsername(@Param("username") String username);
|
||||||
|
|
||||||
|
int insert(MallUser user);
|
||||||
|
|
||||||
|
int countByUsername(@Param("username") String username);
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
package com.zioinfo.mall.cart;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.cart.mapper.CartMapper;
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 장바구니 API — /api/mall/cart. owner=인증 사용자. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/cart")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CartController {
|
||||||
|
|
||||||
|
private final CartMapper mapper;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<Map<String, Object>> list(Authentication auth) {
|
||||||
|
List<MallCartItem> items = mapper.findByOwner(auth.getName());
|
||||||
|
BigDecimal total = items.stream()
|
||||||
|
.map(i -> (i.getUnitPrice() == null ? BigDecimal.ZERO : i.getUnitPrice())
|
||||||
|
.multiply(BigDecimal.valueOf(i.getQuantity() == null ? 0 : i.getQuantity())))
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("items", items);
|
||||||
|
out.put("itemCount", items.size());
|
||||||
|
out.put("totalAmount", total);
|
||||||
|
return ApiResponse.ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<MallCartItem> add(@RequestBody MallCartItem item, Authentication auth) {
|
||||||
|
item.setOwner(auth.getName());
|
||||||
|
if (item.getQuantity() == null || item.getQuantity() < 1) item.setQuantity(1);
|
||||||
|
MallCartItem existing = mapper.findExisting(auth.getName(), item.getProductId(), item.getOptionId());
|
||||||
|
if (existing != null) {
|
||||||
|
int newQty = existing.getQuantity() + item.getQuantity();
|
||||||
|
mapper.updateQty(existing.getId(), auth.getName(), newQty);
|
||||||
|
return ApiResponse.ok(mapper.findById(existing.getId()));
|
||||||
|
}
|
||||||
|
mapper.insert(item);
|
||||||
|
return ApiResponse.ok(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ApiResponse<MallCartItem> updateQty(@PathVariable Long id, @RequestBody Map<String, Integer> req, Authentication auth) {
|
||||||
|
int qty = req.getOrDefault("quantity", 1);
|
||||||
|
if (qty < 1) throw new IllegalArgumentException("ERR-CART-400: 수량은 1 이상");
|
||||||
|
mapper.updateQty(id, auth.getName(), qty);
|
||||||
|
return ApiResponse.ok(mapper.findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ApiResponse<Void> remove(@PathVariable Long id, Authentication auth) {
|
||||||
|
mapper.delete(id, auth.getName());
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping
|
||||||
|
public ApiResponse<Void> clear(Authentication auth) {
|
||||||
|
mapper.clear(auth.getName());
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.zioinfo.mall.cart;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 장바구니 항목 (mall_cart_item). owner=username. */
|
||||||
|
@Data
|
||||||
|
public class MallCartItem {
|
||||||
|
private Long id;
|
||||||
|
private String owner;
|
||||||
|
private Long productId;
|
||||||
|
private Long optionId;
|
||||||
|
private String sizeCode;
|
||||||
|
private String cardMessage;
|
||||||
|
private Integer quantity;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
// 조인 표시용(읽기 전용)
|
||||||
|
private String productName;
|
||||||
|
private BigDecimal unitPrice;
|
||||||
|
private String thumbnail;
|
||||||
|
private String optionLabel;
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package com.zioinfo.mall.cart.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.cart.MallCartItem;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface CartMapper {
|
||||||
|
List<MallCartItem> findByOwner(@Param("owner") String owner);
|
||||||
|
MallCartItem findExisting(@Param("owner") String owner, @Param("productId") Long productId, @Param("optionId") Long optionId);
|
||||||
|
MallCartItem findById(@Param("id") Long id);
|
||||||
|
int insert(MallCartItem item);
|
||||||
|
int updateQty(@Param("id") Long id, @Param("owner") String owner, @Param("quantity") int quantity);
|
||||||
|
int delete(@Param("id") Long id, @Param("owner") String owner);
|
||||||
|
int clear(@Param("owner") String owner);
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package com.zioinfo.mall.category;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.category.mapper.CategoryMapper;
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 카탈로그 카테고리 API — /api/mall/category. GET 공개, 변경 MANAGER+. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/category")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CategoryController {
|
||||||
|
|
||||||
|
private final CategoryMapper mapper;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<List<MallCategory>> list(@RequestParam(required = false) Long parentId,
|
||||||
|
@RequestParam(required = false) Boolean children) {
|
||||||
|
if (Boolean.TRUE.equals(children) || parentId != null) {
|
||||||
|
return ApiResponse.ok(mapper.findByParent(parentId));
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(mapper.findAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/tree")
|
||||||
|
public ApiResponse<List<MallCategory>> tree() {
|
||||||
|
return ApiResponse.ok(mapper.findAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ApiResponse<MallCategory> get(@PathVariable Long id) {
|
||||||
|
return ApiResponse.ok(mapper.findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<MallCategory> create(@RequestBody MallCategory c) {
|
||||||
|
mapper.insert(c);
|
||||||
|
return ApiResponse.ok(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ApiResponse<MallCategory> update(@PathVariable Long id, @RequestBody MallCategory c) {
|
||||||
|
c.setId(id);
|
||||||
|
mapper.update(c);
|
||||||
|
return ApiResponse.ok(mapper.findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable Long id) {
|
||||||
|
mapper.delete(id);
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.zioinfo.mall.category;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** 카탈로그 카테고리 트리 (mall_category). parentId=null 이면 최상위. */
|
||||||
|
@Data
|
||||||
|
public class MallCategory {
|
||||||
|
private Long id;
|
||||||
|
private Long parentId;
|
||||||
|
private String name;
|
||||||
|
private String code;
|
||||||
|
private Integer sortOrder;
|
||||||
|
private Boolean active;
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.zioinfo.mall.category.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.category.MallCategory;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface CategoryMapper {
|
||||||
|
List<MallCategory> findAll();
|
||||||
|
List<MallCategory> findByParent(@Param("parentId") Long parentId);
|
||||||
|
MallCategory findById(@Param("id") Long id);
|
||||||
|
int insert(MallCategory c);
|
||||||
|
int update(MallCategory c);
|
||||||
|
int delete(@Param("id") Long id);
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.zioinfo.mall.common;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ApiResponse<T> {
|
||||||
|
private boolean success;
|
||||||
|
private String message;
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> ok(T data) {
|
||||||
|
return new ApiResponse<>(true, "OK", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> fail(String message) {
|
||||||
|
return new ApiResponse<>(false, message, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
package com.zioinfo.mall.common;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.GCMParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결제·회원 민감정보 암호화 유틸 — AES-256-GCM.
|
||||||
|
*
|
||||||
|
* <p>GUARDiA 보안 불변 규칙: 카드번호·계좌번호 등 결제 민감정보는 평문 저장 금지.
|
||||||
|
* {@code *_enc} 컬럼에 본 유틸로 암호화하여 저장하고, API 응답에는 절대 노출하지 않는다(마스킹만 노출).
|
||||||
|
*
|
||||||
|
* <p>저장 포맷: Base64( IV(12B) || ciphertext || GCM tag(16B) ).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CryptoUtil {
|
||||||
|
|
||||||
|
private static final int IV_LEN = 12;
|
||||||
|
private static final int TAG_BITS = 128;
|
||||||
|
private final SecretKeySpec key;
|
||||||
|
private final SecureRandom random = new SecureRandom();
|
||||||
|
|
||||||
|
public CryptoUtil(@Value("${guardia.crypto.secret:guardia-mall-aes-256-gcm-master-key-2026-zioinfo}") String secret) {
|
||||||
|
try {
|
||||||
|
byte[] digest = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8));
|
||||||
|
this.key = new SecretKeySpec(digest, "AES");
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("암호화 키 초기화 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 평문을 AES-256-GCM으로 암호화하여 Base64 문자열로 반환. null/빈 입력은 그대로 반환. */
|
||||||
|
public String encrypt(String plain) {
|
||||||
|
if (plain == null || plain.isEmpty()) {
|
||||||
|
return plain;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] iv = new byte[IV_LEN];
|
||||||
|
random.nextBytes(iv);
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
|
||||||
|
byte[] ct = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
|
||||||
|
byte[] out = new byte[iv.length + ct.length];
|
||||||
|
System.arraycopy(iv, 0, out, 0, iv.length);
|
||||||
|
System.arraycopy(ct, 0, out, iv.length, ct.length);
|
||||||
|
return Base64.getEncoder().encodeToString(out);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("ERR-MALL-CRYPTO-01: 암호화 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base64 암호문을 복호화. 복호화 실패 시 빈 문자열. */
|
||||||
|
public String decrypt(String enc) {
|
||||||
|
if (enc == null || enc.isEmpty()) {
|
||||||
|
return enc;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] all = Base64.getDecoder().decode(enc);
|
||||||
|
byte[] iv = new byte[IV_LEN];
|
||||||
|
System.arraycopy(all, 0, iv, 0, IV_LEN);
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
|
||||||
|
byte[] pt = cipher.doFinal(all, IV_LEN, all.length - IV_LEN);
|
||||||
|
return new String(pt, StandardCharsets.UTF_8);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 카드/계좌번호 마스킹 — 마지막 4자리만 노출. */
|
||||||
|
public static String maskTail(String value) {
|
||||||
|
if (value == null || value.length() <= 4) {
|
||||||
|
return "****";
|
||||||
|
}
|
||||||
|
String tail = value.substring(value.length() - 4);
|
||||||
|
return "****-****-" + tail;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package com.zioinfo.mall.common;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전역 예외 처리.
|
||||||
|
*
|
||||||
|
* <p>보안 불변 규칙: 스택트레이스를 응답에 절대 노출하지 않는다.
|
||||||
|
* 에러 코드 + 요약 메시지만 반환한다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||||
|
@ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
|
||||||
|
public ApiResponse<Void> handleMaxSize(MaxUploadSizeExceededException e) {
|
||||||
|
log.warn("업로드 크기 초과: {}", e.getMessage());
|
||||||
|
return ApiResponse.fail("ERR-MALL-413: 파일 크기 초과 (최대 20MB)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
|
public ApiResponse<Void> handleIllegalArg(IllegalArgumentException e) {
|
||||||
|
log.warn("잘못된 요청: {}", e.getMessage());
|
||||||
|
return ApiResponse.fail(e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(RuntimeException.class)
|
||||||
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
|
public ApiResponse<Void> handleRuntime(RuntimeException e) {
|
||||||
|
// 스택트레이스 미노출 — 에러 코드/요약만 반환
|
||||||
|
log.warn("업무 오류: {}", e.getMessage());
|
||||||
|
return ApiResponse.fail(e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
public ApiResponse<Void> handleGeneral(Exception e) {
|
||||||
|
log.error("시스템 오류", e);
|
||||||
|
return ApiResponse.fail("ERR-SYS-001");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.zioinfo.mall.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class CorsConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
|
config.setAllowedOriginPatterns(List.of("*"));
|
||||||
|
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||||
|
config.setAllowedHeaders(List.of("*"));
|
||||||
|
config.setAllowCredentials(true);
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", config);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package com.zioinfo.mall.config;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.session.SqlSessionFactory;
|
||||||
|
import org.mybatis.spring.SqlSessionFactoryBean;
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
// 전체 베이스 패키지에서 @Mapper 인터페이스만 등록 — *.mapper 외 위치도 포함
|
||||||
|
@MapperScan(basePackages = "com.zioinfo.mall", annotationClass = Mapper.class)
|
||||||
|
public class MyBatisConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
|
||||||
|
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
|
||||||
|
factory.setDataSource(dataSource);
|
||||||
|
factory.setMapperLocations(
|
||||||
|
new PathMatchingResourcePatternResolver().getResources("classpath:mapper/**/*.xml")
|
||||||
|
);
|
||||||
|
org.apache.ibatis.session.Configuration config = new org.apache.ibatis.session.Configuration();
|
||||||
|
config.setMapUnderscoreToCamelCase(true);
|
||||||
|
factory.setConfiguration(config);
|
||||||
|
return factory.getObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
package com.zioinfo.mall.config;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUARDiA Mall 보안 — JWT 무상태 + RBAC.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>스토어프론트 조회(상품/카탈로그/리뷰 GET) — 공개</li>
|
||||||
|
* <li>장바구니/주문/결제/배송조회/찜/CS — 인증 고객</li>
|
||||||
|
* <li>상품/카탈로그/프로모션/정산 정의 변경 — MANAGER 이상</li>
|
||||||
|
* <li>관리자 API(/api/admin) — ADMIN (감사/설정 조회는 MANAGER 허용)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@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/mall/auth/**").permitAll()
|
||||||
|
.requestMatchers("/actuator/health").permitAll()
|
||||||
|
.requestMatchers("/ws/mall/**").permitAll()
|
||||||
|
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/mall/docs/**", "/api/mall/swagger/**").permitAll()
|
||||||
|
// 스토어프론트 공개 조회 (상품·카탈로그·리뷰·기획전·매장·권역·스케줄 브라우징)
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/mall/product/**", "/api/mall/category/**",
|
||||||
|
"/api/mall/review/**", "/api/mall/promotion/**",
|
||||||
|
"/api/mall/store/**", "/api/mall/schedule/availability",
|
||||||
|
"/api/mall/schedule/holidays").permitAll()
|
||||||
|
// ZIP 진입 조회(공개) — 배송 가능 매장 판정
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/mall/zone/lookup").permitAll()
|
||||||
|
// 등급 혜택 안내(공개) + 진행중 이벤트/배너(공개 스토어프론트)
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/mall/loyalty/tiers", "/api/mall/loyalty/tiers/**",
|
||||||
|
"/api/mall/event/ongoing", "/api/mall/event/banners",
|
||||||
|
"/api/mall/event/{id}").permitAll()
|
||||||
|
// AI 스토어프론트(추천·리뷰요약·자연어검색·카드메시지) 공개
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/mall/ai/recommend", "/api/mall/ai/review-summary/**").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/mall/ai/nl-search", "/api/mall/ai/card-message").permitAll()
|
||||||
|
// 관리자 API
|
||||||
|
.requestMatchers("/api/admin/users/**").hasRole("ADMIN")
|
||||||
|
.requestMatchers("/api/admin/audit").hasAnyRole("ADMIN", "MANAGER")
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/admin/settings").hasAnyRole("ADMIN", "MANAGER")
|
||||||
|
.requestMatchers("/api/admin/settings/**").hasRole("ADMIN")
|
||||||
|
// 운영 분석 — MANAGER 이상
|
||||||
|
.requestMatchers("/api/mall/analytics/**").hasAnyRole("ADMIN", "MANAGER")
|
||||||
|
.requestMatchers("/api/mall/settlement/**").hasAnyRole("ADMIN", "MANAGER")
|
||||||
|
// 상품·카탈로그·프로모션·재고 정의 변경 — MANAGER 이상
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/mall/product/**", "/api/mall/category/**",
|
||||||
|
"/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER")
|
||||||
|
.requestMatchers(HttpMethod.PUT, "/api/mall/product/**", "/api/mall/category/**",
|
||||||
|
"/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER")
|
||||||
|
.requestMatchers(HttpMethod.DELETE, "/api/mall/product/**", "/api/mall/category/**",
|
||||||
|
"/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER")
|
||||||
|
// 그 외 mall API (장바구니·주문·결제·배송·찜·CS·회원·AI) — 인증 사용자
|
||||||
|
.requestMatchers("/api/mall/**").authenticated()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
|
||||||
|
@Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception { return cfg.getAuthenticationManager(); }
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.zioinfo.mall.config;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.realtime.OrderWebSocketHandler;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||||
|
|
||||||
|
/** 실시간 주문 알림 WebSocket — /ws/mall/orders. */
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSocket
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WebSocketConfig implements WebSocketConfigurer {
|
||||||
|
|
||||||
|
private final OrderWebSocketHandler orderWebSocketHandler;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||||
|
registry.addHandler(orderWebSocketHandler, "/ws/mall/orders").setAllowedOrigins("*");
|
||||||
|
}
|
||||||
|
}
|
||||||
70
backend/src/main/java/com/zioinfo/mall/cs/CsController.java
Normal file
70
backend/src/main/java/com/zioinfo/mall/cs/CsController.java
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package com.zioinfo.mall.cs;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.ai.MallAiService;
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import com.zioinfo.mall.cs.mapper.CsMapper;
|
||||||
|
import com.zioinfo.mall.integration.ItsmClient;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고객문의 API — /api/mall/cs.
|
||||||
|
* 문의 등록 시 ITSM SR 자동 생성 + Ollama AI 자동응답 초안 작성(Java 폴백).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/cs")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CsController {
|
||||||
|
|
||||||
|
private final CsMapper mapper;
|
||||||
|
private final ItsmClient itsmClient;
|
||||||
|
private final MallAiService aiService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<List<MallCsTicket>> mine(Authentication auth) {
|
||||||
|
return ApiResponse.ok(mapper.findByOwner(auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/admin")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<List<MallCsTicket>> all(@RequestParam(required = false) String status) {
|
||||||
|
return ApiResponse.ok(mapper.findAll(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 문의 등록 → ITSM SR 생성 + AI 자동응답. */
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<MallCsTicket> create(@RequestBody MallCsTicket t, Authentication auth) {
|
||||||
|
t.setOwner(auth.getName());
|
||||||
|
mapper.insert(t);
|
||||||
|
// ITSM SR 자동 생성 (실패 무시)
|
||||||
|
try {
|
||||||
|
String srId = itsmClient.createSr("[Mall CS] " + t.getSubject(),
|
||||||
|
t.getContent(), "MEDIUM");
|
||||||
|
if (srId != null) mapper.setSr(t.getId(), srId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("CS→ITSM SR 생성 실패(무시): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
// AI 자동응답 초안
|
||||||
|
try {
|
||||||
|
String reply = aiService.csAutoReply(t.getSubject(), t.getContent());
|
||||||
|
if (reply != null && !reply.isBlank()) mapper.setAiReply(t.getId(), reply);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("CS AI 자동응답 실패(무시): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(mapper.findById(t.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/status")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<MallCsTicket> status(@PathVariable Long id, @RequestBody Map<String, String> req) {
|
||||||
|
mapper.updateStatus(id, req.getOrDefault("status", "CLOSED"));
|
||||||
|
return ApiResponse.ok(mapper.findById(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/src/main/java/com/zioinfo/mall/cs/MallCsTicket.java
Normal file
20
backend/src/main/java/com/zioinfo/mall/cs/MallCsTicket.java
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package com.zioinfo.mall.cs;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 고객 문의 (mall_cs_ticket). ITSM SR 연계 + AI 자동응답. */
|
||||||
|
@Data
|
||||||
|
public class MallCsTicket {
|
||||||
|
private Long id;
|
||||||
|
private String owner;
|
||||||
|
private String orderNo;
|
||||||
|
private String category;
|
||||||
|
private String subject;
|
||||||
|
private String content;
|
||||||
|
private String status; // OPEN / ANSWERED / CLOSED
|
||||||
|
private String itsmSrId;
|
||||||
|
private String aiReply;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package com.zioinfo.mall.cs.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.cs.MallCsTicket;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface CsMapper {
|
||||||
|
List<MallCsTicket> findByOwner(@Param("owner") String owner);
|
||||||
|
List<MallCsTicket> findAll(@Param("status") String status);
|
||||||
|
MallCsTicket findById(@Param("id") Long id);
|
||||||
|
int insert(MallCsTicket t);
|
||||||
|
int setSr(@Param("id") Long id, @Param("srId") String srId);
|
||||||
|
int setAiReply(@Param("id") Long id, @Param("aiReply") String aiReply);
|
||||||
|
int updateStatus(@Param("id") Long id, @Param("status") String status);
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
package com.zioinfo.mall.delivery;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import com.zioinfo.mall.delivery.mapper.DeliveryMapper;
|
||||||
|
import com.zioinfo.mall.order.OrderService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배송 API — /api/mall/delivery.
|
||||||
|
*
|
||||||
|
* <p>송장 등록·상태 변경은 운영(MANAGER+). 주문별 배송 조회는 주문 소유 고객.
|
||||||
|
* 배송 상태가 DELIVERED 되면 주문 상태도 SHIPPED→DELIVERED 로 연동.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/delivery")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DeliveryController {
|
||||||
|
|
||||||
|
private final DeliveryMapper mapper;
|
||||||
|
private final OrderService orderService;
|
||||||
|
|
||||||
|
@GetMapping("/order/{orderId}")
|
||||||
|
public ApiResponse<MallDelivery> byOrder(@PathVariable Long orderId, Authentication auth) {
|
||||||
|
orderService.detail(orderId, auth.getName(), isAdmin(auth));
|
||||||
|
return ApiResponse.ok(mapper.findByOrderId(orderId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<List<MallDelivery>> list(@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(defaultValue = "200") int limit) {
|
||||||
|
return ApiResponse.ok(mapper.findAll(status, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 송장 등록(출고) — 주문 PREPARING→SHIPPED 연동. */
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<MallDelivery> create(@RequestBody MallDelivery d, Authentication auth) {
|
||||||
|
d.setStatus("IN_TRANSIT");
|
||||||
|
mapper.insert(d);
|
||||||
|
try { orderService.transition(d.getOrderId(), auth.getName(), true, "SHIPPED"); } catch (Exception ignored) {}
|
||||||
|
return ApiResponse.ok(mapper.findById(d.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/status")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<MallDelivery> status(@PathVariable Long id, @RequestBody Map<String, String> req, Authentication auth) {
|
||||||
|
String to = req.get("status");
|
||||||
|
mapper.updateStatus(id, to);
|
||||||
|
if ("DELIVERED".equals(to)) {
|
||||||
|
MallDelivery d = mapper.findById(id);
|
||||||
|
try { orderService.transition(d.getOrderId(), auth.getName(), true, "DELIVERED"); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(mapper.findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAdmin(Authentication auth) {
|
||||||
|
return auth.getAuthorities().stream()
|
||||||
|
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_MANAGER"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.zioinfo.mall.delivery;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 배송 (mall_delivery). 송장 번호·택배사·상태. */
|
||||||
|
@Data
|
||||||
|
public class MallDelivery {
|
||||||
|
private Long id;
|
||||||
|
private Long orderId;
|
||||||
|
private String carrier; // 택배사
|
||||||
|
private String trackingNo; // 송장번호
|
||||||
|
private String status; // READY / IN_TRANSIT / DELIVERED
|
||||||
|
private String receiverName;
|
||||||
|
private String address;
|
||||||
|
private LocalDateTime shippedAt;
|
||||||
|
private LocalDateTime deliveredAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.zioinfo.mall.delivery.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.delivery.MallDelivery;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface DeliveryMapper {
|
||||||
|
MallDelivery findById(@Param("id") Long id);
|
||||||
|
MallDelivery findByOrderId(@Param("orderId") Long orderId);
|
||||||
|
List<MallDelivery> findAll(@Param("status") String status, @Param("limit") int limit);
|
||||||
|
int insert(MallDelivery d);
|
||||||
|
int update(MallDelivery d);
|
||||||
|
int updateStatus(@Param("id") Long id, @Param("status") String status);
|
||||||
|
}
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
package com.zioinfo.mall.event;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import com.zioinfo.mall.loyalty.LoyaltyService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트/캠페인 API — /api/mall/event.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>진행중 이벤트/배너 조회 — 공개(스토어프론트)</li>
|
||||||
|
* <li>참여(쿠폰 발급) — 인증 고객</li>
|
||||||
|
* <li>이벤트 정의 CRUD·발행·성과·AI 카피 — MANAGER+ (정의/발행) · ADMIN(삭제)</li>
|
||||||
|
* </ul>
|
||||||
|
* 공개 GET 화이트리스트는 SecurityConfig 에 등록.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/event")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class EventController {
|
||||||
|
|
||||||
|
private final EventService service;
|
||||||
|
private final LoyaltyService loyaltyService;
|
||||||
|
|
||||||
|
// ---------- 공개 스토어프론트 ----------
|
||||||
|
/** 진행중 이벤트(등급 필터 옵션). */
|
||||||
|
@GetMapping("/ongoing")
|
||||||
|
public ApiResponse<List<MallEvent>> ongoing(@RequestParam(required = false) String tier) {
|
||||||
|
return ApiResponse.ok(service.ongoing(tier));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 진행중 이벤트 배너(홈 캐러셀). */
|
||||||
|
@GetMapping("/banners")
|
||||||
|
public ApiResponse<List<MallEventBanner>> banners() {
|
||||||
|
return ApiResponse.ok(service.activeBanners());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ApiResponse<MallEvent> detail(@PathVariable Long id) {
|
||||||
|
return ApiResponse.ok(service.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 참여 (인증) ----------
|
||||||
|
/** 이벤트 참여 — 현재 등급 기준 자격 검증 후 연계 쿠폰 발급. */
|
||||||
|
@PostMapping("/{id}/join")
|
||||||
|
public ApiResponse<Map<String, Object>> join(@PathVariable Long id, Authentication auth) {
|
||||||
|
String tier = loyaltyService.currentTierCode(auth.getName());
|
||||||
|
return ApiResponse.ok(service.join(id, auth.getName(), tier));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 관리자 (MANAGER+) ----------
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<List<MallEvent>> list(@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(required = false) String type,
|
||||||
|
@RequestParam(defaultValue = "false") boolean activeOnly) {
|
||||||
|
return ApiResponse.ok(service.list(status, type, activeOnly));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<MallEvent> create(@RequestBody MallEvent e, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.create(e, auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<MallEvent> update(@PathVariable Long id, @RequestBody MallEvent e, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.update(id, e, auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/publish")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<MallEvent> publish(@PathVariable Long id, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.publish(id, auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/end")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<MallEvent> end(@PathVariable Long id, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.end(id, auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable Long id, Authentication auth) {
|
||||||
|
service.delete(id, auth.getName());
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이벤트 성과(참여/매출). */
|
||||||
|
@GetMapping("/{id}/performance")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<Map<String, Object>> performance(@PathVariable Long id) {
|
||||||
|
return ApiResponse.ok(service.performance(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI 이벤트 카피 보조(헤드라인/서브). */
|
||||||
|
@PostMapping("/ai/copy")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<Map<String, Object>> copy(@RequestBody CopyRequest req) {
|
||||||
|
return ApiResponse.ok(service.copyAssist(req.eventType(), req.theme(), req.tone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CopyRequest(String eventType, String theme, String tone) {}
|
||||||
|
}
|
||||||
204
backend/src/main/java/com/zioinfo/mall/event/EventService.java
Normal file
204
backend/src/main/java/com/zioinfo/mall/event/EventService.java
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
package com.zioinfo.mall.event;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.admin.AuditService;
|
||||||
|
import com.zioinfo.mall.ai.OllamaClient;
|
||||||
|
import com.zioinfo.mall.event.mapper.EventMapper;
|
||||||
|
import com.zioinfo.mall.promotion.MallCoupon;
|
||||||
|
import com.zioinfo.mall.promotion.mapper.PromotionMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트/캠페인 서비스 — 발행·참여(연계 쿠폰 자동 발급)·AI 카피 보조·성과 집계.
|
||||||
|
*
|
||||||
|
* <p>연계 쿠폰은 promotion 모듈(mall_coupon)을 재사용한다. AI 카피는 Ollama(localhost)
|
||||||
|
* + Java 폴백. 외부 호출 없음.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class EventService {
|
||||||
|
|
||||||
|
private final EventMapper mapper;
|
||||||
|
private final PromotionMapper promotionMapper;
|
||||||
|
private final OllamaClient ollama;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
// ---------- 조회 ----------
|
||||||
|
public List<MallEvent> list(String status, String type, boolean activeOnly) {
|
||||||
|
return mapper.findEvents(status, type, activeOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 진행중 이벤트(스토어프론트). 등급 지정 시 전체대상 + 해당 등급 전용만. */
|
||||||
|
public List<MallEvent> ongoing(String tier) {
|
||||||
|
List<MallEvent> events = mapper.findOngoing(tier);
|
||||||
|
for (MallEvent e : events) e.setBanners(mapper.findBanners(e.getId(), true));
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MallEvent detail(Long id) {
|
||||||
|
MallEvent e = mapper.findById(id);
|
||||||
|
if (e == null) throw new RuntimeException("ERR-EVT-404: 이벤트를 찾을 수 없습니다");
|
||||||
|
e.setBanners(mapper.findBanners(id, false));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 진행중 이벤트의 활성 배너(홈 캐러셀). */
|
||||||
|
public List<MallEventBanner> activeBanners() {
|
||||||
|
return mapper.findActiveBanners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- CRUD ----------
|
||||||
|
@Transactional
|
||||||
|
public MallEvent create(MallEvent e, String actor) {
|
||||||
|
e.setCreatedBy(actor);
|
||||||
|
mapper.insert(e);
|
||||||
|
if (e.getBanners() != null) {
|
||||||
|
for (MallEventBanner b : e.getBanners()) {
|
||||||
|
b.setEventId(e.getId());
|
||||||
|
mapper.insertBanner(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auditService.log(actor, "EVENT_CREATE", e.getCode() == null ? String.valueOf(e.getId()) : e.getCode(), e.getTitle());
|
||||||
|
return detail(e.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MallEvent update(Long id, MallEvent e, String actor) {
|
||||||
|
e.setId(id);
|
||||||
|
mapper.update(e);
|
||||||
|
if (e.getBanners() != null) {
|
||||||
|
mapper.deleteBannersByEvent(id);
|
||||||
|
for (MallEventBanner b : e.getBanners()) {
|
||||||
|
b.setEventId(id);
|
||||||
|
mapper.insertBanner(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auditService.log(actor, "EVENT_UPDATE", String.valueOf(id), e.getTitle());
|
||||||
|
return detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MallEvent publish(Long id, String actor) {
|
||||||
|
MallEvent e = mapper.findById(id);
|
||||||
|
if (e == null) throw new RuntimeException("ERR-EVT-404: 이벤트를 찾을 수 없습니다");
|
||||||
|
mapper.updateStatus(id, "PUBLISHED");
|
||||||
|
auditService.log(actor, "EVENT_PUBLISH", String.valueOf(id), e.getTitle());
|
||||||
|
return detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MallEvent end(Long id, String actor) {
|
||||||
|
mapper.updateStatus(id, "ENDED");
|
||||||
|
auditService.log(actor, "EVENT_END", String.valueOf(id), null);
|
||||||
|
return detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id, String actor) {
|
||||||
|
mapper.deleteBannersByEvent(id);
|
||||||
|
mapper.delete(id);
|
||||||
|
auditService.log(actor, "EVENT_DELETE", String.valueOf(id), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 참여 (연계 쿠폰 자동 발급) ----------
|
||||||
|
/**
|
||||||
|
* 이벤트 참여 — 등급 자격 검증 후 연계 쿠폰을 발급(발급수 증가)하고 참여 이력 기록.
|
||||||
|
* 쿠폰 미연계 이벤트는 참여 이력만 기록.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> join(Long eventId, String owner, String memberTier) {
|
||||||
|
MallEvent e = mapper.findById(eventId);
|
||||||
|
if (e == null) throw new RuntimeException("ERR-EVT-404: 이벤트를 찾을 수 없습니다");
|
||||||
|
if (!"PUBLISHED".equals(e.getStatus()) || !Boolean.TRUE.equals(e.getActive())) {
|
||||||
|
throw new RuntimeException("ERR-EVT-409: 진행중 이벤트가 아닙니다");
|
||||||
|
}
|
||||||
|
if (!isEligibleTier(e.getTargetTiers(), memberTier)) {
|
||||||
|
throw new RuntimeException("ERR-EVT-403: 이벤트 대상 등급이 아닙니다");
|
||||||
|
}
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("eventId", eventId);
|
||||||
|
out.put("eventCode", e.getCode());
|
||||||
|
String reward = null;
|
||||||
|
Long couponId = e.getCouponId();
|
||||||
|
if (couponId != null) {
|
||||||
|
MallCoupon c = promotionMapper.findCouponById(couponId);
|
||||||
|
if (c != null && Boolean.TRUE.equals(c.getActive())) {
|
||||||
|
promotionMapper.incCouponIssued(couponId);
|
||||||
|
out.put("coupon", Map.of("id", c.getId(), "code", c.getCode(), "name", c.getName()));
|
||||||
|
reward = "COUPON:" + c.getCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.getBonusPointRate() != null) {
|
||||||
|
out.put("bonusPointRate", e.getBonusPointRate());
|
||||||
|
if (reward == null) reward = "BONUS_POINT:" + e.getBonusPointRate();
|
||||||
|
}
|
||||||
|
if (e.getGiftDescription() != null && !e.getGiftDescription().isBlank()) {
|
||||||
|
out.put("gift", e.getGiftDescription());
|
||||||
|
if (reward == null) reward = "GIFT";
|
||||||
|
}
|
||||||
|
mapper.insertParticipation(eventId, owner, couponId, null, reward);
|
||||||
|
out.put("joined", true);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 성과 ----------
|
||||||
|
public Map<String, Object> performance(Long eventId) {
|
||||||
|
MallEvent e = mapper.findById(eventId);
|
||||||
|
if (e == null) throw new RuntimeException("ERR-EVT-404: 이벤트를 찾을 수 없습니다");
|
||||||
|
Map<String, Object> perf = mapper.performance(eventId);
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>(perf == null ? Map.of() : perf);
|
||||||
|
out.put("eventId", eventId);
|
||||||
|
out.put("title", e.getTitle());
|
||||||
|
out.put("status", e.getStatus());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- AI 카피 보조 ----------
|
||||||
|
/** 이벤트 카피(헤드라인/서브) 생성 보조. Ollama 실패 시 템플릿 폴백. */
|
||||||
|
public Map<String, Object> copyAssist(String eventType, String theme, String tone) {
|
||||||
|
String prompt = "You are a flower-shop marketing copywriter. Write a punchy event banner for "
|
||||||
|
+ "type='" + eventType + "' theme='" + (theme == null ? "" : theme) + "' tone='"
|
||||||
|
+ (tone == null ? "warm" : tone) + "'. Reply as: HEADLINE | SUBTEXT.";
|
||||||
|
String ai = ollama.generate(prompt);
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
if (ai != null && !ai.isBlank() && ai.contains("|")) {
|
||||||
|
String[] parts = ai.split("\\|", 2);
|
||||||
|
out.put("headline", parts[0].trim());
|
||||||
|
out.put("subtext", parts[1].trim());
|
||||||
|
out.put("source", "ollama");
|
||||||
|
} else {
|
||||||
|
String[] fb = fallbackCopy(eventType, theme);
|
||||||
|
out.put("headline", fb[0]);
|
||||||
|
out.put("subtext", fb[1]);
|
||||||
|
out.put("source", "fallback");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
private boolean isEligibleTier(String targetTiers, String memberTier) {
|
||||||
|
if (targetTiers == null || targetTiers.isBlank()) return true; // 전체 대상
|
||||||
|
if (memberTier == null) return false;
|
||||||
|
for (String t : targetTiers.split(",")) {
|
||||||
|
if (t.trim().equalsIgnoreCase(memberTier.trim())) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String[] fallbackCopy(String eventType, String theme) {
|
||||||
|
String t = theme == null || theme.isBlank() ? "Fresh Blooms" : theme;
|
||||||
|
return switch (eventType == null ? "" : eventType.toUpperCase()) {
|
||||||
|
case "DISCOUNT" -> new String[]{t + " Sale", "Save on hand-tied bouquets — limited time only."};
|
||||||
|
case "POINT_BONUS" -> new String[]{"Double Points Event", "Earn bonus loyalty points on every " + t + " order."};
|
||||||
|
case "GIFT" -> new String[]{"Free Gift With Purchase", "A little something extra with your " + t + "."};
|
||||||
|
case "TIER_ONLY" -> new String[]{"Members-Only Preview", "Exclusive early access to " + t + " for our top members."};
|
||||||
|
case "SEASON" -> new String[]{t + " Seasonal Collection", "Celebrate the season with our florist's picks."};
|
||||||
|
default -> new String[]{t, "Discover our latest floral arrangements."};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/src/main/java/com/zioinfo/mall/event/MallEvent.java
Normal file
37
backend/src/main/java/com/zioinfo/mall/event/MallEvent.java
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package com.zioinfo.mall.event;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 / 캠페인 (mall_event).
|
||||||
|
*
|
||||||
|
* <p>eventType: DISCOUNT / POINT_BONUS / GIFT / TIER_ONLY / SEASON.
|
||||||
|
* status: DRAFT / PUBLISHED / ENDED. targetTiers·storeIds 는 콤마구분 문자열(빈값=전체).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class MallEvent {
|
||||||
|
private Long id;
|
||||||
|
private String code;
|
||||||
|
private String title;
|
||||||
|
private String eventType; // DISCOUNT/POINT_BONUS/GIFT/TIER_ONLY/SEASON
|
||||||
|
private String description;
|
||||||
|
private LocalDate startDate;
|
||||||
|
private LocalDate endDate;
|
||||||
|
private String targetTiers; // 'GOLD,VIP' (빈값=전체 등급)
|
||||||
|
private String storeIds; // '1,2' (빈값=전 매장/권역)
|
||||||
|
private Long couponId; // 연계 쿠폰(참여 시 발급)
|
||||||
|
private BigDecimal bonusPointRate; // POINT_BONUS 추가 적립률(%)
|
||||||
|
private String giftDescription; // GIFT 사은품 설명
|
||||||
|
private String status; // DRAFT/PUBLISHED/ENDED
|
||||||
|
private Boolean active;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
private List<MallEventBanner> banners;
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.zioinfo.mall.event;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** 이벤트 스토어프론트 배너 (mall_event_banner). */
|
||||||
|
@Data
|
||||||
|
public class MallEventBanner {
|
||||||
|
private Long id;
|
||||||
|
private Long eventId;
|
||||||
|
private String imageUrl;
|
||||||
|
private String headline;
|
||||||
|
private String subtext;
|
||||||
|
private String linkUrl;
|
||||||
|
private Integer sortOrder;
|
||||||
|
private Boolean active;
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package com.zioinfo.mall.event.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.event.MallEvent;
|
||||||
|
import com.zioinfo.mall.event.MallEventBanner;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface EventMapper {
|
||||||
|
// 이벤트
|
||||||
|
List<MallEvent> findEvents(@Param("status") String status, @Param("type") String type,
|
||||||
|
@Param("activeOnly") Boolean activeOnly);
|
||||||
|
List<MallEvent> findOngoing(@Param("tier") String tier);
|
||||||
|
MallEvent findById(@Param("id") Long id);
|
||||||
|
MallEvent findByCode(@Param("code") String code);
|
||||||
|
int insert(MallEvent e);
|
||||||
|
int update(MallEvent e);
|
||||||
|
int updateStatus(@Param("id") Long id, @Param("status") String status);
|
||||||
|
int delete(@Param("id") Long id);
|
||||||
|
|
||||||
|
// 배너
|
||||||
|
List<MallEventBanner> findBanners(@Param("eventId") Long eventId, @Param("activeOnly") Boolean activeOnly);
|
||||||
|
List<MallEventBanner> findActiveBanners();
|
||||||
|
int insertBanner(MallEventBanner b);
|
||||||
|
int deleteBanner(@Param("id") Long id);
|
||||||
|
int deleteBannersByEvent(@Param("eventId") Long eventId);
|
||||||
|
|
||||||
|
// 참여/성과
|
||||||
|
int insertParticipation(@Param("eventId") Long eventId, @Param("owner") String owner,
|
||||||
|
@Param("couponId") Long couponId, @Param("orderNo") String orderNo,
|
||||||
|
@Param("reward") String reward);
|
||||||
|
Map<String, Object> performance(@Param("eventId") Long eventId);
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 주소 검증/지오코딩 추상화. 기본 mock, 운영 시 GoogleMaps/Smarty. */
|
||||||
|
public interface AddressVerifier {
|
||||||
|
/**
|
||||||
|
* 주소 검증 + 지오코딩.
|
||||||
|
* @return { provider, valid, normalized, lat, lng }
|
||||||
|
*/
|
||||||
|
Map<String, Object> verify(String address, String zip);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기사 라우팅 — 다중 주소 최적 동선 정렬.
|
||||||
|
* @return { provider, ordered:[{seq,address,lat,lng,eta}] }
|
||||||
|
*/
|
||||||
|
Map<String, Object> route(java.util.List<String> addresses, String origin);
|
||||||
|
|
||||||
|
String provider();
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avalara AvaTax 어댑터 (mall.tax.provider=avalara). 키는 환경변수 AVALARA_API_KEY만.
|
||||||
|
* 키 없으면 비활성. 실연동은 운영 배포 시 구현.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "mall.tax.provider", havingValue = "avalara")
|
||||||
|
public class AvalaraCalculator implements TaxCalculator {
|
||||||
|
|
||||||
|
@Value("${AVALARA_API_KEY:}")
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> calculate(BigDecimal amount, String zip, String state) {
|
||||||
|
if (apiKey == null || apiKey.isBlank()) {
|
||||||
|
throw new IllegalStateException("ERR-TAX-CONFIG: AVALARA_API_KEY 미설정");
|
||||||
|
}
|
||||||
|
throw new UnsupportedOperationException("ERR-TAX-IMPL: Avalara 실연동은 운영 배포 시 구현");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String provider() {
|
||||||
|
return "avalara";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 이메일 발송 추상화. 기본 mock, 운영 시 SendGrid. 영수증/주문확인. */
|
||||||
|
public interface EmailSender {
|
||||||
|
/** @return { provider, messageId, status } */
|
||||||
|
Map<String, Object> send(String toEmail, String subject, String htmlBody);
|
||||||
|
|
||||||
|
String provider();
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게이트웨이 어댑터 API — /api/mall/gateway.
|
||||||
|
* 세금 견적·주소 검증은 체크아웃에 필요(인증 고객). 라우팅은 MANAGER+. provider 상태는 MANAGER+.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/gateway")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GatewayController {
|
||||||
|
|
||||||
|
private final TaxCalculator taxCalculator;
|
||||||
|
private final AddressVerifier addressVerifier;
|
||||||
|
private final PaymentGateway paymentGateway;
|
||||||
|
private final SmsSender smsSender;
|
||||||
|
private final EmailSender emailSender;
|
||||||
|
|
||||||
|
/** 판매세 견적(체크아웃). */
|
||||||
|
@PostMapping("/tax/quote")
|
||||||
|
public ApiResponse<Map<String, Object>> taxQuote(@RequestBody Map<String, Object> req) {
|
||||||
|
BigDecimal amount = new BigDecimal(String.valueOf(req.getOrDefault("amount", "0")));
|
||||||
|
String zip = (String) req.get("zip");
|
||||||
|
String state = (String) req.get("state");
|
||||||
|
return ApiResponse.ok(taxCalculator.calculate(amount, zip, state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 배송 주소 검증(체크아웃). */
|
||||||
|
@PostMapping("/address/verify")
|
||||||
|
public ApiResponse<Map<String, Object>> verify(@RequestBody Map<String, String> req) {
|
||||||
|
return ApiResponse.ok(addressVerifier.verify(req.get("address"), req.get("zip")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기사 라우팅 — 다중 주소 최적 동선 (관리자앱 ④). */
|
||||||
|
@PostMapping("/address/route")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public ApiResponse<Map<String, Object>> route(@RequestBody Map<String, Object> req) {
|
||||||
|
List<String> addresses = (List<String>) req.getOrDefault("addresses", List.of());
|
||||||
|
String origin = (String) req.get("origin");
|
||||||
|
return ApiResponse.ok(addressVerifier.route(addresses, origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 활성 provider 상태(운영 점검). 키 자체는 노출하지 않음. */
|
||||||
|
@GetMapping("/providers")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<Map<String, Object>> providers() {
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("payment", paymentGateway.provider());
|
||||||
|
out.put("tax", taxCalculator.provider());
|
||||||
|
out.put("address", addressVerifier.provider());
|
||||||
|
out.put("sms", smsSender.provider());
|
||||||
|
out.put("email", emailSender.provider());
|
||||||
|
return ApiResponse.ok(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Maps Places/Directions 어댑터 (mall.address.provider=google).
|
||||||
|
* 키는 환경변수 GOOGLE_MAPS_API_KEY만. 키 없으면 비활성. 실연동은 운영 배포 시 구현.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "mall.address.provider", havingValue = "google")
|
||||||
|
public class GoogleMapsVerifier implements AddressVerifier {
|
||||||
|
|
||||||
|
@Value("${GOOGLE_MAPS_API_KEY:}")
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
private void requireKey() {
|
||||||
|
if (apiKey == null || apiKey.isBlank()) {
|
||||||
|
throw new IllegalStateException("ERR-ADDR-CONFIG: GOOGLE_MAPS_API_KEY 미설정");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> verify(String address, String zip) {
|
||||||
|
requireKey();
|
||||||
|
throw new UnsupportedOperationException("ERR-ADDR-IMPL: Google Maps 실연동은 운영 배포 시 구현");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> route(List<String> addresses, String origin) {
|
||||||
|
requireKey();
|
||||||
|
throw new UnsupportedOperationException("ERR-ADDR-IMPL: Google Maps 라우팅은 운영 배포 시 구현");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String provider() {
|
||||||
|
return "google";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 주소 검증기 (기본). 외부 호출 0.
|
||||||
|
* ZIP 5자리 형식이면 valid 처리, 라우팅은 입력 순서를 유지하며 가짜 ETA 부여.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "mall.address.provider", havingValue = "mock", matchIfMissing = true)
|
||||||
|
public class MockAddressVerifier implements AddressVerifier {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> verify(String address, String zip) {
|
||||||
|
boolean valid = zip != null && zip.matches("\\d{5}") && address != null && !address.isBlank();
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("provider", "mock");
|
||||||
|
out.put("valid", valid);
|
||||||
|
out.put("normalized", address == null ? "" : address.trim());
|
||||||
|
out.put("lat", 40.0 + (zip == null ? 0 : (zip.hashCode() % 1000) / 10000.0));
|
||||||
|
out.put("lng", -74.0 - (zip == null ? 0 : (Math.abs(zip.hashCode()) % 1000) / 10000.0));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> route(List<String> addresses, String origin) {
|
||||||
|
List<Map<String, Object>> ordered = new ArrayList<>();
|
||||||
|
int seq = 1;
|
||||||
|
for (String a : addresses) {
|
||||||
|
Map<String, Object> stop = new LinkedHashMap<>();
|
||||||
|
stop.put("seq", seq);
|
||||||
|
stop.put("address", a);
|
||||||
|
stop.put("lat", 40.0 + seq * 0.01);
|
||||||
|
stop.put("lng", -74.0 - seq * 0.01);
|
||||||
|
stop.put("eta", (seq * 15) + " min");
|
||||||
|
ordered.add(stop);
|
||||||
|
seq++;
|
||||||
|
}
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("provider", "mock");
|
||||||
|
out.put("origin", origin);
|
||||||
|
out.put("ordered", ordered);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String provider() {
|
||||||
|
return "mock";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Mock 이메일 발송기 (기본). 외부 호출 0 — 로그만 남기고 성공 반환. */
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "mall.email.provider", havingValue = "mock", matchIfMissing = true)
|
||||||
|
public class MockEmailSender implements EmailSender {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> send(String toEmail, String subject, String htmlBody) {
|
||||||
|
log.info("[MOCK-EMAIL] to={} subject={}", toEmail, subject);
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("provider", "mock");
|
||||||
|
out.put("messageId", "MOCK-EMAIL-" + UUID.randomUUID().toString().substring(0, 10));
|
||||||
|
out.put("status", "SENT");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String provider() {
|
||||||
|
return "mock";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.common.CryptoUtil;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 결제 게이트웨이 (기본). 외부 호출 0 — 개발/데모용.
|
||||||
|
* 항상 승인 처리하고 가짜 txnId를 발급한다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "mall.payment.provider", havingValue = "mock", matchIfMissing = true)
|
||||||
|
public class MockPaymentGateway implements PaymentGateway {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> charge(String orderNo, BigDecimal amount, String currency, String cardToken) {
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("provider", "mock");
|
||||||
|
out.put("txnId", "MOCK-" + UUID.randomUUID().toString().substring(0, 12));
|
||||||
|
out.put("status", "APPROVED");
|
||||||
|
out.put("amount", amount);
|
||||||
|
out.put("currency", currency == null ? "USD" : currency);
|
||||||
|
out.put("cardMask", CryptoUtil.maskTail(cardToken));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> refund(String txnId, BigDecimal amount) {
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("provider", "mock");
|
||||||
|
out.put("txnId", txnId);
|
||||||
|
out.put("status", "REFUNDED");
|
||||||
|
out.put("amount", amount);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String provider() {
|
||||||
|
return "mock";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Mock SMS/MMS 발송기 (기본). 외부 호출 0 — 로그만 남기고 성공 반환. */
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "mall.sms.provider", havingValue = "mock", matchIfMissing = true)
|
||||||
|
public class MockSmsSender implements SmsSender {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> sendSms(String toPhone, String body) {
|
||||||
|
log.info("[MOCK-SMS] to={} body={}", mask(toPhone), body);
|
||||||
|
return result("SMS");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> sendMms(String toPhone, String body, String mediaUrl) {
|
||||||
|
log.info("[MOCK-MMS] to={} media={} body={}", mask(toPhone), mediaUrl, body);
|
||||||
|
return result("MMS");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> result(String type) {
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("provider", "mock");
|
||||||
|
out.put("messageId", "MOCK-" + type + "-" + UUID.randomUUID().toString().substring(0, 10));
|
||||||
|
out.put("status", "SENT");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mask(String phone) {
|
||||||
|
if (phone == null || phone.length() < 4) return "****";
|
||||||
|
return "***" + phone.substring(phone.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String provider() {
|
||||||
|
return "mock";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 판매세 계산기 (기본). 외부 호출 0.
|
||||||
|
* 주(state)별 대표 세율 테이블로 근사 계산한다(데모용).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "mall.tax.provider", havingValue = "mock", matchIfMissing = true)
|
||||||
|
public class MockTaxCalculator implements TaxCalculator {
|
||||||
|
|
||||||
|
private static final Map<String, BigDecimal> STATE_RATE = Map.ofEntries(
|
||||||
|
Map.entry("NY", new BigDecimal("0.08875")), Map.entry("MA", new BigDecimal("0.0625")),
|
||||||
|
Map.entry("IL", new BigDecimal("0.1025")), Map.entry("CA", new BigDecimal("0.0725")),
|
||||||
|
Map.entry("WA", new BigDecimal("0.1025")), Map.entry("FL", new BigDecimal("0.07")),
|
||||||
|
Map.entry("GA", new BigDecimal("0.089")), Map.entry("TX", new BigDecimal("0.0825"))
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> calculate(BigDecimal amount, String zip, String state) {
|
||||||
|
BigDecimal rate = STATE_RATE.getOrDefault(state == null ? "" : state.toUpperCase(), new BigDecimal("0.07"));
|
||||||
|
BigDecimal tax = amount == null ? BigDecimal.ZERO
|
||||||
|
: amount.multiply(rate).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("provider", "mock");
|
||||||
|
out.put("rate", rate);
|
||||||
|
out.put("taxAmount", tax);
|
||||||
|
out.put("jurisdiction", (state == null ? "US" : state) + (zip == null ? "" : " " + zip));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String provider() {
|
||||||
|
return "mock";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 결제 게이트웨이 추상화. 기본 mock, 운영 시 Stripe. */
|
||||||
|
public interface PaymentGateway {
|
||||||
|
/**
|
||||||
|
* 결제 승인.
|
||||||
|
* @return { provider, txnId, status(APPROVED/DECLINED), cardMask }
|
||||||
|
*/
|
||||||
|
Map<String, Object> charge(String orderNo, BigDecimal amount, String currency, String cardToken);
|
||||||
|
|
||||||
|
/** 환불. @return { txnId, status(REFUNDED/FAILED) } */
|
||||||
|
Map<String, Object> refund(String txnId, BigDecimal amount);
|
||||||
|
|
||||||
|
String provider();
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SendGrid 이메일 어댑터 (mall.email.provider=sendgrid).
|
||||||
|
* 키는 환경변수 SENDGRID_API_KEY만. 키 없으면 비활성.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "mall.email.provider", havingValue = "sendgrid")
|
||||||
|
public class SendGridEmailSender implements EmailSender {
|
||||||
|
|
||||||
|
@Value("${SENDGRID_API_KEY:}")
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> send(String toEmail, String subject, String htmlBody) {
|
||||||
|
if (apiKey == null || apiKey.isBlank()) {
|
||||||
|
throw new IllegalStateException("ERR-EMAIL-CONFIG: SENDGRID_API_KEY 미설정");
|
||||||
|
}
|
||||||
|
throw new UnsupportedOperationException("ERR-EMAIL-IMPL: SendGrid 실연동은 운영 배포 시 구현");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String provider() {
|
||||||
|
return "sendgrid";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smarty(SmartyStreets) 주소 검증 어댑터 (mall.address.provider=smarty).
|
||||||
|
* 키는 환경변수 SMARTY_AUTH_ID/SMARTY_AUTH_TOKEN만. 키 없으면 비활성.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "mall.address.provider", havingValue = "smarty")
|
||||||
|
public class SmartyVerifier implements AddressVerifier {
|
||||||
|
|
||||||
|
@Value("${SMARTY_AUTH_ID:}")
|
||||||
|
private String authId;
|
||||||
|
@Value("${SMARTY_AUTH_TOKEN:}")
|
||||||
|
private String authToken;
|
||||||
|
|
||||||
|
private void requireKey() {
|
||||||
|
if (authId == null || authId.isBlank() || authToken == null || authToken.isBlank()) {
|
||||||
|
throw new IllegalStateException("ERR-ADDR-CONFIG: SMARTY 인증정보 미설정");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> verify(String address, String zip) {
|
||||||
|
requireKey();
|
||||||
|
throw new UnsupportedOperationException("ERR-ADDR-IMPL: Smarty 실연동은 운영 배포 시 구현");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> route(List<String> addresses, String origin) {
|
||||||
|
requireKey();
|
||||||
|
throw new UnsupportedOperationException("ERR-ADDR-IMPL: Smarty는 라우팅 미지원 — Google 사용");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String provider() {
|
||||||
|
return "smarty";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** SMS/MMS 발송 추상화. 기본 mock, 운영 시 Twilio. 배송 SMS·꽃사진 MMS. */
|
||||||
|
public interface SmsSender {
|
||||||
|
/** 텍스트 SMS. @return { provider, messageId, status } */
|
||||||
|
Map<String, Object> sendSms(String toPhone, String body);
|
||||||
|
|
||||||
|
/** 꽃사진 MMS(Proof of Quality). @return { provider, messageId, status } */
|
||||||
|
Map<String, Object> sendMms(String toPhone, String body, String mediaUrl);
|
||||||
|
|
||||||
|
String provider();
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stripe 결제 어댑터 (운영 시 활성, mall.payment.provider=stripe).
|
||||||
|
*
|
||||||
|
* <p>API 키는 환경변수(STRIPE_SECRET_KEY)에서만 주입. 평문 코드 금지.
|
||||||
|
* 키가 없으면 비활성(예외)으로 동작하여 실수로 외부 호출되지 않게 한다.
|
||||||
|
* 실제 Stripe SDK 호출은 운영 배포 시 구현 — 스텁은 키 검증만 수행한다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "mall.payment.provider", havingValue = "stripe")
|
||||||
|
public class StripeGateway implements PaymentGateway {
|
||||||
|
|
||||||
|
@Value("${STRIPE_SECRET_KEY:}")
|
||||||
|
private String secretKey;
|
||||||
|
|
||||||
|
private void requireKey() {
|
||||||
|
if (secretKey == null || secretKey.isBlank()) {
|
||||||
|
throw new IllegalStateException("ERR-PAY-CONFIG: STRIPE_SECRET_KEY 미설정 — Stripe 어댑터 비활성");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> charge(String orderNo, BigDecimal amount, String currency, String cardToken) {
|
||||||
|
requireKey();
|
||||||
|
// 운영 배포 시 Stripe PaymentIntents API 연동.
|
||||||
|
throw new UnsupportedOperationException("ERR-PAY-IMPL: Stripe 실연동은 운영 배포 시 구현");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> refund(String txnId, BigDecimal amount) {
|
||||||
|
requireKey();
|
||||||
|
throw new UnsupportedOperationException("ERR-PAY-IMPL: Stripe 환불은 운영 배포 시 구현");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String provider() {
|
||||||
|
return "stripe";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 판매세 계산 추상화. 기본 mock, 운영 시 TaxJar/Avalara (목적지 기반). */
|
||||||
|
public interface TaxCalculator {
|
||||||
|
/**
|
||||||
|
* 목적지 기반 판매세 계산.
|
||||||
|
* @return { provider, rate, taxAmount, jurisdiction }
|
||||||
|
*/
|
||||||
|
Map<String, Object> calculate(BigDecimal amount, String zip, String state);
|
||||||
|
|
||||||
|
String provider();
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TaxJar 어댑터 (mall.tax.provider=taxjar). 키는 환경변수 TAXJAR_API_KEY만.
|
||||||
|
* 키 없으면 비활성. 실연동은 운영 배포 시 구현.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "mall.tax.provider", havingValue = "taxjar")
|
||||||
|
public class TaxJarCalculator implements TaxCalculator {
|
||||||
|
|
||||||
|
@Value("${TAXJAR_API_KEY:}")
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> calculate(BigDecimal amount, String zip, String state) {
|
||||||
|
if (apiKey == null || apiKey.isBlank()) {
|
||||||
|
throw new IllegalStateException("ERR-TAX-CONFIG: TAXJAR_API_KEY 미설정");
|
||||||
|
}
|
||||||
|
throw new UnsupportedOperationException("ERR-TAX-IMPL: TaxJar 실연동은 운영 배포 시 구현");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String provider() {
|
||||||
|
return "taxjar";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.zioinfo.mall.gateway;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Twilio SMS/MMS 어댑터 (mall.sms.provider=twilio).
|
||||||
|
* 키는 환경변수 TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN/TWILIO_FROM만. 키 없으면 비활성.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "mall.sms.provider", havingValue = "twilio")
|
||||||
|
public class TwilioSmsSender implements SmsSender {
|
||||||
|
|
||||||
|
@Value("${TWILIO_ACCOUNT_SID:}")
|
||||||
|
private String accountSid;
|
||||||
|
@Value("${TWILIO_AUTH_TOKEN:}")
|
||||||
|
private String authToken;
|
||||||
|
@Value("${TWILIO_FROM:}")
|
||||||
|
private String fromNumber;
|
||||||
|
|
||||||
|
private void requireKey() {
|
||||||
|
if (accountSid == null || accountSid.isBlank() || authToken == null || authToken.isBlank()) {
|
||||||
|
throw new IllegalStateException("ERR-SMS-CONFIG: Twilio 인증정보 미설정");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> sendSms(String toPhone, String body) {
|
||||||
|
requireKey();
|
||||||
|
throw new UnsupportedOperationException("ERR-SMS-IMPL: Twilio SMS 실연동은 운영 배포 시 구현");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> sendMms(String toPhone, String body, String mediaUrl) {
|
||||||
|
requireKey();
|
||||||
|
throw new UnsupportedOperationException("ERR-SMS-IMPL: Twilio MMS 실연동은 운영 배포 시 구현");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String provider() {
|
||||||
|
return "twilio";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package com.zioinfo.mall.integration;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUARDiA CRM API 클라이언트.
|
||||||
|
*
|
||||||
|
* <p>회원=고객 연계. 주문 이력을 CRM 고객 인사이트로 전달한다.
|
||||||
|
* 응답은 새니타이저로 정제 후 반환. 실패 시 빈 Map, 스택트레이스 미노출.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CrmClient {
|
||||||
|
|
||||||
|
@Value("${guardia.crm-url:http://localhost:8004}")
|
||||||
|
private String crmUrl;
|
||||||
|
|
||||||
|
private final WebClient.Builder webClientBuilder;
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> call(String path, HttpMethod method, Object body) {
|
||||||
|
try {
|
||||||
|
WebClient.RequestBodySpec spec = webClientBuilder.baseUrl(crmUrl).build().method(method).uri(path);
|
||||||
|
WebClient.RequestHeadersSpec<?> headersSpec =
|
||||||
|
(body != null && method != HttpMethod.GET) ? spec.bodyValue(body) : spec;
|
||||||
|
Map<String, Object> result = headersSpec.retrieve()
|
||||||
|
.bodyToMono(Map.class).map(m -> (Map<String, Object>) m).block();
|
||||||
|
if (result == null) return Collections.emptyMap();
|
||||||
|
return (Map<String, Object>) ItsmSecuritySanitizer.clean(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("CRM 연동 일시 실패 [{} {}]: {}", method, path, e.getMessage());
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 회원 가입/주문 시 CRM 고객 upsert. */
|
||||||
|
public Map<String, Object> upsertCustomer(Map<String, Object> customer) {
|
||||||
|
return call("/api/crm/customers/upsert", HttpMethod.POST, customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CRM 고객 인사이트 조회. */
|
||||||
|
public Map<String, Object> getCustomerInsight(String customerKey) {
|
||||||
|
return call("/api/crm/customers/" + customerKey + "/insight", HttpMethod.GET, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package com.zioinfo.mall.integration;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUARDiA ERP API 클라이언트.
|
||||||
|
*
|
||||||
|
* <p>재고 동기화·정산(매입/매출 전표) 연계. 응답은 새니타이저로 정제 후 반환.
|
||||||
|
* 실패 시 빈 Map 반환, 스택트레이스 미노출.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ErpClient {
|
||||||
|
|
||||||
|
@Value("${guardia.erp-url:http://localhost:8003}")
|
||||||
|
private String erpUrl;
|
||||||
|
|
||||||
|
private final WebClient.Builder webClientBuilder;
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> call(String path, HttpMethod method, Object body) {
|
||||||
|
try {
|
||||||
|
WebClient.RequestBodySpec spec = webClientBuilder.baseUrl(erpUrl).build().method(method).uri(path);
|
||||||
|
WebClient.RequestHeadersSpec<?> headersSpec =
|
||||||
|
(body != null && method != HttpMethod.GET) ? spec.bodyValue(body) : spec;
|
||||||
|
Map<String, Object> result = headersSpec.retrieve()
|
||||||
|
.bodyToMono(Map.class).map(m -> (Map<String, Object>) m).block();
|
||||||
|
if (result == null) return Collections.emptyMap();
|
||||||
|
return (Map<String, Object>) ItsmSecuritySanitizer.clean(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("ERP 연동 일시 실패 [{} {}]: {}", method, path, e.getMessage());
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ERP 재고 조회 (SKU 기준). */
|
||||||
|
public Map<String, Object> getStock(String sku) {
|
||||||
|
return call("/api/erp/inventory/" + sku, HttpMethod.GET, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ERP 매출 전표 생성 (정산 연계). */
|
||||||
|
public Map<String, Object> postSalesVoucher(Map<String, Object> voucher) {
|
||||||
|
return call("/api/erp/finance/sales-voucher", HttpMethod.POST, voucher);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package com.zioinfo.mall.integration;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUARDiA 연계 조회 API — /api/mall/integration.
|
||||||
|
*
|
||||||
|
* <p>모든 응답은 ItsmSecuritySanitizer 로 정제된 상태로 반환된다(자격증명 제거).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/integration")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class IntegrationController {
|
||||||
|
|
||||||
|
private final ItsmClient itsm;
|
||||||
|
private final ErpClient erp;
|
||||||
|
private final CrmClient crm;
|
||||||
|
|
||||||
|
/** ITSM SR 상태 조회 (CS 연계). */
|
||||||
|
@GetMapping("/itsm/sr/{srId}")
|
||||||
|
public ApiResponse<Map<String, Object>> sr(@PathVariable String srId) {
|
||||||
|
return ApiResponse.ok(itsm.getSr(srId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ERP 재고 조회 (SKU). */
|
||||||
|
@GetMapping("/erp/stock/{sku}")
|
||||||
|
public ApiResponse<Map<String, Object>> erpStock(@PathVariable String sku) {
|
||||||
|
return ApiResponse.ok(erp.getStock(sku));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CRM 고객 인사이트 조회. */
|
||||||
|
@GetMapping("/crm/customer/{key}/insight")
|
||||||
|
public ApiResponse<Map<String, Object>> crmInsight(@PathVariable("key") String key) {
|
||||||
|
return ApiResponse.ok(crm.getCustomerInsight(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
package com.zioinfo.mall.integration;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUARDiA ITSM API 클라이언트.
|
||||||
|
*
|
||||||
|
* <p>고객 문의(CS)를 ITSM SR로 자동 생성한다. 모든 응답은
|
||||||
|
* {@link ItsmSecuritySanitizer#clean(Object)}로 자격증명을 제거한 뒤 반환한다.
|
||||||
|
* 실패 시 빈 Map/List 반환, 스택트레이스 미노출 (요약 로그만).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ItsmClient {
|
||||||
|
|
||||||
|
@Value("${guardia.itsm-url:http://localhost:9001}")
|
||||||
|
private String itsmUrl;
|
||||||
|
|
||||||
|
private final WebClient.Builder webClientBuilder;
|
||||||
|
|
||||||
|
private WebClient client() {
|
||||||
|
return webClientBuilder.baseUrl(itsmUrl).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> call(String path, HttpMethod method, Object body) {
|
||||||
|
try {
|
||||||
|
WebClient.RequestBodySpec spec = client().method(method).uri(path);
|
||||||
|
WebClient.RequestHeadersSpec<?> headersSpec =
|
||||||
|
(body != null && method != HttpMethod.GET) ? spec.bodyValue(body) : spec;
|
||||||
|
Map<String, Object> result = headersSpec.retrieve()
|
||||||
|
.bodyToMono(Map.class)
|
||||||
|
.map(m -> (Map<String, Object>) m)
|
||||||
|
.block();
|
||||||
|
if (result == null) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
return (Map<String, Object>) ItsmSecuritySanitizer.clean(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("ITSM 연동 일시 실패 [{} {}]: {}", method, path, e.getMessage());
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CS 문의 → ITSM SR 생성.
|
||||||
|
*
|
||||||
|
* @return 생성된 SR ID, 실패 시 null
|
||||||
|
*/
|
||||||
|
public String createSr(String title, String content, String priority) {
|
||||||
|
Map<String, Object> payload = Map.of(
|
||||||
|
"title", title,
|
||||||
|
"content", content,
|
||||||
|
"priority", priority == null ? "MEDIUM" : priority,
|
||||||
|
"source", "MALL"
|
||||||
|
);
|
||||||
|
Map<String, Object> result = call("/api/tasks", HttpMethod.POST, payload);
|
||||||
|
Object id = result.getOrDefault("sr_id", result.get("id"));
|
||||||
|
return id != null ? String.valueOf(id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SR 상태 조회. */
|
||||||
|
public Map<String, Object> getSr(String srId) {
|
||||||
|
return call("/api/tasks/" + srId, HttpMethod.GET, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package com.zioinfo.mall.integration;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 GUARDiA 연동 응답 보안 새니타이저.
|
||||||
|
*
|
||||||
|
* <p>GUARDiA 보안 불변 규칙: 서버 자격증명(IP/SSH 계정/비밀번호 등)을
|
||||||
|
* API 응답에 절대 노출하지 않는다. ITSM/ERP/CRM 응답을 Mall로 반환하기 전
|
||||||
|
* 반드시 {@link #clean(Object)}를 호출한다.
|
||||||
|
*
|
||||||
|
* <p>Map/List 구조를 재귀적으로 순회하며 민감 필드를 제거한다.
|
||||||
|
*/
|
||||||
|
public final class ItsmSecuritySanitizer {
|
||||||
|
|
||||||
|
/** 응답에서 완전 제거할 민감 필드 키 (대소문자 무시). */
|
||||||
|
private static final Set<String> SENSITIVE_KEYS = Set.of(
|
||||||
|
"ip_addr",
|
||||||
|
"ssh_user",
|
||||||
|
"os_pw_enc",
|
||||||
|
"password",
|
||||||
|
"ssh_key",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
"card_no_enc",
|
||||||
|
"account_no_enc"
|
||||||
|
);
|
||||||
|
|
||||||
|
private ItsmSecuritySanitizer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Object clean(Object data) {
|
||||||
|
if (data instanceof Map<?, ?> map) {
|
||||||
|
Map<String, Object> cleaned = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||||
|
String key = String.valueOf(entry.getKey());
|
||||||
|
if (isSensitive(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cleaned.put(key, clean(entry.getValue()));
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
if (data instanceof List<?> list) {
|
||||||
|
List<Object> cleaned = new ArrayList<>(list.size());
|
||||||
|
for (Object item : list) {
|
||||||
|
cleaned.add(clean(item));
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSensitive(String key) {
|
||||||
|
if (key == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return SENSITIVE_KEYS.contains(key.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package com.zioinfo.mall.inventory;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import com.zioinfo.mall.integration.ErpClient;
|
||||||
|
import com.zioinfo.mall.inventory.mapper.InventoryMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 재고 API — /api/mall/inventory. 조회/변경 MANAGER+. ERP 동기화 연계. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/inventory")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public class InventoryController {
|
||||||
|
|
||||||
|
private final InventoryMapper mapper;
|
||||||
|
private final ErpClient erpClient;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<List<MallInventory>> list(@RequestParam(required = false) Boolean lowStock) {
|
||||||
|
return ApiResponse.ok(mapper.findAll(lowStock));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ApiResponse<MallInventory> get(@PathVariable Long id) {
|
||||||
|
return ApiResponse.ok(mapper.findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<MallInventory> create(@RequestBody MallInventory inv) {
|
||||||
|
mapper.insert(inv);
|
||||||
|
return ApiResponse.ok(mapper.findById(inv.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/adjust")
|
||||||
|
public ApiResponse<MallInventory> adjust(@PathVariable Long id, @RequestBody Map<String, Integer> req) {
|
||||||
|
mapper.adjust(id, req.getOrDefault("delta", 0));
|
||||||
|
return ApiResponse.ok(mapper.findById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ERP 재고 동기화 — ErpClient.getStock(sku) 결과 반영. 실패 시 PENDING 유지. */
|
||||||
|
@PostMapping("/{id}/erp-sync")
|
||||||
|
public ApiResponse<MallInventory> erpSync(@PathVariable Long id) {
|
||||||
|
MallInventory inv = mapper.findById(id);
|
||||||
|
if (inv == null) throw new RuntimeException("ERR-INV-404: 재고 정보 없음");
|
||||||
|
Map<String, Object> erp = erpClient.getStock(inv.getSku());
|
||||||
|
Object qty = erp.get("onHand");
|
||||||
|
if (qty == null) qty = erp.get("quantity");
|
||||||
|
if (qty != null) {
|
||||||
|
mapper.setErpSync(id, "SYNCED", ((Number) qty).intValue());
|
||||||
|
} else {
|
||||||
|
mapper.setErpSync(id, "PENDING", null);
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(mapper.findById(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.zioinfo.mall.inventory;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 재고 (mall_inventory). productId/optionId 단위. ERP 연계 동기화. */
|
||||||
|
@Data
|
||||||
|
public class MallInventory {
|
||||||
|
private Long id;
|
||||||
|
private Long productId;
|
||||||
|
private Long optionId;
|
||||||
|
private String sku;
|
||||||
|
private Integer onHand; // 가용 재고
|
||||||
|
private Integer reserved; // 예약(주문 미출고)
|
||||||
|
private Integer safetyStock;
|
||||||
|
private String erpSyncStatus; // SYNCED / PENDING / NONE
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.zioinfo.mall.inventory;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 매장별 당일 재고 (mall_store_inventory). available=ON/OFF 토글. */
|
||||||
|
@Data
|
||||||
|
public class MallStoreInventory {
|
||||||
|
private Long id;
|
||||||
|
private Long storeId;
|
||||||
|
private Long productId;
|
||||||
|
private String productName; // 조인 표시용
|
||||||
|
private String thumbnail; // 조인 표시용
|
||||||
|
private Boolean available; // 당일 입고 ON/OFF
|
||||||
|
private Integer onHand;
|
||||||
|
private String updatedBy;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package com.zioinfo.mall.inventory;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.admin.AuditService;
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import com.zioinfo.mall.inventory.mapper.StoreInventoryMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장별 당일 재고 ON/OFF API — /api/mall/store-inventory.
|
||||||
|
* 관리자앱 '원터치' 토글. StoreManager는 자기 매장만(서비스 가드는 호출자 검증 권장).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/store-inventory")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public class StoreInventoryController {
|
||||||
|
|
||||||
|
private final StoreInventoryMapper mapper;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
/** 매장별 마스터 상품 리스트(토글 화면). */
|
||||||
|
@GetMapping("/store/{storeId}")
|
||||||
|
public ApiResponse<List<MallStoreInventory>> byStore(@PathVariable Long storeId) {
|
||||||
|
return ApiResponse.ok(mapper.findByStore(storeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원터치 ON/OFF 토글 — OFF 시 해당 지역 온라인 즉시 품절. */
|
||||||
|
@PutMapping("/store/{storeId}/product/{productId}/toggle")
|
||||||
|
public ApiResponse<MallStoreInventory> toggle(@PathVariable Long storeId, @PathVariable Long productId,
|
||||||
|
@RequestBody Map<String, Boolean> req, Authentication auth) {
|
||||||
|
boolean available = req.getOrDefault("available", true);
|
||||||
|
mapper.setAvailable(storeId, productId, available, auth.getName());
|
||||||
|
auditService.log(auth.getName(), "STORE_INV_TOGGLE",
|
||||||
|
"store=" + storeId + ",product=" + productId, available ? "ON" : "OFF");
|
||||||
|
return ApiResponse.ok(mapper.findOne(storeId, productId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 재고 수량 조정. */
|
||||||
|
@PutMapping("/store/{storeId}/product/{productId}/adjust")
|
||||||
|
public ApiResponse<MallStoreInventory> adjust(@PathVariable Long storeId, @PathVariable Long productId,
|
||||||
|
@RequestBody Map<String, Integer> req) {
|
||||||
|
mapper.adjustOnHand(storeId, productId, req.getOrDefault("delta", 0));
|
||||||
|
return ApiResponse.ok(mapper.findOne(storeId, productId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/upsert")
|
||||||
|
public ApiResponse<MallStoreInventory> upsert(@RequestBody MallStoreInventory si, Authentication auth) {
|
||||||
|
si.setUpdatedBy(auth.getName());
|
||||||
|
mapper.upsert(si);
|
||||||
|
return ApiResponse.ok(mapper.findOne(si.getStoreId(), si.getProductId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.zioinfo.mall.inventory.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.inventory.MallInventory;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface InventoryMapper {
|
||||||
|
List<MallInventory> findAll(@Param("lowStock") Boolean lowStock);
|
||||||
|
MallInventory findById(@Param("id") Long id);
|
||||||
|
MallInventory findBySku(@Param("sku") String sku);
|
||||||
|
int insert(MallInventory inv);
|
||||||
|
int adjust(@Param("id") Long id, @Param("delta") int delta);
|
||||||
|
int setErpSync(@Param("id") Long id, @Param("status") String status, @Param("onHand") Integer onHand);
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.zioinfo.mall.inventory.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.inventory.MallStoreInventory;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface StoreInventoryMapper {
|
||||||
|
/** 매장별 마스터 상품 리스트(ON/OFF 토글용). */
|
||||||
|
List<MallStoreInventory> findByStore(@Param("storeId") Long storeId);
|
||||||
|
MallStoreInventory findOne(@Param("storeId") Long storeId, @Param("productId") Long productId);
|
||||||
|
/** 특정 ZIP의 매장들에서 가용한 상품 ID 집합(스토어프론트 필터). */
|
||||||
|
List<Long> availableProductIds(@Param("storeId") Long storeId);
|
||||||
|
int upsert(MallStoreInventory si);
|
||||||
|
int setAvailable(@Param("storeId") Long storeId, @Param("productId") Long productId,
|
||||||
|
@Param("available") boolean available, @Param("updatedBy") String updatedBy);
|
||||||
|
int adjustOnHand(@Param("storeId") Long storeId, @Param("productId") Long productId, @Param("delta") int delta);
|
||||||
|
}
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
package com.zioinfo.mall.loyalty;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.admin.AuditService;
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import com.zioinfo.mall.loyalty.mapper.LoyaltyMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등급별 고객관리 API — /api/mall/loyalty.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>내 등급/혜택/포인트 조회·사용 — 인증 고객</li>
|
||||||
|
* <li>등급 기준·혜택 정의 CRUD, 등급 재산정, 포인트 조정 — ADMIN</li>
|
||||||
|
* <li>등급별 매출 — MANAGER+</li>
|
||||||
|
* </ul>
|
||||||
|
* 등급 혜택 목록(GET /tiers)은 스토어프론트 노출을 위해 공개(SecurityConfig 화이트리스트).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/loyalty")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LoyaltyController {
|
||||||
|
|
||||||
|
private final LoyaltyService service;
|
||||||
|
private final LoyaltyMapper mapper;
|
||||||
|
|
||||||
|
// ---------- 등급 혜택 정의 ----------
|
||||||
|
/** 등급 혜택 목록 — 공개(스토어프론트 등급 안내). */
|
||||||
|
@GetMapping("/tiers")
|
||||||
|
public ApiResponse<List<MallTierBenefit>> tiers(@RequestParam(defaultValue = "true") boolean activeOnly) {
|
||||||
|
return ApiResponse.ok(service.tiers(activeOnly));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/tiers/{code}")
|
||||||
|
public ApiResponse<MallTierBenefit> tier(@PathVariable String code) {
|
||||||
|
return ApiResponse.ok(service.tierByCode(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/tiers")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ApiResponse<MallTierBenefit> createTier(@RequestBody MallTierBenefit t) {
|
||||||
|
mapper.insertTier(t);
|
||||||
|
return ApiResponse.ok(mapper.findTierById(t.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/tiers/{id}")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ApiResponse<MallTierBenefit> updateTier(@PathVariable Long id, @RequestBody MallTierBenefit t) {
|
||||||
|
t.setId(id);
|
||||||
|
mapper.updateTier(t);
|
||||||
|
return ApiResponse.ok(mapper.findTierById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/tiers/{id}")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ApiResponse<Void> deleteTier(@PathVariable Long id) {
|
||||||
|
mapper.deleteTier(id);
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 내 등급 ----------
|
||||||
|
/** 내 등급/혜택/누적/포인트 통합 조회. */
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ApiResponse<Map<String, Object>> me(Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.myStatus(auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 포인트 ----------
|
||||||
|
@GetMapping("/points/balance")
|
||||||
|
public ApiResponse<Map<String, Object>> balance(Authentication auth) {
|
||||||
|
return ApiResponse.ok(Map.of("owner", auth.getName(), "balance", service.balance(auth.getName())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/points/history")
|
||||||
|
public ApiResponse<List<MallPointLedger>> history(
|
||||||
|
@RequestParam(defaultValue = "100") int limit, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.history(auth.getName(), limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 포인트 사용(결제 시 차감). */
|
||||||
|
@PostMapping("/points/use")
|
||||||
|
public ApiResponse<Map<String, Object>> use(@RequestBody UsePointRequest req, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.usePoints(auth.getName(), req.points(), req.orderNo()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 관리자 포인트 조정(가감). */
|
||||||
|
@PostMapping("/points/adjust")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ApiResponse<Map<String, Object>> adjust(@RequestBody AdjustRequest req, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.adjust(req.owner(), req.points(), req.reason(), auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 등급 재산정 ----------
|
||||||
|
/** 단일 고객 등급 재산정 (본인 또는 ADMIN). */
|
||||||
|
@PostMapping("/recalc")
|
||||||
|
public ApiResponse<Map<String, Object>> recalc(@RequestParam(required = false) String owner,
|
||||||
|
Authentication auth) {
|
||||||
|
boolean admin = auth.getAuthorities().stream()
|
||||||
|
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
|
||||||
|
String target = (admin && owner != null && !owner.isBlank()) ? owner : auth.getName();
|
||||||
|
return ApiResponse.ok(service.recalcOne(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전체 회원 등급 일괄 재산정(배치성) — ADMIN. */
|
||||||
|
@PostMapping("/recalc-all")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ApiResponse<Map<String, Object>> recalcAll() {
|
||||||
|
return ApiResponse.ok(service.recalcAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 등급별 매출 ----------
|
||||||
|
@GetMapping("/analytics/by-tier")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<List<Map<String, Object>>> byTier(@RequestParam(defaultValue = "30") int days) {
|
||||||
|
return ApiResponse.ok(service.salesByTier(days));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UsePointRequest(int points, String orderNo) {}
|
||||||
|
public record AdjustRequest(String owner, int points, String reason) {}
|
||||||
|
}
|
||||||
@ -0,0 +1,247 @@
|
|||||||
|
package com.zioinfo.mall.loyalty;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.admin.AuditService;
|
||||||
|
import com.zioinfo.mall.loyalty.mapper.LoyaltyMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등급별 고객관리 서비스 — 등급 자동 산정 + 포인트 적립/사용/만료.
|
||||||
|
*
|
||||||
|
* <p>등급은 최근 12개월 누적 구매액·주문수를 등급 기준(mall_tier_benefit)과 비교하여
|
||||||
|
* 충족하는 가장 높은 등급으로 자동 산정한다. 산정 결과는 mall_member.tier 로 sync 한다.
|
||||||
|
* 포인트 적립은 등급별 적립률(pointEarnRate)을 적용한다. 외부 호출 없음.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LoyaltyService {
|
||||||
|
|
||||||
|
private final LoyaltyMapper mapper;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
private static final int POINT_EXPIRE_DAYS = 365;
|
||||||
|
|
||||||
|
/** 등급 혜택 목록(rank ASC). */
|
||||||
|
public List<MallTierBenefit> tiers(boolean activeOnly) {
|
||||||
|
return mapper.findTiers(activeOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MallTierBenefit tierByCode(String code) {
|
||||||
|
return mapper.findTierByCode(code == null ? "BASIC" : code.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 자격 등급 코드(12개월 누적 기준). */
|
||||||
|
public String currentTierCode(String owner) {
|
||||||
|
return qualify(mapper.spend12m(owner)).getTierCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 내 등급/혜택 + 12개월 누적 + 포인트 잔액 통합 조회.
|
||||||
|
* 저장된 등급(mall_member.tier)과 현재 자격을 함께 제공한다.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> myStatus(String owner) {
|
||||||
|
Map<String, Object> spend = mapper.spend12m(owner);
|
||||||
|
MallTierBenefit qualified = qualify(spend);
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("owner", owner);
|
||||||
|
out.put("spend12m", spend.getOrDefault("spend", 0));
|
||||||
|
out.put("orderCount12m", spend.getOrDefault("orderCount", 0));
|
||||||
|
out.put("tier", qualified.getTierCode());
|
||||||
|
out.put("tierName", qualified.getName());
|
||||||
|
out.put("benefit", qualified);
|
||||||
|
out.put("pointBalance", balance(owner));
|
||||||
|
out.put("nextTier", nextTierGap(qualified, spend));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 12개월 누적값으로 자격 등급 산정(가장 높은 충족 등급). 폴백: BASIC. */
|
||||||
|
public MallTierBenefit qualify(Map<String, Object> spend12m) {
|
||||||
|
BigDecimal spend = toBig(spend12m.get("spend"));
|
||||||
|
long orders = toLong(spend12m.get("orderCount"));
|
||||||
|
List<MallTierBenefit> tiers = mapper.findTiers(true);
|
||||||
|
MallTierBenefit best = null;
|
||||||
|
for (MallTierBenefit t : tiers) {
|
||||||
|
BigDecimal minSpend = t.getMinSpend12m() == null ? BigDecimal.ZERO : t.getMinSpend12m();
|
||||||
|
int minOrders = t.getMinOrders12m() == null ? 0 : t.getMinOrders12m();
|
||||||
|
if (spend.compareTo(minSpend) >= 0 && orders >= minOrders) {
|
||||||
|
if (best == null || (t.getTierRank() != null && best.getTierRank() != null
|
||||||
|
&& t.getTierRank() > best.getTierRank())) {
|
||||||
|
best = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best != null) return best;
|
||||||
|
MallTierBenefit basic = mapper.findTierByCode("BASIC");
|
||||||
|
return basic != null ? basic : fallbackBasic();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 다음 등급까지 부족분(있으면). */
|
||||||
|
private Map<String, Object> nextTierGap(MallTierBenefit current, Map<String, Object> spend12m) {
|
||||||
|
List<MallTierBenefit> tiers = mapper.findTiers(true);
|
||||||
|
MallTierBenefit next = null;
|
||||||
|
int curRank = current.getTierRank() == null ? 1 : current.getTierRank();
|
||||||
|
for (MallTierBenefit t : tiers) {
|
||||||
|
if (t.getTierRank() != null && t.getTierRank() == curRank + 1) { next = t; break; }
|
||||||
|
}
|
||||||
|
if (next == null) return Map.of("isTop", true);
|
||||||
|
BigDecimal spend = toBig(spend12m.get("spend"));
|
||||||
|
long orders = toLong(spend12m.get("orderCount"));
|
||||||
|
BigDecimal spendGap = (next.getMinSpend12m() == null ? BigDecimal.ZERO : next.getMinSpend12m())
|
||||||
|
.subtract(spend).max(BigDecimal.ZERO);
|
||||||
|
long orderGap = Math.max(0, (next.getMinOrders12m() == null ? 0 : next.getMinOrders12m()) - orders);
|
||||||
|
Map<String, Object> gap = new LinkedHashMap<>();
|
||||||
|
gap.put("isTop", false);
|
||||||
|
gap.put("nextTier", next.getTierCode());
|
||||||
|
gap.put("nextTierName", next.getName());
|
||||||
|
gap.put("spendNeeded", spendGap);
|
||||||
|
gap.put("ordersNeeded", orderGap);
|
||||||
|
return gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 단일 고객 등급 재산정 + member.tier sync. 변경 시 감사로그. */
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> recalcOne(String owner) {
|
||||||
|
Map<String, Object> spend = mapper.spend12m(owner);
|
||||||
|
MallTierBenefit q = qualify(spend);
|
||||||
|
mapper.updateMemberTier(owner, q.getTierCode());
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("owner", owner);
|
||||||
|
out.put("tier", q.getTierCode());
|
||||||
|
out.put("spend12m", spend.getOrDefault("spend", 0));
|
||||||
|
out.put("orderCount12m", spend.getOrDefault("orderCount", 0));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전체 회원 등급 일괄 재산정(배치성). */
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> recalcAll() {
|
||||||
|
List<String> users = mapper.allMemberUsernames();
|
||||||
|
int updated = 0;
|
||||||
|
Map<String, Integer> dist = new LinkedHashMap<>();
|
||||||
|
for (String u : users) {
|
||||||
|
MallTierBenefit q = qualify(mapper.spend12m(u));
|
||||||
|
mapper.updateMemberTier(u, q.getTierCode());
|
||||||
|
dist.merge(q.getTierCode(), 1, Integer::sum);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
auditService.log("LOYALTY_RECALC_ALL", "members", "count=" + updated);
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("recalculated", updated);
|
||||||
|
out.put("distribution", dist);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- 포인트
|
||||||
|
public int balance(String owner) {
|
||||||
|
Integer b = mapper.balance(owner);
|
||||||
|
return b == null ? 0 : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<MallPointLedger> history(String owner, int limit) {
|
||||||
|
return mapper.history(owner, limit <= 0 || limit > 500 ? 100 : limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결제 완료 시 등급별 적립률로 포인트 자동 적립(주문 흐름 훅).
|
||||||
|
* 이미 동일 주문번호로 EARN 기록이 있으면 중복 방지(idempotent).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int accrueForOrder(String owner, String orderNo, BigDecimal payAmount) {
|
||||||
|
if (owner == null || payAmount == null || payAmount.signum() <= 0) return 0;
|
||||||
|
MallTierBenefit tier = qualify(mapper.spend12m(owner));
|
||||||
|
BigDecimal rate = tier.getPointEarnRate() == null ? BigDecimal.ONE : tier.getPointEarnRate();
|
||||||
|
int points = payAmount.multiply(rate).divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN).intValue();
|
||||||
|
if (points <= 0) return 0;
|
||||||
|
MallPointLedger e = new MallPointLedger();
|
||||||
|
e.setOwner(owner);
|
||||||
|
e.setEntryType("EARN");
|
||||||
|
e.setPoints(points);
|
||||||
|
e.setOrderNo(orderNo);
|
||||||
|
e.setReason("Order " + orderNo + " (" + tier.getTierCode() + " " + rate + "%)");
|
||||||
|
e.setExpireAt(LocalDate.now().plusDays(POINT_EXPIRE_DAYS));
|
||||||
|
mapper.insertPoint(e);
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 포인트 사용(차감). 잔액 부족 시 예외. */
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> usePoints(String owner, int points, String orderNo) {
|
||||||
|
if (points <= 0) throw new IllegalArgumentException("ERR-LOY-400: 사용 포인트는 1 이상이어야 합니다");
|
||||||
|
int bal = balance(owner);
|
||||||
|
if (points > bal) throw new RuntimeException("ERR-LOY-409: 포인트 잔액 부족 (잔액 " + bal + ")");
|
||||||
|
MallPointLedger e = new MallPointLedger();
|
||||||
|
e.setOwner(owner);
|
||||||
|
e.setEntryType("USE");
|
||||||
|
e.setPoints(-points);
|
||||||
|
e.setOrderNo(orderNo);
|
||||||
|
e.setReason(orderNo == null ? "Point redemption" : "Redeemed on order " + orderNo);
|
||||||
|
mapper.insertPoint(e);
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("used", points);
|
||||||
|
out.put("balance", balance(owner));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 관리자 포인트 조정(가감). */
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> adjust(String owner, int points, String reason, String actor) {
|
||||||
|
MallPointLedger e = new MallPointLedger();
|
||||||
|
e.setOwner(owner);
|
||||||
|
e.setEntryType("ADJUST");
|
||||||
|
e.setPoints(points);
|
||||||
|
e.setReason(reason == null ? "Admin adjustment" : reason);
|
||||||
|
mapper.insertPoint(e);
|
||||||
|
auditService.log(actor, "LOYALTY_POINT_ADJUST", owner, "points=" + points);
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("owner", owner);
|
||||||
|
out.put("adjusted", points);
|
||||||
|
out.put("balance", balance(owner));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VIP 우선 슬롯 판정 헬퍼 — 해당 고객 등급에 priority_slot 혜택이 있는지.
|
||||||
|
* schedule 모듈이 피크시즌 우선 배정 시 호출.
|
||||||
|
*/
|
||||||
|
public boolean hasPrioritySlot(String owner) {
|
||||||
|
MallTierBenefit t = qualify(mapper.spend12m(owner));
|
||||||
|
return Boolean.TRUE.equals(t.getPrioritySlot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> salesByTier(int days) {
|
||||||
|
return mapper.salesByTier(days <= 0 ? 30 : days);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- helpers
|
||||||
|
private BigDecimal toBig(Object o) {
|
||||||
|
if (o == null) return BigDecimal.ZERO;
|
||||||
|
if (o instanceof BigDecimal b) return b;
|
||||||
|
if (o instanceof Number n) return BigDecimal.valueOf(n.doubleValue());
|
||||||
|
try { return new BigDecimal(o.toString()); } catch (Exception e) { return BigDecimal.ZERO; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private long toLong(Object o) {
|
||||||
|
if (o == null) return 0L;
|
||||||
|
if (o instanceof Number n) return n.longValue();
|
||||||
|
try { return Long.parseLong(o.toString()); } catch (Exception e) { return 0L; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private MallTierBenefit fallbackBasic() {
|
||||||
|
MallTierBenefit b = new MallTierBenefit();
|
||||||
|
b.setTierCode("BASIC");
|
||||||
|
b.setName("일반 (Basic)");
|
||||||
|
b.setTierRank(1);
|
||||||
|
b.setDiscountRate(BigDecimal.ZERO);
|
||||||
|
b.setPointEarnRate(BigDecimal.ONE);
|
||||||
|
b.setPrioritySlot(false);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.zioinfo.mall.loyalty;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포인트 원장 (mall_point_ledger).
|
||||||
|
*
|
||||||
|
* <p>entryType: EARN(적립 +) / USE(사용 -) / EXPIRE(만료 -) / ADJUST(조정).
|
||||||
|
* 잔액 = SUM(points).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class MallPointLedger {
|
||||||
|
private Long id;
|
||||||
|
private String owner;
|
||||||
|
private String entryType; // EARN/USE/EXPIRE/ADJUST
|
||||||
|
private Integer points; // 적립 +, 사용/만료 -
|
||||||
|
private String orderNo;
|
||||||
|
private String reason;
|
||||||
|
private LocalDate expireAt; // 적립분 만료일(EARN)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.zioinfo.mall.loyalty;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등급별 혜택 정의 (mall_tier_benefit).
|
||||||
|
*
|
||||||
|
* <p>tierCode: BASIC/SILVER/GOLD/VIP. 자동 산정 시 12개월 누적 구매액/주문수와 비교.
|
||||||
|
* 혜택 정의 CRUD는 ADMIN.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class MallTierBenefit {
|
||||||
|
private Long id;
|
||||||
|
private String tierCode; // BASIC/SILVER/GOLD/VIP
|
||||||
|
private String name;
|
||||||
|
private Integer tierRank; // 1=BASIC ... 4=VIP
|
||||||
|
private BigDecimal minSpend12m; // 등급 진입 누적 구매액(12개월)
|
||||||
|
private Integer minOrders12m; // 등급 진입 누적 주문수(12개월)
|
||||||
|
private BigDecimal discountRate; // 등급 할인율(%)
|
||||||
|
private BigDecimal freeShipThreshold; // 무료배송 임계액(NULL=없음, 0=항상 무료)
|
||||||
|
private BigDecimal pointEarnRate; // 포인트 적립률(% of pay_amount)
|
||||||
|
private Boolean prioritySlot; // 피크시즌 우선 타임슬롯
|
||||||
|
private Boolean birthdayCoupon; // 생일 쿠폰/꽃
|
||||||
|
private Boolean exclusiveEvents; // 전용 이벤트 접근
|
||||||
|
private String color;
|
||||||
|
private Boolean active;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package com.zioinfo.mall.loyalty.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.loyalty.MallPointLedger;
|
||||||
|
import com.zioinfo.mall.loyalty.MallTierBenefit;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface LoyaltyMapper {
|
||||||
|
// 등급 혜택 정의
|
||||||
|
List<MallTierBenefit> findTiers(@Param("activeOnly") Boolean activeOnly);
|
||||||
|
MallTierBenefit findTierByCode(@Param("tierCode") String tierCode);
|
||||||
|
MallTierBenefit findTierById(@Param("id") Long id);
|
||||||
|
int insertTier(MallTierBenefit t);
|
||||||
|
int updateTier(MallTierBenefit t);
|
||||||
|
int deleteTier(@Param("id") Long id);
|
||||||
|
|
||||||
|
// 12개월 누적 구매 집계 (주문 PAID 이후 상태)
|
||||||
|
Map<String, Object> spend12m(@Param("owner") String owner);
|
||||||
|
|
||||||
|
// 회원 등급 sync (mall_member.tier)
|
||||||
|
int updateMemberTier(@Param("username") String username, @Param("tier") String tier);
|
||||||
|
List<String> allMemberUsernames();
|
||||||
|
|
||||||
|
// 포인트 원장
|
||||||
|
int insertPoint(MallPointLedger e);
|
||||||
|
Integer balance(@Param("owner") String owner);
|
||||||
|
List<MallPointLedger> history(@Param("owner") String owner, @Param("limit") int limit);
|
||||||
|
|
||||||
|
// 등급별 매출 (analytics)
|
||||||
|
List<Map<String, Object>> salesByTier(@Param("days") int days);
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.zioinfo.mall.member;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 회원 (mall_member). CRM 고객과 연계. */
|
||||||
|
@Data
|
||||||
|
public class MallMember {
|
||||||
|
private Long id;
|
||||||
|
private String username;
|
||||||
|
private String displayName;
|
||||||
|
private String email;
|
||||||
|
private String phone;
|
||||||
|
private String defaultZip;
|
||||||
|
private String defaultAddress;
|
||||||
|
private String crmCustomerKey;
|
||||||
|
private String tier;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package com.zioinfo.mall.member;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import com.zioinfo.mall.integration.CrmClient;
|
||||||
|
import com.zioinfo.mall.integration.ItsmSecuritySanitizer;
|
||||||
|
import com.zioinfo.mall.member.mapper.MemberMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 회원 API — /api/mall/member. 본인 프로필 + CRM 인사이트 연계(새니타이즈). */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/member")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MemberController {
|
||||||
|
|
||||||
|
private final MemberMapper mapper;
|
||||||
|
private final CrmClient crmClient;
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ApiResponse<MallMember> me(Authentication auth) {
|
||||||
|
return ApiResponse.ok(mapper.findByUsername(auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/me")
|
||||||
|
public ApiResponse<MallMember> update(@RequestBody MallMember m, Authentication auth) {
|
||||||
|
m.setUsername(auth.getName());
|
||||||
|
mapper.upsert(m);
|
||||||
|
return ApiResponse.ok(mapper.findByUsername(auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CRM 고객 인사이트(구매 성향) — 응답은 ItsmSecuritySanitizer로 정제. */
|
||||||
|
@GetMapping("/me/insight")
|
||||||
|
public ApiResponse<Map<String, Object>> insight(Authentication auth) {
|
||||||
|
Map<String, Object> raw = crmClient.getCustomerInsight(auth.getName());
|
||||||
|
Object cleaned = ItsmSecuritySanitizer.clean(raw == null ? new LinkedHashMap<>() : raw);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> safe = (Map<String, Object>) cleaned;
|
||||||
|
return ApiResponse.ok(safe);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package com.zioinfo.mall.member.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.member.MallMember;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface MemberMapper {
|
||||||
|
MallMember findByUsername(@Param("username") String username);
|
||||||
|
int upsert(MallMember m);
|
||||||
|
}
|
||||||
44
backend/src/main/java/com/zioinfo/mall/order/MallOrder.java
Normal file
44
backend/src/main/java/com/zioinfo/mall/order/MallOrder.java
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package com.zioinfo.mall.order;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주문 (mall_order).
|
||||||
|
*
|
||||||
|
* <p>상태 전이: PENDING → PAID → PREPARING → SHIPPED → DELIVERED → CONFIRMED
|
||||||
|
* (취소: CANCELLED, 환불: REFUNDED)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class MallOrder {
|
||||||
|
private Long id;
|
||||||
|
private String orderNo;
|
||||||
|
private String owner; // username (고객)
|
||||||
|
private Long storeId; // 처리 매장
|
||||||
|
private String fulfillmentType; // DELIVERY / PICKUP
|
||||||
|
private String status;
|
||||||
|
private BigDecimal totalAmount;
|
||||||
|
private BigDecimal discountAmount;
|
||||||
|
private BigDecimal taxAmount;
|
||||||
|
private BigDecimal surgeAmount;
|
||||||
|
private BigDecimal payAmount;
|
||||||
|
private String receiverName;
|
||||||
|
private String receiverPhone;
|
||||||
|
private String address;
|
||||||
|
private String deliveryZip;
|
||||||
|
private java.time.LocalDate scheduledDate;
|
||||||
|
private Long slotId;
|
||||||
|
private String slotLabel;
|
||||||
|
private String cardMessage;
|
||||||
|
private String memo;
|
||||||
|
private Long couponId;
|
||||||
|
private String proofPhotoUrl; // 제작완료 꽃사진(Proof of Quality)
|
||||||
|
private String podPhotoUrl; // 문앞사진(Proof of Delivery)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
private List<MallOrderItem> items;
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.zioinfo.mall.order;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/** 주문 항목 (mall_order_item). */
|
||||||
|
@Data
|
||||||
|
public class MallOrderItem {
|
||||||
|
private Long id;
|
||||||
|
private Long orderId;
|
||||||
|
private Long productId;
|
||||||
|
private Long optionId;
|
||||||
|
private String sizeCode;
|
||||||
|
private String productName;
|
||||||
|
private String optionLabel;
|
||||||
|
private BigDecimal unitPrice;
|
||||||
|
private Integer quantity;
|
||||||
|
private BigDecimal lineAmount;
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
package com.zioinfo.mall.order;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import com.zioinfo.mall.order.mapper.OrderMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 주문 API — /api/mall/order. 인증 고객. 관리자 목록은 MANAGER+. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/order")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OrderController {
|
||||||
|
|
||||||
|
private final OrderService service;
|
||||||
|
private final OrderMapper mapper;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<List<MallOrder>> myOrders(@RequestParam(required = false) String status, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.myOrders(auth.getName(), status));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전체 주문 목록 (운영) — MANAGER+. */
|
||||||
|
@GetMapping("/admin")
|
||||||
|
@org.springframework.security.access.prepost.PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<List<MallOrder>> allOrders(@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(defaultValue = "200") int limit) {
|
||||||
|
return ApiResponse.ok(mapper.findAll(status, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 매장별 주문 (Live Order Dashboard — Fulfillment Time 정렬) — MANAGER+. */
|
||||||
|
@GetMapping("/store/{storeId}")
|
||||||
|
@org.springframework.security.access.prepost.PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<List<MallOrder>> storeOrders(@PathVariable Long storeId,
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(defaultValue = "200") int limit) {
|
||||||
|
return ApiResponse.ok(mapper.findByStore(storeId, status, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 제작완료 꽃사진(Proof of Quality) 업로드 → 고객 MMS 발송 — MANAGER+. */
|
||||||
|
@PostMapping("/{id}/proof-photo")
|
||||||
|
@org.springframework.security.access.prepost.PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<MallOrder> proofPhoto(@PathVariable Long id, @RequestBody Map<String, String> req, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.attachProofPhoto(id, req.get("photoUrl"), auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 문앞사진(Proof of Delivery) 업로드 — MANAGER+. */
|
||||||
|
@PostMapping("/{id}/pod-photo")
|
||||||
|
@org.springframework.security.access.prepost.PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||||
|
public ApiResponse<MallOrder> podPhoto(@PathVariable Long id, @RequestBody Map<String, String> req, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.attachPodPhoto(id, req.get("photoUrl"), auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ApiResponse<MallOrder> get(@PathVariable Long id, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.detail(id, auth.getName(), isAdmin(auth)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/checkout")
|
||||||
|
public ApiResponse<MallOrder> checkout(@RequestBody OrderService.CheckoutRequest req, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.checkout(auth.getName(), req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/status")
|
||||||
|
public ApiResponse<MallOrder> transition(@PathVariable Long id, @RequestBody Map<String, String> req, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.transition(id, auth.getName(), isAdmin(auth), req.get("status")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAdmin(Authentication auth) {
|
||||||
|
return auth.getAuthorities().stream()
|
||||||
|
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_MANAGER"));
|
||||||
|
}
|
||||||
|
}
|
||||||
225
backend/src/main/java/com/zioinfo/mall/order/OrderService.java
Normal file
225
backend/src/main/java/com/zioinfo/mall/order/OrderService.java
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
package com.zioinfo.mall.order;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.admin.AuditService;
|
||||||
|
import com.zioinfo.mall.cart.MallCartItem;
|
||||||
|
import com.zioinfo.mall.cart.mapper.CartMapper;
|
||||||
|
import com.zioinfo.mall.integration.CrmClient;
|
||||||
|
import com.zioinfo.mall.order.mapper.OrderMapper;
|
||||||
|
import com.zioinfo.mall.product.MallProduct;
|
||||||
|
import com.zioinfo.mall.product.mapper.ProductMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주문 서비스 — 체크아웃·상태 전이.
|
||||||
|
*
|
||||||
|
* <p>상태 전이 규칙(허용 맵)으로 비정상 전이를 차단한다.
|
||||||
|
* 결제 완료(PAID) 시 재고 차감·판매수 증가, 주문 이력을 CRM 고객 인사이트로 전달.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
private final OrderMapper orderMapper;
|
||||||
|
private final CartMapper cartMapper;
|
||||||
|
private final ProductMapper productMapper;
|
||||||
|
private final CrmClient crmClient;
|
||||||
|
private final AuditService auditService;
|
||||||
|
private final com.zioinfo.mall.realtime.OrderNotifier orderNotifier;
|
||||||
|
private final com.zioinfo.mall.gateway.SmsSender smsSender;
|
||||||
|
private final com.zioinfo.mall.loyalty.LoyaltyService loyaltyService;
|
||||||
|
|
||||||
|
/** 허용 상태 전이 그래프. */
|
||||||
|
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
|
||||||
|
"PENDING", Set.of("PAID", "CANCELLED"),
|
||||||
|
"PAID", Set.of("PREPARING", "CANCELLED", "REFUNDED"),
|
||||||
|
"PREPARING", Set.of("SHIPPED", "CANCELLED"),
|
||||||
|
"SHIPPED", Set.of("DELIVERED"),
|
||||||
|
"DELIVERED", Set.of("CONFIRMED", "REFUNDED"),
|
||||||
|
"CONFIRMED", Set.of("REFUNDED")
|
||||||
|
);
|
||||||
|
|
||||||
|
public List<MallOrder> myOrders(String owner, String status) {
|
||||||
|
return orderMapper.findByOwner(owner, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MallOrder detail(Long id, String owner, boolean admin) {
|
||||||
|
MallOrder o = orderMapper.findById(id);
|
||||||
|
if (o == null) {
|
||||||
|
throw new RuntimeException("ERR-ORD-404: 주문을 찾을 수 없습니다");
|
||||||
|
}
|
||||||
|
if (!admin && !o.getOwner().equals(owner)) {
|
||||||
|
throw new RuntimeException("ERR-ORD-403: 본인 주문만 조회할 수 있습니다");
|
||||||
|
}
|
||||||
|
o.setItems(orderMapper.findItems(id));
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 장바구니 기반 주문 생성(PENDING). */
|
||||||
|
@Transactional
|
||||||
|
public MallOrder checkout(String owner, CheckoutRequest req) {
|
||||||
|
List<MallCartItem> cart = cartMapper.findByOwner(owner);
|
||||||
|
if (cart.isEmpty()) {
|
||||||
|
throw new RuntimeException("ERR-ORD-400: 장바구니가 비어 있습니다");
|
||||||
|
}
|
||||||
|
MallOrder order = new MallOrder();
|
||||||
|
order.setOrderNo(generateOrderNo());
|
||||||
|
order.setOwner(owner);
|
||||||
|
order.setStoreId(req.storeId());
|
||||||
|
order.setFulfillmentType(req.fulfillmentType() == null ? "DELIVERY" : req.fulfillmentType().toUpperCase());
|
||||||
|
order.setStatus("PENDING");
|
||||||
|
order.setReceiverName(req.receiverName());
|
||||||
|
order.setReceiverPhone(req.receiverPhone());
|
||||||
|
order.setAddress(req.address());
|
||||||
|
order.setDeliveryZip(req.deliveryZip());
|
||||||
|
order.setScheduledDate(req.scheduledDate());
|
||||||
|
order.setSlotId(req.slotId());
|
||||||
|
order.setSlotLabel(req.slotLabel());
|
||||||
|
order.setCardMessage(req.cardMessage());
|
||||||
|
order.setMemo(req.memo());
|
||||||
|
order.setCouponId(req.couponId());
|
||||||
|
|
||||||
|
BigDecimal total = BigDecimal.ZERO;
|
||||||
|
List<MallOrderItem> items = new ArrayList<>();
|
||||||
|
for (MallCartItem ci : cart) {
|
||||||
|
BigDecimal unit = ci.getUnitPrice() == null ? BigDecimal.ZERO : ci.getUnitPrice();
|
||||||
|
int qty = ci.getQuantity() == null ? 0 : ci.getQuantity();
|
||||||
|
BigDecimal line = unit.multiply(BigDecimal.valueOf(qty));
|
||||||
|
MallOrderItem it = new MallOrderItem();
|
||||||
|
it.setProductId(ci.getProductId());
|
||||||
|
it.setOptionId(ci.getOptionId());
|
||||||
|
it.setSizeCode(ci.getSizeCode());
|
||||||
|
it.setProductName(ci.getProductName());
|
||||||
|
it.setOptionLabel(ci.getOptionLabel());
|
||||||
|
it.setUnitPrice(unit);
|
||||||
|
it.setQuantity(qty);
|
||||||
|
it.setLineAmount(line);
|
||||||
|
items.add(it);
|
||||||
|
total = total.add(line);
|
||||||
|
}
|
||||||
|
BigDecimal discount = req.discountAmount() == null ? BigDecimal.ZERO : req.discountAmount();
|
||||||
|
BigDecimal tax = req.taxAmount() == null ? BigDecimal.ZERO : req.taxAmount();
|
||||||
|
BigDecimal surge = req.surgeAmount() == null ? BigDecimal.ZERO : req.surgeAmount();
|
||||||
|
order.setTotalAmount(total);
|
||||||
|
order.setDiscountAmount(discount);
|
||||||
|
order.setTaxAmount(tax);
|
||||||
|
order.setSurgeAmount(surge);
|
||||||
|
order.setPayAmount(total.subtract(discount).add(tax).add(surge).max(BigDecimal.ZERO));
|
||||||
|
orderMapper.insert(order);
|
||||||
|
for (MallOrderItem it : items) {
|
||||||
|
it.setOrderId(order.getId());
|
||||||
|
orderMapper.insertItem(it);
|
||||||
|
}
|
||||||
|
cartMapper.clear(owner);
|
||||||
|
auditService.log(owner, "ORDER_CREATE", order.getOrderNo(), "amount=" + order.getPayAmount());
|
||||||
|
order.setItems(items);
|
||||||
|
// 실시간 주문 알림 — 매장 관리자앱 Live Order Dashboard 푸시
|
||||||
|
orderNotifier.broadcastNewOrder(order);
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 상태 전이 — 허용 맵 검증. */
|
||||||
|
@Transactional
|
||||||
|
public MallOrder transition(Long id, String owner, boolean admin, String to) {
|
||||||
|
MallOrder o = detail(id, owner, admin);
|
||||||
|
String from = o.getStatus();
|
||||||
|
if (to == null || to.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("ERR-ORD-400: 대상 상태 필수");
|
||||||
|
}
|
||||||
|
to = to.toUpperCase();
|
||||||
|
Set<String> allowed = TRANSITIONS.getOrDefault(from, Set.of());
|
||||||
|
if (!allowed.contains(to)) {
|
||||||
|
throw new RuntimeException("ERR-ORD-409: 허용되지 않은 상태 전이 " + from + " -> " + to);
|
||||||
|
}
|
||||||
|
// 고객은 취소/구매확정만 가능
|
||||||
|
if (!admin && !(to.equals("CANCELLED") || to.equals("CONFIRMED"))) {
|
||||||
|
throw new RuntimeException("ERR-ORD-403: 해당 상태 전이 권한 없음");
|
||||||
|
}
|
||||||
|
orderMapper.updateStatus(id, to);
|
||||||
|
if ("PAID".equals(to)) {
|
||||||
|
for (MallOrderItem it : o.getItems()) {
|
||||||
|
productMapper.incSales(it.getProductId(), it.getQuantity());
|
||||||
|
}
|
||||||
|
feedCrm(o);
|
||||||
|
// 등급별 적립률로 포인트 자동 적립 (실패 무시 — 주문 흐름 차단 금지)
|
||||||
|
try {
|
||||||
|
int earned = loyaltyService.accrueForOrder(o.getOwner(), o.getOrderNo(), o.getPayAmount());
|
||||||
|
if (earned > 0) {
|
||||||
|
auditService.log(o.getOwner(), "LOYALTY_POINT_EARN", o.getOrderNo(), "points=" + earned);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("포인트 적립 실패(무시): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auditService.log(admin ? AuditService.currentActor() : owner,
|
||||||
|
"ORDER_STATUS", o.getOrderNo(), from + " -> " + to);
|
||||||
|
MallOrder updated = detail(id, owner, admin);
|
||||||
|
orderNotifier.broadcastStatus(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제작완료 꽃사진 첨부 → 고객 MMS 발송(Proof of Quality, Ode à la Rose 패턴).
|
||||||
|
* MMS는 게이트웨이 어댑터(기본 mock) 경유. 발송 실패는 무시(사진 저장은 유지).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public MallOrder attachProofPhoto(Long id, String photoUrl, String actor) {
|
||||||
|
MallOrder o = orderMapper.findById(id);
|
||||||
|
if (o == null) throw new RuntimeException("ERR-ORD-404: 주문을 찾을 수 없습니다");
|
||||||
|
orderMapper.setProofPhoto(id, photoUrl);
|
||||||
|
try {
|
||||||
|
if (o.getReceiverPhone() != null && photoUrl != null) {
|
||||||
|
smsSender.sendMms(o.getReceiverPhone(),
|
||||||
|
"Your bouquet for order " + o.getOrderNo() + " is ready — here's a photo of today's arrangement!",
|
||||||
|
photoUrl);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Proof MMS 발송 실패(무시): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
auditService.log(actor, "ORDER_PROOF_PHOTO", o.getOrderNo(), "mms-sent");
|
||||||
|
return orderMapper.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 문앞사진(Proof of Delivery) 첨부. */
|
||||||
|
@Transactional
|
||||||
|
public MallOrder attachPodPhoto(Long id, String photoUrl, String actor) {
|
||||||
|
MallOrder o = orderMapper.findById(id);
|
||||||
|
if (o == null) throw new RuntimeException("ERR-ORD-404: 주문을 찾을 수 없습니다");
|
||||||
|
orderMapper.setPodPhoto(id, photoUrl);
|
||||||
|
auditService.log(actor, "ORDER_POD_PHOTO", o.getOrderNo(), "pod-attached");
|
||||||
|
return orderMapper.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 주문 이력 → CRM 고객 인사이트 (실패 무시). */
|
||||||
|
private void feedCrm(MallOrder o) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
payload.put("customerKey", o.getOwner());
|
||||||
|
payload.put("orderNo", o.getOrderNo());
|
||||||
|
payload.put("amount", o.getPayAmount());
|
||||||
|
payload.put("source", "MALL");
|
||||||
|
crmClient.upsertCustomer(payload);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("CRM 주문 피드 실패(무시): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateOrderNo() {
|
||||||
|
return "ORD" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
||||||
|
+ String.format("%03d", new Random().nextInt(1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CheckoutRequest(Long storeId, String fulfillmentType,
|
||||||
|
String receiverName, String receiverPhone, String address, String deliveryZip,
|
||||||
|
java.time.LocalDate scheduledDate, Long slotId, String slotLabel,
|
||||||
|
String cardMessage, String memo, Long couponId,
|
||||||
|
BigDecimal discountAmount, BigDecimal taxAmount, BigDecimal surgeAmount) {}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.zioinfo.mall.order.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.order.MallOrder;
|
||||||
|
import com.zioinfo.mall.order.MallOrderItem;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface OrderMapper {
|
||||||
|
List<MallOrder> findByOwner(@Param("owner") String owner, @Param("status") String status);
|
||||||
|
List<MallOrder> findAll(@Param("status") String status, @Param("limit") int limit);
|
||||||
|
List<MallOrder> findByStore(@Param("storeId") Long storeId, @Param("status") String status, @Param("limit") int limit);
|
||||||
|
MallOrder findById(@Param("id") Long id);
|
||||||
|
MallOrder findByOrderNo(@Param("orderNo") String orderNo);
|
||||||
|
int insert(MallOrder o);
|
||||||
|
int updateStatus(@Param("id") Long id, @Param("status") String status);
|
||||||
|
int setProofPhoto(@Param("id") Long id, @Param("url") String url);
|
||||||
|
int setPodPhoto(@Param("id") Long id, @Param("url") String url);
|
||||||
|
|
||||||
|
List<MallOrderItem> findItems(@Param("orderId") Long orderId);
|
||||||
|
int insertItem(MallOrderItem item);
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.zioinfo.mall.payment;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결제 (mall_payment).
|
||||||
|
*
|
||||||
|
* <p>보안: card_no_enc / account_no_enc 는 AES-256-GCM 암호화 저장.
|
||||||
|
* API 응답에는 절대 노출하지 않고 마스킹된 cardMasked / accountMasked 만 노출.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class MallPayment {
|
||||||
|
private Long id;
|
||||||
|
private Long orderId;
|
||||||
|
private String method; // CARD / VBANK / TRANSFER
|
||||||
|
private String pgTid;
|
||||||
|
private BigDecimal amount;
|
||||||
|
private String status; // READY / PAID / FAILED / CANCELLED
|
||||||
|
private String cardNoEnc; // 내부 저장(응답 제외)
|
||||||
|
private String accountNoEnc; // 내부 저장(응답 제외)
|
||||||
|
private String vbankNo; // 가상계좌 번호(발급)
|
||||||
|
private String vbankBank;
|
||||||
|
private LocalDateTime paidAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
// 응답 노출용(마스킹)
|
||||||
|
private String cardMasked;
|
||||||
|
private String accountMasked;
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package com.zioinfo.mall.payment;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/** 결제 API — /api/mall/payment. 인증 고객. 카드/계좌번호 응답 미노출(마스킹만). */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/payment")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PaymentController {
|
||||||
|
|
||||||
|
private final PaymentService service;
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<MallPayment> pay(@RequestBody PaymentService.PayRequest req, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.pay(auth.getName(), isAdmin(auth), req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/vbank-confirm")
|
||||||
|
public ApiResponse<MallPayment> confirmVbank(@PathVariable Long id, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.confirmVbank(auth.getName(), isAdmin(auth), id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/order/{orderId}")
|
||||||
|
public ApiResponse<MallPayment> byOrder(@PathVariable Long orderId, Authentication auth) {
|
||||||
|
return ApiResponse.ok(service.getByOrder(orderId, auth.getName(), isAdmin(auth)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAdmin(Authentication auth) {
|
||||||
|
return auth.getAuthorities().stream()
|
||||||
|
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_MANAGER"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
package com.zioinfo.mall.payment;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.admin.AuditService;
|
||||||
|
import com.zioinfo.mall.common.CryptoUtil;
|
||||||
|
import com.zioinfo.mall.order.MallOrder;
|
||||||
|
import com.zioinfo.mall.order.OrderService;
|
||||||
|
import com.zioinfo.mall.order.mapper.OrderMapper;
|
||||||
|
import com.zioinfo.mall.payment.mapper.PaymentMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Random;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결제 서비스 — 모의 PG / 가상계좌.
|
||||||
|
*
|
||||||
|
* <p>보안: 카드/계좌 번호는 {@link CryptoUtil} 로 암호화 저장하고, 응답 시 마스킹만 노출(redact()).
|
||||||
|
* 모의 PG 는 항상 승인(데모) — pgTid 발급. VBANK 는 가상계좌 번호 발급 후 READY.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PaymentService {
|
||||||
|
|
||||||
|
private final PaymentMapper paymentMapper;
|
||||||
|
private final OrderMapper orderMapper;
|
||||||
|
private final OrderService orderService;
|
||||||
|
private final CryptoUtil crypto;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
/** 결제 요청 — 주문 소유자만. CARD: 즉시 승인→주문 PAID. VBANK: 가상계좌 발급(READY). */
|
||||||
|
@Transactional
|
||||||
|
public MallPayment pay(String owner, boolean admin, PayRequest req) {
|
||||||
|
MallOrder order = orderService.detail(req.orderId(), owner, admin);
|
||||||
|
if (!"PENDING".equals(order.getStatus())) {
|
||||||
|
throw new RuntimeException("ERR-PAY-409: 결제 가능한 주문 상태가 아닙니다");
|
||||||
|
}
|
||||||
|
MallPayment p = new MallPayment();
|
||||||
|
p.setOrderId(order.getId());
|
||||||
|
p.setAmount(order.getPayAmount());
|
||||||
|
String method = req.method() == null ? "CARD" : req.method().toUpperCase();
|
||||||
|
p.setMethod(method);
|
||||||
|
if (req.cardNo() != null && !req.cardNo().isBlank()) {
|
||||||
|
p.setCardNoEnc(crypto.encrypt(req.cardNo()));
|
||||||
|
}
|
||||||
|
if (req.accountNo() != null && !req.accountNo().isBlank()) {
|
||||||
|
p.setAccountNoEnc(crypto.encrypt(req.accountNo()));
|
||||||
|
}
|
||||||
|
if ("VBANK".equals(method)) {
|
||||||
|
p.setStatus("READY");
|
||||||
|
p.setVbankBank(req.bank() == null ? "지오은행" : req.bank());
|
||||||
|
p.setVbankNo(generateVbank());
|
||||||
|
paymentMapper.insert(p);
|
||||||
|
} else {
|
||||||
|
// 모의 PG 즉시 승인
|
||||||
|
p.setStatus("PAID");
|
||||||
|
p.setPgTid("PG-" + UUID.randomUUID().toString().substring(0, 12).toUpperCase());
|
||||||
|
paymentMapper.insert(p);
|
||||||
|
orderService.transition(order.getId(), owner, true, "PAID");
|
||||||
|
}
|
||||||
|
auditService.log(owner, "PAYMENT", String.valueOf(order.getOrderNo()),
|
||||||
|
method + " " + p.getStatus() + " amount=" + p.getAmount());
|
||||||
|
return redact(paymentMapper.findById(p.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 가상계좌 입금 확인(모의) — READY → PAID + 주문 PAID. */
|
||||||
|
@Transactional
|
||||||
|
public MallPayment confirmVbank(String owner, boolean admin, Long paymentId) {
|
||||||
|
MallPayment p = paymentMapper.findById(paymentId);
|
||||||
|
if (p == null) {
|
||||||
|
throw new RuntimeException("ERR-PAY-404: 결제 정보 없음");
|
||||||
|
}
|
||||||
|
if (!"READY".equals(p.getStatus())) {
|
||||||
|
throw new RuntimeException("ERR-PAY-409: 입금 대기 상태가 아닙니다");
|
||||||
|
}
|
||||||
|
paymentMapper.updateStatus(paymentId, "PAID");
|
||||||
|
orderService.transition(p.getOrderId(), owner, true, "PAID");
|
||||||
|
auditService.log(owner, "PAYMENT_VBANK_CONFIRM", String.valueOf(p.getOrderId()), "PAID");
|
||||||
|
return redact(paymentMapper.findById(paymentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public MallPayment getByOrder(Long orderId, String owner, boolean admin) {
|
||||||
|
// 주문 소유 검증
|
||||||
|
orderService.detail(orderId, owner, admin);
|
||||||
|
MallPayment p = paymentMapper.findByOrderId(orderId);
|
||||||
|
return p == null ? null : redact(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 민감 필드 마스킹 — card_no_enc/account_no_enc 는 절대 응답에 포함하지 않는다. */
|
||||||
|
private MallPayment redact(MallPayment p) {
|
||||||
|
if (p.getCardNoEnc() != null && !p.getCardNoEnc().isEmpty()) {
|
||||||
|
p.setCardMasked(CryptoUtil.maskTail(crypto.decrypt(p.getCardNoEnc())));
|
||||||
|
}
|
||||||
|
if (p.getAccountNoEnc() != null && !p.getAccountNoEnc().isEmpty()) {
|
||||||
|
p.setAccountMasked(CryptoUtil.maskTail(crypto.decrypt(p.getAccountNoEnc())));
|
||||||
|
}
|
||||||
|
p.setCardNoEnc(null);
|
||||||
|
p.setAccountNoEnc(null);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateVbank() {
|
||||||
|
Random r = new Random();
|
||||||
|
return String.format("%03d-%04d-%06d", r.nextInt(1000), r.nextInt(10000), r.nextInt(1000000));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PayRequest(Long orderId, String method, String cardNo, String accountNo, String bank) {}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.zioinfo.mall.payment.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.payment.MallPayment;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface PaymentMapper {
|
||||||
|
MallPayment findById(@Param("id") Long id);
|
||||||
|
MallPayment findByOrderId(@Param("orderId") Long orderId);
|
||||||
|
List<MallPayment> findAll(@Param("status") String status, @Param("limit") int limit);
|
||||||
|
int insert(MallPayment p);
|
||||||
|
int updateStatus(@Param("id") Long id, @Param("status") String status);
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package com.zioinfo.mall.product;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 상품 (mall_product). 옵션/이미지 포함. */
|
||||||
|
@Data
|
||||||
|
public class MallProduct {
|
||||||
|
private Long id;
|
||||||
|
private Long categoryId;
|
||||||
|
private String sku;
|
||||||
|
private String name;
|
||||||
|
private String brand;
|
||||||
|
private String description;
|
||||||
|
private BigDecimal price;
|
||||||
|
private BigDecimal salePrice;
|
||||||
|
private String status; // ON_SALE / SOLD_OUT / HIDDEN
|
||||||
|
private Integer stock; // 단순 재고(옵션 미사용 시) — inventory 모듈과 동기
|
||||||
|
private String thumbnail;
|
||||||
|
private BigDecimal ratingAvg;
|
||||||
|
private Integer reviewCount;
|
||||||
|
private Integer salesCount;
|
||||||
|
private String occasion; // BIRTHDAY/ANNIVERSARY/SYMPATHY/...
|
||||||
|
private String flowerType; // ROSES/TULIPS/LILIES/...
|
||||||
|
private Integer shelfLifeDays; // 생화 시한성(유통기한 일수)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
private List<MallProductOption> options;
|
||||||
|
private List<MallProductImage> images;
|
||||||
|
private List<MallProductSize> sizes; // 3사이즈(Original/Deluxe/Grand)
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.zioinfo.mall.product;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** 상품 이미지 (mall_product_image). */
|
||||||
|
@Data
|
||||||
|
public class MallProductImage {
|
||||||
|
private Long id;
|
||||||
|
private Long productId;
|
||||||
|
private String url;
|
||||||
|
private Integer sortOrder;
|
||||||
|
private Boolean isMain;
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package com.zioinfo.mall.product;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/** 상품 옵션 (mall_product_option). 예: 색상=빨강, 사이즈=L. */
|
||||||
|
@Data
|
||||||
|
public class MallProductOption {
|
||||||
|
private Long id;
|
||||||
|
private Long productId;
|
||||||
|
private String optionName; // 예: "색상"
|
||||||
|
private String optionValue; // 예: "빨강"
|
||||||
|
private BigDecimal extraPrice;
|
||||||
|
private Integer stock;
|
||||||
|
private String skuSuffix;
|
||||||
|
private String optionType; // VASE / CARD_MESSAGE / WRAP
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.zioinfo.mall.product;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/** 상품 사이즈 (mall_product_size). ORIGINAL/DELUXE/GRAND — Bouqs 패턴. */
|
||||||
|
@Data
|
||||||
|
public class MallProductSize {
|
||||||
|
private Long id;
|
||||||
|
private Long productId;
|
||||||
|
private String sizeCode; // ORIGINAL / DELUXE / GRAND
|
||||||
|
private String label;
|
||||||
|
private BigDecimal price;
|
||||||
|
private Integer stemCount;
|
||||||
|
private Integer sortOrder;
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.zioinfo.mall.product;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.common.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 상품 API — /api/mall/product. GET 공개(스토어프론트), 변경 MANAGER+. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mall/product")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProductController {
|
||||||
|
|
||||||
|
private final ProductService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<Map<String, Object>> list(
|
||||||
|
@RequestParam(required = false) Long categoryId,
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(required = false) String occasion,
|
||||||
|
@RequestParam(required = false) String flowerType,
|
||||||
|
@RequestParam(required = false) java.math.BigDecimal maxPrice,
|
||||||
|
@RequestParam(required = false) String sort,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ApiResponse.ok(service.search(categoryId, keyword, status, occasion, flowerType, maxPrice, sort, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ApiResponse<MallProduct> get(@PathVariable Long id) {
|
||||||
|
return ApiResponse.ok(service.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<MallProduct> create(@RequestBody MallProduct p) {
|
||||||
|
return ApiResponse.ok(service.create(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ApiResponse<MallProduct> update(@PathVariable Long id, @RequestBody MallProduct p) {
|
||||||
|
return ApiResponse.ok(service.update(id, p));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/status")
|
||||||
|
public ApiResponse<Void> status(@PathVariable Long id, @RequestBody Map<String, String> req) {
|
||||||
|
service.updateStatus(id, req.get("status"));
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable Long id) {
|
||||||
|
service.delete(id);
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
package com.zioinfo.mall.product;
|
||||||
|
|
||||||
|
import com.zioinfo.mall.product.mapper.ProductMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProductService {
|
||||||
|
|
||||||
|
private final ProductMapper mapper;
|
||||||
|
|
||||||
|
public Map<String, Object> search(Long categoryId, String keyword, String status,
|
||||||
|
String occasion, String flowerType, java.math.BigDecimal maxPrice,
|
||||||
|
String sort, int page, int size) {
|
||||||
|
int safeSize = (size <= 0 || size > 100) ? 20 : size;
|
||||||
|
int safePage = Math.max(page, 0);
|
||||||
|
List<MallProduct> items = mapper.search(categoryId, keyword, status, occasion, flowerType, maxPrice,
|
||||||
|
sort, safeSize, safePage * safeSize);
|
||||||
|
int total = mapper.countSearch(categoryId, keyword, status, occasion, flowerType, maxPrice);
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("items", items);
|
||||||
|
out.put("total", total);
|
||||||
|
out.put("page", safePage);
|
||||||
|
out.put("size", safeSize);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 상세 — 옵션·이미지 포함. */
|
||||||
|
public MallProduct detail(Long id) {
|
||||||
|
MallProduct p = mapper.findById(id);
|
||||||
|
if (p == null) {
|
||||||
|
throw new RuntimeException("ERR-PRD-404: 상품을 찾을 수 없습니다");
|
||||||
|
}
|
||||||
|
p.setOptions(mapper.findOptions(id));
|
||||||
|
p.setImages(mapper.findImages(id));
|
||||||
|
p.setSizes(mapper.findSizes(id));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MallProduct create(MallProduct p) {
|
||||||
|
if (p.getName() == null || p.getName().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("ERR-PRD-400: 상품명 필수");
|
||||||
|
}
|
||||||
|
if (p.getPrice() == null) {
|
||||||
|
throw new IllegalArgumentException("ERR-PRD-400: 가격 필수");
|
||||||
|
}
|
||||||
|
mapper.insert(p);
|
||||||
|
persistChildren(p);
|
||||||
|
return detail(p.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MallProduct update(Long id, MallProduct p) {
|
||||||
|
p.setId(id);
|
||||||
|
mapper.update(p);
|
||||||
|
if (p.getOptions() != null) {
|
||||||
|
mapper.deleteOptions(id);
|
||||||
|
}
|
||||||
|
if (p.getImages() != null) {
|
||||||
|
mapper.deleteImages(id);
|
||||||
|
}
|
||||||
|
if (p.getSizes() != null) {
|
||||||
|
mapper.deleteSizes(id);
|
||||||
|
}
|
||||||
|
persistChildren(p);
|
||||||
|
return detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persistChildren(MallProduct p) {
|
||||||
|
if (p.getOptions() != null) {
|
||||||
|
for (MallProductOption o : p.getOptions()) {
|
||||||
|
o.setProductId(p.getId());
|
||||||
|
mapper.insertOption(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (p.getImages() != null) {
|
||||||
|
for (MallProductImage img : p.getImages()) {
|
||||||
|
img.setProductId(p.getId());
|
||||||
|
mapper.insertImage(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (p.getSizes() != null) {
|
||||||
|
for (MallProductSize s : p.getSizes()) {
|
||||||
|
s.setProductId(p.getId());
|
||||||
|
mapper.insertSize(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateStatus(Long id, String status) {
|
||||||
|
mapper.updateStatus(id, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(Long id) {
|
||||||
|
mapper.deleteOptions(id);
|
||||||
|
mapper.deleteImages(id);
|
||||||
|
mapper.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user