feat: GUARDiA Mall v1.0 — 옴니채널 꽃집 e-커머스 (백엔드 8011 + 프론트 + 모바일 고객/관리자앱) + CI/CD

This commit is contained in:
GUARDiA 2026-06-13 20:23:46 +09:00
commit b519efe629
213 changed files with 15553 additions and 0 deletions

59
Jenkinsfile vendored Normal file
View 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
View 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>

View 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);
}
}

View File

@ -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) {}
}

View File

@ -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;
}
}

View File

@ -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";
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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());
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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));
}
}

View 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.");
};
}
}

View 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 "";
}
}
}

View File

@ -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));
}
}

View File

@ -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);
}

View File

@ -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) {}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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;
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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");
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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(); }
}

View File

@ -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("*");
}
}

View 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));
}
}

View 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;
}

View File

@ -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);
}

View File

@ -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 되면 주문 상태도 SHIPPEDDELIVERED 연동.
*/
@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"));
}
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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) {}
}

View 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."};
};
}
}

View 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;
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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";
}
}

View File

@ -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();
}

View File

@ -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);
}
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View File

@ -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();
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View File

@ -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();
}

View File

@ -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";
}
}

View File

@ -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();
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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));
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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()));
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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) {}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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);
}

View 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;
}

View File

@ -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;
}

View File

@ -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"));
}
}

View 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) {}
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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"));
}
}

View File

@ -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) {}
}

View File

@ -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);
}

View File

@ -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)
}

View File

@ -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;
}

View File

@ -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
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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