o2o-site-AEO/ontology/src/merchants/merchants.service.ts
Mina Choi 01098835e9 [chore] docker-compose,ontology: 온톨로지를 이 레포로 들여 compose 한 벌로 띄운다 — 앱 Dockerfile 신설
발행이 SiteOntology 를 부르는데 서버는 따로 띄워야 했다. 실측(2026-09-14): 서버가 없으면
`[seo] SiteOntology 실패 — 키워드 없이 발행: ConnectError` 로 빌드는 성공하고 메타만 빈다 —
화면으로는 안 보이는 종류다. 한 벌로 묶어 "코드는 올라갔는데 서버가 없는" 상태를 없앤다.

- ontology/: gitea.o2o.kr/Web4ai/o2o-site-ontology 를 이 레포로 편입(그 원격은 그대로 남는다)
- ontology/Dockerfile(신규): 베이스는 node:22-slim. alpine 은 임베딩 런타임(onnxruntime)이
  musl 바이너리를 안 줘서 적재가 ERR_DLOPEN_FAILED 로 죽는다 — 빌드는 성공하고 실행에서만 터진다
- docker-compose.yml: ontology · ontology-postgres(pgvector) · ontology-redis 추가.
  자체 DB 를 쓰는 이유는 pgvector 확장 때문이다 — web4ai_db 를 남의 서비스 확장에 묶지 않는다
- 임베딩 모델(120MB)은 이미지에 굽지 않고 볼륨(ontology-model)에 남긴다
- 컨테이너끼리는 `http://ontology:3100` 으로 만난다. `.env` 의 127.0.0.1 은 컨테이너 자기 자신이라 안 닿는다

검증: 3개 기동 · 백엔드 컨테이너에서 ontology:3100/demo HTTP 200 · 마이그레이션·시드 완료

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 17:41:03 +09:00

93 lines
3.2 KiB
TypeScript

import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { PG } from '../db/db.module';
import { asJson, Sql } from '../db/db';
export interface MerchantRow {
id: string;
external_id: string;
name: string;
industry_id: string | null;
region_id: string | null;
description: string;
profile: Record<string, unknown>;
site_url: string | null;
last_generated_at: Date | null;
}
export interface MerchantWithTaxonomy extends MerchantRow {
industry_name: string | null;
industry_path: string | null;
region_name: string | null;
region_path: string | null;
}
export interface UpsertMerchantDto {
externalId: string;
name: string;
industryId?: string | null;
regionId?: string | null;
description?: string;
profile?: Record<string, unknown>;
siteUrl?: string | null;
}
@Injectable()
export class MerchantsService {
constructor(@Inject(PG) private readonly sql: Sql) {}
/** o2o-site-AEO 가 사이트를 발행할 때 호출하는 진입점 */
async upsert(dto: UpsertMerchantDto): Promise<MerchantRow> {
const rows = await this.sql<MerchantRow[]>`
INSERT INTO merchant (external_id, name, industry_id, region_id, description, profile, site_url)
VALUES (${dto.externalId}, ${dto.name}, ${dto.industryId ?? null}, ${dto.regionId ?? null},
${dto.description ?? ''}, ${this.sql.json(asJson(dto.profile ?? {}))}, ${dto.siteUrl ?? null})
ON CONFLICT (external_id) DO UPDATE SET
name = EXCLUDED.name,
industry_id = EXCLUDED.industry_id,
region_id = EXCLUDED.region_id,
description = EXCLUDED.description,
profile = EXCLUDED.profile,
site_url = EXCLUDED.site_url,
updated_at = now()
RETURNING *`;
return rows[0];
}
async findWithTaxonomy(idOrExternalId: string): Promise<MerchantWithTaxonomy> {
const rows = await this.sql<MerchantWithTaxonomy[]>`
SELECT m.*,
i.name AS industry_name, i.path::text AS industry_path,
r.name AS region_name, r.path::text AS region_path
FROM merchant m
LEFT JOIN industry i ON i.id = m.industry_id
LEFT JOIN region r ON r.id = m.region_id
WHERE m.external_id = ${idOrExternalId}
OR (${isUuid(idOrExternalId)} AND m.id::text = ${idOrExternalId})
LIMIT 1`;
if (!rows[0]) throw new NotFoundException(`merchant not found: ${idOrExternalId}`);
return rows[0];
}
async list(limit = 50) {
return this.sql<MerchantRow[]>`
SELECT * FROM merchant ORDER BY created_at DESC LIMIT ${limit}`;
}
/** 주기 리프레시 대상: 한 번도 생성 안 됐거나 N일 지난 업체 */
async findStale(intervalDays: number, limit: number) {
return this.sql<Array<{ id: string; external_id: string }>>`
SELECT id, external_id FROM merchant
WHERE last_generated_at IS NULL
OR last_generated_at < now() - (${intervalDays} || ' days')::interval
ORDER BY last_generated_at NULLS FIRST
LIMIT ${limit}`;
}
async markGenerated(merchantId: string) {
await this.sql`UPDATE merchant SET last_generated_at = now() WHERE id = ${merchantId}`;
}
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isUuid = (v: string) => UUID_RE.test(v);