chore(deploy): Mall 클린 재동기화 + Claude provider 전환

- ai.provider flip 마이그레이션 추가(db/202_ai_provider_claude.sql, mall_setting UPSERT claude)
- application.yml schema-locations 에 202 등재(sql.init mode=always 멱등)
- workspace 정본 기준 클린 재동기화(부분 auto-sync 드리프트 해소)
- Claude 실패/키 미설정 시 AiTextRouter 가 Ollama 자동 폴백(무중단)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-07-11 11:12:04 +09:00
parent 870541448a
commit 84c3000411
39 changed files with 1476 additions and 704 deletions

View File

@ -27,10 +27,14 @@
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</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.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>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
<!-- 로컬 임베디드 학습 저장소(DuckDB) — AI 피드백/추론 로그 격리 파일(/opt/guardia-mall/data). 외부 호출 없음. -->
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>1.1.3</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.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-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>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.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>${springdoc.version}</version></dependency>
<!-- TOTP 2FA (RFC 6238 · SHA1 · 30s · 6자리) — UIWS(uiws build.gradle) 동일 좌표. QR 생성 위해 zxing 전이 포함. -->
<dependency><groupId>dev.samstevens.totp</groupId><artifactId>totp</artifactId><version>1.7.1</version></dependency>
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></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-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-websocket</artifactId></dependency>

View File

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

View File

