발행이 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>
93 lines
3.2 KiB
TypeScript
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);
|