From b8ca09d382a876206478acc0821ee263e41af64f Mon Sep 17 00:00:00 2001 From: takch02 Date: Tue, 19 May 2026 16:07:28 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20=ED=8C=8C=ED=8A=B8=EB=84=88=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C=EC=A0=81=20=ED=95=84=ED=84=B0=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit industry / country / partnership / size 쿼리 파라미터를 API에서 제거. 국가 감지는 서비스 레이어에서 쿼리 텍스트를 분석해 자동으로 처리하는 방식으로 전환. Co-Authored-By: Claude Sonnet 4.6 --- .../partners/partners.controller.spec.ts | 15 +------------- src/modules/partners/partners.controller.ts | 20 ------------------- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/src/modules/partners/partners.controller.spec.ts b/src/modules/partners/partners.controller.spec.ts index b8c6704..6b5097d 100644 --- a/src/modules/partners/partners.controller.spec.ts +++ b/src/modules/partners/partners.controller.spec.ts @@ -38,24 +38,11 @@ describe("PartnersController", () => { it("모든 파라미터를 서비스에 전달", async () => { mockPartnersService.search.mockResolvedValue({ data: [] }); - await controller.search( - null, - "acme", - 5, - "Automotive", - "Korea", - "OEM", - "1-10", - "buyer-id", - ); + await controller.search(null, "acme", 5, "buyer-id"); expect(mockPartnersService.search).toHaveBeenCalledWith({ q: "acme", limit: 5, - industry: "Automotive", - country: "Korea", - partnership: "OEM", - size: "1-10", buyerId: "buyer-id", }); }); diff --git a/src/modules/partners/partners.controller.ts b/src/modules/partners/partners.controller.ts index ebe9898..29f4708 100644 --- a/src/modules/partners/partners.controller.ts +++ b/src/modules/partners/partners.controller.ts @@ -32,18 +32,6 @@ export class PartnersController { type: Number, description: "결과 수 (기본 10)", }) - @ApiQuery({ name: "industry", required: false, description: "산업 필터" }) - @ApiQuery({ name: "country", required: false, description: "국가 필터" }) - @ApiQuery({ - name: "partnership", - required: false, - description: "파트너십 태그 필터", - }) - @ApiQuery({ - name: "size", - required: false, - description: "기업 규모 (예: 1-10, 11-50, 51-200)", - }) @ApiQuery({ name: "buyerId", required: false, @@ -60,19 +48,11 @@ export class PartnersController { @OptionalCurrentUser() user: SessionUser | null, @Query("q") q: string, @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit?: number, - @Query("industry") industry?: string, - @Query("country") country?: string, - @Query("partnership") partnership?: string, - @Query("size") size?: string, @Query("buyerId") buyerId?: string, ) { return this.partnersService.search({ q, limit, - industry, - country, - partnership, - size, buyerId, userId: user?.userId, }); From 52a5583b841f501f42a6da05449882f0c2f585a4 Mon Sep 17 00:00:00 2001 From: takch02 Date: Tue, 19 May 2026 16:08:13 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=ED=8C=8C=ED=8A=B8=EB=84=88=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=BF=BC=EB=A6=AC=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EA=B5=AD=EA=B0=80=20=EA=B0=90=EC=A7=80=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=B0=EA=B3=BC=20=ED=92=88=EC=A7=88=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - COUNTRY_QUERY_MAP + detectCountryFromQuery: 쿼리 텍스트에서 국가명(한/영) 자동 감지 - 명시적 industry/country/partnership/size 필터 및 INDUSTRY_MAPPING 제거 - filter-only browsing(1.8) 제거, 쿼리 없을 때 전체 조회로 단일화 - RRF 중복 이름 제거 (seenNames) - 벡터 후보 수 확대: numCandidates 100→500, limit 100→200 - 웹 폴백 임계값 조정: dbResults.length === 0 → < 3 Co-Authored-By: Claude Sonnet 4.6 --- src/modules/partners/partners.service.spec.ts | 27 +- src/modules/partners/partners.service.ts | 275 +++++++----------- 2 files changed, 115 insertions(+), 187 deletions(-) diff --git a/src/modules/partners/partners.service.spec.ts b/src/modules/partners/partners.service.spec.ts index bf3d59d..240dd9f 100644 --- a/src/modules/partners/partners.service.spec.ts +++ b/src/modules/partners/partners.service.spec.ts @@ -97,37 +97,17 @@ describe("PartnersService", () => { // ── browse 모드 ─────────────────────────────────────────────────────────────── - describe("browse 모드 (필터만, 쿼리 없음)", () => { - it("industry 매핑 필터 적용 후 score=1.0", async () => { - // AI 분석 모킹 - jest.spyOn(service as any, "generateHyDEAndKeywords").mockResolvedValue({ - profile: "", - keywords: "", - }); - + describe("browse 모드 (쿼리 없음, 전체 조회)", () => { + it("쿼리 없을 때 전체 조회 후 score=1.0", async () => { sellerModel.find.mockReturnValue( buildQueryMock([{ _id: "c1", name: "Acme" }]), ); - const result = await service.search({ - q: "", - industry: "IT / AI / SaaS", - }); + const result = await service.search({ q: "" }); expect(sellerModel.find).toHaveBeenCalled(); expect(result.data[0].score).toBe(1.0); }); - - it("country 필터 적용", async () => { - sellerModel.find.mockReturnValue(buildQueryMock([])); - - await service.search({ q: "", country: "Korea" }); - - expect(sellerModel.find).toHaveBeenCalledWith( - expect.objectContaining({ "location.country": "Korea" }), - expect.any(Object), - ); - }); }); // ── 벡터 검색 ───────────────────────────────────────────────────────────────── @@ -286,7 +266,6 @@ describe("PartnersService", () => { await service.search({ q: "", - industry: "IT / AI / SaaS", userId: USER_ID, }); diff --git a/src/modules/partners/partners.service.ts b/src/modules/partners/partners.service.ts index 80b93f7..b3a6a03 100644 --- a/src/modules/partners/partners.service.ts +++ b/src/modules/partners/partners.service.ts @@ -7,52 +7,9 @@ import { Buyer, BuyerDocument } from "../buyers/schemas/buyer.schema"; import { User, UserDocument } from "../users/schemas/user.schema"; import { EmbeddingsService } from "../embeddings/embeddings.service"; -const INDUSTRY_MAPPING: Record = { - "Automotive / EV Parts": [ - "Mobility / Automation / Manufacturing", - "Industrial & Manufacturing", - "Mobility", - "Automotive", - "Car parts", - "EV", - ], - "IT / AI / SaaS": ["IT / AI / SaaS", "Tech & Electronics", "Software"], - "Healthcare / Bio / Medical": [ - "Healthcare / Bio / Medical", - "Health & Bio", - "Medical", - ], - "Green Energy / Climate Tech / Smart City": [ - "Green Energy / Climate Tech / Smart City", - "Energy & Environment", - ], - "Mobility / Automation / Manufacturing": [ - "Mobility / Automation / Manufacturing", - "Industrial & Manufacturing", - "Mobility", - ], - "Beauty / Consumer Goods / Food": [ - "Beauty / Consumer Goods / Food", - "Beauty & Cosmetics", - "Food & Beverage", - "Consumer Goods", - ], - "Content / Culture / Edutech": [ - "Content / Culture / Edutech", - "Content", - "Education", - ], - "Fintech / Smart Finance": ["Fintech / Smart Finance", "Finance"], - Other: ["Other", "(Unspecified)"], -}; - export interface SearchOptions { q: string; limit?: number; - industry?: string; - country?: string; - partnership?: string; - size?: string; buyerId?: string; userId?: string; } @@ -95,8 +52,6 @@ const mapBuyerToCommon = (b: any) => ({ updatedAt: b.updatedAt, }); -const escapeRegex = (v: string) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - @Injectable() export class PartnersService { private readonly logger = new Logger(PartnersService.name); @@ -113,25 +68,16 @@ export class PartnersService { async search(opts: SearchOptions): Promise { const startTime = performance.now(); - const { - q, - limit = 10, - industry, - country, - partnership, - size, - buyerId, - } = opts; + const { q, limit = 10, buyerId } = opts; let forceWebSearch = false; let tavilyQuery = q ?? ""; let detectedIntent = "seller"; let intentData: any = null; let aiResponse = ""; + let detectedCountry: string | null = null; - this.logger.log( - `[Search Start] query: "${q}", industry: ${industry}, country: ${country}`, - ); + this.logger.log(`[Search Start] query: "${q}"`); if (q) { const intentStartTime = performance.now(); @@ -184,8 +130,10 @@ export class PartnersService { forceWebSearch = true; } + detectedCountry = detectCountryFromQuery(qLower); + this.logger.log( - `[Step 1: Intent Analysis] took ${Math.round(performance.now() - intentStartTime)}ms. detectedIntent: ${detectedIntent}, forceWebSearch: ${forceWebSearch}`, + `[Step 1: Intent Analysis] took ${Math.round(performance.now() - intentStartTime)}ms. detectedIntent: ${detectedIntent}, forceWebSearch: ${forceWebSearch}, detectedCountry: ${detectedCountry}`, ); } @@ -211,7 +159,7 @@ export class PartnersService { let hydeDocument: string | null = null; let aiKeywords: string | null = null; - if (!forceWebSearch && q) { + if (q) { const aiStartTime = performance.now(); // 1.0 AI Query Understanding (HyDE + Keywords) @@ -227,36 +175,10 @@ export class PartnersService { const textSearchTask = (async () => { const textStartTime = performance.now(); const filter: Record = { $text: { $search: aiKeywords } }; - if (industry) { - if (isBuyerSearch) { - filter.$and = [ - { $text: { $search: aiKeywords } }, - { - $or: [ - { - industry_kr: { - $regex: escapeRegex(industry), - $options: "i", - }, - }, - { - industry_en: { - $regex: escapeRegex(industry), - $options: "i", - }, - }, - ], - }, - ]; - } else { - filter.industry = INDUSTRY_MAPPING[industry] - ? { $in: INDUSTRY_MAPPING[industry] } - : industry; - } - } - if (country) { - if (isBuyerSearch) filter.country = country; - else filter["location.country"] = country; + if (detectedCountry) { + const countryRegex = new RegExp(detectedCountry, "i"); + if (isBuyerSearch) filter.country = countryRegex; + else filter["location.country"] = countryRegex; } if (excludeObjectIds.length > 0) filter._id = { $nin: excludeObjectIds }; @@ -319,28 +241,17 @@ export class PartnersService { index: indexName, path: "embedding", queryVector: vector, - numCandidates: 100, - limit: 100, + numCandidates: 500, // 데이터 규모에 따라 조정해야함 + limit: 200, // 데이터 규모에 따라 조정해야함 }, }, ]; const matchStage: Record = {}; - if (industry) { - if (isBuyerSearch) { - matchStage.$or = [ - { industry_kr: { $regex: industry, $options: "i" } }, - { industry_en: { $regex: industry, $options: "i" } }, - ]; - } else { - matchStage.industry = INDUSTRY_MAPPING[industry] - ? { $in: INDUSTRY_MAPPING[industry] } - : industry; - } - } - if (country) { - if (isBuyerSearch) matchStage.country = country; - else matchStage["location.country"] = country; + if (detectedCountry) { + const countryRegex = new RegExp(detectedCountry, "i"); + if (isBuyerSearch) matchStage.country = countryRegex; + else matchStage["location.country"] = countryRegex; } if (excludeObjectIds.length > 0) matchStage._id = { $nin: excludeObjectIds }; @@ -372,6 +283,9 @@ export class PartnersService { }); } + this.logger.log( + `[Step 2.Vector] queryVector dim=${vector.length}, index=${indexName}`, + ); try { const raw = await activeModel.aggregate(pipeline); vectorResults = isBuyerSearch ? raw.map(mapBuyerToCommon) : raw; @@ -379,7 +293,9 @@ export class PartnersService { `[Step 2.Vector] Found ${vectorResults.length} items`, ); } catch (err: any) { - this.logger.error(`[Search] Vector search error: ${err.message}`); + this.logger.error( + `[Search] Vector search error: ${JSON.stringify(err)}`, + ); } })(); @@ -413,6 +329,7 @@ export class PartnersService { const textDocMap = new Map(); textResults.forEach((r) => textDocMap.set(r._id.toString(), r)); + const seenNames = new Set(); dbResults = [...allIds] .map((id) => { const vRank = vectorRankMap.get(id) ?? GHOST_RANK; @@ -428,64 +345,15 @@ export class PartnersService { }; }) .sort((a, b) => b.score - a.score) + .filter((r) => { + const key = (r.name || r.name_kr || "").trim(); + if (!key || seenNames.has(key)) return false; + seenNames.add(key); + return true; + }) .slice(0, Number(limit)); } - // --- 1.8. Filter-only browsing --- - if ( - !forceWebSearch && - !q && - (industry || country || (isBuyerSearch ? false : partnership || size)) - ) { - const filter: Record = {}; - if (industry) { - if (isBuyerSearch) { - filter.$or = [ - { industry_kr: { $regex: industry, $options: "i" } }, - { industry_en: { $regex: industry, $options: "i" } }, - ]; - } else { - filter.industry = INDUSTRY_MAPPING[industry] - ? { $in: INDUSTRY_MAPPING[industry] } - : industry; - } - } - if (country) { - if (isBuyerSearch) filter.country = country; - else filter["location.country"] = country; - } - if (!isBuyerSearch) { - if (partnership) filter.tags = partnership; - if (size) filter.sizeBucket = size; - } - if (excludeObjectIds.length > 0) filter._id = { $nin: excludeObjectIds }; - - const projection = isBuyerSearch - ? { - name_kr: 1, - name_en: 1, - industry_kr: 1, - industry_en: 1, - country: 1, - intro_kr: 1, - intro_en: 1, - website: 1, - email: 1, - updatedAt: 1, - } - : SEARCH_PROJECTION; - - const raw = await activeModel - .find(filter as any, projection as any) - .limit(Number(limit)) - .sort({ updatedAt: -1 } as any) - .lean(); - dbResults = raw.map((r) => { - const common = isBuyerSearch ? mapBuyerToCommon(r) : r; - return { ...common, score: 1.0 }; - }); - } - // --- 1.9. Default show-all --- if (!forceWebSearch && !q && dbResults.length === 0) { const projection = isBuyerSearch @@ -519,7 +387,7 @@ export class PartnersService { } // --- 2. Web search fallback --- - const shouldFallbackToWeb = forceWebSearch || dbResults.length === 0; + const shouldFallbackToWeb = dbResults.length < 3; if (shouldFallbackToWeb && q) { const webStartTime = performance.now(); @@ -1069,6 +937,87 @@ const REGION_KEYWORDS = [ "north america", ]; +const COUNTRY_QUERY_MAP: { kr: string; en: string }[] = [ + { kr: "미국", en: "USA" }, + { kr: "캐나다", en: "Canada" }, + { kr: "멕시코", en: "Mexico" }, + { kr: "브라질", en: "Brazil" }, + { kr: "칠레", en: "Chile" }, + { kr: "아르헨티나", en: "Argentina" }, + { kr: "콜롬비아", en: "Colombia" }, + { kr: "페루", en: "Peru" }, + { kr: "영국", en: "UK" }, + { kr: "독일", en: "Germany" }, + { kr: "프랑스", en: "France" }, + { kr: "이탈리아", en: "Italy" }, + { kr: "스페인", en: "Spain" }, + { kr: "네덜란드", en: "Netherlands" }, + { kr: "벨기에", en: "Belgium" }, + { kr: "러시아", en: "Russia" }, + { kr: "폴란드", en: "Poland" }, + { kr: "터키", en: "Turkey" }, + { kr: "일본", en: "Japan" }, + { kr: "중국", en: "China" }, + { kr: "인도", en: "India" }, + { kr: "베트남", en: "Vietnam" }, + { kr: "태국", en: "Thailand" }, + { kr: "인도네시아", en: "Indonesia" }, + { kr: "인니", en: "Indonesia" }, + { kr: "필리핀", en: "Philippines" }, + { kr: "말레이시아", en: "Malaysia" }, + { kr: "싱가포르", en: "Singapore" }, + { kr: "호주", en: "Australia" }, + { kr: "대만", en: "Taiwan" }, + { kr: "사우디", en: "Saudi Arabia" }, + { kr: "uae", en: "UAE" }, + { kr: "이집트", en: "Egypt" }, + { kr: "남아공", en: "South Africa" }, + { kr: "나이지리아", en: "Nigeria" }, + { kr: "우즈베키스탄", en: "Uzbekistan" }, + { kr: "카자흐스탄", en: "Kazakhstan" }, + { kr: "아제르바이잔", en: "Azerbaijan" }, + { kr: "이란", en: "Iran" }, + { kr: "이라크", en: "Iraq" }, + { kr: "파키스탄", en: "Pakistan" }, + { kr: "방글라데시", en: "Bangladesh" }, + { kr: "스리랑카", en: "Sri Lanka" }, + { kr: "미얀마", en: "Myanmar" }, + { kr: "캄보디아", en: "Cambodia" }, + { kr: "라오스", en: "Laos" }, + { kr: "몽골", en: "Mongolia" }, + { kr: "이스라엘", en: "Israel" }, + { kr: "스웨덴", en: "Sweden" }, + { kr: "핀란드", en: "Finland" }, + { kr: "노르웨이", en: "Norway" }, + { kr: "덴마크", en: "Denmark" }, + { kr: "체코", en: "Czech Republic" }, + { kr: "헝가리", en: "Hungary" }, + { kr: "루마니아", en: "Romania" }, + { kr: "우크라이나", en: "Ukraine" }, + { kr: "포르투갈", en: "Portugal" }, + { kr: "그리스", en: "Greece" }, + { kr: "뉴질랜드", en: "New Zealand" }, + { kr: "남아프리카", en: "South Africa" }, + { kr: "에티오피아", en: "Ethiopia" }, + { kr: "케냐", en: "Kenya" }, + { kr: "가나", en: "Ghana" }, + { kr: "모로코", en: "Morocco" }, + { kr: "튀니지", en: "Tunisia" }, + { kr: "알제리", en: "Algeria" }, +]; + +function detectCountryFromQuery(qLower: string): string | null { + for (const { kr, en } of COUNTRY_QUERY_MAP) { + if ( + qLower.includes(kr.toLowerCase()) || + qLower.includes(en.toLowerCase()) + ) { + return en; + } + } + return null; +} + function buildTavilyQuery(originalQuery: string, intent: string): string { const qL = originalQuery.toLowerCase(); From 63e8e3317420deda978bb70d31cc1532f80058fe Mon Sep 17 00:00:00 2001 From: takch02 Date: Tue, 19 May 2026 16:08:31 +0900 Subject: [PATCH 3/5] =?UTF-8?q?docs:=20README=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로젝트 소개, 핵심 가치, 기술 스택, 아키텍처 다이어그램, API 엔드포인트, 비즈니스 모델 섹션 추가. 빠른 시작 가이드 구체화. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 175 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6e0bedb..15a38cb 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,186 @@ -# K-Statra +# K-Statra Backend -B2B 파트너 매칭 플랫폼. NestJS + MongoDB Atlas + XRPL. +> AI 기반 무역 파트너 매칭 및 XRPL 에스크로 결제 플랫폼 + +**"33년 무역현장 경력의 노하우와 AI 매칭 기술, 그리고 XRPL의 에스크로 기능을 결합하여 중소기업의 해외 판로 개척과 안전한 무역 결제를 돕는 차세대 B2B 플랫폼"** + +- 웹 데모: https://k-statra-frontend.vercel.app +- GitHub 조직: https://github.com/orgs/K-Statra/repositories + +--- + +## 핵심 가치 + +| 구분 | 기존 SWIFT | K-Statra (XRPL) | +|------|-----------|-----------------| +| 수수료 | 건당 3~5% | 0.1% 미만 (90% 절감) | +| 정산 속도 | 3~5일 | 3~5초 | +| 투명성 | 이메일·서류 의존 | Tx Hash 실시간 추적 | +| 보안 | 바이어 단독 승인 | 2-of-3 Multi-sig 에스크로 | + +--- + +## 주요 기능 +ㅌ₩ +### 1. AI 기반 파트너 매칭 +- MongoDB Atlas Vector Search를 활용한 벡터 유사도 검색 +- 벡터 검색 → 텍스트 폴백 → 웹검색 + LLM 인텐트 분석 순으로 동작 +- 산업·국가·파트너십 태그 필터 지원 + +### 2. 하이브리드 XRPL 결제 +- 소액 샘플 대금: XRP 결제 (속도 우선) +- 무역 대금: RLUSD 스테이블코인 결제 (Multi-sig 2/3 방식 + 임시 지갑을 통해 에스크로 기능을 대체할 예정 - 현재 RLUSD는 에스크로 기능 Disable 상태) + +### 3. 이벤트 기반 3단계 에스크로 +- 계약 → 선적(B/L) → 수령 시점에 맞춰 대금을 단계별 분할 집행 +- XRPL Native Escrow 활용 (별도 스마트 컨트랙트 불필요) +- 거래 전용 Vault 지갑 생성 + 마스터 키 비활성화로 자금 보호 +- 수입자·수출자·중재자 2-of-3 Multi-sig로 분쟁 리스크 차단 +- Bull Queue + Outbox 패턴으로 에스크로 생성 안정성 보장 + +--- + +## 기술 스택 + +| 구분 | 기술 | +|------|------| +| 프레임워크 | NestJS (TypeScript) | +| 데이터베이스 | MongoDB Atlas (Replica Set, Vector Search) | +| 캐시 / 큐 | Redis + Bull | +| 블록체인 | XRPL (xrpl.js) | +| 인프라 | Docker, Railway | +| API 문서 | Swagger (OpenAPI) | + +--- + +## 모듈 구조 + +``` +src/modules/ +├── auth/ # 회원가입·로그인 (세션 기반) +├── users/ # 유저 스키마 (Buyer / Seller) +├── my-business/ # 내 기업 정보 관리 +├── partners/ # AI 파트너 검색 (벡터·텍스트·LLM) +├── escrow-payments/ # XRPL 에스크로 결제 CRUD + 처리 +├── xrpl/ # XRPL 클라이언트 서비스 +├── embeddings/ # 벡터 임베딩 생성 +└── outbox/ # Outbox 패턴 (Change Stream 기반) +``` + +## 아키텍처 +```mermaid +graph LR + %% Client Zone + Client((Client\nBuyer/Seller)) + + %% Application Zone + subgraph "Application Layer (NestJS)" + API[NestJS API Server] + Auth[Auth/Session Guard] + Logic[Business & Match Logic] + OutboxPub[Outbox Publisher] + end + + %% Data Zone + subgraph "Data & Event Layer" + Redis[(Redis\nSession)] + Mongo[(MongoDB Replica Set\nBusiness Data & Outbox)] + ChangeStream((Change Streams)) + Queue[(Bull Queue)] + Worker[Escrow Worker\n& Recovery Scheduler] + end + + %% External Zone + subgraph "External Services" + XRPL[XRPL Testnet] + LLM[External LLM\n& Vector Search] + end + + %% Flow - General + Client -->|REST API| API + API --> Auth + Auth <--> Redis + API --> Logic + + %% Flow - AI Matching + Logic <-->|RAG / Intent| LLM + + %% Flow - Escrow & Outbox (Core) + API --> OutboxPub + OutboxPub -->|Atomic Transaction| Mongo + Mongo -->|Watch| ChangeStream + ChangeStream -->|Push Event| Queue + Queue -->|Consume| Worker + Worker <-->|EscrowCreate / Approve| XRPL + Worker -->|Update Status| Mongo +``` +--- ## 빠른 시작 -**백엔드** +### 사전 요건 + +- Node.js 20+ +- Docker & Docker Compose +- MongoDB Atlas 클러스터 (또는 로컬 Replica Set) + +### 로컬 실행 + ```bash -cd backend -cp .env.example .env # MONGODB_URI 등 설정 +# 1. 의존성 설치 npm install + +# 2. 환경변수 설정 +cp .env.example .env +# .env에서 MONGODB_URI, XRPL_*, OPENAI_API_KEY 등 설정 + +# 3. MongoDB + Redis 실행 +docker-compose up -d + +# 4. 개발 서버 실행 npm run start:dev ``` Swagger UI: `http://localhost:4000/api/docs` + +### Docker 전체 실행 + +```bash +docker-compose up -d +``` + +--- + +## XRPL 테스트넷 지갑 + +| 역할 | 주소 | +|------|------| +| Buyer | `rJvbMhFjmfAd5DZAVhXe7kuPBKmhBMkaCH` | +| Seller | `ra7MVxG3MUCqym6opZBQXj9bSx5P7s5B4Y` | + +--- + +## API 주요 엔드포인트 + +| 그룹 | 경로 | 설명 | +|------|------|------| +| Auth | `POST /auth/register` | 회원가입 | +| Auth | `POST /auth/login` | 로그인 | +| Partners | `GET /partners/search?q=` | AI 파트너 검색 | +| Escrow | `POST /escrow-payments` | 에스크로 생성 | +| Escrow | `GET /escrow-payments` | 에스크로 목록 조회 | +| Escrow | `GET /escrow-payments/users/wallet/:address` | 지갑 주소로 유저 조회 | +| My Business | `GET /my-business` | 내 기업 정보 조회 | + +전체 API는 Swagger UI에서 확인하세요. + +--- + +## 비즈니스 모델 + +- **서비스 수수료**: 결제액의 0.2% (네트워크 수수료 0.000012 XRP 별도) +- **AI 파트너 추천**: 구독 모델 (SaaS) +- 연간 1,000건 × 평균 $50,000 가정 시 → 연간 약 1억 원 이상 수수료 수익 목표 + +--- From 2f2dee6313b20f192d79b6b46cada9f54a303091 Mon Sep 17 00:00:00 2001 From: takch02 Date: Tue, 19 May 2026 16:19:41 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20dbResults.length=20<=203=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/partners/partners.service.spec.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/modules/partners/partners.service.spec.ts b/src/modules/partners/partners.service.spec.ts index 240dd9f..d812247 100644 --- a/src/modules/partners/partners.service.spec.ts +++ b/src/modules/partners/partners.service.spec.ts @@ -117,6 +117,8 @@ describe("PartnersService", () => { embeddingsService.embed.mockResolvedValue(new Array(64).fill(0.1)); sellerModel.aggregate.mockResolvedValue([ { _id: "c1", name: "Acme", score: 0.9 }, + { _id: "c2", name: "Beta", score: 0.8 }, + { _id: "c3", name: "Gamma", score: 0.7 }, ]); const result = await service.search({ q: "화장품 제조사" }); @@ -136,6 +138,8 @@ describe("PartnersService", () => { industry: "Beauty / Consumer Goods / Food", score: 12, }, + { _id: "c2", name: "Alpha Corp", score: 8 }, + { _id: "c3", name: "Beta Corp", score: 4 }, ]), ); @@ -152,12 +156,16 @@ describe("PartnersService", () => { embeddingsService.embed.mockResolvedValue(new Array(64).fill(0.1)); sellerModel.aggregate.mockResolvedValue([]); // 벡터 결과 없음 sellerModel.find.mockReturnValue( - buildQueryMock([{ _id: "c2", name: "Beta", score: 3 }]), + buildQueryMock([ + { _id: "c2", name: "Beta", score: 3 }, + { _id: "c3", name: "Alpha", score: 2 }, + { _id: "c4", name: "Gamma", score: 1 }, + ]), ); const result = await service.search({ q: "식품" }); - expect(result.data).toHaveLength(1); + expect(result.data).toHaveLength(3); expect(result.data[0].name).toBe("Beta"); }); }); @@ -169,6 +177,8 @@ describe("PartnersService", () => { buyerModel.find.mockReturnValue( buildQueryMock([ { _id: "b1", name_kr: "라온바이어", country: "US", score: 10 }, + { _id: "b2", name_kr: "서울바이어", country: "KR", score: 8 }, + { _id: "b3", name_kr: "부산바이어", country: "KR", score: 6 }, ]), ); From 66e9966fc7addb6e974715a099d55e77dad22b8b Mon Sep 17 00:00:00 2001 From: takch02 Date: Wed, 20 May 2026 09:40:55 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EB=82=98=EB=9D=BC=20=ED=83=90?= =?UTF-8?q?=EC=83=89=20=EC=8B=9C=20includes=20=EC=9D=B4=EC=9A=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=98=A4=ED=83=90=20=EB=B0=9C=EC=83=9D,=20regex?= =?UTF-8?q?=EB=A1=9C=20=ED=95=84=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/partners/partners.service.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/modules/partners/partners.service.ts b/src/modules/partners/partners.service.ts index b3a6a03..626d8e6 100644 --- a/src/modules/partners/partners.service.ts +++ b/src/modules/partners/partners.service.ts @@ -346,7 +346,13 @@ export class PartnersService { }) .sort((a, b) => b.score - a.score) .filter((r) => { - const key = (r.name || r.name_kr || "").trim(); + const key = ( + r.name || + r.name_kr || + r.name_en || + r.nameEn || + "" + ).trim(); if (!key || seenNames.has(key)) return false; seenNames.add(key); return true; @@ -1006,12 +1012,18 @@ const COUNTRY_QUERY_MAP: { kr: string; en: string }[] = [ { kr: "알제리", en: "Algeria" }, ]; +function includesCountryToken(qLower: string, token: string): boolean { + const t = token.toLowerCase(); + if (/^[a-z\s]+$/.test(t)) { + const escaped = t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(?:^|\\b)${escaped}(?:\\b|$)`, "i").test(qLower); + } + return qLower.includes(t); +} + function detectCountryFromQuery(qLower: string): string | null { for (const { kr, en } of COUNTRY_QUERY_MAP) { - if ( - qLower.includes(kr.toLowerCase()) || - qLower.includes(en.toLowerCase()) - ) { + if (includesCountryToken(qLower, kr) || includesCountryToken(qLower, en)) { return en; } }