@ -83,6 +83,14 @@ public class AdminUserService {
return UserDto.from(user); return UserDto.from(user);
} }
/** 관리자 OTP 초기화: 대상 사용자 시크릿·확정 플래그 폐기 → 다음 로그인 시 QR 재등록 유도. */
public UserDto otpReset(Long id) {
MallUser user = require(id);
mapper.clearOtp(id);
auditService.log("USER_OTP_RESET", user.getUsername(), "OTP 초기화(재등록 유도)");
return UserDto.from(user);
}
public void delete(Long id, String currentUsername) { public void delete(Long id, String currentUsername) {
MallUser user = require(id); MallUser user = require(id);
if (user.getUsername().equals(currentUsername)) { if (user.getUsername().equals(currentUsername)) {

View File

@ -22,6 +22,9 @@ public interface AdminUserMapper {
int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash); int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash);
/** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false → 다음 로그인 시 QR 재등록 유도. */
int clearOtp(@Param("id") Long id);
int deleteById(@Param("id") Long id); int deleteById(@Param("id") Long id);
int countAdmins(); int countAdmins();

View File

@ -1,5 +1,7 @@
package com.zioinfo.mall.ai; package com.zioinfo.mall.ai;
import com.zioinfo.mall.ai.service.AiTextRouter;
import com.zioinfo.mall.common.ai.TextAiClient.GenResult;
import com.zioinfo.mall.inventory.mapper.StoreInventoryMapper; import com.zioinfo.mall.inventory.mapper.StoreInventoryMapper;
import com.zioinfo.mall.product.MallProduct; import com.zioinfo.mall.product.MallProduct;
import com.zioinfo.mall.product.mapper.ProductMapper; import com.zioinfo.mall.product.mapper.ProductMapper;
@ -22,11 +24,20 @@ import java.util.*;
@RequiredArgsConstructor @RequiredArgsConstructor
public class MallAiService { public class MallAiService {
private final OllamaClient ollama; private final AiTextRouter aiRouter; // provider 라우팅(ClaudeOllama) + infer 로그. 실패 아래 Java 폴백.
private final ProductMapper productMapper; private final ProductMapper productMapper;
private final ReviewMapper reviewMapper; private final ReviewMapper reviewMapper;
private final StoreInventoryMapper storeInventoryMapper; private final StoreInventoryMapper storeInventoryMapper;
/**
* 선택된 provider(Claude/Ollama) 텍스트 생성. degraded/빈응답이면 문자열 반환
* 기능의 기존 결정론적 Java 폴백이 그대로 동작(무회귀).
*/
private String aiGenerate(String prompt) {
GenResult r = aiRouter.generate(prompt);
return (r != null && !r.degraded() && r.text() != null) ? r.text() : "";
}
/** 1. 상품 추천 — 행사/키워드 기반. AI 실패 시 인기/평점 폴백. */ /** 1. 상품 추천 — 행사/키워드 기반. AI 실패 시 인기/평점 폴백. */
public List<MallProduct> recommend(String occasion, String keyword, int limit) { 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); List<MallProduct> pool = productMapper.search(null, keyword, "ON_SALE", occasion, null, null, "sales", 30, 0);
@ -37,7 +48,7 @@ public class MallAiService {
String prompt = "You are a florist recommender. From this catalog: [" + names + "]. " String prompt = "You are a florist recommender. From this catalog: [" + names + "]. "
+ "Recommend up to " + limit + " bouquets for occasion='" + (occasion == null ? "any" : occasion) + "Recommend up to " + limit + " bouquets for occasion='" + (occasion == null ? "any" : occasion)
+ "' keyword='" + (keyword == null ? "" : keyword) + "'. Reply ONLY product names comma-separated."; + "' keyword='" + (keyword == null ? "" : keyword) + "'. Reply ONLY product names comma-separated.";
String ai = ollama.generate(prompt); String ai = aiGenerate(prompt);
if (ai != null && !ai.isBlank()) { if (ai != null && !ai.isBlank()) {
List<MallProduct> ordered = reorderByAi(pool, ai); List<MallProduct> ordered = reorderByAi(pool, ai);
if (!ordered.isEmpty()) return ordered.subList(0, Math.min(limit, ordered.size())); if (!ordered.isEmpty()) return ordered.subList(0, Math.min(limit, ordered.size()));
@ -58,7 +69,7 @@ public class MallAiService {
} }
String prompt = "Summarize these flower bouquet reviews in 2 concise sentences (pros/cons):\n" String prompt = "Summarize these flower bouquet reviews in 2 concise sentences (pros/cons):\n"
+ String.join("\n", contents); + String.join("\n", contents);
String ai = ollama.generate(prompt); String ai = aiGenerate(prompt);
if (ai != null && !ai.isBlank()) { if (ai != null && !ai.isBlank()) {
out.put("summary", ai); out.put("summary", ai);
out.put("source", "ollama"); out.put("source", "ollama");
@ -89,7 +100,7 @@ public class MallAiService {
public String csAutoReply(String subject, String content) { 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" String prompt = "You are a polite flower-shop customer support agent. Write a short helpful reply (<=4 sentences) to:\n"
+ "Subject: " + subject + "\nMessage: " + content; + "Subject: " + subject + "\nMessage: " + content;
String ai = ollama.generate(prompt); String ai = aiGenerate(prompt);
if (ai != null && !ai.isBlank()) return ai; if (ai != null && !ai.isBlank()) return ai;
return "Thank you for reaching out about \"" + subject + "\". We're sorry for any inconvenience. " 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. " + "Our team is reviewing your request and will follow up shortly. "
@ -121,7 +132,7 @@ public class MallAiService {
} }
String prompt = "Compose a creative 'Daily Standard' bouquet name and 1-line description using surplus flowers: [" String prompt = "Compose a creative 'Daily Standard' bouquet name and 1-line description using surplus flowers: ["
+ String.join(", ", surplus) + "]. Reply as: NAME | DESCRIPTION."; + String.join(", ", surplus) + "]. Reply as: NAME | DESCRIPTION.";
String ai = ollama.generate(prompt); String ai = aiGenerate(prompt);
if (ai != null && !ai.isBlank()) { if (ai != null && !ai.isBlank()) {
out.put("bouquet", ai); out.put("bouquet", ai);
out.put("source", "ollama"); out.put("source", "ollama");
@ -165,7 +176,7 @@ public class MallAiService {
String prompt = "Write 3 short flower-card messages for occasion='" + occasion String prompt = "Write 3 short flower-card messages for occasion='" + occasion
+ "' tone='" + (tone == null ? "warm" : tone) + "' recipient='" + (recipient == null ? "" : recipient) + "' tone='" + (tone == null ? "warm" : tone) + "' recipient='" + (recipient == null ? "" : recipient)
+ "'. One per line, no numbering."; + "'. One per line, no numbering.";
String ai = ollama.generate(prompt); String ai = aiGenerate(prompt);
if (ai != null && !ai.isBlank()) { if (ai != null && !ai.isBlank()) {
List<String> lines = new ArrayList<>(); List<String> lines = new ArrayList<>();
for (String l : ai.split("\n")) { for (String l : ai.split("\n")) {

View File

@ -30,20 +30,33 @@ public class OllamaClient {
this.model = model; this.model = model;
} }
@SuppressWarnings("unchecked") /** 기본 모델(guardia.ollama-text-model)로 생성. 실패 시 빈 문자열. */
public String generate(String prompt) { public String generate(String prompt) {
return generateText(prompt, model);
}
/**
* 지정 모델로 평문 생성(AiTextRouter Ollama 경로·Claude 폴백 공용 진입점).
*
* <p>모델 미지정 서버 기본(guardia.ollama-text-model). localhost Ollama 호출하며,
* 장애/오프라인/타임아웃 예외 없이 문자열 반환(호출자가 폴백 수행). [GUARDiA-MALL]
*/
@SuppressWarnings("unchecked")
public String generateText(String prompt, String reqModel) {
if (prompt == null || prompt.isBlank()) return "";
String useModel = (reqModel == null || reqModel.isBlank()) ? model : reqModel.trim();
try { try {
Map<String, Object> body = Map.of("model", model, "prompt", prompt, "stream", false); Map<String, Object> body = Map.of("model", useModel, "prompt", prompt, "stream", false);
Map<String, Object> res = builder.baseUrl(ollamaUrl).build() Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
.post().uri("/api/generate").bodyValue(body) .post().uri("/api/generate").bodyValue(body)
.retrieve().bodyToMono(Map.class) .retrieve().bodyToMono(Map.class)
.timeout(Duration.ofSeconds(30)) .timeout(Duration.ofSeconds(120))
.map(m -> (Map<String, Object>) m).block(); .map(m -> (Map<String, Object>) m).block();
if (res == null) return ""; if (res == null) return "";
Object r = res.get("response"); Object r = res.get("response");
return r == null ? "" : String.valueOf(r).trim(); return r == null ? "" : String.valueOf(r).trim();
} catch (Exception e) { } catch (Exception e) {
log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getMessage()); log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getClass().getSimpleName());
return ""; return "";
} }
} }

View File

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

View File

@ -1,33 +1,121 @@
package com.zioinfo.mall.auth; package com.zioinfo.mall.auth;
import com.zioinfo.mall.admin.AuditService;
import com.zioinfo.mall.auth.dto.AuthHelperResult;
import com.zioinfo.mall.auth.dto.ChangePasswordRequest;
import com.zioinfo.mall.auth.dto.FindIdRequest;
import com.zioinfo.mall.auth.dto.FindIdResponse;
import com.zioinfo.mall.auth.dto.ResetPasswordRequest;
import com.zioinfo.mall.auth.dto.SignupRequest;
import com.zioinfo.mall.auth.mapper.UserMapper; import com.zioinfo.mall.auth.mapper.UserMapper;
import com.zioinfo.mall.uiws.auth.OtpAuthService;
import com.zioinfo.mall.uiws.auth.TwoFactorService;
import com.zioinfo.mall.uiws.common.UiwsApiException;
import com.zioinfo.mall.uiws.common.UiwsErrorCode;
import com.zioinfo.mall.uiws.common.mail.MailSender;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.util.Map; import java.util.Map;
/**
* GUARDiA Mall 인증 서비스.
*
* <p> 고객/운영 분리: Mall {@code mall_account} 단일 테이블/단일 로그인이지만 역할로 구분된다.
* <ul>
* <li>고객(USER) 쇼핑 로그인: 2FA 미적용(기존 단일 JWT 흐름 그대로, 회귀 0).</li>
* <li>운영(ADMIN/MANAGER) 관리자/매장 로그인: UIWS 2FA 레이어 적용(verify-token + 이메일코드 + 실패잠금).</li>
* </ul>
* 2FA 전역 토글({@code mall.uiws.auth.twofa-enabled}) off 운영 로그인도 단일 JWT(회귀 0).
*/
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthService { public class AuthService {
private static final SecureRandom RANDOM = new SecureRandom();
/** 임시 비밀번호 문자셋(혼동 문자 0/O/1/l/I 제외). */
private static final String TMP_PW_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789@#$%";
private final UserMapper userMapper; private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final TwoFactorService twoFactorService;
private final OtpAuthService otpAuthService;
private final MailSender mailSender;
private final AuditService auditService;
public String login(String username, String password) { /** 운영(2FA 대상) 역할 여부 — 고객(USER)은 제외. */
private static boolean isOperationsRole(String role) {
return "ADMIN".equalsIgnoreCase(role) || "MANAGER".equalsIgnoreCase(role);
}
/**
* 1차 로그인.
* @return 고객 또는 2FA off: { token, type, twofa:"false" }
* 운영 + 2FA on : { twofa:"true", verifyToken, step:"EMAIL", maskedEmail }
*/
public Map<String, String> login(String username, String password) {
MallUser user = userMapper.findByUsername(username); MallUser user = userMapper.findByUsername(username);
// 잠금 우선 차단(존재하는 운영 계정에 한해 존재 여부 누설 최소화)
if (user != null && isOperationsRole(user.getRole()) && twoFactorService.isLocked(user)) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
if (user == null || !user.isActive()) { if (user == null || !user.isActive()) {
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정"); throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
} }
if (!passwordEncoder.matches(password, user.getPasswordHash())) { // 회원가입 승인 게이트(로그인 보조 이식): signup 으로 가입한 운영자(approved=false) 비번 일치 차단.
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치"); // approved NULL(기존 계정/고객 register 흐름)이면 게이트 미적용 회귀 0.
} if (isOperationsRole(user.getRole()) && Boolean.FALSE.equals(user.getApproved())) {
return jwtUtil.generate(username, user.getRole()); throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 이용 가능합니다.");
} }
/** 고객 셀프 회원가입 — 항상 USER 역할로 생성. */ boolean opsRole = isOperationsRole(user.getRole());
public String register(String username, String password, String displayName) { // 2단계 인증 대상: 운영(ADMIN/MANAGER) 로그인만. OTP(TOTP) 우선, 없으면 이메일코드.
boolean otpTarget = otpAuthService.isEnabled() && opsRole;
boolean emailTarget = twoFactorService.isEnabled() && opsRole;
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
// 운영 + 2FA(OTP 또는 이메일) 활성 실패 누적/잠금. 고객/비활성 기존 동작(메시지만) 유지.
if (otpTarget || emailTarget) {
twoFactorService.recordLoginFailure(username);
MallUser after = userMapper.findByUsername(username);
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
}
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
}
// 비밀번호 검증 통과 2단계 우선순위: OTP > 이메일코드 > 단일 로그인
if (otpTarget) {
// { twofa:true, verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }
return otpAuthService.beginOtp(user);
}
if (emailTarget) {
Map<String, String> step1 = twoFactorService.beginTwoFactor(user);
return Map.of(
"twofa", "true",
"verifyToken", step1.get("verifyToken"),
"step", step1.get("step"),
"maskedEmail", step1.getOrDefault("maskedEmail", ""));
}
// 고객(USER) 또는 2FA 비활성 기존 단일 로그인 흐름(회귀 0)
if (opsRole) {
userMapper.resetLoginFail(username);
}
String token = jwtUtil.generate(username, user.getRole());
return Map.of("twofa", "false", "token", token, "type", "Bearer");
}
/** 고객 셀프 회원가입 — 항상 USER 역할로 생성(2FA 미적용 대상). */
public Map<String, String> register(String username, String password, String displayName) {
if (username == null || username.isBlank() || password == null || password.isBlank()) { if (username == null || username.isBlank() || password == null || password.isBlank()) {
throw new IllegalArgumentException("ERR-AUTH-400: username/password 필수"); throw new IllegalArgumentException("ERR-AUTH-400: username/password 필수");
} }
@ -41,7 +129,8 @@ public class AuthService {
user.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName); user.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName);
user.setActive(true); user.setActive(true);
userMapper.insert(user); userMapper.insert(user);
return jwtUtil.generate(username, "USER"); String token = jwtUtil.generate(username, "USER");
return Map.of("twofa", "false", "token", token, "type", "Bearer");
} }
public Map<String, String> me(String token) { public Map<String, String> me(String token) {
@ -49,4 +138,122 @@ public class AuthService {
String role = jwtUtil.getRole(token); String role = jwtUtil.getRole(token);
return Map.of("username", username, "role", role); return Map.of("username", username, "role", role);
} }
/**
* 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIWS changePassword 미러.
* 현재 비번 불일치 PASSWORD_MISMATCH, 기존과 동일 PASSWORD_SAME_AS_OLD.
* 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙).
*/
@Transactional
public void changePassword(String username, ChangePasswordRequest req) {
MallUser user = userMapper.findByUsername(username);
if (user == null) {
throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
}
if (!passwordEncoder.matches(req.currentPassword(), user.getPasswordHash())) {
throw new UiwsApiException(UiwsErrorCode.PASSWORD_MISMATCH);
}
if (passwordEncoder.matches(req.newPassword(), user.getPasswordHash())) {
throw new UiwsApiException(UiwsErrorCode.PASSWORD_SAME_AS_OLD);
}
userMapper.updatePasswordByUsername(username, passwordEncoder.encode(req.newPassword()));
auditService.log(username, "PASSWORD_CHANGE", username, "본인 비밀번호 변경");
}
// 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화
// 대상: Mall 관리자/운영자 계정(mall_account, ADMIN/MANAGER). 고객(USER) register 흐름과 분리.
/**
* 운영자 회원가입(승인 대기). username/email 중복 검사 approved=false·role=MANAGER INSERT.
* 비밀번호는 BCrypt 저장. 승인 전까지 로그인 차단(login 승인 게이트).
*/
@Transactional
public AuthHelperResult signup(SignupRequest req) {
if (req.username() == null || req.username().isBlank()
|| req.password() == null || req.password().length() < 4
|| req.email() == null || req.email().isBlank()) {
return new AuthHelperResult(false, "아이디·비밀번호(4자 이상)·이메일은 필수입니다.");
}
if (userMapper.countByUsername(req.username()) > 0) {
return new AuthHelperResult(false, "이미 사용 중인 아이디입니다.");
}
if (userMapper.countByEmail(req.email()) > 0) {
return new AuthHelperResult(false, "이미 등록된 이메일입니다.");
}
MallUser u = new MallUser();
u.setUsername(req.username());
u.setPasswordHash(passwordEncoder.encode(req.password()));
u.setDisplayName(req.displayName() != null && !req.displayName().isBlank()
? req.displayName() : req.username());
u.setEmail(req.email());
userMapper.signup(u);
log.info("[auth-helper] signup pending approval: username={}", req.username());
return new AuthHelperResult(true, "가입 신청이 접수되었습니다. 관리자 승인 후 로그인할 수 있습니다.");
}
/**
* 아이디 찾기: 표시명+이메일 동시 일치 운영자 1건 조회. username 부분 마스킹 반환.
* 미발견 found=false(원문 username 절대 미노출).
*/
public FindIdResponse findId(FindIdRequest req) {
if (req.displayName() == null || req.displayName().isBlank()
|| req.email() == null || req.email().isBlank()) {
return new FindIdResponse(false, "");
}
MallUser u = userMapper.findByDisplayNameAndEmail(req.displayName(), req.email());
if (u == null) {
return new FindIdResponse(false, "");
}
return new FindIdResponse(true, maskUsername(u.getUsername()));
}
/**
* 비밀번호 초기화: username+email 일치 검증 임시비번 생성·BCrypt 저장·잠금/실패카운트 해제.
* 임시비번은 메일(미설정 LogMailSender 로그)로만 전달. API 응답·로그 메시지에 비번 미노출.
* 대상 미존재여도 success=true(계정 열거 방지).
*/
@Transactional
public AuthHelperResult resetPassword(ResetPasswordRequest req) {
final String okMsg = "임시 비밀번호를 등록된 이메일로 발송했습니다. 메일을 확인하세요.";
if (req.username() == null || req.username().isBlank()
|| req.email() == null || req.email().isBlank()) {
return new AuthHelperResult(false, "아이디와 이메일을 모두 입력하세요.");
}
MallUser u = userMapper.findByUsernameAndEmail(req.username(), req.email());
if (u == null) {
// 존재 여부 누설 방지 동일 성공 메시지 반환(실제 발송 없음).
log.info("[auth-helper] reset-password no match (suppressed): username={}", req.username());
return new AuthHelperResult(true, okMsg);
}
String tempPw = generateTempPassword();
userMapper.updatePasswordHash(req.username(), passwordEncoder.encode(tempPw));
String subject = "[GUARDiA Mall] 임시 비밀번호 안내";
String body = String.format(
"안녕하세요 %s 님,\n임시 비밀번호는 [%s] 입니다.\n로그인 후 즉시 비밀번호를 변경하세요.",
u.getDisplayName() != null ? u.getDisplayName() : u.getUsername(), tempPw);
// 메일 본문에만 임시비번 포함. mailSender 미설정 환경은 LogMailSender 폴백(서버 로그).
mailSender.send(u.getEmail(), subject, body);
log.info("[auth-helper] reset-password issued temp pw (sent via mail/log): username={}", req.username());
return new AuthHelperResult(true, okMsg);
}
private static String generateTempPassword() {
StringBuilder sb = new StringBuilder(10);
for (int i = 0; i < 10; i++) {
sb.append(TMP_PW_CHARS.charAt(RANDOM.nextInt(TMP_PW_CHARS.length())));
}
return sb.toString();
}
/** username 부분 마스킹: 앞 2자만 노출(예: admin → ad***). 2자 이하는 첫 글자만. */
private static String maskUsername(String username) {
if (username == null || username.isBlank()) {
return "";
}
if (username.length() <= 2) {
return username.charAt(0) + "*";
}
return username.substring(0, 2) + "*".repeat(Math.max(1, username.length() - 2));
}
} }

View File

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

View File

@ -34,6 +34,47 @@ public class JwtUtil {
.compact(); .compact();
} }
/**
* UIWS 2FA 이식: 1차 로그인 통과 발급하는 단기 verify-token.
* purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 보호 API 접근 불가).
*/
public String generateVerifyToken(String username, long validitySeconds) {
return Jwts.builder()
.subject(username)
.claim("purpose", "2fa")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L))
.signWith(key())
.compact();
}
/** verify-token 검증 후 username 반환. 유효하지 않거나 purpose!=2fa 면 null. */
public String parseVerifyTokenUsername(String token) {
try {
Claims c = parse(token);
if (!"2fa".equals(c.get("purpose", String.class))) {
return null;
}
return c.getSubject();
} catch (JwtException | IllegalArgumentException e) {
log.debug("verify-token 검증 실패: {}", e.getMessage());
return null;
}
}
/**
* 보안: 토큰이 2FA verify-token(purpose=2fa)인지 판별.
* JwtFilter access 토큰만 인증 컨텍스트로 인정하도록 verify-token 걸러내는 사용.
* verify-token access 동일 서명키라 isValid() 통과 반드시 별도 차단(2FA 우회 방지).
*/
public boolean isVerifyToken(String token) {
try {
return "2fa".equals(parse(token).get("purpose", String.class));
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public Claims parse(String token) { public Claims parse(String token) {
return Jwts.parser().verifyWith(key()).build() return Jwts.parser().verifyWith(key()).build()
.parseSignedClaims(token).getPayload(); .parseSignedClaims(token).getPayload();

View File

@ -3,7 +3,7 @@ package com.zioinfo.mall.auth;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** 계정 (mall_account). 역할: ADMIN/MANAGER/USER(고객). */ /** 계정 (mall_account). 역할: ADMIN/MANAGER(운영) · USER(고객). */
@Data @Data
public class MallUser { public class MallUser {
private Long id; private Long id;
@ -13,4 +13,28 @@ public class MallUser {
private String displayName; private String displayName;
private boolean active; private boolean active;
private LocalDateTime createdAt; private LocalDateTime createdAt;
// UIWS 2FA 이식 컬럼 (mall_account ALTER, db/91_uiws_port.sql)
// 2FA는 운영(ADMIN/MANAGER) 로그인에만 적용 고객(USER) 쇼핑 로그인은 회귀 0.
/** 2FA 발송 대상 이메일(원본 mall_account 미보유 → 91_uiws_port.sql 에서 추가). */
private String email;
/** 이메일 인증코드(6자리). 발급 후 verify 단계에서 검증. API 응답에는 절대 미포함. */
private String emailVerifyCode;
/** 인증코드 만료시각. */
private LocalDateTime emailVerifyExpire;
/** 로그인 실패 누적 횟수(기본 0). max-login-fail 도달 시 locked. */
private Integer loginFailCount;
/** 계정 잠금 여부(기본 false). */
private Boolean locked;
/** TOTP 시크릿(UIWS OTP 경로). 등록 확정 전 보류 시크릿도 여기 저장. API 응답에 절대 미포함. */
private String otpSecret;
/** OTP 등록 확정 여부(기본 false). 최초 로그인 verify 성공 시 true 로 확정(멱등). */
private Boolean otpEnabled;
// 로그인 보조 이식 컬럼 (mall_account ALTER, db/91_uiws_port.sql)
/**
* 회원가입 승인 게이트. signup 으로 가입한 운영자 계정은 false 승인 로그인 차단.
* 기존 계정/고객(USER) NULL login 게이트는 FALSE(명시적 미승인) 차단(회귀 0).
*/
private Boolean approved;
} }

View File

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

View File

@ -71,6 +71,10 @@ public class SecurityConfig {
.requestMatchers("/api/admin/audit").hasAnyRole("ADMIN", "MANAGER") .requestMatchers("/api/admin/audit").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.GET, "/api/admin/settings").hasAnyRole("ADMIN", "MANAGER") .requestMatchers(HttpMethod.GET, "/api/admin/settings").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers("/api/admin/settings/**").hasRole("ADMIN") .requestMatchers("/api/admin/settings/**").hasRole("ADMIN")
// AI 플랫폼(LLM provider) 설정 ADMIN 전용(조회/갱신/연결테스트)
.requestMatchers("/api/admin/ai-config/**", "/api/admin/ai-config").hasRole("ADMIN")
// AI 답변 피드백 수집(로컬 DuckDB + 중앙 rag 전달) 인증 사용자
.requestMatchers(HttpMethod.POST, "/api/ai/feedback").authenticated()
// 운영 분석 MANAGER 이상 // 운영 분석 MANAGER 이상
.requestMatchers("/api/mall/analytics/**").hasAnyRole("ADMIN", "MANAGER") .requestMatchers("/api/mall/analytics/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers("/api/mall/settlement/**").hasAnyRole("ADMIN", "MANAGER") .requestMatchers("/api/mall/settlement/**").hasAnyRole("ADMIN", "MANAGER")
@ -81,8 +85,14 @@ public class SecurityConfig {
"/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER") "/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.DELETE, "/api/mall/product/**", "/api/mall/category/**", .requestMatchers(HttpMethod.DELETE, "/api/mall/product/**", "/api/mall/category/**",
"/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER") "/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER")
// mall API (장바구니·주문·결제·배송··CS·회원·AI) 인증 사용자 // 최신 AI 기법(중앙 guardia-rag) 운영 의사결정·토글 변경은 MANAGER+ (추천/피드백/토글조회는 인증 사용자)
.requestMatchers(HttpMethod.POST, "/api/mall/rag/demand-plan").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.PUT, "/api/mall/rag/toggles/**").hasAnyRole("ADMIN", "MANAGER")
// mall API (장바구니·주문·결제·배송··CS·회원·AI·RAG 추천/피드백/토글조회) 인증 사용자
.requestMatchers("/api/mall/**").authenticated() .requestMatchers("/api/mall/**").authenticated()
// UIWS system(권한관리) 이식: 공개 룩업(부서/거래처 트리) 무인증, 시스템관리 API 운영자(ADMIN/MANAGER)
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/system/**").hasAnyRole("ADMIN", "MANAGER")
// 나머지 모든 API/WS/Actuator는 인증 (아래 SPA permit 보다 먼저 API 노출 방지) // 나머지 모든 API/WS/Actuator는 인증 (아래 SPA permit 보다 먼저 API 노출 방지)
.requestMatchers("/api/**", "/ws/**", "/actuator/**").authenticated() .requestMatchers("/api/**", "/ws/**", "/actuator/**").authenticated()
// 스토어프론트 SPA 딥링크(/app·/cart·/events·/category·/product·/checkout·/mypage·/orders·/search ) // 스토어프론트 SPA 딥링크(/app·/cart·/events·/category·/product·/checkout·/mypage·/orders·/search )

View File

@ -5,10 +5,12 @@ import com.zioinfo.mall.integration.CrmClient;
import com.zioinfo.mall.integration.ItsmSecuritySanitizer; import com.zioinfo.mall.integration.ItsmSecuritySanitizer;
import com.zioinfo.mall.member.mapper.MemberMapper; import com.zioinfo.mall.member.mapper.MemberMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** 회원 API — /api/mall/member. 본인 프로필 + CRM 인사이트 연계(새니타이즈). */ /** 회원 API — /api/mall/member. 본인 프로필 + CRM 인사이트 연계(새니타이즈). */
@ -32,6 +34,27 @@ public class MemberController {
return ApiResponse.ok(mapper.findByUsername(auth.getName())); return ApiResponse.ok(mapper.findByUsername(auth.getName()));
} }
/**
* 관리자 회원 목록/검색 MANAGER+ 전용.
*
* <p>보안 불변: 이메일·전화번호는 매퍼에서 마스킹된 값만, 상세 주소는 비포함(MallMemberSummary).
* 주문수·누적결제액 집계 동반. 키워드(아이디/이름)·등급 필터 지원.
*/
@GetMapping("/admin")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ApiResponse<Map<String, Object>> adminList(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String tier,
@RequestParam(defaultValue = "100") int limit) {
int safeLimit = (limit <= 0 || limit > 500) ? 100 : limit;
List<MallMemberSummary> items = mapper.adminList(keyword, tier, safeLimit);
int total = mapper.countAdminList(keyword, tier);
Map<String, Object> out = new LinkedHashMap<>();
out.put("items", items);
out.put("total", total);
return ApiResponse.ok(out);
}
/** CRM 고객 인사이트(구매 성향) — 응답은 ItsmSecuritySanitizer로 정제. */ /** CRM 고객 인사이트(구매 성향) — 응답은 ItsmSecuritySanitizer로 정제. */
@GetMapping("/me/insight") @GetMapping("/me/insight")
public ApiResponse<Map<String, Object>> insight(Authentication auth) { public ApiResponse<Map<String, Object>> insight(Authentication auth) {

View File

@ -1,11 +1,21 @@
package com.zioinfo.mall.member.mapper; package com.zioinfo.mall.member.mapper;
import com.zioinfo.mall.member.MallMember; import com.zioinfo.mall.member.MallMember;
import com.zioinfo.mall.member.MallMemberSummary;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper @Mapper
public interface MemberMapper { public interface MemberMapper {
MallMember findByUsername(@Param("username") String username); MallMember findByUsername(@Param("username") String username);
int upsert(MallMember m); int upsert(MallMember m);
/** 관리자 회원 목록(검색·등급 필터). 주문수/매출 집계 포함, PII 비노출. */
List<MallMemberSummary> adminList(@Param("keyword") String keyword,
@Param("tier") String tier,
@Param("limit") int limit);
int countAdminList(@Param("keyword") String keyword, @Param("tier") String tier);
} }

View File

@ -47,11 +47,33 @@ public class SubscriptionController {
if (s == null || !s.getOwner().equals(auth.getName())) { if (s == null || !s.getOwner().equals(auth.getName())) {
throw new RuntimeException("ERR-SUB-403: 본인 구독만 변경할 수 있습니다"); throw new RuntimeException("ERR-SUB-403: 본인 구독만 변경할 수 있습니다");
} }
String to = req.getOrDefault("status", "ACTIVE").toUpperCase(); mapper.updateStatus(id, normalizeStatus(req.get("status")));
mapper.updateStatus(id, to);
return ApiResponse.ok(mapper.findById(id)); return ApiResponse.ok(mapper.findById(id));
} }
/**
* 관리자 구독 상태 변경(일시정지/재개/취소) MANAGER+ 전용. 소유자 제한 없음.
*/
@PutMapping("/admin/{id}/status")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ApiResponse<MallSubscription> adminStatus(@PathVariable Long id, @RequestBody Map<String, String> req) {
MallSubscription s = mapper.findById(id);
if (s == null) {
throw new RuntimeException("ERR-SUB-404: 구독을 찾을 수 없습니다");
}
mapper.updateStatus(id, normalizeStatus(req.get("status")));
return ApiResponse.ok(mapper.findById(id));
}
/** 허용 상태(ACTIVE/PAUSED/CANCELLED)만 통과. */
private String normalizeStatus(String raw) {
String to = raw == null ? "ACTIVE" : raw.toUpperCase();
if (!to.equals("ACTIVE") && !to.equals("PAUSED") && !to.equals("CANCELLED")) {
throw new IllegalArgumentException("ERR-SUB-400: 허용되지 않는 상태입니다");
}
return to;
}
private LocalDate nextDate(String freq) { private LocalDate nextDate(String freq) {
LocalDate base = LocalDate.now(); LocalDate base = LocalDate.now();
if ("MONTHLY".equalsIgnoreCase(freq)) return base.plusMonths(1); if ("MONTHLY".equalsIgnoreCase(freq)) return base.plusMonths(1);

View File

@ -8,12 +8,20 @@ spring:
username: ${DB_USER:mall_user} username: ${DB_USER:mall_user}
password: ${DB_PASS:mall_pass2026} password: ${DB_PASS:mall_pass2026}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
# UIWS 이식: 부팅 시 91_uiws_port.sql(업무 9테이블 + mall_account 2FA ALTER) 멱등 적용.
# 전부 IF NOT EXISTS / ADD COLUMN IF NOT EXISTS / ON CONFLICT DO NOTHING → mode:always 재실행 안전.
# schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 시드 충돌 회피 위해 여기 미포함).
sql:
init:
mode: ${SQL_INIT_MODE:always}
schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/93_auth_otp.sql,classpath:db/104_seed_ai_config.sql,classpath:db/202_ai_provider_claude.sql
continue-on-error: true
servlet: servlet:
multipart: multipart:
max-file-size: 20MB max-file-size: 20MB
max-request-size: 20MB max-request-size: 20MB
mybatis: mybatis:
mapper-locations: classpath:mapper/*.xml mapper-locations: classpath:mapper/**/*.xml # ** : 하위 mapper/uiws/*.xml(UIWS 이식) 포함
configuration: configuration:
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
@ -34,13 +42,34 @@ mall:
provider: ${MALL_SMS_PROVIDER:mock} # mock | twilio provider: ${MALL_SMS_PROVIDER:mock} # mock | twilio
email: email:
provider: ${MALL_EMAIL_PROVIDER:mock} # mock | sendgrid provider: ${MALL_EMAIL_PROVIDER:mock} # mock | sendgrid
# ── UIWS 이식: 2FA(운영 로그인) + 첨부 업로드 설정 (mall.uiws.*) ──────────────
uiws:
auth:
twofa-enabled: ${UIWS_2FA:true} # off=운영 로그인도 단일 JWT(회귀 0). 고객(USER)은 항상 미적용.
verify-token-validity-seconds: 300 # 1차 통과 후 verify-token 5분
email-code-validity-seconds: 300 # 이메일 인증코드 5분
max-login-fail: 5 # 실패 5회 시 운영 계정 잠금
mail:
mode: ${UIWS_MAIL_MODE:log} # LogMailSender 폴백(외부 API 0). smtp 는 설정 시만.
upload:
upload-dir: ${UIWS_UPLOAD_DIR:./uploads/uiws}
guardia: guardia:
itsm-url: ${ITSM_URL:http://localhost:9001} itsm-url: ${ITSM_URL:http://localhost:9001}
erp-url: ${ERP_URL:http://localhost:8003} erp-url: ${ERP_URL:http://localhost:8003}
crm-url: ${CRM_URL:http://localhost:8004} crm-url: ${CRM_URL:http://localhost:8004}
ocr-url: ${OCR_URL:http://localhost:8005} ocr-url: ${OCR_URL:http://localhost:8005}
ollama-url: ${OLLAMA_URL:http://localhost:11434} ollama-url: ${OLLAMA_URL:http://localhost:11434}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3} ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
# 로컬 임베디드 DuckDB 학습 저장소 파일(솔루션 격리). 경로 미가용/드라이버 부재 시 자동 비활성(no-op).
mall:
learning:
duckdb-path: ${MALL_LEARNING_DUCKDB:/opt/guardia-mall/data/mall_learning.duckdb}
# 중앙 guardia-rag(온프레미스 전용) — 최신 AI 기법 경유. 미가용 시 Mall 로컬 폴백(degraded)
rag:
base-url: ${RAG_URL:http://127.0.0.1:8020}
timeout-ms: ${RAG_TIMEOUT_MS:120000}
enabled: ${RAG_ENABLED:true}
solution: mall
crypto: crypto:
secret: ${CRYPTO_SECRET:guardia-mall-aes-256-gcm-master-key-2026-zioinfo} secret: ${CRYPTO_SECRET:guardia-mall-aes-256-gcm-master-key-2026-zioinfo}
jwt: jwt:

View File

@ -0,0 +1,22 @@
-- =====================================================================
-- GUARDiA Mall — 202. AI provider 를 claude 로 flip (멱등 UPSERT)
-- 대상 DB : mall_db (테이블 mall_setting)
-- 적용 : application.yml spring.sql.init.mode=always + schema-locations 말미 등재.
-- 104_seed_ai_config.sql(provider=ollama, ON CONFLICT DO NOTHING) 이후 실행되어
-- 기존 provider 값을 claude 로 덮어쓴다(UPSERT).
-- 설명 : 전 솔루션 Claude 전환 방침에 따라 Mall 런타임 AI provider 를 claude 로 활성화.
-- * isClaudeActive() = provider=claude · ANTHROPIC_API_KEY 설정됨 · ai.enabled=true
-- * 키 미설정/Claude 실패 시 AiTextRouter 가 Ollama(선택모델) 자동 폴백 → 무중단.
-- 보안 : Claude API 키는 DB 에 저장하지 않는다 — 서버 환경변수 ANTHROPIC_API_KEY 로만 주입.
-- 본 파일에 키/시크릿/IP/비밀번호 일절 미포함.
-- 멱등 : INSERT ... ON CONFLICT (key) DO UPDATE SET value='claude'. 재실행 안전.
-- 무회귀 : provider=claude 라도 키 미설정/degraded 시 Ollama 폴백(기존 동작 보존).
-- =====================================================================
SET client_encoding = 'UTF8';
INSERT INTO mall_setting (key, value, updated_at)
VALUES ('ai.provider', 'claude', NOW())
ON CONFLICT (key) DO UPDATE SET value = 'claude', updated_at = NOW();
-- end 202_ai_provider_claude.sql

View File

@ -47,7 +47,20 @@ INSERT INTO mall_setting (key, value) VALUES
('hours_saturday','Sat 9:00 AM - 4:00 PM'), ('hours_saturday','Sat 9:00 AM - 4:00 PM'),
('hours_sunday','Sun 9:00 AM - 12:00 PM'), ('hours_sunday','Sun 9:00 AM - 12:00 PM'),
('payment_provider','mock'),('tax_provider','mock'),('address_provider','mock'), ('payment_provider','mock'),('tax_provider','mock'),('address_provider','mock'),
('sms_provider','mock'),('email_provider','mock') ('sms_provider','mock'),('email_provider','mock'),
-- 최신 AI 기법(중앙 guardia-rag) 토글 — 무거운 기법(graphrag·rerank·tool_use·stream)은 서버 RAM 제약상 기본 off
('rag.enabled','true'),
('rag.retrieval_mode','vector'),
('rag.rerank','false'),
('rag.graphrag','false'),
('rag.tool_use','false'),
('rag.structured','true'),
('rag.stream','false'),
('rag.top_k','6'),
('rag.agent_max_steps','4'),
('rag.faithfulness_threshold','0.5'),
('rag.temperature','0.2'),
('rag.generation_model','llama3.2:1b')
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS mall_ai_result ( CREATE TABLE IF NOT EXISTS mall_ai_result (

View File

@ -32,6 +32,7 @@
<update id="updateRole">UPDATE mall_account SET role = #{role} WHERE id = #{id}</update> <update id="updateRole">UPDATE mall_account SET role = #{role} WHERE id = #{id}</update>
<update id="updateActive">UPDATE mall_account SET is_active = #{active} WHERE id = #{id}</update> <update id="updateActive">UPDATE mall_account SET is_active = #{active} WHERE id = #{id}</update>
<update id="updatePassword">UPDATE mall_account SET password_hash = #{passwordHash} WHERE id = #{id}</update> <update id="updatePassword">UPDATE mall_account SET password_hash = #{passwordHash} WHERE id = #{id}</update>
<update id="clearOtp">UPDATE mall_account SET otp_secret = NULL, otp_enabled = false WHERE id = #{id}</update>
<delete id="deleteById">DELETE FROM mall_account WHERE id = #{id}</delete> <delete id="deleteById">DELETE FROM mall_account WHERE id = #{id}</delete>
<select id="countAdmins" resultType="int"> <select id="countAdmins" resultType="int">

View File

@ -11,4 +11,44 @@
display_name = EXCLUDED.display_name, email = EXCLUDED.email, phone = EXCLUDED.phone, display_name = EXCLUDED.display_name, email = EXCLUDED.email, phone = EXCLUDED.phone,
default_zip = EXCLUDED.default_zip, default_address = EXCLUDED.default_address default_zip = EXCLUDED.default_zip, default_address = EXCLUDED.default_address
</insert> </insert>
<!--
관리자 회원 목록 — PII 비노출.
email/phone 은 SQL 레벨에서 마스킹(앞 2자 + *** + 도메인 / 끝 4자리만). 상세 주소는 미선택.
주문수/누적결제액은 mall_order 를 owner(=username) 로 LEFT JOIN 집계.
-->
<sql id="adminWhere">
<where>
<if test="keyword != null and keyword != ''">
AND (m.username ILIKE '%' || #{keyword} || '%' OR m.display_name ILIKE '%' || #{keyword} || '%')
</if>
<if test="tier != null and tier != ''">AND m.tier = #{tier}</if>
</where>
</sql>
<select id="adminList" resultType="com.zioinfo.mall.member.MallMemberSummary">
SELECT
m.id, m.username, m.display_name AS displayName,
CASE WHEN m.email IS NULL OR m.email = '' THEN NULL
WHEN POSITION('@' IN m.email) > 2
THEN SUBSTRING(m.email FROM 1 FOR 2) || '***' || SUBSTRING(m.email FROM POSITION('@' IN m.email))
ELSE '***' || SUBSTRING(m.email FROM POSITION('@' IN m.email)) END AS emailMasked,
CASE WHEN m.phone IS NULL OR LENGTH(m.phone) &lt; 4 THEN NULL
ELSE '***-****-' || SUBSTRING(m.phone FROM LENGTH(m.phone) - 3) END AS phoneMasked,
m.default_zip AS defaultZip, m.tier, m.created_at AS createdAt,
COALESCE(o.order_count, 0) AS orderCount,
COALESCE(o.total_spent, 0) AS totalSpent
FROM mall_member m
LEFT JOIN (
SELECT owner, COUNT(*) AS order_count, SUM(COALESCE(pay_amount, total_amount, 0)) AS total_spent
FROM mall_order WHERE status NOT IN ('CANCELLED','REFUNDED','FAILED') GROUP BY owner
) o ON o.owner = m.username
<include refid="adminWhere"/>
ORDER BY m.created_at DESC
LIMIT #{limit}
</select>
<select id="countAdminList" resultType="int">
SELECT COUNT(*) FROM mall_member m <include refid="adminWhere"/>
</select>
</mapper> </mapper>

View File

@ -11,10 +11,21 @@
<result property="displayName" column="display_name"/> <result property="displayName" column="display_name"/>
<result property="active" column="is_active"/> <result property="active" column="is_active"/>
<result property="createdAt" column="created_at"/> <result property="createdAt" column="created_at"/>
<!-- UIWS 2FA 이식 컬럼 (mall_account ALTER, db/91_uiws_port.sql) -->
<result property="email" column="email"/>
<result property="emailVerifyCode" column="email_verify_code"/>
<result property="emailVerifyExpire" column="email_verify_expire"/>
<result property="loginFailCount" column="login_fail_count"/>
<result property="locked" column="locked"/>
<result property="otpSecret" column="otp_secret"/>
<result property="otpEnabled" column="otp_enabled"/>
<!-- 로그인 보조 이식: 회원가입 승인 게이트 -->
<result property="approved" column="approved"/>
</resultMap> </resultMap>
<select id="findByUsername" resultMap="userMap"> <select id="findByUsername" resultMap="userMap">
SELECT id, username, password_hash, role, display_name, is_active, created_at SELECT id, username, password_hash, role, display_name, is_active, created_at,
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
FROM mall_account FROM mall_account
WHERE username = #{username} WHERE username = #{username}
</select> </select>
@ -29,4 +40,42 @@
SELECT COUNT(*) FROM mall_account WHERE username = #{username} SELECT COUNT(*) FROM mall_account WHERE username = #{username}
</select> </select>
<!-- ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 ─────────────── -->
<select id="countByEmail" resultType="int">
SELECT COUNT(*) FROM mall_account WHERE email = #{email}
</select>
<!-- 회원가입(승인 대기): 운영자 신청 → role=MANAGER·is_active=true·approved=false·실패카운트0·미잠금 고정 -->
<insert id="signup" parameterType="com.zioinfo.mall.auth.MallUser"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO mall_account (username, password_hash, display_name, role, email,
is_active, approved, login_fail_count, locked)
VALUES (#{username}, #{passwordHash}, #{displayName}, 'MANAGER', #{email},
true, false, 0, false)
</insert>
<select id="findByDisplayNameAndEmail" resultMap="userMap">
SELECT id, username, password_hash, role, display_name, is_active, created_at,
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
FROM mall_account
WHERE display_name = #{displayName} AND email = #{email}
ORDER BY id
LIMIT 1
</select>
<select id="findByUsernameAndEmail" resultMap="userMap">
SELECT id, username, password_hash, role, display_name, is_active, created_at,
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
FROM mall_account
WHERE username = #{username} AND email = #{email}
</select>
<!-- 임시비번 적용 + 잠금/실패카운트 해제(초기화 시) -->
<update id="updatePasswordHash">
UPDATE mall_account
SET password_hash = #{passwordHash}, locked = false, login_fail_count = 0
WHERE username = #{username}
</update>
</mapper> </mapper>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#fdfaf5" /> <meta name="theme-color" content="#fdfaf5" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.ico" sizes="any" /> <link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
@ -15,8 +16,8 @@
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Playfair+Display:wght@500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Playfair+Display:wght@500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<script type="module" crossorigin src="/assets/index-CUN395kM.js"></script> <script type="module" crossorigin src="/assets/index-Dj2NJ6H6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BzL8NSpt.css"> <link rel="stylesheet" crossorigin href="/assets/index-CWjUgOzG.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#fdfaf5" /> <meta name="theme-color" content="#fdfaf5" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.ico" sizes="any" /> <link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -41,6 +41,24 @@ import UserManagement from './admin/UserManagement'
import AuditLog from './admin/AuditLog' import AuditLog from './admin/AuditLog'
import Settings from './admin/Settings' import Settings from './admin/Settings'
import AdminApp from './admin/AdminApp' import AdminApp from './admin/AdminApp'
import AiTechniques from './admin/AiTechniques'
import AiPlatformSettings from './admin/AiPlatformSettings'
import MyPage from './admin/MyPage'
import WiseAiPage from './pages/WiseAiPage'
// UIWS 이식 — 업무 모듈(관리자 영역 병합, 고객 쇼핑 화면 무영향)
import UiwsLayout from './pages/uiws/UiwsLayout'
import UiwsWorklog from './pages/uiws/WorklogList'
import UiwsSchedule from './pages/uiws/ScheduleCalendar'
import UiwsMessage from './pages/uiws/MessageBox'
import UiwsStats from './pages/uiws/StatsPivot'
// UIWS 시스템관리(권한 RBAC) — 관리자 영역 (ADMIN 전용)
import UiwsRole from './pages/uiws/system/RoleManagement'
import UiwsRoleMenu from './pages/uiws/system/RoleMenuPermission'
import UiwsCode from './pages/uiws/system/CodeManagement'
import UiwsMenu from './pages/uiws/system/MenuManagement'
import UiwsDept from './pages/uiws/system/DeptManagement'
import UiwsCompany from './pages/uiws/system/CompanyManagement'
const qc = new QueryClient({ const qc = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 30_000 } }, defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 30_000 } },
@ -80,6 +98,7 @@ export default function App() {
<Route path="/admin" element={<AdminLayout />}> <Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} /> <Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} /> <Route path="dashboard" element={<Dashboard />} />
<Route path="mypage" element={<MyPage />} />
<Route path="stores" element={<Stores />} /> <Route path="stores" element={<Stores />} />
<Route path="products" element={<Products />} /> <Route path="products" element={<Products />} />
<Route path="inventory" element={<Inventory />} /> <Route path="inventory" element={<Inventory />} />
@ -94,7 +113,22 @@ export default function App() {
<Route path="users" element={<UserManagement />} /> <Route path="users" element={<UserManagement />} />
<Route path="audit" element={<AuditLog />} /> <Route path="audit" element={<AuditLog />} />
<Route path="settings" element={<Settings />} /> <Route path="settings" element={<Settings />} />
<Route path="wise-ai" element={<WiseAiPage />} />
<Route path="ai-techniques" element={<AiTechniques />} />
<Route path="ai-platform" element={<AiPlatformSettings />} />
<Route path="app" element={<AdminApp />} /> <Route path="app" element={<AdminApp />} />
{/* UIWS 이식 업무 모듈 (관리자 영역 병합) */}
<Route path="uiws/worklog" element={<UiwsLayout><UiwsWorklog /></UiwsLayout>} />
<Route path="uiws/schedule" element={<UiwsLayout><UiwsSchedule /></UiwsLayout>} />
<Route path="uiws/message" element={<UiwsLayout><UiwsMessage /></UiwsLayout>} />
<Route path="uiws/stats" element={<UiwsLayout><UiwsStats /></UiwsLayout>} />
{/* UIWS 시스템관리(권한 RBAC) — ADMIN 전용(사이드바 가드 + 백엔드 권한) */}
<Route path="uiws/system/roles" element={<UiwsLayout><UiwsRole /></UiwsLayout>} />
<Route path="uiws/system/role-menus" element={<UiwsLayout><UiwsRoleMenu /></UiwsLayout>} />
<Route path="uiws/system/codes" element={<UiwsLayout><UiwsCode /></UiwsLayout>} />
<Route path="uiws/system/menus" element={<UiwsLayout><UiwsMenu /></UiwsLayout>} />
<Route path="uiws/system/depts" element={<UiwsLayout><UiwsDept /></UiwsLayout>} />
<Route path="uiws/system/companies" element={<UiwsLayout><UiwsCompany /></UiwsLayout>} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View File

@ -1,8 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Outlet, Navigate, NavLink, useNavigate } from 'react-router-dom' import { Outlet, Navigate, NavLink, Link, useNavigate } from 'react-router-dom'
import { import {
LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone, LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone,
Repeat, CalendarClock, BarChart3, UserCog, ScrollText, Settings, LogOut, UserCircle, ArrowLeftRight, Smartphone, Repeat, CalendarClock, BarChart3, UserCog, ScrollText, Settings, LogOut, UserCircle, ArrowLeftRight, Smartphone,
ClipboardList, CalendarDays, Mail, PieChart, Sparkles, Cpu,
ShieldCheck, KeySquare, ListTree, Menu as MenuIcon, Building2, Briefcase, BrainCircuit,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getMe } from '../api/client' import { getMe } from '../api/client'
@ -28,6 +30,28 @@ const adminLinks = [
{ to: '/admin/settings', key: 'settings', icon: Settings, roles: ['ADMIN'] }, { to: '/admin/settings', key: 'settings', icon: Settings, roles: ['ADMIN'] },
{ to: '/admin/app', key: 'appInstall', icon: Smartphone, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/app', key: 'appInstall', icon: Smartphone, roles: ['ADMIN', 'MANAGER'] },
] ]
// 최신 AI 기법(중앙 guardia-rag) 토글 — 라벨 i18n 미의존(고정 표기), 변경은 MANAGER+(USER 차단)
const aiLinks = [
{ to: '/admin/wise-ai', label: 'WISE AI', icon: BrainCircuit, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/ai-techniques', label: 'AI Techniques', icon: Sparkles, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/ai-platform', label: 'AI 플랫폼 설정', icon: Cpu, roles: ['ADMIN'] },
]
// UIWS 이식 — 업무 모듈(관리자 영역 병합). 라벨은 i18n 미의존(고정 한/영 안전 표기).
const uiwsLinks = [
{ to: '/admin/uiws/worklog', label: '업무일지 Worklog', icon: ClipboardList, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/uiws/schedule', label: '일정 Schedule', icon: CalendarDays, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/uiws/message', label: '쪽지 Message', icon: Mail, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/uiws/stats', label: '업무통계 Stats', icon: PieChart, roles: ['ADMIN', 'MANAGER'] },
]
// UIWS 시스템관리(권한 RBAC) — SuperAdmin(ADMIN) 전용. 고객앱/기존 admin 보존, 백엔드 권한과 이중 가드.
const sysLinks = [
{ to: '/admin/uiws/system/roles', label: '권한 관리', icon: ShieldCheck, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/role-menus', label: '역할-메뉴 권한', icon: KeySquare, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/codes', label: '공통코드', icon: ListTree, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/menus', label: '메뉴 관리', icon: MenuIcon, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/depts', label: '부서 관리', icon: Building2, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/companies', label: '거래처 관리', icon: Briefcase, roles: ['ADMIN'] },
]
const linkClass = ({ isActive }: { isActive: boolean }) => const linkClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 px-5 py-2.5 text-sm transition-colors ${isActive ? 'bg-card text-brand border-r-2 border-brand' : 'text-slate-300 hover:bg-card/60'}` `flex items-center gap-3 px-5 py-2.5 text-sm transition-colors ${isActive ? 'bg-card text-brand border-r-2 border-brand' : 'text-slate-300 hover:bg-card/60'}`
@ -57,6 +81,9 @@ export default function AdminLayout() {
const visible = links.filter(l => !role || l.roles.includes(role)) const visible = links.filter(l => !role || l.roles.includes(role))
const visibleAdmin = adminLinks.filter(l => l.roles.includes(role)) const visibleAdmin = adminLinks.filter(l => l.roles.includes(role))
const visibleAi = aiLinks.filter(l => !role || l.roles.includes(role))
const visibleUiws = uiwsLinks.filter(l => !role || l.roles.includes(role))
const visibleSys = sysLinks.filter(l => l.roles.includes(role)) // ADMIN 전용(role 비어있으면 비표시)
const logout = () => { const logout = () => {
localStorage.removeItem('mall_admin_token'); localStorage.removeItem('mall_role'); localStorage.removeItem('mall_admin_user') localStorage.removeItem('mall_admin_token'); localStorage.removeItem('mall_role'); localStorage.removeItem('mall_admin_user')
@ -76,6 +103,18 @@ export default function AdminLayout() {
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3">{t('admin.system')}</div> <div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3">{t('admin.system')}</div>
{visibleAdmin.map(({ to, key, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{t(`admin.nav.${key}`)}</NavLink>)} {visibleAdmin.map(({ to, key, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{t(`admin.nav.${key}`)}</NavLink>)}
</>)} </>)}
{visibleAi.length > 0 && (<>
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3">AI</div>
{visibleAi.map(({ to, label, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{label}</NavLink>)}
</>)}
{visibleUiws.length > 0 && (<>
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3"> (UIWS)</div>
{visibleUiws.map(({ to, label, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{label}</NavLink>)}
</>)}
{visibleSys.length > 0 && (<>
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3"> ()</div>
{visibleSys.map(({ to, label, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{label}</NavLink>)}
</>)}
</nav> </nav>
<div className="p-4 text-[11px] text-slate-500 border-t border-edge">{t('admin.onPremiseTag')}</div> <div className="p-4 text-[11px] text-slate-500 border-t border-edge">{t('admin.onPremiseTag')}</div>
</aside> </aside>
@ -84,7 +123,7 @@ export default function AdminLayout() {
<div className="text-sm text-slate-400 truncate">{t('admin.header')}</div> <div className="text-sm text-slate-400 truncate">{t('admin.header')}</div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<LanguageSwitcher variant="admin" /> <LanguageSwitcher variant="admin" />
<span className="flex items-center gap-1.5 text-sm text-slate-300"><UserCircle size={18} /> {who || 'admin'} <span className="text-[10px] text-brand">{role}</span></span> <Link to="/admin/mypage" className="flex items-center gap-1.5 text-sm text-slate-300 hover:text-brand" title="마이페이지 / My Page"><UserCircle size={18} /> {who || 'admin'} <span className="text-[10px] text-brand">{role}</span></Link>
<button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand"><LogOut size={16} /> {t('admin.signOut')}</button> <button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand"><LogOut size={16} /> {t('admin.signOut')}</button>
</div> </div>
</header> </header>

View File

@ -2,42 +2,103 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { login, getMe } from '../api/client' import { login, getMe } from '../api/client'
import { verify2fa, verifyOtp, authSignup, authFindId, authResetPassword } from '../api/uiws'
import LanguageSwitcher from '../i18n/LanguageSwitcher' import LanguageSwitcher from '../i18n/LanguageSwitcher'
/** 로그인 보조 모달 종류(UIWS auth 패턴 이식). */
type HelperKind = 'signup' | 'findId' | 'resetPw'
/** 2차 인증 방식 — OTP: Authenticator 등록됨, OTP_SETUP: 최초 QR 등록, EMAIL: 이메일 코드(하위호환). */
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
/**
* /() .
* - UIWS 2FA 이식: 운영 (ADMIN/MANAGER) + twofa-enabled 1 verify-token + .
* { twofa:"false", token } (2FA off off 0).
* { twofa:"true", verifyToken, maskedEmail } /api/mall/auth/verify token.
* - / ( ). (admin email ) .
*/
export default function AdminLogin() { export default function AdminLogin() {
const { t } = useTranslation() const { t } = useTranslation()
const [username, setUsername] = useState('admin') const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [err, setErr] = useState('') const [err, setErr] = useState('')
// 2FA 단계
const [step, setStep] = useState<'login' | 'verify'>('login')
const [verifyToken, setVerifyToken] = useState('')
const [verifyMethod, setVerifyMethod] = useState<VerifyMethod>('EMAIL')
const [maskedEmail, setMaskedEmail] = useState('')
const [qrImage, setQrImage] = useState('') // OTP_SETUP 등록 순간만 존재
const [secret, setSecret] = useState('') // OTP_SETUP 등록 순간만 존재
const [code, setCode] = useState('')
// 로그인 보조 모달(UIWS auth 패턴 이식): 회원가입 / 아이디찾기 / 비밀번호 초기화
const [helper, setHelper] = useState<HelperKind | null>(null)
const nav = useNavigate() const nav = useNavigate()
const isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
const isSetup = verifyMethod === 'OTP_SETUP'
useEffect(() => { useEffect(() => {
document.documentElement.classList.add('admin-shell') document.documentElement.classList.add('admin-shell')
return () => document.documentElement.classList.remove('admin-shell') return () => document.documentElement.classList.remove('admin-shell')
}, []) }, [])
const submit = async (e: React.FormEvent) => { /** 토큰 저장 + 역할 확인 후 진입(공통). */
e.preventDefault(); setErr('') const finishLogin = async (token: string) => {
try {
const res = await login(username, password)
const token = res.data?.data?.token
if (!token) throw new Error('no token')
// Store the admin token under a key separate from the customer session (mall_token) — used by the interceptor in the /admin area
localStorage.setItem('mall_admin_token', token) localStorage.setItem('mall_admin_token', token)
const me: any = await getMe().catch(() => null) const me: any = await getMe().catch(() => null)
if (me?.role) localStorage.setItem('mall_role', me.role) if (me?.role) localStorage.setItem('mall_role', me.role)
if (me?.username) localStorage.setItem('mall_admin_user', me.username) if (me?.username) localStorage.setItem('mall_admin_user', me.username)
if (me?.role === 'USER') { setErr(t('admin.login.errNoPriv')); localStorage.removeItem('mall_admin_token'); return } if (me?.role === 'USER') { setErr(t('admin.login.errNoPriv')); localStorage.removeItem('mall_admin_token'); return }
nav('/admin/dashboard') nav('/admin/dashboard')
}
const submitLogin = async (e: React.FormEvent) => {
e.preventDefault(); setErr('')
try {
const res = await login(username, password)
const data = res.data?.data || {}
if (data.twofa === 'true') {
// 운영 계정 2FA — verifyMethod 로 분기(OTP/OTP_SETUP: Authenticator, 그 외: 이메일 코드)
setVerifyToken(data.verifyToken || '')
setVerifyMethod((data.verifyMethod as VerifyMethod) || 'EMAIL')
setMaskedEmail(data.maskedEmail || '')
setQrImage(data.qrImage || '')
setSecret(data.secret || '')
setCode('')
setStep('verify')
return
}
const token = data.token
if (!token) throw new Error('no token')
await finishLogin(token)
} catch { } catch {
setErr(t('admin.login.errFailed')) setErr(t('admin.login.errFailed'))
} }
} }
const submitVerify = async (e: React.FormEvent) => {
e.preventDefault(); setErr('')
try {
// OTP·OTP_SETUP 은 /verify-otp, 이메일은 /verify(하위호환)
const res = isOtp
? await verifyOtp(verifyToken, code.trim())
: await verify2fa(verifyToken, code.trim())
const token = res.data?.data?.token
if (!token) throw new Error('no token')
// 시크릿 잔존 방지
setQrImage(''); setSecret('')
await finishLogin(token)
} catch {
setErr('인증 코드가 올바르지 않거나 만료되었습니다. (Invalid or expired code)')
}
}
return ( return (
<div className="admin-shell min-h-screen flex items-center justify-center bg-ink text-[#e6edf6]"> <div className="admin-shell min-h-screen flex items-center justify-center bg-ink text-[#e6edf6]">
<div className="absolute top-5 right-5"><LanguageSwitcher variant="admin" /></div> <div className="absolute top-5 right-5"><LanguageSwitcher variant="admin" /></div>
<form onSubmit={submit} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
{step === 'login' ? (
<form onSubmit={submitLogin} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
<div className="flex items-center gap-2 justify-center mb-6"> <div className="flex items-center gap-2 justify-center mb-6">
<img src="/login.png" alt="GUARDiA" style={{height:36,width:'auto'}} onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} /> <img src="/login.png" alt="GUARDiA" style={{height:36,width:'auto'}} onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
<span className="text-xl font-bold">{t('admin.login.title')}</span> <span className="text-xl font-bold">{t('admin.login.title')}</span>
@ -51,8 +112,159 @@ export default function AdminLogin() {
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" /> className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>} {err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
<button className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90">{t('admin.login.signIn')}</button> <button className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90">{t('admin.login.signIn')}</button>
<p className="text-center text-[11px] text-slate-500 mt-4">{t('admin.login.storefrontHere')} <a href="/" className="text-brand">{t('admin.login.here')}</a></p> {/* 로그인 보조 3종(UIWS auth 이식): 운영자 회원가입 / 아이디 찾기 / 비밀번호 초기화 */}
<div className="flex items-center justify-center gap-2 text-[11px] text-slate-400 mt-4">
<button type="button" onClick={() => setHelper('signup')} className="hover:text-brand"> </button>
<span className="text-slate-600">|</span>
<button type="button" onClick={() => setHelper('findId')} className="hover:text-brand"> </button>
<span className="text-slate-600">|</span>
<button type="button" onClick={() => setHelper('resetPw')} className="hover:text-brand"> </button>
</div>
<p className="text-center text-[11px] text-slate-500 mt-3">{t('admin.login.storefrontHere')} <a href="/" className="text-brand">{t('admin.login.here')}</a></p>
</form> </form>
) : (
<form onSubmit={submitVerify} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
<div className="flex items-center gap-2 justify-center mb-4">
<span className="text-xl font-bold">2 / 2FA</span>
</div>
<p className="text-center text-sm text-slate-400 mb-2"> 2 .</p>
{isOtp ? (
isSetup ? (
<>
<p className="text-center text-[12px] text-slate-500 mb-3">
. QR을 Authenticator (Google·Microsoft) 6 .
</p>
{qrImage && (
<div className="flex justify-center mb-3">
<img src={qrImage} alt="OTP QR" width={180} height={180}
style={{ background: '#fff', padding: 8, borderRadius: 8 }} />
</div>
)}
{secret && (
<div className="mb-4">
<div className="text-[11px] text-slate-500 mb-1">QR </div>
<code className="block text-xs text-accent bg-ink border border-edge rounded-md px-2 py-1.5 break-all select-all">
{secret}
</code>
</div>
)}
</>
) : (
<p className="text-center text-[12px] text-slate-500 mb-6">
Authenticator 6 .
</p>
)
) : (
<p className="text-center text-[12px] text-slate-500 mb-6">
{maskedEmail || '등록된 이메일'} .
</p>
)}
<label className="block text-xs text-slate-400 mb-1"> (6)</label>
<input value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
inputMode="numeric" autoComplete="one-time-code" maxLength={6} autoFocus
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm tracking-[0.4em] text-center focus:border-brand outline-none" />
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
<button className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90">{isSetup ? '등록하고 로그인 / Register' : '확인 / Verify'}</button>
<button type="button" onClick={() => { setStep('login'); setCode(''); setErr(''); setQrImage(''); setSecret('') }}
className="w-full py-2 mt-2 rounded-lg text-slate-400 text-xs hover:text-brand"> / Back</button>
</form>
)}
{helper && <AuthHelperModal kind={helper} onClose={() => setHelper(null)} />}
</div>
)
}
/**
* (UIWS auth ) / / .
* (bg-panel/bg-card/border-edge/bg-brand/text-ink) .
* 보안: 서버가 · .
*/
function AuthHelperModal({ kind, onClose }: { kind: HelperKind; onClose: () => void }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [email, setEmail] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [ok, setOk] = useState(false)
const title = kind === 'signup' ? '운영자 가입 (승인 대기)'
: kind === 'findId' ? '아이디 찾기' : '비밀번호 초기화'
const submit = async (e: React.FormEvent) => {
e.preventDefault(); setBusy(true); setMsg(''); setOk(false)
try {
if (kind === 'signup') {
const res = await authSignup({ username, password, displayName, email })
const d = res.data?.data || {}
setOk(!!d.success); setMsg(d.message || '처리되었습니다.')
} else if (kind === 'findId') {
const res = await authFindId({ displayName, email })
const d = res.data?.data || {}
setOk(!!d.found)
setMsg(d.found ? `회원님의 아이디는 [ ${d.maskedUsername} ] 입니다.` : '일치하는 계정을 찾을 수 없습니다.')
} else {
const res = await authResetPassword({ username, email })
const d = res.data?.data || {}
setOk(!!d.success); setMsg(d.message || '처리되었습니다.')
}
} catch {
setMsg('요청을 처리하지 못했습니다. 잠시 후 다시 시도하세요.')
} finally {
setBusy(false)
}
}
const field = 'w-full mb-3 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="w-[360px] bg-panel border border-edge rounded-2xl p-7 text-[#e6edf6]" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<span className="text-lg font-bold">{title}</span>
<button type="button" onClick={onClose} className="text-slate-400 hover:text-brand text-xl leading-none">×</button>
</div>
<form onSubmit={submit}>
{(kind === 'signup' || kind === 'resetPw') && (
<>
<label className="block text-xs text-slate-400 mb-1"></label>
<input value={username} onChange={e => setUsername(e.target.value)} className={field} autoFocus />
</>
)}
{kind === 'signup' && (
<>
<label className="block text-xs text-slate-400 mb-1"> (4 )</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className={field} />
</>
)}
{(kind === 'signup' || kind === 'findId') && (
<>
<label className="block text-xs text-slate-400 mb-1"> ()</label>
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className={field} autoFocus={kind === 'findId'} />
</>
)}
<label className="block text-xs text-slate-400 mb-1"></label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} className={field} />
{msg && <p className={`text-xs mb-3 ${ok ? 'text-emerald-400' : 'text-amber-400'}`}>{msg}</p>}
<button disabled={busy}
className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
{busy ? '처리 중…' : (kind === 'signup' ? '가입 신청' : kind === 'findId' ? '아이디 찾기' : '임시 비밀번호 발송')}
</button>
</form>
{kind === 'signup' && (
<p className="text-[11px] text-slate-500 mt-3"> .</p>
)}
{kind === 'resetPw' && (
<p className="text-[11px] text-slate-500 mt-3"> .</p>
)}
</div>
</div> </div>
) )
} }

View File

@ -1,16 +1,23 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Users, RefreshCw } from 'lucide-react' import { Users, RefreshCw, Search } from 'lucide-react'
import { getLoyaltyByTier, recalcAllLoyalty } from '../api/client' import { getLoyaltyByTier, recalcAllLoyalty, getAdminMembers } from '../api/client'
import Chart from '../components/Chart' import Chart from '../components/Chart'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import { money } from '../store/shop' import { money } from '../store/shop'
const TIERS = ['', 'STANDARD', 'BASIC', 'SILVER', 'GOLD', 'VIP']
export default function Members() { export default function Members() {
const qc = useQueryClient() const qc = useQueryClient()
const [kw, setKw] = useState('')
const [tier, setTier] = useState('')
const { data: byTier } = useQuery({ queryKey: ['by-tier'], queryFn: () => getLoyaltyByTier(30) }) const { data: byTier } = useQuery({ queryKey: ['by-tier'], queryFn: () => getLoyaltyByTier(30) })
const { data: members } = useQuery({ queryKey: ['admin-members', kw, tier], queryFn: () => getAdminMembers(kw, tier, 200) })
const recalc = async () => { await recalcAllLoyalty().catch(() => {}); qc.invalidateQueries({ queryKey: ['by-tier'] }) } const recalc = async () => { await recalcAllLoyalty().catch(() => {}); qc.invalidateQueries({ queryKey: ['by-tier'] }) }
const rows = (byTier || []).map((t: any) => ({ name: t.tier, customers: t.customers, sales: t.sales })) const rows = (byTier || []).map((t: any) => ({ name: t.tier, customers: t.customers, sales: t.sales }))
const list = members?.items || []
return ( return (
<div> <div>
@ -30,7 +37,8 @@ export default function Members() {
</div> </div>
</div> </div>
<div className="bg-card border border-edge rounded-xl overflow-hidden"> <div className="bg-card border border-edge rounded-xl p-5 mb-5">
<h2 className="text-sm font-semibold mb-3">Tier Summary</h2>
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel"> <thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3">Tier</th><th className="text-right">Customers</th><th className="text-right">Orders</th><th className="text-right">Sales</th> <th className="text-left p-3">Tier</th><th className="text-right">Customers</th><th className="text-right">Orders</th><th className="text-right">Sales</th>
@ -48,6 +56,46 @@ export default function Members() {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* 회원 목록/검색 — PII(이메일·전화)는 마스킹된 값만 표시 */}
<div className="bg-card border border-edge rounded-xl overflow-hidden">
<div className="flex items-center justify-between p-4 border-b border-edge">
<h2 className="text-sm font-semibold">Member List <span className="text-slate-500 font-normal">({members?.total ?? 0})</span></h2>
<div className="flex items-center gap-2">
<select value={tier} onChange={e => setTier(e.target.value)} className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm">
{TIERS.map(t => <option key={t} value={t}>{t || 'All Tiers'}</option>)}
</select>
<div className="flex items-center gap-2 bg-panel border border-edge rounded-lg px-3 py-2">
<Search size={15} className="text-slate-500" />
<input value={kw} onChange={e => setKw(e.target.value)} placeholder="Search by name or ID" className="bg-transparent text-sm outline-none w-48" />
</div>
</div>
</div>
<table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3">Customer</th><th className="text-left">Email</th><th className="text-left">Phone</th>
<th className="text-left">ZIP</th><th className="text-center">Tier</th><th className="text-right">Orders</th><th className="text-right">Lifetime Value</th><th className="text-left">Joined</th>
</tr></thead>
<tbody>
{list.map((m: any) => (
<tr key={m.id} className="border-b border-edge/50 hover:bg-panel/50">
<td className="p-3">
<div className="font-medium">{m.displayName || m.username}</div>
<div className="text-xs text-slate-500 font-mono">@{m.username}</div>
</td>
<td className="text-slate-400">{m.emailMasked || '-'}</td>
<td className="text-slate-400">{m.phoneMasked || '-'}</td>
<td className="text-slate-400">{m.defaultZip || '-'}</td>
<td className="text-center"><StatusBadge status={m.tier} /></td>
<td className="text-right text-slate-400">{m.orderCount}</td>
<td className="text-right">{money(m.totalSpent)}</td>
<td className="text-slate-400 text-xs">{m.createdAt ? String(m.createdAt).slice(0, 10) : '-'}</td>
</tr>
))}
{!list.length && <tr><td colSpan={8} className="text-center text-slate-500 py-8">No members found.</td></tr>}
</tbody>
</table>
</div>
</div> </div>
) )
} }

View File

@ -1,35 +1,184 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Flower2, Search } from 'lucide-react' import { Flower2, Search, Plus, Pencil, Trash2, X } from 'lucide-react'
import { getProducts, setProductStatus } from '../api/client' import {
getProducts, getProduct, getCategories, createProduct, updateProduct, deleteProduct, setProductStatus,
} from '../api/client'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import { money } from '../store/shop' import { money } from '../store/shop'
const STATUSES = ['ON_SALE', 'SOLD_OUT', 'HIDDEN']
const OCCASIONS = ['', 'BIRTHDAY', 'ANNIVERSARY', 'SYMPATHY', 'LOVE', 'CONGRATS', 'GET_WELL', 'THANK_YOU']
const FLOWERS = ['', 'ROSES', 'TULIPS', 'LILIES', 'ORCHIDS', 'SUNFLOWERS', 'MIXED']
type SizeRow = { sizeCode: string; label: string; price: string; stemCount: string }
const EMPTY = {
id: 0, categoryId: '', sku: '', name: '', brand: '', description: '',
price: '', salePrice: '', status: 'ON_SALE', stock: '0', thumbnail: '',
occasion: '', flowerType: '', shelfLifeDays: '5',
sizes: [] as SizeRow[],
}
export default function Products() { export default function Products() {
const qc = useQueryClient() const qc = useQueryClient()
const [kw, setKw] = useState('') const [kw, setKw] = useState('')
const [open, setOpen] = useState(false)
const [form, setForm] = useState<any>({ ...EMPTY })
const [err, setErr] = useState('')
const [saving, setSaving] = useState(false)
const { data } = useQuery({ queryKey: ['admin-products', kw], queryFn: () => getProducts({ keyword: kw, size: 100, status: '' }) }) const { data } = useQuery({ queryKey: ['admin-products', kw], queryFn: () => getProducts({ keyword: kw, size: 100, status: '' }) })
const { data: cats } = useQuery({ queryKey: ['admin-cats'], queryFn: getCategories })
const items = data?.items || [] const items = data?.items || []
const categories = cats || []
const refresh = () => qc.invalidateQueries({ queryKey: ['admin-products'] })
const cycle = async (id: number, status: string) => { const cycle = async (id: number, status: string) => {
const next = status === 'ACTIVE' ? 'HIDDEN' : 'ACTIVE' const next = status === 'ON_SALE' ? 'HIDDEN' : 'ON_SALE'
await setProductStatus(id, next).catch(() => {}); qc.invalidateQueries({ queryKey: ['admin-products'] }) await setProductStatus(id, next).catch(() => {}); refresh()
} }
const openNew = () => { setForm({ ...EMPTY }); setErr(''); setOpen(true) }
const openEdit = async (id: number) => {
setErr('')
const p = await getProduct(id).catch(() => null)
if (!p) return
setForm({
id: p.id, categoryId: p.categoryId ?? '', sku: p.sku ?? '', name: p.name ?? '', brand: p.brand ?? '',
description: p.description ?? '', price: String(p.price ?? ''), salePrice: p.salePrice != null ? String(p.salePrice) : '',
status: p.status || 'ON_SALE', stock: String(p.stock ?? 0), thumbnail: p.thumbnail ?? '',
occasion: p.occasion ?? '', flowerType: p.flowerType ?? '', shelfLifeDays: String(p.shelfLifeDays ?? 5),
sizes: (p.sizes || []).map((s: any) => ({ sizeCode: s.sizeCode || '', label: s.label || '', price: String(s.price ?? ''), stemCount: String(s.stemCount ?? '') })),
})
setOpen(true)
}
const addSize = () => setForm((f: any) => ({ ...f, sizes: [...f.sizes, { sizeCode: 'ORIGINAL', label: '', price: '', stemCount: '' }] }))
const setSize = (i: number, k: string, v: string) => setForm((f: any) => ({ ...f, sizes: f.sizes.map((s: SizeRow, idx: number) => idx === i ? { ...s, [k]: v } : s) }))
const delSize = (i: number) => setForm((f: any) => ({ ...f, sizes: f.sizes.filter((_: SizeRow, idx: number) => idx !== i) }))
const save = async () => {
if (!form.name.trim()) { setErr('Product name is required.'); return }
if (form.price === '' || isNaN(Number(form.price))) { setErr('A valid price is required.'); return }
setSaving(true); setErr('')
const payload: any = {
categoryId: form.categoryId === '' ? null : Number(form.categoryId),
sku: form.sku.trim() || null, name: form.name.trim(), brand: form.brand.trim() || null,
description: form.description.trim() || null, price: Number(form.price),
salePrice: form.salePrice === '' ? null : Number(form.salePrice),
status: form.status, stock: form.stock === '' ? 0 : Number(form.stock),
thumbnail: form.thumbnail.trim() || null, occasion: form.occasion || null,
flowerType: form.flowerType || null, shelfLifeDays: form.shelfLifeDays === '' ? 5 : Number(form.shelfLifeDays),
sizes: form.sizes
.filter((s: SizeRow) => s.sizeCode && s.price !== '')
.map((s: SizeRow, i: number) => ({
sizeCode: s.sizeCode, label: s.label || s.sizeCode, price: Number(s.price),
stemCount: s.stemCount === '' ? null : Number(s.stemCount), sortOrder: i,
})),
}
try {
if (form.id) await updateProduct(form.id, payload)
else await createProduct(payload)
setOpen(false); refresh()
} catch {
setErr('Failed to save the product. Please check the inputs.')
} finally { setSaving(false) }
}
const remove = async (id: number, name: string) => {
if (!window.confirm(`Delete product "${name}"? This cannot be undone.`)) return
await deleteProduct(id).catch(() => {}); refresh()
}
const fld = 'bg-panel border border-edge rounded-lg px-3 py-2 text-sm'
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2"><Flower2 className="text-brand" size={22} /><h1 className="text-xl font-bold"> </h1></div> <div className="flex items-center gap-2"><Flower2 className="text-brand" size={22} /><h1 className="text-xl font-bold"> </h1></div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 bg-card border border-edge rounded-lg px-3 py-2"> <div className="flex items-center gap-2 bg-card border border-edge rounded-lg px-3 py-2">
<Search size={15} className="text-slate-500" /> <Search size={15} className="text-slate-500" />
<input value={kw} onChange={e => setKw(e.target.value)} placeholder="상품 검색" className="bg-transparent text-sm outline-none w-48" /> <input value={kw} onChange={e => setKw(e.target.value)} placeholder="상품 검색" className="bg-transparent text-sm outline-none w-48" />
</div> </div>
<button onClick={openNew} className="flex items-center gap-1.5 bg-brand text-ink text-sm font-semibold px-3 py-2 rounded-lg"><Plus size={15} /> New Product</button>
</div> </div>
</div>
{open && (
<div className="fixed inset-0 z-50 bg-black/60 flex items-start justify-center overflow-y-auto p-4" onClick={() => !saving && setOpen(false)}>
<div className="bg-card border border-edge rounded-xl p-5 w-full max-w-2xl mt-8 space-y-3" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold">{form.id ? 'Edit Product' : 'New Product'}</h2>
<button onClick={() => setOpen(false)} className="text-slate-400 hover:text-slate-200"><X size={18} /></button>
</div>
{err && <div className="text-xs text-rose-400 bg-rose-500/10 border border-rose-500/30 rounded-lg px-3 py-2">{err}</div>}
<div className="grid grid-cols-3 gap-3">
<input value={form.sku} onChange={e => setForm({ ...form, sku: e.target.value })} placeholder="SKU" className={fld} />
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="Product Name *" className={`${fld} col-span-2`} />
</div>
<div className="grid grid-cols-3 gap-3">
<select value={form.categoryId} onChange={e => setForm({ ...form, categoryId: e.target.value })} className={fld}>
<option value="">Category</option>
{categories.map((c: any) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<input value={form.brand} onChange={e => setForm({ ...form, brand: e.target.value })} placeholder="Brand" className={fld} />
<select value={form.status} onChange={e => setForm({ ...form, status: e.target.value })} className={fld}>
{STATUSES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="Description" rows={2} className={`${fld} w-full`} />
<div className="grid grid-cols-4 gap-3">
<input type="number" value={form.price} onChange={e => setForm({ ...form, price: e.target.value })} placeholder="Price *" className={fld} />
<input type="number" value={form.salePrice} onChange={e => setForm({ ...form, salePrice: e.target.value })} placeholder="Sale Price" className={fld} />
<input type="number" value={form.stock} onChange={e => setForm({ ...form, stock: e.target.value })} placeholder="Stock" className={fld} />
<input type="number" value={form.shelfLifeDays} onChange={e => setForm({ ...form, shelfLifeDays: e.target.value })} placeholder="Shelf Life (days)" className={fld} />
</div>
<div className="grid grid-cols-3 gap-3">
<select value={form.occasion} onChange={e => setForm({ ...form, occasion: e.target.value })} className={fld}>
{OCCASIONS.map(o => <option key={o} value={o}>{o || 'Occasion'}</option>)}
</select>
<select value={form.flowerType} onChange={e => setForm({ ...form, flowerType: e.target.value })} className={fld}>
{FLOWERS.map(o => <option key={o} value={o}>{o || 'Flower Type'}</option>)}
</select>
<input value={form.thumbnail} onChange={e => setForm({ ...form, thumbnail: e.target.value })} placeholder="Thumbnail URL" className={fld} />
</div>
<div className="border border-edge rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-300">Sizes (Original / Deluxe / Grand)</span>
<button onClick={addSize} className="text-xs flex items-center gap-1 text-brand"><Plus size={12} /> Add Size</button>
</div>
{form.sizes.length === 0 && <p className="text-xs text-slate-500">No size variants. Base price is used.</p>}
{form.sizes.map((s: SizeRow, i: number) => (
<div key={i} className="grid grid-cols-12 gap-2 items-center">
<select value={s.sizeCode} onChange={e => setSize(i, 'sizeCode', e.target.value)} className={`${fld} col-span-3`}>
{['ORIGINAL', 'DELUXE', 'GRAND'].map(c => <option key={c}>{c}</option>)}
</select>
<input value={s.label} onChange={e => setSize(i, 'label', e.target.value)} placeholder="Label" className={`${fld} col-span-3`} />
<input type="number" value={s.price} onChange={e => setSize(i, 'price', e.target.value)} placeholder="Price" className={`${fld} col-span-3`} />
<input type="number" value={s.stemCount} onChange={e => setSize(i, 'stemCount', e.target.value)} placeholder="Stems" className={`${fld} col-span-2`} />
<button onClick={() => delSize(i)} className="col-span-1 text-rose-400 flex justify-center"><Trash2 size={14} /></button>
</div>
))}
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button onClick={() => setOpen(false)} disabled={saving} className="border border-edge text-slate-400 text-sm px-4 py-2 rounded-lg">Cancel</button>
<button onClick={save} disabled={saving} className="bg-brand text-ink font-semibold text-sm px-5 py-2 rounded-lg disabled:opacity-50">{saving ? 'Saving…' : (form.id ? 'Update' : 'Create')}</button>
</div>
</div>
</div>
)}
<div className="bg-card border border-edge rounded-xl overflow-hidden"> <div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel"> <thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3">SKU</th><th className="text-left"></th><th className="text-left"></th> <th className="text-left p-3">SKU</th><th className="text-left"></th><th className="text-left"></th>
<th className="text-right"></th><th className="text-right"></th><th className="text-right"></th><th className="text-right"></th><th className="text-center"></th> <th className="text-right"></th><th className="text-right"></th><th className="text-right"></th><th className="text-right"></th><th className="text-center"></th><th className="text-center"></th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{items.map((p: any) => ( {items.map((p: any) => (
@ -42,9 +191,15 @@ export default function Products() {
<td className="text-right text-slate-400">{p.salesCount}</td> <td className="text-right text-slate-400">{p.salesCount}</td>
<td className="text-right text-amber-400">{p.ratingAvg?.toFixed(1) || '-'}</td> <td className="text-right text-amber-400">{p.ratingAvg?.toFixed(1) || '-'}</td>
<td className="text-center"><button onClick={() => cycle(p.id, p.status)}><StatusBadge status={p.status} /></button></td> <td className="text-center"><button onClick={() => cycle(p.id, p.status)}><StatusBadge status={p.status} /></button></td>
<td className="text-center">
<div className="inline-flex gap-1">
<button onClick={() => openEdit(p.id)} title="Edit" className="text-xs bg-panel border border-edge text-slate-300 px-2 py-1 rounded inline-flex items-center gap-1"><Pencil size={12} /></button>
<button onClick={() => remove(p.id, p.name)} title="Delete" className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded inline-flex items-center gap-1"><Trash2 size={12} /></button>
</div>
</td>
</tr> </tr>
))} ))}
{!items.length && <tr><td colSpan={8} className="text-center text-slate-500 py-8"> .</td></tr>} {!items.length && <tr><td colSpan={9} className="text-center text-slate-500 py-8"> .</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -1,20 +1,25 @@
import { useQuery } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Repeat } from 'lucide-react' import { Repeat, Pause, Play, Ban } from 'lucide-react'
import { getAdminSubscriptions } from '../api/client' import { getAdminSubscriptions, setAdminSubscriptionStatus } from '../api/client'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import { money } from '../store/shop' import { money } from '../store/shop'
export default function Subscriptions() { export default function Subscriptions() {
const qc = useQueryClient()
const { data: subs } = useQuery({ queryKey: ['admin-subs'], queryFn: getAdminSubscriptions }) const { data: subs } = useQuery({ queryKey: ['admin-subs'], queryFn: getAdminSubscriptions })
const list = subs || [] const list = subs || []
const refresh = () => qc.invalidateQueries({ queryKey: ['admin-subs'] })
const change = async (id: number, status: string) => { await setAdminSubscriptionStatus(id, status).catch(() => {}); refresh() }
const cancel = async (id: number) => { if (window.confirm('Cancel this subscription? It cannot be resumed.')) await change(id, 'CANCELLED') }
return ( return (
<div> <div>
<div className="flex items-center gap-2 mb-5"><Repeat className="text-brand" size={22} /><h1 className="text-xl font-bold">Subscriptions</h1></div> <div className="flex items-center gap-2 mb-5"><Repeat className="text-brand" size={22} /><h1 className="text-xl font-bold">Subscriptions</h1></div>
<div className="bg-card border border-edge rounded-xl overflow-hidden"> <div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel"> <thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3">Customer</th><th className="text-left">Product</th><th className="text-left">Frequency</th><th className="text-left">Next Delivery</th><th className="text-right">Amount</th><th className="text-center">Status</th> <th className="text-left p-3">Customer</th><th className="text-left">Product</th><th className="text-left">Frequency</th><th className="text-left">Next Delivery</th><th className="text-right">Amount</th><th className="text-center">Status</th><th className="text-center">Actions</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{list.map((s: any) => ( {list.map((s: any) => (
@ -25,9 +30,23 @@ export default function Subscriptions() {
<td className="text-slate-400">{s.nextDeliveryDate || '-'}</td> <td className="text-slate-400">{s.nextDeliveryDate || '-'}</td>
<td className="text-right">{money(s.price)}</td> <td className="text-right">{money(s.price)}</td>
<td className="text-center"><StatusBadge status={s.status} /></td> <td className="text-center"><StatusBadge status={s.status} /></td>
<td className="text-center">
<div className="inline-flex gap-1">
{s.status === 'ACTIVE' && (
<button onClick={() => change(s.id, 'PAUSED')} className="text-xs bg-amber-500/15 text-amber-400 px-2 py-1 rounded inline-flex items-center gap-1"><Pause size={12} /> Pause</button>
)}
{s.status === 'PAUSED' && (
<button onClick={() => change(s.id, 'ACTIVE')} className="text-xs bg-emerald-500/15 text-emerald-400 px-2 py-1 rounded inline-flex items-center gap-1"><Play size={12} /> Resume</button>
)}
{s.status !== 'CANCELLED' && (
<button onClick={() => cancel(s.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded inline-flex items-center gap-1"><Ban size={12} /> Cancel</button>
)}
{s.status === 'CANCELLED' && <span className="text-xs text-slate-500"></span>}
</div>
</td>
</tr> </tr>
))} ))}
{!list.length && <tr><td colSpan={6} className="text-center text-slate-500 py-8">No subscriptions found.</td></tr>} {!list.length && <tr><td colSpan={7} className="text-center text-slate-500 py-8">No subscriptions found.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { UserCog, Plus } from 'lucide-react' import { UserCog, Plus } from 'lucide-react'
import { getUsers, createUser, updateUserRole, updateUserActive, resetPassword, deleteUser } from '../api/client' import { getUsers, createUser, updateUserRole, updateUserActive, resetPassword, deleteUser, adminOtpReset } from '../api/client'
const ROLES = ['USER', 'MANAGER', 'ADMIN'] const ROLES = ['USER', 'MANAGER', 'ADMIN']
@ -17,6 +17,8 @@ export default function UserManagement() {
const toggleActive = async (id: number, active: boolean) => { await updateUserActive(id, !active).catch(() => {}); refresh() } const toggleActive = async (id: number, active: boolean) => { await updateUserActive(id, !active).catch(() => {}); refresh() }
const reset = async (id: number) => { const pw = prompt('New password'); if (pw) { await resetPassword(id, pw).catch(() => {}); alert('Password updated') } } const reset = async (id: number) => { const pw = prompt('New password'); if (pw) { await resetPassword(id, pw).catch(() => {}); alert('Password updated') } }
const del = async (id: number) => { if (confirm('Are you sure you want to delete?')) { await deleteUser(id).catch(() => {}); refresh() } } const del = async (id: number) => { if (confirm('Are you sure you want to delete?')) { await deleteUser(id).catch(() => {}); refresh() } }
// OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 시 재등록). 시크릿 미조회.
const otpReset = async (u: any) => { if (confirm(`'${u.username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`)) { await adminOtpReset(u.id).catch(() => {}); alert('OTP를 초기화했습니다.'); refresh() } }
return ( return (
<div> <div>
@ -53,6 +55,7 @@ export default function UserManagement() {
<td className="text-center"> <td className="text-center">
<div className="inline-flex gap-1"> <div className="inline-flex gap-1">
<button onClick={() => reset(u.id)} className="text-xs bg-panel border border-edge px-2 py-1 rounded text-slate-300">Password</button> <button onClick={() => reset(u.id)} className="text-xs bg-panel border border-edge px-2 py-1 rounded text-slate-300">Password</button>
<button onClick={() => otpReset(u)} className="text-xs bg-panel border border-edge px-2 py-1 rounded text-slate-300" title="OTP 초기화">OTP Reset</button>
<button onClick={() => del(u.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded">Delete</button> <button onClick={() => del(u.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded">Delete</button>
</div> </div>
</td> </td>

View File

@ -36,6 +36,14 @@ export const register = (username: string, password: string, displayName: string
api.post('/api/mall/auth/register', { username, password, displayName }) api.post('/api/mall/auth/register', { username, password, displayName })
export const getMe = () => u(api.get('/api/mall/auth/me')) export const getMe = () => u(api.get('/api/mall/auth/me'))
/* : OTP 2 (//) + (, access )
* 불변: otpSetup (secret/qrImage/otpAuthUri) / . */
export const otpSetup = () => u<{ secret: string; otpAuthUri: string; qrImage: string }>(api.post('/api/mall/auth/otp/setup', {}))
export const otpConfirm = (code: string) => u(api.post('/api/mall/auth/otp/confirm', { code }))
export const otpDisable = () => u(api.post('/api/mall/auth/otp/disable', {}))
export const changePassword = (currentPassword: string, newPassword: string) =>
u(api.post('/api/mall/auth/change-password', { currentPassword, newPassword }))
/* ───────────── 1. 매장 Store ───────────── */ /* ───────────── 1. 매장 Store ───────────── */
export const getStores = (activeOnly = true) => u(api.get(`/api/mall/store?activeOnly=${activeOnly}`)) export const getStores = (activeOnly = true) => u(api.get(`/api/mall/store?activeOnly=${activeOnly}`))
export const getStore = (id: number) => u(api.get(`/api/mall/store/${id}`)) export const getStore = (id: number) => u(api.get(`/api/mall/store/${id}`))
@ -112,6 +120,8 @@ export const getSubscriptions = () => u(api.get('/api/mall/subscription'))
export const createSubscription = (d: object) => u(api.post('/api/mall/subscription', d)) export const createSubscription = (d: object) => u(api.post('/api/mall/subscription', d))
export const setSubscriptionStatus = (id: number, status: string) => u(api.put(`/api/mall/subscription/${id}/status`, { status })) export const setSubscriptionStatus = (id: number, status: string) => u(api.put(`/api/mall/subscription/${id}/status`, { status }))
export const getAdminSubscriptions = () => u(api.get('/api/mall/subscription/admin')) export const getAdminSubscriptions = () => u(api.get('/api/mall/subscription/admin'))
// 관리자(MANAGER+) 구독 상태 변경 — 소유자 제한 없음
export const setAdminSubscriptionStatus = (id: number, status: string) => u(api.put(`/api/mall/subscription/admin/${id}/status`, { status }))
/* ───────────── 10. 매장간 재고 이양 ───────────── */ /* ───────────── 10. 매장간 재고 이양 ───────────── */
export const getTransfers = (status = '', storeId = '') => { export const getTransfers = (status = '', storeId = '') => {
@ -139,6 +149,11 @@ export const deleteReview = (id: number) => api.delete(`/api/mall/review/${id}`)
export const getMember = () => u(api.get('/api/mall/member/me')) export const getMember = () => u(api.get('/api/mall/member/me'))
export const updateMember = (d: object) => u(api.put('/api/mall/member/me', d)) export const updateMember = (d: object) => u(api.put('/api/mall/member/me', d))
export const getMemberInsight = () => u(api.get('/api/mall/member/me/insight')) export const getMemberInsight = () => u(api.get('/api/mall/member/me/insight'))
// 관리자 회원 목록/검색 (PII 마스킹 응답). items/total 래핑.
export const getAdminMembers = (keyword = '', tier = '', limit = 100) => {
const p = new URLSearchParams(); if (keyword) p.set('keyword', keyword); if (tier) p.set('tier', tier); p.set('limit', String(limit))
return u(api.get(`/api/mall/member/admin?${p}`))
}
/* ───────────── 14. CS 문의 ───────────── */ /* ───────────── 14. CS 문의 ───────────── */
export const getMyCs = () => u(api.get('/api/mall/cs')) export const getMyCs = () => u(api.get('/api/mall/cs'))
@ -189,6 +204,8 @@ export const updateUserRole = (id: number, role: string) => u(api.put(`/api/admi
export const updateUserActive = (id: number, active: boolean) => u(api.put(`/api/admin/users/${id}/active`, { active })) export const updateUserActive = (id: number, active: boolean) => u(api.put(`/api/admin/users/${id}/active`, { active }))
export const resetPassword = (id: number, password: string) => u(api.put(`/api/admin/users/${id}/password`, { password })) export const resetPassword = (id: number, password: string) => u(api.put(`/api/admin/users/${id}/password`, { password }))
export const deleteUser = (id: number) => api.delete(`/api/admin/users/${id}`) export const deleteUser = (id: number) => api.delete(`/api/admin/users/${id}`)
// 관리자 OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 시 재등록). 시크릿 미조회.
export const adminOtpReset = (id: number) => u(api.post(`/api/admin/users/${id}/otp-reset`, {}))
export const getAuditLogs = (action = '', actor = '', limit = 100) => { export const getAuditLogs = (action = '', actor = '', limit = 100) => {
const p = new URLSearchParams(); if (action) p.set('action', action); if (actor) p.set('actor', actor); p.set('limit', String(limit)) const p = new URLSearchParams(); if (action) p.set('action', action); if (actor) p.set('actor', actor); p.set('limit', String(limit))
return u(api.get(`/api/admin/audit?${p}`)) return u(api.get(`/api/admin/audit?${p}`))
@ -234,3 +251,20 @@ export const deleteEvent = (id: number) => api.delete(`/api/mall/event/${id}`)
export const getEventPerformance = (id: number) => u(api.get(`/api/mall/event/${id}/performance`)) export const getEventPerformance = (id: number) => u(api.get(`/api/mall/event/${id}/performance`))
export const aiEventCopy = (eventType: string, theme: string, tone: string) => export const aiEventCopy = (eventType: string, theme: string, tone: string) =>
u(api.post('/api/mall/event/ai/copy', { eventType, theme, tone })) u(api.post('/api/mall/event/ai/copy', { eventType, theme, tone }))
/* ───────────── 23. 최신 AI 기법(중앙 guardia-rag) ───────────── */
export interface RagToggles {
ragEnabled: boolean; ragAvailable: boolean; retrievalMode: string
rerank: boolean; graphrag: boolean; toolUse: boolean; structured: boolean; stream: boolean
topK: number; agentMaxSteps: number; faithfulnessThreshold: number; temperature: number; generationModel: string
}
export const getRagToggles = () => u<RagToggles>(api.get('/api/mall/rag/toggles'))
export const updateRagToggle = (key: string, value: string | number | boolean) =>
u<RagToggles>(api.put(`/api/mall/rag/toggles/${key}`, { value }))
// 추천·자연어 검색(/answer hybrid + /structured, 근거+보류) — 인증 사용자
export const ragRecommend = (req: object) => u(api.post('/api/mall/rag/recommend', req))
// WISE AI 일반 지식 질의(Q&A) — /rag/answer 근거·인용·보류. 인증 사용자
export const ragAsk = (query: string) => u(api.post('/api/mall/rag/ask', { query }))
// 수요예측·재고이양 추천(/agent tool-use, 승인 게이트) — MANAGER+
export const ragDemandPlan = (req: object) => u(api.post('/api/mall/rag/demand-plan', req))
export const ragFeedback = (req: object) => u(api.post('/api/mall/rag/feedback', req))