fix(ai-modules): JSONB ::text 응답 자동 파싱 + workspace 카드 깨짐 수정

백엔드:
- 6개 AI Service (group/apiKey/provider/conversation/agent/scheduler) 가 응답 메서드에서
  `parseJsonField` 헬퍼로 JSONB(::text) 컬럼 (connectors / config / permissions /
  metadata / tool_calls / notification / tools) 을 String → Object 자동 변환.
- 모범 패턴 (`AuditLogService.processChanges`, `BusinessRuleService.parseJsonField`,
  `DataflowDiagramService.parseJsonbFields`) 동일하게 적용.
- model 의 String getter 는 그대로 유지 — `MultiAgentExecutionEngine` 등
  내부 LLM 호출 chain 영향 없음 (`getEntityById` 분리).
- 컨트롤러 시그니처 generic 만 변경 (return type Map).

프론트엔드:
- `safeArray<T>` / `safeObject<T>` 헬퍼 (`lib/utils.ts`) — 백엔드가 미파싱 String 으로
  올 때 graceful fallback. 빈 배열/객체 반환.
- `workspace/page.tsx` 멤버 카드:
  - `safeArray(member.connectors)` 적용 → `.map()` 폭발 차단.
  - 좁은 viewport 에서 한글 텍스트 한 글자씩 세로로 깨지던 문제 해결
    (`flex-wrap` + `truncate` + `whitespace-nowrap` + `max-w` + `title`).

