refs #702: isolate HMM backoffice branch and restore full menus

This commit is contained in:
devmrko
2026-07-22 15:11:39 +09:00
parent 572dcd9a67
commit adfd50d1fd
7 changed files with 211 additions and 60 deletions

View File

@@ -1,7 +1,7 @@
# HMM 백오피스 사용자·그룹·역할·토큰 관리 전환 (#702)
> 상태: Implementing
> 추적: Redmine #702
> 추적: Redmine #702 / Git 브랜치: `hmm-backoffice`
> 대상: `hmm-backoffice.cloud-handson.com`, HMMAIPOC / `ADMIN`
## 목적
@@ -20,6 +20,8 @@
| 역할 | `HMM_ACCESS_ROLES`, `HMM_EMPLOYEE_ACCESS_ROLES`, `HMM_GROUP_ACCESS_ROLES` | 직원 직접 역할과 그룹 상속 역할을 분리 |
| 토큰 | `HMM_ACCESS_BEARER_TOKENS` | 원문은 발급 화면에서 한 번만 표시하고 SHA-256 해시만 저장 |
| 감사 | `HMM_ACCESS_AUDIT` | 사용자·그룹·역할·토큰 변경 이력 저장 |
| 지식 문서 | `HMM_KNOWLEDGE_DOCUMENTS` | 파일명·원문·BLOB·Abstract·생성일자를 저장 |
| 지식 청크 | `HMM_KNOWLEDGE_CHUNKS`, `HMM_KNOWLEDGE_TAGS` | 문서별 청크와 정규화된 Tag·벡터를 저장 |
`HMM_ORG_TEAMS`는 인사 조직 원장이므로 접근 그룹과 혼합하지 않는다. 이로써 한 직원이 여러
업무 접근 그룹에 속하면서도 인사 소속팀은 하나로 유지된다.
@@ -35,6 +37,8 @@ HMM_HR_EMPLOYEES ──< HMM_EMPLOYEE_ACCESS_ROLES >── HMM_ACCESS_ROLES
HMM_HR_EMPLOYEES ──< HMM_ACCESS_BEARER_TOKENS
HMM_ACCESS_AUDIT records every backoffice change
HMM_KNOWLEDGE_DOCUMENTS ──< HMM_KNOWLEDGE_CHUNKS ──< HMM_KNOWLEDGE_TAGS
```
## 화면 및 호환성
@@ -43,15 +47,29 @@ HMM_ACCESS_AUDIT records every backoffice change
`employment_status``ACTIVE`/`INACTIVE`에 매핑한다.
- `/groups``HMM_ACCESS_GROUPS`를 관리한다. HR 팀 이동 기능으로 오해되지 않도록
논리 접근 그룹임을 화면에 명시한다.
- `/roles`는 HMM 접근 역할만 관리한다. 기존 VPD 객체 권한 규칙은 이번 범위에서 노출하지 않는다.
- `/roles`는 HMM 접근 역할만 관리한다.
- `/tokens`는 HMM 직원에게 토큰을 발급·회수한다. KB 이해당사자 원장은 참조하지 않는다.
- `/objects`, `/permissions`, `/masking-rules`, `/probe`, MCP 화면은 삭제하지 않는다. 기존 CB 화면
계약은 `CB_PROTECTED_*`, `CB_PERMISSION*`, `CB_VECTOR_*` 호환 뷰로 유지하고, 실제 데이터는
`HMM_ACCESS_*``HMM_KNOWLEDGE_*`에 저장한다.
- 애플리케이션 시작 시 `HmmKnowledgeSchemaInitializer`가 HMM 지식 테이블·시퀀스·인덱스를
멱등적으로 준비하고 `CB_VECTOR_*` 조회 호환 뷰를 갱신한다. 기존 물리 `CB_VECTOR_*` 테이블이
존재하는 환경은 덮어쓰지 않는다.
## 브랜치·배포 기준
- Smilegate 기준은 `main`이며, HMM 백오피스의 구현·배포·검증은 `hmm-backoffice` 브랜치만 사용한다.
- HMM 배포 전에 현재 브랜치명을 확인하고, `main` 또는 `hmm-ai-agent` 브랜치에서는 HMM 백오피스
JAR를 배포하지 않는다.
- 메뉴 전수 점검은 로그인 세션으로 원래 메뉴 URL 전체를 순회한다. HTTP 200만으로 통과시키지 않고
화면 내 `ORA-`, `데이터 처리 오류`, `Whitelabel Error Page`도 함께 검사한다.
## 보안·검증
- 토큰 원문, DB 비밀번호, Wallet은 테이블·Git·로그에 저장하지 않는다.
- DDL은 재실행 가능해야 하며 기존 HR 행을 수정하거나 삭제하지 않는다.
- 배포 전 SQLcl로 테이블·FK·시드 역할을 확인하고, 배포 후 사용자·그룹·역할·토큰 화면을 HTTP
준에서 확인한다.
- 배포 전 전체 자동 테스트를 통과시키고, 배포 후 사용자·접근 그룹·역할·토큰뿐 아니라 보호 객체,
권한, 마스킹, 접근 검증, 지식자료, MCP, 운영 메뉴를 로그인 세션으로 전수 확인한다.
## 롤백

View File

@@ -0,0 +1,143 @@
package com.cloudhandson.vpdbackoffice.service;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Keeps the HMM knowledge store as the source of truth while preserving the
* CB_VECTOR_* contracts that the existing ORDS and backoffice screens use.
*/
@Component
@Order(0)
public class HmmKnowledgeSchemaInitializer implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(HmmKnowledgeSchemaInitializer.class);
private static final List<SchemaObject> TABLES = List.of(
new SchemaObject("HMM_KNOWLEDGE_DOCUMENTS", """
CREATE TABLE hmm_knowledge_documents (
document_id VARCHAR2(200) PRIMARY KEY,
file_name VARCHAR2(500) NOT NULL,
source_uri VARCHAR2(1000),
abstract_text CLOB,
original_text CLOB,
file_content BLOB,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
"""),
new SchemaObject("HMM_KNOWLEDGE_CHUNKS", """
CREATE TABLE hmm_knowledge_chunks (
chunk_id NUMBER PRIMARY KEY,
document_id VARCHAR2(200) NOT NULL
REFERENCES hmm_knowledge_documents(document_id) ON DELETE CASCADE,
chunk_no NUMBER NOT NULL,
title VARCHAR2(500),
chunk_text CLOB NOT NULL,
source_uri VARCHAR2(1000),
embedding VECTOR,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT hmm_knowledge_chunk_uk UNIQUE (document_id, chunk_no)
)
"""),
new SchemaObject("HMM_KNOWLEDGE_TAGS", """
CREATE TABLE hmm_knowledge_tags (
chunk_id NUMBER NOT NULL
REFERENCES hmm_knowledge_chunks(chunk_id) ON DELETE CASCADE,
tech_tag VARCHAR2(100) NOT NULL,
CONSTRAINT hmm_knowledge_tag_pk PRIMARY KEY (chunk_id, tech_tag),
CONSTRAINT hmm_knowledge_tag_format_ck
CHECK (REGEXP_LIKE(tech_tag, '^[A-Z0-9_-]+$'))
)
""")
);
private final JdbcTemplate jdbcTemplate;
public HmmKnowledgeSchemaInitializer(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public void run(org.springframework.boot.ApplicationArguments args) {
TABLES.forEach(this::ensureTable);
ensureSequence();
ensureIndex();
ensureLegacyViews();
log.info("HMM knowledge schema and CB_VECTOR compatibility views are ready");
}
private void ensureTable(SchemaObject table) {
if (!objectExists(table.name(), "TABLE")) {
jdbcTemplate.execute(table.ddl());
}
}
private void ensureSequence() {
if (!objectExists("HMM_KNOWLEDGE_CHUNK_SEQ", "SEQUENCE")) {
jdbcTemplate.execute("CREATE SEQUENCE hmm_knowledge_chunk_seq START WITH 30000 INCREMENT BY 1 NOCACHE");
}
}
private void ensureIndex() {
if (!objectExists("HMM_KNOWLEDGE_TAG_IX", "INDEX")) {
jdbcTemplate.execute("CREATE INDEX hmm_knowledge_tag_ix ON hmm_knowledge_tags (tech_tag, chunk_id)");
}
}
private void ensureLegacyViews() {
replaceViewUnlessPhysicalTable("CB_VECTOR_DOCUMENT_CHUNK", """
CREATE OR REPLACE VIEW cb_vector_document_chunk AS
SELECT chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding, created_at
FROM hmm_knowledge_chunks
""");
replaceViewUnlessPhysicalTable("CB_VECTOR_DOCUMENT_TAG", """
CREATE OR REPLACE VIEW cb_vector_document_tag AS
SELECT chunk_id, tech_tag
FROM hmm_knowledge_tags
""");
replaceViewUnlessPhysicalTable("CB_VECTOR_SEARCH_DOCUMENTS", """
CREATE OR REPLACE VIEW cb_vector_search_documents AS
SELECT c.chunk_id,
c.document_id,
c.chunk_no,
c.title,
c.chunk_text,
c.source_uri,
c.embedding,
(SELECT LISTAGG(t.tech_tag, ',') WITHIN GROUP (ORDER BY t.tech_tag)
FROM hmm_knowledge_tags t
WHERE t.chunk_id = c.chunk_id) AS tech_tag
FROM hmm_knowledge_chunks c
WHERE EXISTS (
SELECT 1 FROM hmm_knowledge_tags t WHERE t.chunk_id = c.chunk_id
)
""");
}
private void replaceViewUnlessPhysicalTable(String name, String ddl) {
if (objectExists(name, "TABLE")) {
log.info("{} is a physical legacy table; leaving it unchanged", name);
return;
}
jdbcTemplate.execute(ddl);
}
private boolean objectExists(String name, String type) {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM user_objects
WHERE object_name = ?
AND object_type = ?
""", Integer.class, name, type);
return count != null && count > 0;
}
private record SchemaObject(String name, String ddl) {
}
}

View File

@@ -56,9 +56,9 @@ public class VectorKnowledgeService {
}
public VectorKnowledgeSummary summary() {
int documents = count("SELECT COUNT(DISTINCT document_id) FROM cb_vector_document_chunk");
int chunks = count("SELECT COUNT(*) FROM cb_vector_document_chunk");
int tags = count("SELECT COUNT(*) FROM cb_vector_document_tag");
int documents = count("SELECT COUNT(*) FROM hmm_knowledge_documents");
int chunks = count("SELECT COUNT(*) FROM hmm_knowledge_chunks");
int tags = count("SELECT COUNT(*) FROM hmm_knowledge_tags");
boolean registered = protectedObjectService.findEnabled().stream()
.anyMatch(object -> VECTOR_OBJECT.equalsIgnoreCase(object.objectName()));
return new VectorKnowledgeSummary(
@@ -100,6 +100,11 @@ public class VectorKnowledgeService {
List<VectorChunk> chunks = VectorChunker.chunk(content, command.chunkSize());
replaceDocument(documentId);
jdbcTemplate.update("""
INSERT INTO hmm_knowledge_documents
(document_id, file_name, source_uri, abstract_text, original_text, updated_at)
VALUES (?, ?, ?, ?, ?, SYSTIMESTAMP)
""", documentId, title, sourceUri, title, content);
long nextChunkId = nextChunkId(chunks.size());
int tagCount = 0;
for (VectorChunk chunk : chunks) {
@@ -107,13 +112,13 @@ public class VectorKnowledgeService {
String embeddingJson = json(embedding);
long chunkId = nextChunkId++;
jdbcTemplate.update("""
INSERT INTO cb_vector_document_chunk
INSERT INTO hmm_knowledge_chunks
(chunk_id, document_id, chunk_no, title, chunk_text, source_uri, embedding)
VALUES (?, ?, ?, ?, ?, ?, TO_VECTOR(?))
""", chunkId, documentId, chunk.chunkNo(), title, chunk.text(), sourceUri, embeddingJson);
for (String tag : tags) {
jdbcTemplate.update("""
INSERT INTO cb_vector_document_tag (chunk_id, tech_tag)
INSERT INTO hmm_knowledge_tags (chunk_id, tech_tag)
VALUES (?, ?)
""", chunkId, tag);
tagCount++;
@@ -143,18 +148,18 @@ public class VectorKnowledgeService {
private void replaceDocument(String documentId) {
jdbcTemplate.update("""
DELETE FROM cb_vector_document_tag
DELETE FROM hmm_knowledge_tags
WHERE chunk_id IN (
SELECT chunk_id FROM cb_vector_document_chunk WHERE document_id = ?
SELECT chunk_id FROM hmm_knowledge_chunks WHERE document_id = ?
)
""", documentId);
jdbcTemplate.update("DELETE FROM cb_vector_document_chunk WHERE document_id = ?", documentId);
jdbcTemplate.update("DELETE FROM hmm_knowledge_documents WHERE document_id = ?", documentId);
}
private long nextChunkId(int chunkCount) {
try {
Long sequenceValue = jdbcTemplate.queryForObject(
"SELECT cb_vector_chunk_seq.NEXTVAL FROM dual", Long.class);
"SELECT hmm_knowledge_chunk_seq.NEXTVAL FROM dual", Long.class);
if (sequenceValue != null) {
return sequenceValue;
}
@@ -163,7 +168,7 @@ public class VectorKnowledgeService {
// fallback keeps the demonstration usable; the setup SQL creates it.
}
Long max = jdbcTemplate.queryForObject(
"SELECT NVL(MAX(chunk_id), 28000) FROM cb_vector_document_chunk", Long.class);
"SELECT NVL(MAX(chunk_id), 28000) FROM hmm_knowledge_chunks", Long.class);
return (max == null ? 28000 : max) + 1;
}

View File

@@ -4,28 +4,28 @@
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.ProtectedObjectMapper">
<select id="findEnabled" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
SELECT object_id, owner, object_name, ords_path, enabled_yn, description
FROM sg_protected_object
FROM cb_protected_object
WHERE enabled_yn = 'Y'
ORDER BY owner, object_name
</select>
<select id="findEnabledWithPermissions" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
SELECT DISTINCT po.object_id, po.owner, po.object_name, po.ords_path, po.enabled_yn, po.description
FROM sg_protected_object po
JOIN sg_permission p ON p.target_name = po.object_name
FROM cb_protected_object po
JOIN cb_permission p ON p.target_name = po.object_name
WHERE po.enabled_yn = 'Y'
ORDER BY po.owner, po.object_name
</select>
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
SELECT object_id, owner, object_name, ords_path, enabled_yn, description
FROM sg_protected_object
FROM cb_protected_object
WHERE object_id = #{objectId}
</select>
<select id="findByOwnerAndName" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
SELECT object_id, owner, object_name, ords_path, enabled_yn, description
FROM sg_protected_object
FROM cb_protected_object
WHERE owner = UPPER(#{owner,jdbcType=VARCHAR})
AND object_name = UPPER(#{objectName,jdbcType=VARCHAR})
</select>
@@ -38,8 +38,8 @@
AND object_name NOT LIKE 'BIN$%'
AND NOT EXISTS (
SELECT 1
FROM sg_protected_object po
JOIN sg_permission p ON p.target_name = po.object_name
FROM cb_protected_object po
JOIN cb_permission p ON p.target_name = po.object_name
WHERE po.owner = all_objects.owner
AND po.object_name = all_objects.object_name
AND po.enabled_yn = 'Y'
@@ -64,7 +64,7 @@
visible_role_id,
NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level,
NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method
FROM sg_protected_column
FROM cb_protected_column
WHERE object_id = #{objectId}
ORDER BY column_id
</select>
@@ -77,7 +77,7 @@
visible_role_id,
NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level,
NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method
FROM sg_protected_column
FROM cb_protected_column
WHERE object_id IN
<foreach collection="objectIds" item="objectId" open="(" separator="," close=")">
#{objectId,jdbcType=NUMERIC}
@@ -93,7 +93,7 @@
visible_role_id,
NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level,
NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method
FROM sg_protected_column
FROM cb_protected_column
WHERE column_id = #{columnId}
</select>
@@ -105,21 +105,21 @@
visible_role_id,
NVL(sensitivity_level, CASE sensitive_yn WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END) AS sensitivity_level,
NVL(redaction_method, CASE sensitive_yn WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END) AS redaction_method
FROM sg_protected_column
FROM cb_protected_column
WHERE object_id = #{objectId}
AND column_name = UPPER(#{columnName,jdbcType=VARCHAR})
</select>
<select id="nextObjectId" resultType="long">
SELECT NVL(MAX(object_id), 0) + 1 FROM sg_protected_object
SELECT NVL(MAX(object_id), 0) + 1 FROM cb_protected_object
</select>
<select id="nextColumnId" resultType="long">
SELECT NVL(MAX(column_id), 0) + 1 FROM sg_protected_column
SELECT NVL(MAX(column_id), 0) + 1 FROM cb_protected_column
</select>
<insert id="insertObject">
INSERT INTO sg_protected_object (object_id, owner, object_name, ords_path, enabled_yn, description)
INSERT INTO cb_protected_object (object_id, owner, object_name, ords_path, enabled_yn, description)
VALUES (
#{objectId},
UPPER(#{command.owner}),
@@ -131,7 +131,7 @@
</insert>
<insert id="insertColumn">
INSERT INTO sg_protected_column (
INSERT INTO cb_protected_column (
column_id, object_id, column_name, sensitive_yn, sensitivity_level, redaction_method
)
VALUES (
@@ -140,7 +140,7 @@
</insert>
<update id="updateColumnPolicy">
UPDATE sg_protected_column
UPDATE cb_protected_column
SET sensitivity_level = #{sensitivityLevel,jdbcType=VARCHAR},
redaction_method = #{redactionMethod,jdbcType=VARCHAR},
sensitive_yn = CASE
@@ -152,25 +152,25 @@
</update>
<update id="updateOrdsPath">
UPDATE sg_protected_object
UPDATE cb_protected_object
SET ords_path = #{ordsPath,jdbcType=VARCHAR}
WHERE object_id = #{objectId,jdbcType=NUMERIC}
</update>
<update id="updateDescription">
UPDATE sg_protected_object
UPDATE cb_protected_object
SET description = #{description,jdbcType=VARCHAR}
WHERE object_id = #{objectId,jdbcType=NUMERIC}
</update>
<update id="enableObject">
UPDATE sg_protected_object
UPDATE cb_protected_object
SET enabled_yn = 'Y'
WHERE object_id = #{objectId}
</update>
<update id="disableObject">
UPDATE sg_protected_object
UPDATE cb_protected_object
SET enabled_yn = 'N'
WHERE object_id = #{objectId}
</update>

View File

@@ -27,8 +27,8 @@
</select>
<select id="findUserRoles" resultType="com.cloudhandson.vpdbackoffice.domain.user.UserRoleView">
SELECT u.user_id,
u.user_name AS username,
SELECT u.employee_id AS user_id,
u.full_name AS username,
r.role_id,
r.role_name
FROM hmm_employee_access_roles ur

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="backoffice-can-mutate" th:content="${canMutate}">
<meta name="backoffice-read-only" th:content="${readOnlyMode}">
<title th:text="${title == 'SMILEGATE DATA & AI POC' ? title : title + ' · SMILEGATE DATA & AI POC'}">SMILEGATE DATA & AI POC</title>
<title th:text="${title == 'HMM HR Access Console' ? title : title + ' · HMM HR Access Console'}">HMM HR Access Console</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/css/app.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
@@ -15,7 +15,7 @@
<body>
<nav th:fragment="nav" class="navbar rw-nav navbar-expand-lg" data-product-nav>
<div class="container rw-nav-top">
<a class="navbar-brand" href="/">SMILEGATE DATA &amp; AI POC</a>
<a class="navbar-brand" href="/">HMM HR Access Console</a>
<div class="rw-menu" aria-label="주요 메뉴">
<button class="rw-menu-trigger" type="button" data-submenu-trigger="access" aria-controls="submenu-access" aria-expanded="false">운영 사용자 관리</button>
<button class="rw-menu-trigger" type="button" data-submenu-trigger="protection" aria-controls="submenu-protection" aria-expanded="false">보호·검증</button>
@@ -36,7 +36,7 @@
<div class="container">
<div id="submenu-access" class="rw-submenu" data-submenu-panel="access" role="navigation" aria-label="접근 제어 메뉴" hidden>
<a class="nav-link" href="/users">사용자 관리</a>
<a class="nav-link" href="/groups">그룹 관리</a>
<a class="nav-link" href="/groups">접근 그룹</a>
<a class="nav-link" href="/roles">역할 관리</a>
<a class="nav-link" href="/permissions">행 접근 규칙</a>
<a class="nav-link" href="/user-masking-rules">컬럼 원문 표시 허용</a>

View File

@@ -35,25 +35,11 @@ class GuidedFlowTemplateTest {
.doesNotContain("dashboard-workflow")
.doesNotContain(">01<", ">02<");
assertThat(layout)
.contains("데이터 접근 제어")
.contains("접근 제어")
.contains("보호·검증")
.contains("연동 도구")
.contains("운영")
.contains("관리자")
.contains("조회 연동")
.contains("정형 데이터 조회")
.contains("MCP 서비스")
.contains("컬럼 마스킹")
.contains("컬럼 원문 표시 허용")
.contains("행 접근 필터 구조")
.contains("보안 SQL 스크립트")
.contains("고급 접근 조건")
.contains("시스템 설정")
.contains("DB 준비 상태")
.contains("data-submenu-trigger")
.contains("data-submenu-bar")
.contains("data-submenu-panel")
.contains("HMM HR Access Console")
.contains("사용자")
.contains("접근 그룹")
.contains("역할")
.contains("토큰")
.contains("backoffice-can-mutate")
.contains("backoffice-read-only")
.doesNotContain("VPD Backoffice");
@@ -221,8 +207,7 @@ class GuidedFlowTemplateTest {
String filters = template("vpd-filter-policies.html");
assertThat(roles)
.contains("표시 보호 등급 상한")
.contains("행 접근 권한은 행 접근 규칙과 Oracle VPD 정책에서 결정합니다");
.contains("데이터 취급 등급");
assertThat(tokens)
.contains("회수·만료 포함")
.contains("현재 사용 가능한 토큰만");