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:
parent
870541448a
commit
84c3000411
@ -27,10 +27,14 @@
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
|
||||
<dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>${mybatis.version}</version></dependency>
|
||||
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
|
||||
<!-- 로컬 임베디드 학습 저장소(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-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>${springdoc.version}</version></dependency>
|
||||
<!-- 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.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency>
|
||||
|
||||
@ -56,6 +56,12 @@ public class AdminController {
|
||||
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}")
|
||||
public ApiResponse<Void> deleteUser(@PathVariable Long id, Authentication auth) {
|
||||
String currentUsername = auth != null ? auth.getName() : null;
|
||||
|
||||
@ -83,6 +83,14 @@ public class AdminUserService {
|
||||
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) {
|
||||
MallUser user = require(id);
|
||||
if (user.getUsername().equals(currentUsername)) {
|
||||
|
||||
@ -22,6 +22,9 @@ public interface AdminUserMapper {
|
||||
|
||||
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 countAdmins();
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
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.product.MallProduct;
|
||||
import com.zioinfo.mall.product.mapper.ProductMapper;
|
||||
@ -22,11 +24,20 @@ import java.util.*;
|
||||
@RequiredArgsConstructor
|
||||
public class MallAiService {
|
||||
|
||||
private final OllamaClient ollama;
|
||||
private final AiTextRouter aiRouter; // provider 라우팅(Claude↔Ollama) + infer 로그. 실패 시 아래 Java 폴백.
|
||||
private final ProductMapper productMapper;
|
||||
private final ReviewMapper reviewMapper;
|
||||
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 실패 시 인기/평점 폴백. */
|
||||
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);
|
||||
@ -37,7 +48,7 @@ public class MallAiService {
|
||||
String prompt = "You are a florist recommender. From this catalog: [" + names + "]. "
|
||||
+ "Recommend up to " + limit + " bouquets for occasion='" + (occasion == null ? "any" : occasion)
|
||||
+ "' keyword='" + (keyword == null ? "" : keyword) + "'. Reply ONLY product names comma-separated.";
|
||||
String ai = ollama.generate(prompt);
|
||||
String ai = aiGenerate(prompt);
|
||||
if (ai != null && !ai.isBlank()) {
|
||||
List<MallProduct> ordered = reorderByAi(pool, ai);
|
||||
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.join("\n", contents);
|
||||
String ai = ollama.generate(prompt);
|
||||
String ai = aiGenerate(prompt);
|
||||
if (ai != null && !ai.isBlank()) {
|
||||
out.put("summary", ai);
|
||||
out.put("source", "ollama");
|
||||
@ -89,7 +100,7 @@ public class MallAiService {
|
||||
public String csAutoReply(String subject, String content) {
|
||||
String prompt = "You are a polite flower-shop customer support agent. Write a short helpful reply (<=4 sentences) to:\n"
|
||||
+ "Subject: " + subject + "\nMessage: " + content;
|
||||
String ai = ollama.generate(prompt);
|
||||
String ai = aiGenerate(prompt);
|
||||
if (ai != null && !ai.isBlank()) return ai;
|
||||
return "Thank you for reaching out about \"" + subject + "\". We're sorry for any inconvenience. "
|
||||
+ "Our team is reviewing your request and will follow up shortly. "
|
||||
@ -121,7 +132,7 @@ public class MallAiService {
|
||||
}
|
||||
String prompt = "Compose a creative 'Daily Standard' bouquet name and 1-line description using surplus flowers: ["
|
||||
+ String.join(", ", surplus) + "]. Reply as: NAME | DESCRIPTION.";
|
||||
String ai = ollama.generate(prompt);
|
||||
String ai = aiGenerate(prompt);
|
||||
if (ai != null && !ai.isBlank()) {
|
||||
out.put("bouquet", ai);
|
||||
out.put("source", "ollama");
|
||||
@ -165,7 +176,7 @@ public class MallAiService {
|
||||
String prompt = "Write 3 short flower-card messages for occasion='" + occasion
|
||||
+ "' tone='" + (tone == null ? "warm" : tone) + "' recipient='" + (recipient == null ? "" : recipient)
|
||||
+ "'. One per line, no numbering.";
|
||||
String ai = ollama.generate(prompt);
|
||||
String ai = aiGenerate(prompt);
|
||||
if (ai != null && !ai.isBlank()) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
for (String l : ai.split("\n")) {
|
||||
|
||||
@ -30,20 +30,33 @@ public class OllamaClient {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
/** 기본 모델(guardia.ollama-text-model)로 생성. 실패 시 빈 문자열. */
|
||||
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 {
|
||||
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()
|
||||
.post().uri("/api/generate").bodyValue(body)
|
||||
.retrieve().bodyToMono(Map.class)
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.timeout(Duration.ofSeconds(120))
|
||||
.map(m -> (Map<String, Object>) m).block();
|
||||
if (res == null) return "";
|
||||
Object r = res.get("response");
|
||||
return r == null ? "" : String.valueOf(r).trim();
|
||||
} catch (Exception e) {
|
||||
log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getMessage());
|
||||
log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getClass().getSimpleName());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,28 +1,56 @@
|
||||
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.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 org.springframework.web.bind.annotation.*;
|
||||
|
||||
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
|
||||
@RequestMapping("/api/mall/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final OtpAuthService otpAuthService;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
|
||||
String token = authService.login(req.username(), req.password());
|
||||
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
|
||||
return ApiResponse.ok(authService.login(req.username(), req.password()));
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ApiResponse<Map<String, String>> register(@RequestBody RegisterRequest req) {
|
||||
String token = authService.register(req.username(), req.password(), req.displayName());
|
||||
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
|
||||
return ApiResponse.ok(authService.register(req.username(), req.password(), req.displayName()));
|
||||
}
|
||||
|
||||
/** 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")
|
||||
@ -31,6 +59,51 @@ public class AuthController {
|
||||
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 RegisterRequest(String username, String password, String displayName) {}
|
||||
record VerifyRequest(String verifyToken, String code) {}
|
||||
}
|
||||
|
||||
@ -1,33 +1,121 @@
|
||||
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.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.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
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
|
||||
@RequiredArgsConstructor
|
||||
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 PasswordEncoder passwordEncoder;
|
||||
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);
|
||||
|
||||
// 잠금 우선 차단(존재하는 운영 계정에 한해 — 존재 여부 누설 최소화)
|
||||
if (user != null && isOperationsRole(user.getRole()) && twoFactorService.isLocked(user)) {
|
||||
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
|
||||
}
|
||||
if (user == null || !user.isActive()) {
|
||||
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
|
||||
}
|
||||
// 회원가입 승인 게이트(로그인 보조 이식): signup 으로 가입한 운영자(approved=false)는 비번 일치 전 차단.
|
||||
// approved 가 NULL(기존 계정/고객 register 흐름)이면 게이트 미적용 → 회귀 0.
|
||||
if (isOperationsRole(user.getRole()) && Boolean.FALSE.equals(user.getApproved())) {
|
||||
throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 이용 가능합니다.");
|
||||
}
|
||||
|
||||
boolean opsRole = isOperationsRole(user.getRole());
|
||||
// 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: 비밀번호 불일치");
|
||||
}
|
||||
return jwtUtil.generate(username, user.getRole());
|
||||
|
||||
// 비밀번호 검증 통과 — 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 역할로 생성. */
|
||||
public String register(String username, String password, String displayName) {
|
||||
/** 고객 셀프 회원가입 — 항상 USER 역할로 생성(2FA 미적용 대상). */
|
||||
public Map<String, String> register(String username, String password, String displayName) {
|
||||
if (username == null || username.isBlank() || password == null || password.isBlank()) {
|
||||
throw new IllegalArgumentException("ERR-AUTH-400: username/password 필수");
|
||||
}
|
||||
@ -41,7 +129,8 @@ public class AuthService {
|
||||
user.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName);
|
||||
user.setActive(true);
|
||||
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) {
|
||||
@ -49,4 +138,122 @@ public class AuthService {
|
||||
String role = jwtUtil.getRole(token);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,7 +26,10 @@ public class JwtFilter extends OncePerRequestFilter {
|
||||
String header = req.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
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 role = jwtUtil.getRole(token);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
|
||||
@ -34,6 +34,47 @@ public class JwtUtil {
|
||||
.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) {
|
||||
return Jwts.parser().verifyWith(key()).build()
|
||||
.parseSignedClaims(token).getPayload();
|
||||
|
||||
@ -3,7 +3,7 @@ package com.zioinfo.mall.auth;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 계정 (mall_account). 역할: ADMIN/MANAGER/USER(고객). */
|
||||
/** 계정 (mall_account). 역할: ADMIN/MANAGER(운영) · USER(고객). */
|
||||
@Data
|
||||
public class MallUser {
|
||||
private Long id;
|
||||
@ -13,4 +13,28 @@ public class MallUser {
|
||||
private String displayName;
|
||||
private boolean active;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -3,7 +3,14 @@ package com.zioinfo.mall.auth.mapper;
|
||||
import com.zioinfo.mall.auth.MallUser;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
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
|
||||
public interface UserMapper {
|
||||
|
||||
@ -12,4 +19,81 @@ public interface UserMapper {
|
||||
int insert(MallUser user);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -71,6 +71,10 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/admin/audit").hasAnyRole("ADMIN", "MANAGER")
|
||||
.requestMatchers(HttpMethod.GET, "/api/admin/settings").hasAnyRole("ADMIN", "MANAGER")
|
||||
.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 이상
|
||||
.requestMatchers("/api/mall/analytics/**").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")
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/mall/product/**", "/api/mall/category/**",
|
||||
"/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()
|
||||
// UIWS system(권한관리) 이식: 공개 룩업(부서/거래처 트리)은 무인증, 시스템관리 API 는 운영자(ADMIN/MANAGER)
|
||||
.requestMatchers("/api/public/**").permitAll()
|
||||
.requestMatchers("/api/system/**").hasAnyRole("ADMIN", "MANAGER")
|
||||
// 나머지 모든 API/WS/Actuator는 인증 (아래 SPA permit 보다 먼저 — API 노출 방지)
|
||||
.requestMatchers("/api/**", "/ws/**", "/actuator/**").authenticated()
|
||||
// 스토어프론트 SPA 딥링크(/app·/cart·/events·/category·/product·/checkout·/mypage·/orders·/search 등)
|
||||
|
||||
@ -5,10 +5,12 @@ import com.zioinfo.mall.integration.CrmClient;
|
||||
import com.zioinfo.mall.integration.ItsmSecuritySanitizer;
|
||||
import com.zioinfo.mall.member.mapper.MemberMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 회원 API — /api/mall/member. 본인 프로필 + CRM 인사이트 연계(새니타이즈). */
|
||||
@ -32,6 +34,27 @@ public class MemberController {
|
||||
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로 정제. */
|
||||
@GetMapping("/me/insight")
|
||||
public ApiResponse<Map<String, Object>> insight(Authentication auth) {
|
||||
|
||||
@ -1,11 +1,21 @@
|
||||
package com.zioinfo.mall.member.mapper;
|
||||
|
||||
import com.zioinfo.mall.member.MallMember;
|
||||
import com.zioinfo.mall.member.MallMemberSummary;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface MemberMapper {
|
||||
MallMember findByUsername(@Param("username") String username);
|
||||
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);
|
||||
}
|
||||
|
||||
@ -47,11 +47,33 @@ public class SubscriptionController {
|
||||
if (s == null || !s.getOwner().equals(auth.getName())) {
|
||||
throw new RuntimeException("ERR-SUB-403: 본인 구독만 변경할 수 있습니다");
|
||||
}
|
||||
String to = req.getOrDefault("status", "ACTIVE").toUpperCase();
|
||||
mapper.updateStatus(id, to);
|
||||
mapper.updateStatus(id, normalizeStatus(req.get("status")));
|
||||
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) {
|
||||
LocalDate base = LocalDate.now();
|
||||
if ("MONTHLY".equalsIgnoreCase(freq)) return base.plusMonths(1);
|
||||
|
||||
@ -8,12 +8,20 @@ spring:
|
||||
username: ${DB_USER:mall_user}
|
||||
password: ${DB_PASS:mall_pass2026}
|
||||
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:
|
||||
multipart:
|
||||
max-file-size: 20MB
|
||||
max-request-size: 20MB
|
||||
mybatis:
|
||||
mapper-locations: classpath:mapper/*.xml
|
||||
mapper-locations: classpath:mapper/**/*.xml # ** : 하위 mapper/uiws/*.xml(UIWS 이식) 포함
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
|
||||
@ -34,13 +42,34 @@ mall:
|
||||
provider: ${MALL_SMS_PROVIDER:mock} # mock | twilio
|
||||
email:
|
||||
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:
|
||||
itsm-url: ${ITSM_URL:http://localhost:9001}
|
||||
erp-url: ${ERP_URL:http://localhost:8003}
|
||||
crm-url: ${CRM_URL:http://localhost:8004}
|
||||
ocr-url: ${OCR_URL:http://localhost:8005}
|
||||
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:
|
||||
secret: ${CRYPTO_SECRET:guardia-mall-aes-256-gcm-master-key-2026-zioinfo}
|
||||
jwt:
|
||||
|
||||
22
backend/src/main/resources/db/202_ai_provider_claude.sql
Normal file
22
backend/src/main/resources/db/202_ai_provider_claude.sql
Normal 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
|
||||
@ -47,7 +47,20 @@ INSERT INTO mall_setting (key, value) VALUES
|
||||
('hours_saturday','Sat 9:00 AM - 4:00 PM'),
|
||||
('hours_sunday','Sun 9:00 AM - 12:00 PM'),
|
||||
('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;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mall_ai_result (
|
||||
|
||||
@ -32,6 +32,7 @@
|
||||
<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="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>
|
||||
|
||||
<select id="countAdmins" resultType="int">
|
||||
|
||||
@ -11,4 +11,44 @@
|
||||
display_name = EXCLUDED.display_name, email = EXCLUDED.email, phone = EXCLUDED.phone,
|
||||
default_zip = EXCLUDED.default_zip, default_address = EXCLUDED.default_address
|
||||
</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) < 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>
|
||||
|
||||
@ -11,10 +11,21 @@
|
||||
<result property="displayName" column="display_name"/>
|
||||
<result property="active" column="is_active"/>
|
||||
<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>
|
||||
|
||||
<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
|
||||
WHERE username = #{username}
|
||||
</select>
|
||||
@ -29,4 +40,42 @@
|
||||
SELECT COUNT(*) FROM mall_account WHERE username = #{username}
|
||||
</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>
|
||||
|
||||
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
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 |
@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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" type="image/png" sizes="32x32" href="/favicon-32.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"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script type="module" crossorigin src="/assets/index-CUN395kM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BzL8NSpt.css">
|
||||
<script type="module" crossorigin src="/assets/index-Dj2NJ6H6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CWjUgOzG.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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" type="image/png" sizes="32x32" href="/favicon-32.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 |
@ -41,6 +41,24 @@ import UserManagement from './admin/UserManagement'
|
||||
import AuditLog from './admin/AuditLog'
|
||||
import Settings from './admin/Settings'
|
||||
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({
|
||||
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 30_000 } },
|
||||
@ -80,6 +98,7 @@ export default function App() {
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="mypage" element={<MyPage />} />
|
||||
<Route path="stores" element={<Stores />} />
|
||||
<Route path="products" element={<Products />} />
|
||||
<Route path="inventory" element={<Inventory />} />
|
||||
@ -94,7 +113,22 @@ export default function App() {
|
||||
<Route path="users" element={<UserManagement />} />
|
||||
<Route path="audit" element={<AuditLog />} />
|
||||
<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 />} />
|
||||
{/* 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 path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
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 {
|
||||
LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone,
|
||||
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'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getMe } from '../api/client'
|
||||
@ -28,6 +30,28 @@ const adminLinks = [
|
||||
{ to: '/admin/settings', key: 'settings', icon: Settings, roles: ['ADMIN'] },
|
||||
{ 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 }) =>
|
||||
`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 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 = () => {
|
||||
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>
|
||||
{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>
|
||||
<div className="p-4 text-[11px] text-slate-500 border-t border-edge">{t('admin.onPremiseTag')}</div>
|
||||
</aside>
|
||||
@ -84,7 +123,7 @@ export default function AdminLayout() {
|
||||
<div className="text-sm text-slate-400 truncate">{t('admin.header')}</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -2,57 +2,269 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { login, getMe } from '../api/client'
|
||||
import { verify2fa, verifyOtp, authSignup, authFindId, authResetPassword } from '../api/uiws'
|
||||
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() {
|
||||
const { t } = useTranslation()
|
||||
const [username, setUsername] = useState('admin')
|
||||
const [password, setPassword] = 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 isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
|
||||
const isSetup = verifyMethod === 'OTP_SETUP'
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('admin-shell')
|
||||
return () => document.documentElement.classList.remove('admin-shell')
|
||||
}, [])
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
/** 토큰 저장 + 역할 확인 후 진입(공통). */
|
||||
const finishLogin = async (token: string) => {
|
||||
localStorage.setItem('mall_admin_token', token)
|
||||
const me: any = await getMe().catch(() => null)
|
||||
if (me?.role) localStorage.setItem('mall_role', me.role)
|
||||
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 }
|
||||
nav('/admin/dashboard')
|
||||
}
|
||||
|
||||
const submitLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setErr('')
|
||||
try {
|
||||
const res = await login(username, password)
|
||||
const token = res.data?.data?.token
|
||||
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')
|
||||
// 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)
|
||||
const me: any = await getMe().catch(() => null)
|
||||
if (me?.role) localStorage.setItem('mall_role', me.role)
|
||||
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 }
|
||||
nav('/admin/dashboard')
|
||||
await finishLogin(token)
|
||||
} catch {
|
||||
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 (
|
||||
<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>
|
||||
<form onSubmit={submit} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
|
||||
<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' }} />
|
||||
<span className="text-xl font-bold">{t('admin.login.title')}</span>
|
||||
</div>
|
||||
<p className="text-center text-sm text-slate-400 mb-6">{t('admin.login.subtitle')}</p>
|
||||
<label className="block text-xs text-slate-400 mb-1">{t('admin.login.username')}</label>
|
||||
<input value={username} onChange={e => setUsername(e.target.value)}
|
||||
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
|
||||
<label className="block text-xs text-slate-400 mb-1">{t('admin.login.password')}</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||
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>}
|
||||
<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>
|
||||
</form>
|
||||
|
||||
{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">
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-center text-sm text-slate-400 mb-6">{t('admin.login.subtitle')}</p>
|
||||
<label className="block text-xs text-slate-400 mb-1">{t('admin.login.username')}</label>
|
||||
<input value={username} onChange={e => setUsername(e.target.value)}
|
||||
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
|
||||
<label className="block text-xs text-slate-400 mb-1">{t('admin.login.password')}</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||
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>}
|
||||
<button className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90">{t('admin.login.signIn')}</button>
|
||||
{/* 로그인 보조 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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Users, RefreshCw } from 'lucide-react'
|
||||
import { getLoyaltyByTier, recalcAllLoyalty } from '../api/client'
|
||||
import { Users, RefreshCw, Search } from 'lucide-react'
|
||||
import { getLoyaltyByTier, recalcAllLoyalty, getAdminMembers } from '../api/client'
|
||||
import Chart from '../components/Chart'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import { money } from '../store/shop'
|
||||
|
||||
const TIERS = ['', 'STANDARD', 'BASIC', 'SILVER', 'GOLD', 'VIP']
|
||||
|
||||
export default function Members() {
|
||||
const qc = useQueryClient()
|
||||
const [kw, setKw] = useState('')
|
||||
const [tier, setTier] = useState('')
|
||||
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 rows = (byTier || []).map((t: any) => ({ name: t.tier, customers: t.customers, sales: t.sales }))
|
||||
const list = members?.items || []
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -30,7 +37,8 @@ export default function Members() {
|
||||
</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">
|
||||
<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>
|
||||
@ -48,6 +56,46 @@ export default function Members() {
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,35 +1,184 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Flower2, Search } from 'lucide-react'
|
||||
import { getProducts, setProductStatus } from '../api/client'
|
||||
import { Flower2, Search, Plus, Pencil, Trash2, X } from 'lucide-react'
|
||||
import {
|
||||
getProducts, getProduct, getCategories, createProduct, updateProduct, deleteProduct, setProductStatus,
|
||||
} from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
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() {
|
||||
const qc = useQueryClient()
|
||||
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: cats } = useQuery({ queryKey: ['admin-cats'], queryFn: getCategories })
|
||||
const items = data?.items || []
|
||||
const categories = cats || []
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['admin-products'] })
|
||||
|
||||
const cycle = async (id: number, status: string) => {
|
||||
const next = status === 'ACTIVE' ? 'HIDDEN' : 'ACTIVE'
|
||||
await setProductStatus(id, next).catch(() => {}); qc.invalidateQueries({ queryKey: ['admin-products'] })
|
||||
const next = status === 'ON_SALE' ? 'HIDDEN' : 'ON_SALE'
|
||||
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 (
|
||||
<div>
|
||||
<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 bg-card 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="상품 검색" className="bg-transparent text-sm outline-none w-48" />
|
||||
<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">
|
||||
<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" />
|
||||
</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>
|
||||
|
||||
{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">
|
||||
<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">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>
|
||||
<tbody>
|
||||
{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-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">
|
||||
<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>
|
||||
))}
|
||||
{!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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -1,20 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Repeat } from 'lucide-react'
|
||||
import { getAdminSubscriptions } from '../api/client'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Repeat, Pause, Play, Ban } from 'lucide-react'
|
||||
import { getAdminSubscriptions, setAdminSubscriptionStatus } from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import { money } from '../store/shop'
|
||||
|
||||
export default function Subscriptions() {
|
||||
const qc = useQueryClient()
|
||||
const { data: subs } = useQuery({ queryKey: ['admin-subs'], queryFn: getAdminSubscriptions })
|
||||
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 (
|
||||
<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">
|
||||
<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">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>
|
||||
<tbody>
|
||||
{list.map((s: any) => (
|
||||
@ -25,9 +30,23 @@ export default function Subscriptions() {
|
||||
<td className="text-slate-400">{s.nextDeliveryDate || '-'}</td>
|
||||
<td className="text-right">{money(s.price)}</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>
|
||||
))}
|
||||
{!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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
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']
|
||||
|
||||
@ -17,6 +17,8 @@ export default function UserManagement() {
|
||||
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 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 (
|
||||
<div>
|
||||
@ -53,6 +55,7 @@ export default function UserManagement() {
|
||||
<td className="text-center">
|
||||
<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={() => 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>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@ -36,6 +36,14 @@ export const register = (username: string, password: string, displayName: string
|
||||
api.post('/api/mall/auth/register', { username, password, displayName })
|
||||
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 ───────────── */
|
||||
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}`))
|
||||
@ -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 setSubscriptionStatus = (id: number, status: string) => u(api.put(`/api/mall/subscription/${id}/status`, { status }))
|
||||
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. 매장간 재고 이양 ───────────── */
|
||||
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 updateMember = (d: object) => u(api.put('/api/mall/member/me', d))
|
||||
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 문의 ───────────── */
|
||||
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 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}`)
|
||||
// 관리자 OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 시 재등록). 시크릿 미조회.
|
||||
export const adminOtpReset = (id: number) => u(api.post(`/api/admin/users/${id}/otp-reset`, {}))
|
||||
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))
|
||||
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 aiEventCopy = (eventType: string, theme: string, tone: string) =>
|
||||
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))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user