그렘린 1000마리 폭격 + architect 자문으로 발견. workspace `Application error`,
`memberConnectors.map is not a function` 모두 해결.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 16:11:50 +09:00
parent 53f2638b82
commit 04cea72f33
10 changed files with 283 additions and 28 deletions
@@ -1,7 +1,6 @@
package com.erp.ai.controller;
import com.erp.ai.dto.ApiKeyCreateRequest;
import com.erp.ai.model.AiAgentApiKey;
import com.erp.ai.service.AiAgentApiKeyService;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
@@ -22,7 +21,7 @@ public class AiAgentApiKeyController {
private final AiAgentApiKeyService apiKeyService;
@GetMapping(value = {"", "/list"})
public ResponseEntity<ApiResponse<List<AiAgentApiKey>>> list(
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> list(
@RequestAttribute(value = "user_id", required = false) String userId,
@RequestAttribute(value = "role", required = false) String role) {
boolean isAdmin = "SUPER_ADMIN".equals(role) || "COMPANY_ADMIN".equals(role);
@@ -10,6 +10,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/ai-agents")
@@ -20,7 +21,7 @@ public class AiAgentController {
private final AiAgentService aiAgentService;
@GetMapping
public ResponseEntity<ApiResponse<List<AiAgent>>> list(
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> list(
@RequestAttribute(value = "company_code", required = false) String companyCode,
@RequestAttribute(value = "role", required = false) String role,
@RequestParam(required = false) String status,
@@ -34,8 +35,8 @@ public class AiAgentController {
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<AiAgent>> getById(@PathVariable long id) {
AiAgent agent = aiAgentService.getById(id);
public ResponseEntity<ApiResponse<Map<String, Object>>> getById(@PathVariable long id) {
Map<String, Object> agent = aiAgentService.getById(id);
if (agent == null) {
return ResponseEntity.status(404).body(ApiResponse.error("에이전트를 찾을 수 없습니다."));
}
@@ -17,8 +17,10 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
/**
* AI API 키 관리 서비스.
@@ -37,8 +39,41 @@ public class AiAgentApiKeyService {
@Qualifier("aiObjectMapper")
private final ObjectMapper objectMapper;
public List<AiAgentApiKey> list(String userId, boolean isAdmin) {
return isAdmin ? apiKeyMapper.listAll() : apiKeyMapper.listByUser(userId);
public List<Map<String, Object>> list(String userId, boolean isAdmin) {
List<AiAgentApiKey> keys = isAdmin ? apiKeyMapper.listAll() : apiKeyMapper.listByUser(userId);
return keys.stream().map(this::toResponseMap).toList();
}
private Map<String, Object> toResponseMap(AiAgentApiKey k) {
Map<String, Object> row = new HashMap<>();
row.put("id", k.getId());
row.put("name", k.getName());
row.put("key_prefix", k.getKey_prefix());
row.put("user_id", k.getUser_id());
row.put("company_code", k.getCompany_code());
row.put("agent_id", k.getAgent_id());
row.put("permissions", k.getPermissions());
row.put("rate_limit", k.getRate_limit());
row.put("monthly_token_limit", k.getMonthly_token_limit());
row.put("status", k.getStatus());
row.put("last_used_at", k.getLast_used_at());
row.put("usage_count", k.getUsage_count());
row.put("total_tokens", k.getTotal_tokens());
row.put("expires_at", k.getExpires_at());
row.put("created_at", k.getCreated_at());
parseJsonField(row, "permissions");
return row;
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try {
row.put(key, objectMapper.readValue(s, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
public AiAgentApiKey getById(long id) {
@@ -4,8 +4,10 @@ import com.erp.ai.mapper.AiAgentConversationMapper;
import com.erp.ai.mapper.AiAgentMessageMapper;
import com.erp.ai.model.AiAgentConversation;
import com.erp.ai.model.AiAgentMessage;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -26,12 +28,15 @@ public class AiAgentConversationService {
private final AiAgentConversationMapper conversationMapper;
private final AiAgentMessageMapper messageMapper;
@Qualifier("aiObjectMapper")
private final ObjectMapper objectMapper;
public Map<String, Object> list(int page, int limit, Long agentId) {
int offset = (page - 1) * limit;
List<AiAgentConversation> conversations = conversationMapper.list(agentId, limit, offset);
int total = conversationMapper.count(agentId);
Map<String, Object> result = new HashMap<>();
result.put("conversations", conversations);
result.put("conversations", conversations.stream().map(this::convToResponseMap).toList());
result.put("total", total);
return result;
}
@@ -42,11 +47,57 @@ public class AiAgentConversationService {
? messageMapper.listByConversationId(id)
: List.of();
Map<String, Object> result = new HashMap<>();
result.put("conversation", conv);
result.put("messages", messages);
result.put("conversation", conv != null ? convToResponseMap(conv) : null);
result.put("messages", messages.stream().map(this::messageToResponseMap).toList());
return result;
}
private Map<String, Object> convToResponseMap(AiAgentConversation c) {
Map<String, Object> row = new HashMap<>();
row.put("id", c.getId());
row.put("conversation_id", c.getConversation_id());
row.put("agent_id", c.getAgent_id());
row.put("user_id", c.getUser_id());
row.put("api_key_id", c.getApi_key_id());
row.put("title", c.getTitle());
row.put("message_count", c.getMessage_count());
row.put("total_tokens", c.getTotal_tokens());
row.put("status", c.getStatus());
row.put("metadata", c.getMetadata());
row.put("created_at", c.getCreated_at());
row.put("updated_at", c.getUpdated_at());
row.put("agent_name", c.getAgent_name());
row.put("display_name", c.getDisplay_name());
parseJsonField(row, "metadata");
return row;
}
private Map<String, Object> messageToResponseMap(AiAgentMessage m) {
Map<String, Object> row = new HashMap<>();
row.put("id", m.getId());
row.put("conversation_id", m.getConversation_id());
row.put("role", m.getRole());
row.put("content", m.getContent());
row.put("tool_calls", m.getTool_calls());
row.put("token_count", m.getToken_count());
row.put("metadata", m.getMetadata());
row.put("created_at", m.getCreated_at());
parseJsonField(row, "tool_calls");
parseJsonField(row, "metadata");
return row;
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try {
row.put(key, objectMapper.readValue(s, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
@Transactional
public AiAgentConversation createConversation(Long agentId, String userId, Long apiKeyId) {
AiAgentConversation conv = new AiAgentConversation();
@@ -53,14 +53,46 @@ public class AiAgentGroupService {
result.put("created_by", group.getCreated_by());
result.put("created_at", group.getCreated_at());
result.put("updated_at", group.getUpdated_at());
result.put("members", members);
result.put("members", members.stream().map(this::memberToResponseMap).toList());
return result;
}
/**
* AiAgentGroupMember → API 응답용 Map.
* JSONB(::text) 컬럼 connectors / config 를 Object 로 파싱.
* MultiAgentExecutionEngine 의 raw String 사용처는 model 을 직접 사용하므로 영향 없음.
*/
private Map<String, Object> memberToResponseMap(AiAgentGroupMember m) {
Map<String, Object> row = new HashMap<>();
row.put("id", m.getId());
row.put("group_id", m.getGroup_id());
row.put("agent_id", m.getAgent_id());
row.put("role_name", m.getRole_name());
row.put("connectors", m.getConnectors());
row.put("execution_order", m.getExecution_order());
row.put("config", m.getConfig());
row.put("created_at", m.getCreated_at());
row.put("updated_at", m.getUpdated_at());
row.put("agent_name", m.getAgent_name());
row.put("agent_model", m.getAgent_model());
parseJsonField(row, "connectors");
parseJsonField(row, "config");
return row;
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try {
row.put(key, objectMapper.readValue(s, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
public AiAgentGroup getEntityById(long id) {
AiAgentGroup g = groupMapper.getById(id);
if (g == null) return null;
return g;
return groupMapper.getById(id);
}
public AiAgentGroup getByGroupId(String groupId) {
@@ -5,8 +5,10 @@ import com.erp.ai.exception.AiAgentException;
import com.erp.ai.mapper.AiLlmProviderMapper;
import com.erp.ai.model.AiLlmProvider;
import com.erp.ai.util.AesGcmCipher;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -28,6 +30,9 @@ public class AiAgentProviderService {
private final AiLlmProviderMapper providerMapper;
private final AesGcmCipher cipher;
@Qualifier("aiObjectMapper")
private final ObjectMapper objectMapper;
/** 키 마스킹된 목록 */
public List<Map<String, Object>> list() {
return providerMapper.listAll().stream()
@@ -35,7 +40,14 @@ public class AiAgentProviderService {
.toList();
}
public AiLlmProvider getById(long id) {
public Map<String, Object> getById(long id) {
AiLlmProvider p = providerMapper.getById(id);
if (p == null) return null;
return toMaskedMap(p);
}
/** 내부용 — model 원본 반환 (getActiveProviders, LLM 호출 경로) */
public AiLlmProvider getEntityById(long id) {
return providerMapper.getById(id);
}
@@ -121,6 +133,18 @@ public class AiAgentProviderService {
// 마스킹: 마지막 4글자만 노출
String enc = p.getApi_key_encrypted();
m.put("api_key_masked", enc != null && enc.length() >= 4 ? "****" + enc.substring(enc.length() - 4) : "");
parseJsonField(m, "config");
return m;
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try {
row.put(key, objectMapper.readValue(s, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
}
@@ -30,15 +30,22 @@ public class AiAgentService {
@Qualifier("aiObjectMapper")
private final ObjectMapper objectMapper;
public List<AiAgent> list(String status, String companyCode, String search) {
public List<Map<String, Object>> list(String status, String companyCode, String search) {
Map<String, Object> filters = new HashMap<>();
if (status != null) filters.put("status", status);
if (companyCode != null) filters.put("company_code", companyCode);
if (search != null && !search.isBlank()) filters.put("search", "%" + search + "%");
return agentMapper.list(filters);
return agentMapper.list(filters).stream().map(this::toResponseMap).toList();
}
public AiAgent getById(long id) {
public Map<String, Object> getById(long id) {
AiAgent agent = agentMapper.getById(id);
if (agent == null) return null;
return toResponseMap(agent);
}
/** 내부용 — model 원본 반환 (MultiAgentExecutionEngine 등 LLM 호출 경로는 agentMapper 직접 사용) */
public AiAgent getEntityById(long id) {
return agentMapper.getById(id);
}
@@ -46,6 +53,37 @@ public class AiAgentService {
return agentMapper.getByAgentId(agentId);
}
private Map<String, Object> toResponseMap(AiAgent a) {
Map<String, Object> row = new HashMap<>();
row.put("id", a.getId());
row.put("agent_id", a.getAgent_id());
row.put("name", a.getName());
row.put("description", a.getDescription());
row.put("model", a.getModel());
row.put("system_prompt", a.getSystem_prompt());
row.put("tools", a.getTools());
row.put("config", a.getConfig());
row.put("status", a.getStatus());
row.put("company_code", a.getCompany_code());
row.put("created_by", a.getCreated_by());
row.put("created_at", a.getCreated_at());
row.put("updated_at", a.getUpdated_at());
parseJsonField(row, "tools");
parseJsonField(row, "config");
return row;
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try {
row.put(key, objectMapper.readValue(s, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
@Transactional
public AiAgent create(AgentRequest req, String userId) {
if (req.getAgent_id() == null || req.getAgent_id().isBlank()) {
@@ -24,7 +24,9 @@ import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;
/**
@@ -66,14 +68,53 @@ public class AiSchedulerService {
}
}
public List<AiAgentSchedule> list() {
return scheduleMapper.listAll();
public List<Map<String, Object>> list() {
return scheduleMapper.listAll().stream().map(this::toResponseMap).toList();
}
public AiAgentSchedule getById(long id) {
public Map<String, Object> getById(long id) {
AiAgentSchedule s = scheduleMapper.getById(id);
if (s == null) return null;
return toResponseMap(s);
}
/** 내부용 — model 원본 반환 (Quartz 등록 경로) */
public AiAgentSchedule getEntityById(long id) {
return scheduleMapper.getById(id);
}
private Map<String, Object> toResponseMap(AiAgentSchedule s) {
Map<String, Object> row = new HashMap<>();
row.put("id", s.getId());
row.put("name", s.getName());
row.put("group_id", s.getGroup_id());
row.put("cron_expression", s.getCron_expression());
row.put("timezone", s.getTimezone());
row.put("input_message", s.getInput_message());
row.put("notification", s.getNotification());
row.put("is_active", s.getIs_active());
row.put("last_run_at", s.getLast_run_at());
row.put("run_count", s.getRun_count());
row.put("company_code", s.getCompany_code());
row.put("created_by", s.getCreated_by());
row.put("created_at", s.getCreated_at());
row.put("updated_at", s.getUpdated_at());
row.put("group_name", s.getGroup_name());
parseJsonField(row, "notification");
return row;
}
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try {
row.put(key, objectMapper.readValue(s, Object.class));
} catch (Exception e) {
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
}
}
}
@Transactional
public AiAgentSchedule create(ScheduleRequest req, String userId) {
if (!CronExpression.isValidExpression(req.getCron_expression())) {
@@ -13,6 +13,7 @@ import { toast } from "sonner";
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { safeArray } from "@/lib/utils";
const CONNECTOR_ICONS: Record<string, any> = { database: Database, rest_api: Globe, file: FileText, plc: Cpu, crawler: Bug };
const CONNECTOR_COLORS: Record<string, string> = { database: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400", rest_api: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", file: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", plc: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400", crawler: "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400" };
@@ -595,7 +596,7 @@ const CONNECTOR_TYPE_OPTIONS = [
function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRemoveConnector }: MemberCardProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedType, setSelectedType] = useState<string | null>(null);
const memberConnectors = member.connectors || [];
const memberConnectors = safeArray<any>(member.connectors);
const filteredConnectors = selectedType
? connectors.filter((c) => c.type === selectedType)
@@ -603,14 +604,14 @@ function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRem
return (
<div>
<div className="flex items-start justify-between">
<div className="flex items-start justify-between gap-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<Bot className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-semibold">{member.role_name}</span>
<Badge variant="outline" className="h-4 text-[9px]">{member.agent_name || "에이전트"}</Badge>
<div className="flex flex-wrap items-center gap-1 min-w-0">
<Bot className="h-3.5 w-3.5 text-primary shrink-0" />
<span className="text-xs font-semibold truncate min-w-0 max-w-full" title={member.role_name}>{member.role_name}</span>
<Badge variant="outline" className="h-4 text-[9px] shrink-0 max-w-[140px] truncate inline-block whitespace-nowrap" title={member.agent_name}>{member.agent_name || "에이전트"}</Badge>
</div>
<p className="mt-0.5 ml-5 text-[10px] text-muted-foreground font-mono">{member.agent_model}</p>
<p className="mt-0.5 ml-5 text-[10px] text-muted-foreground font-mono truncate" title={member.agent_model}>{member.agent_model}</p>
</div>
<button className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive hover:bg-destructive/10" onClick={onRemove}>
<Trash2 className="h-3 w-3" />
+33
View File
@@ -5,6 +5,39 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* 백엔드 JSONB(::text) 응답이 String 으로 올 때 안전하게 array 로 변환.
* INVYONE 백엔드의 모든 ::text AS jsonb 컬럼이 가끔 미파싱 String 으로 옵니다.
*/
export const safeArray = <T = unknown>(v: unknown): T[] => {
if (Array.isArray(v)) return v as T[];
if (typeof v === "string") {
try {
const p = JSON.parse(v);
return Array.isArray(p) ? (p as T[]) : [];
} catch {
return [];
}
}
return [];
};
/**
* JSONB string → object 안전 변환. array/null/undefined → 빈 객체.
*/
export const safeObject = <T extends Record<string, unknown> = Record<string, unknown>>(v: unknown): T => {
if (v && typeof v === "object" && !Array.isArray(v)) return v as T;
if (typeof v === "string") {
try {
const p = JSON.parse(v);
return p && typeof p === "object" && !Array.isArray(p) ? (p as T) : ({} as T);
} catch {
return {} as T;
}
}
return {} as T;
};
/**
* 파일 크기를 사람이 읽기 쉬운 형태로 포맷팅
*/