diff --git a/README.md b/README.md index a914dbd..f556833 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,43 @@ Prometheus 노출 형식이라 Grafana Agent·Prometheus 가 그대로 읽는다 라벨은 서버가 정해 `riskLevelLabel` 로 내려준다. 앱·문자·제휴사가 **같은 단계를 같은 말로** 불러야 하기 때문이다(각자 번역하면 조용히 달라진다). +## 다국어 — 특히 응급대처법 + +공개 응답은 `?lang=` 또는 `Accept-Language` 를 따른다(`ko` `en` `zh` `ja`, 규칙은 +`shared/i18n/locale.decorator.ts` 한 곳). ⚠️ 언어로 갈라지는 응답은 **캐시 키에도 언어가 +들어가야 한다** — 안 그러면 한국어 응답이 영어 요청에 그대로 나간다. + +`GET /public/guides` 는 오랫동안 이 규칙에서 빠져 있었다(#94). 그 엔드포인트가 싣고 있던 것이 +**응급대처법**과 **면책 문구**다 — 쏘인 직후에 읽는 글과, 책임 범위를 밝히는 문장이다. +읽지 못하는 사람에게는 고지가 이뤄지지 않은 것과 같다. + +### 원문이 바뀌면 번역을 버린다 + +`seed.ts` 는 `FIRST_AID` 를 **재시드마다 강제로 덮어쓴다.** 국립수산과학원이 지침을 바꿨는데 +옛 문구가 남아 있으면 사람이 다치기 때문이다 — 옛 지침은 식초·알코올을 권했는데, 지금은 그게 +자포(독침) 발사를 촉진한다고 본다. + +번역을 붙이면 그 위험이 그대로 옮겨온다. 한국어만 갱신되고 영어가 옛 지침으로 남으면 +**외국인 방문객은 현행과 반대되는 응급처치를 안내받는다.** 읽을 수 있다는 점이 오히려 피해를 키운다. + +그래서 번역 행에 **번역 시점의 원문 해시**를 함께 저장하고, 읽을 때 원문과 어긋나면 그 번역을 +쓰지 않고 한국어로 떨어뜨린다. + +``` +static_guides ko 원문 (여기가 원본) +static_guide_translations en/zh/ja + source_hash + ↳ 해시가 다르면 무시 → ko 로 폴백 +``` + +응답의 `locale` 이 **실제로 내보낸 언어**다. 요청 언어와 다를 수 있고, 화면은 그걸 보고 +"원문(한국어)" 임을 표시할 수 있다. + +⚠️ 이 장치는 **번역이 낡았다고 알려주지는 않는다.** 조용히 한국어로 떨어질 뿐이다. +원문을 고친 사람이 번역도 고쳐야 한다는 것은 여전히 운영 규칙이다. + +⚠️ 현재 en/zh/ja 문안은 **원어민·의료 검토 전 초안**이다. 검토를 기다리는 동안 한국어만 +나가는 것보다 낫다고 보고 넣었다. + ## 알림 도달 | 채널 | 닿는 곳 | 비고 | @@ -593,10 +630,11 @@ IP 상한에 걸린 사람에게는 "잠시 후 다시" 대신 **모바일 데 로 네 경로를 한꺼번에 닫아 둘 수 있다. 6. **하지 않은 것과 그 이유** - **해역별 룰 파라미터** — 전국 확장 전에는 근거가 없다. 백테스트 표본이 제주 기준 - 136주뿐이라 해역별로 쪼개면 구간마다 수십 주가 되고, 그 크기에서 나온 가중치 차이는 - 신호가 아니라 잡음이다. **데이터가 먼저다.** + **156단위(78주 × 2시군구, 고밀도 34)** 뿐이라 해역별로 쪼개면 구간마다 수십 주가 되고, + 그 크기에서 나온 가중치 차이는 신호가 아니라 잡음이다. **데이터가 먼저다.** - **FCM** — 네이티브 앱이 없어 보낼 대상이 없다. 웹푸시는 이미 동작한다. - **재난문자(CBS)** — 코드가 아니라 **행안부 권한** 문제다. - - **다국어 DB 콘텐츠** — 코드에 든 문구는 4개 언어로 나간다(`docs/i18n.md`). 해변 이름· - 해파리 종 정보·알림 문구는 **DB 콘텐츠**라 번역 컬럼과 검수 주체가 함께 있어야 한다 - (종 정보는 오역이 안전에 직결된다). + - **다국어 DB 콘텐츠 (일부만 됨)** — 코드에 든 문구와 **안내/고지 문구**(응급대처법 포함)는 + 4개 언어로 나간다(`docs/i18n.md`, 위 "다국어" 절). 남은 것은 해변 이름·**해파리 종 정보**· + 알림 문구다. 종 정보는 오역이 안전에 직결되므로 번역 컬럼보다 **검수 주체**가 먼저 정해져야 + 한다 — guides 는 원문이 4건뿐이고 출처가 하나(국립수산과학원)라 먼저 손댈 수 있었다. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 052451e..e345e38 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -137,9 +137,30 @@ model StaticGuide { createdAt DateTime @default(now()) @map("created_at") @db.DateTime(0) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(0) + translations StaticGuideTranslation[] + @@map("static_guides") } +/// 안내/고지 문구의 다국어 문안. ko 는 여기 두지 않는다 — 원문은 StaticGuide 에 있다. +model StaticGuideTranslation { + id BigInt @id @default(autoincrement()) + guideId BigInt @map("guide_id") + locale String @db.VarChar(10) + title String? @db.VarChar(200) + body String @db.Text + /// 번역 시점의 원문(제목+본문) 해시. 원문이 바뀌면 달라져 **이 번역은 무시된다** — + /// 한국어만 갱신된 상태에서 옛 응급처치를 외국인에게 내보내지 않기 위한 장치다. + sourceHash String @map("source_hash") @db.Char(64) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(0) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(0) + + guide StaticGuide @relation(fields: [guideId], references: [id], onDelete: Cascade) + + @@unique([guideId, locale], name: "uk_static_guide_translations_guide_locale") + @@map("static_guide_translations") +} + model RiskRecommendation { id BigInt @id @default(autoincrement()) actionCode String @unique @map("action_code") @db.VarChar(50) diff --git a/prisma/seed.ts b/prisma/seed.ts index 611f116..8502934 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,4 +1,5 @@ import { randomBytes, scryptSync } from 'node:crypto'; +import { guideSourceHash } from '../src/contexts/beach/domain/guide-translation'; import { PrismaClient } from '@prisma/client'; /** @@ -465,6 +466,129 @@ const FIRST_AID_BODY = [ '출처: 국립수산과학원 「해파리 응급대처법」 (2026-07-14 확인)', ].join('\n'); +/** + * 안내/고지 문구의 다국어 문안 (#94). + * + * ⚠️ **응급대처법은 안전 문서다.** 의역하지 않고 원문의 절차·금지사항을 그대로 옮긴다. + * 특히 "수돗물로 씻지 마세요" 와 "온찜질 45℃" 는 잘못 옮기면 피해가 커지는 항목이다. + * + * ⚠️ **원어민·의료 검토를 받지 않은 초안이다.** 배포 전 검토가 필요하다. 다만 검토를 + * 기다리는 동안 외국인 방문객에게 한국어만 나가는 것보다는 낫다고 판단했다 — + * 원문이 바뀌면 자동으로 한국어로 떨어지는 안전장치(source_hash)가 있다. + * + * ko 는 여기 없다. 원문은 static_guides 에 있고, 여기 또 두면 둘이 갈라진다. + */ +const FIRST_AID_EN = [ + 'If you are stung by a jellyfish, get out of the water immediately.', + '', + '[Mild stings]', + '1. Quickly remove any remaining tentacles with seawater or sterile saline, and rinse the area thoroughly.', + '2. If pain persists, apply a warm compress (around 45°C / 113°F) to relieve it.', + '3. Check that the wound has settled.', + '', + '[If severe symptoms appear]', + 'If there is difficulty breathing, loss of consciousness, or whole-body pain,', + 'call 119 immediately and ask for medical help (perform CPR if needed).', + 'The person must then be taken to a hospital for emergency treatment.', + '', + '[Must do]', + '· Do NOT rinse with tap water. It makes the stinging cells fire more and worsens the injury.', + '· Keep skin exposure to a minimum when entering the water.', + '', + 'Source: National Institute of Fisheries Science, "Jellyfish First Aid" (checked 2026-07-14)', +].join('\n'); + +const FIRST_AID_ZH = [ + '被水母蜇伤后,请立即离开水中。', + '', + '【轻度蜇伤】', + '1. 用海水或生理盐水迅速清除残留的触手,并充分冲洗伤口。', + '2. 如仍有疼痛,可用温热敷(约45℃)缓解疼痛。', + '3. 确认伤口已充分稳定。', + '', + '【出现严重症状时】', + '如出现呼吸困难、意识不清、全身疼痛等症状,', + '请立即拨打119并请求医疗救助(必要时进行心肺复苏)。', + '随后必须送往医院接受急救治疗。', + '', + '【务必遵守】', + '· 请勿用自来水冲洗。这会使刺细胞发射增加,加重伤害。', + '· 入水时请尽量减少皮肤暴露。', + '', + '来源:韩国国立水产科学院《水母应急处理方法》(2026-07-14 确认)', +].join('\n'); + +const FIRST_AID_JA = [ + 'クラゲに刺されたら、直ちに水から上がってください。', + '', + '【軽く刺された場合】', + '1. 刺された部位に残った触手を海水または生理食塩水で速やかに取り除き、十分に洗い流します。', + '2. 痛みが残る場合は、温罨法(45℃前後)で痛みを和らげます。', + '3. 傷の状態が落ち着いたか確認します。', + '', + '【重い症状が出た場合】', + '呼吸困難・意識不明・全身の痛みなどの症状が出た場合は、', + '直ちに119番に通報し、医療スタッフの助けを求めてください(必要に応じて心肺蘇生)。', + 'その後、病院へ搬送して救急治療を受ける必要があります。', + '', + '【必ず守ること】', + '· 水道水で洗わないでください。クラゲの刺胞の発射が増え、被害が大きくなります。', + '· 入水の際は肌の露出を最小限にしてください。', + '', + '出典:国立水産科学院「クラゲ応急対処法」(2026-07-14 確認)', +].join('\n'); + +/** guideCode → 언어별 문안. 없는 언어는 한국어 원문으로 떨어진다. */ +const GUIDE_TRANSLATIONS: Record> = { + DISCLAIMER_PUBLIC: { + en: { + title: 'About JellySafe risk information', + body: 'JellySafe risk levels are reference information. Instructions from on-site lifeguards and the operating authority take precedence.', + }, + zh: { + title: '关于风险信息的说明', + body: 'JellySafe 的风险等级仅供参考。现场救生员及运营机构的最终指示优先。', + }, + ja: { + title: '危険度参考情報のご案内', + body: 'JellySafe の危険度は参考情報です。現場の安全要員および運営機関の最終案内が優先します。', + }, + }, + DISCLAIMER_ADMIN: { + en: { + title: 'Note for operational decisions', + body: 'AI classification results are not confirmed data until an administrator reviews them. Take final action according to your organization\u2019s criteria.', + }, + zh: { + title: '运营判断说明', + body: 'AI 判别结果在管理员确认前不是确定数据。请按运营机构标准采取最终措施。', + }, + ja: { + title: '運用判断のご案内', + body: 'AI 判別結果は管理者の確認前は確定データではありません。運営機関の基準に従って最終対応してください。', + }, + }, + SAFETY_SEVERE: { + en: { + title: 'Severe level safety notice', + body: 'Please refrain from entering the water and consider an alternative beach. If stung, notify a lifeguard immediately.', + }, + zh: { + title: '严重等级安全提示', + body: '请避免入水,建议改用其他海水浴场。若被蜇伤,请立即告知救生员。', + }, + ja: { + title: '「深刻」段階の安全案内', + body: '入水はお控えいただき、他の海水浴場のご利用をおすすめします。刺された場合はすぐに安全要員にお知らせください。', + }, + }, + FIRST_AID: { + en: { title: 'Jellyfish sting first aid', body: FIRST_AID_EN }, + zh: { title: '水母蜇伤应急处理方法', body: FIRST_AID_ZH }, + ja: { title: 'クラゲ接触被害の応急対処法', body: FIRST_AID_JA }, + }, +}; + async function seedGuides() { const guides = [ { guideCode: 'DISCLAIMER_PUBLIC', targetType: 'public', title: '위험도 참고 정보 안내', body: 'JellySafe 위험도는 참고 정보이며, 현장 안전요원 및 운영기관의 최종 안내가 우선합니다.', displayOrder: 1 }, @@ -472,13 +596,36 @@ async function seedGuides() { { guideCode: 'SAFETY_SEVERE', targetType: 'public', riskLevel: 'severe', title: '심각 단계 안전 안내', body: '입수를 자제하고 대체 해변 이용을 권장합니다. 쏘임 시 즉시 안전요원에게 알리세요.', displayOrder: 2 }, { guideCode: 'FIRST_AID', targetType: 'public', title: '해파리 접촉피해 응급대처법', body: FIRST_AID_BODY, displayOrder: 3 }, ]; + let translationCount = 0; for (const g of guides) { // 응급처치 문구는 update 에도 넣는다. 지침이 바뀌었는데 재시드해도 옛 문구가 남아 있으면 // 사람이 다칠 수 있다(다른 시드와 달리 update:{} 가 아니다). const update = g.guideCode === 'FIRST_AID' ? { title: g.title, body: g.body } : {}; - await prisma.staticGuide.upsert({ where: { guideCode: g.guideCode }, update, create: g }); + const saved = await prisma.staticGuide.upsert({ + where: { guideCode: g.guideCode }, + update, + create: g, + }); + + // 번역은 **원문 해시와 함께** 저장한다. 원문이 바뀌면 해시가 달라져 그 번역은 무시되고 + // 한국어로 떨어진다 — 한국어만 갱신된 상태에서 옛 응급처치를 외국인에게 내보내지 + // 않기 위한 장치다(domain/guide-translation.ts). + const sourceHash = guideSourceHash(saved.title, saved.body); + for (const [locale, text] of Object.entries(GUIDE_TRANSLATIONS[g.guideCode] ?? {})) { + await prisma.staticGuideTranslation.upsert({ + where: { + uk_static_guide_translations_guide_locale: { guideId: saved.id, locale }, + }, + update: { title: text.title, body: text.body, sourceHash }, + create: { guideId: saved.id, locale, title: text.title, body: text.body, sourceHash }, + }); + translationCount += 1; + } } - console.log(` ✓ 안내/고지 문구 ${guides.length}건 (응급대처법 포함 — 출처: 국립수산과학원)`); + console.log( + ` ✓ 안내/고지 문구 ${guides.length}건 (응급대처법 포함 — 출처: 국립수산과학원)` + + `, 번역 ${translationCount}건 (en/zh/ja — 원어민 검토 전 초안)`, + ); } /** diff --git a/prisma/sql/009-static-guide-translations.sql b/prisma/sql/009-static-guide-translations.sql new file mode 100644 index 0000000..b4b753e --- /dev/null +++ b/prisma/sql/009-static-guide-translations.sql @@ -0,0 +1,48 @@ +-- ===================================================================================== +-- 009. static_guide_translations — 안내/고지 문구의 다국어 문안 +-- +-- `/public/guides` 가 언어 설정을 따르지 않아 **응급대처법이 한국어로만 나가고 있었다**(#94). +-- 그 글은 쏘인 직후에 읽는 글이고, 면책 문구는 책임 범위를 밝히는 문장이다 — 읽지 못하는 +-- 사람에게는 고지가 이뤄지지 않은 것과 같다. +-- +-- ── 왜 컬럼이 아니라 테이블인가 ──────────────────────────────────────────────────── +-- title_en / body_en / title_zh … 로 늘리면 언어를 추가할 때마다 DDL 이 필요하고, 비어 있는 +-- 칸과 "번역이 없음" 이 구분되지 않는다. 행으로 두면 없는 언어는 그냥 행이 없다. +-- +-- ── ⚠️ source_hash — 낡은 번역을 내보내지 않기 위한 것 ───────────────────────────── +-- seed.ts 는 FIRST_AID 를 **재시드마다 강제로 덮어쓴다.** 국립수산과학원이 지침을 바꿨는데 +-- 옛 문구가 남아 있으면 사람이 다칠 수 있기 때문이다(그 주석이 이미 코드에 있다). +-- +-- 번역을 붙이면 그 위험이 그대로 옮겨온다 — 한국어만 갱신되고 영어가 옛 지침으로 남으면, +-- 외국인 방문객은 **현행과 반대되는 응급처치를 안내받는다.** +-- +-- 그래서 번역 시점의 원문 해시를 함께 저장한다. 읽을 때 원문 해시가 다르면 그 번역은 +-- **쓰지 않고 한국어로 떨어뜨린다.** 읽을 수 있지만 틀릴 수 있는 글보다, 기계번역이라도 +-- 스스로 돌려 읽을 수 있는 현행 원문이 낫다. +-- +-- 적용: +-- mysql -u root -p jellysafe < prisma/sql/009-static-guide-translations.sql +-- ===================================================================================== + +CREATE TABLE IF NOT EXISTS static_guide_translations ( + id BIGINT NOT NULL AUTO_INCREMENT, + guide_id BIGINT NOT NULL, + + -- ko 는 여기 두지 않는다. 원문은 static_guides 에 있고, 여기 또 두면 둘이 갈라진다. + locale VARCHAR(10) COLLATE utf8mb4_bin NOT NULL, + + title VARCHAR(200) NULL, + body TEXT NOT NULL, + + -- 번역한 시점의 **원문(제목+본문) 해시**. 원문이 바뀌면 값이 달라져 번역이 무시된다. + source_hash CHAR(64) COLLATE utf8mb4_bin NOT NULL, + + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + UNIQUE KEY uk_static_guide_translations_guide_locale (guide_id, locale), + + CONSTRAINT fk_static_guide_translations_guide + FOREIGN KEY (guide_id) REFERENCES static_guides (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='안내/고지 문구 다국어 문안 — 원문이 바뀌면 source_hash 불일치로 무시된다'; diff --git a/src/contexts/beach/adapter/in/web/dto/static-guide.response.ts b/src/contexts/beach/adapter/in/web/dto/static-guide.response.ts index afb1d18..af7c859 100644 --- a/src/contexts/beach/adapter/in/web/dto/static-guide.response.ts +++ b/src/contexts/beach/adapter/in/web/dto/static-guide.response.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { SUPPORTED_LOCALES } from '@shared/i18n/locale'; /** G-006 안내/고지 문구 응답 (StaticGuideView 미러). */ export class StaticGuideResponse { @@ -8,5 +9,19 @@ export class StaticGuideResponse { @ApiProperty({ example: 'danger', nullable: true }) riskLevel!: string | null; @ApiProperty({ example: '입수 주의 안내', nullable: true }) title!: string | null; @ApiProperty({ example: '현재 위험 단계에서는 입수를 자제해 주세요.' }) body!: string; + + @ApiProperty({ + example: 'ko', + enum: SUPPORTED_LOCALES as readonly string[], + description: [ + '**실제로 내보낸 언어.** 요청 언어와 다를 수 있다.', + '', + '⚠️ 번역이 없거나 **원문이 바뀐 뒤의 낡은 번역**이면 `ko` 로 떨어진다 —', + '응급대처법 지침이 갱신됐는데 옛 번역을 내보내면 현행과 반대되는 처치를 안내하게 된다.', + '화면에서 "원문(한국어)" 임을 표시할지 판단하는 데 쓴다.', + ].join(' '), + }) + locale!: string; + @ApiProperty({ example: 1 }) displayOrder!: number; } diff --git a/src/contexts/beach/adapter/in/web/public-guide.controller.ts b/src/contexts/beach/adapter/in/web/public-guide.controller.ts index c5dd89a..9c781ac 100644 --- a/src/contexts/beach/adapter/in/web/public-guide.controller.ts +++ b/src/contexts/beach/adapter/in/web/public-guide.controller.ts @@ -1,4 +1,6 @@ import { Controller, Get, Inject, Query } from '@nestjs/common'; +import { RequestLocale } from '@shared/i18n/locale.decorator'; +import { Locale } from '@shared/i18n/locale'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOkDataArray } from '@shared/http/api-response.decorator'; import { @@ -26,12 +28,24 @@ export class PublicGuideController { '- `riskLevel` 로 필터하면 해당 위험 단계용 문구만 받는다.', '- `targetType` 으로 대상(일반 사용자/운영자)을 구분한다.', '', + '**언어** — `?lang=` 또는 `Accept-Language` 를 따른다(ko/en/zh/ja).', + '응답의 `locale` 이 **실제로 내보낸 언어**이고, 요청 언어와 다를 수 있다.', + '', + '⚠️ 번역이 없거나 **원문이 바뀐 뒤의 낡은 번역**이면 한국어 원문으로 떨어진다.', + '응급대처법은 지침이 바뀌면 재시드로 원문이 갱신되는데, 그때 옛 번역을 그대로 내보내면', + '**현행과 반대되는 응급처치를 안내하게 된다.** 읽을 수 있지만 틀릴 수 있는 글보다', + '기계번역으로라도 돌려 읽을 수 있는 현행 원문이 낫다고 보고 그렇게 했다.', + '', '인증 불필요.', ].join('\n'), }) @ApiOkDataArray(StaticGuideResponse) @Get() - list(@Query() query: ListGuidesQuery) { - return this.listGuides.list({ targetType: query.targetType, riskLevel: query.riskLevel }); + list(@Query() query: ListGuidesQuery, @RequestLocale() locale: Locale) { + return this.listGuides.list({ + targetType: query.targetType, + riskLevel: query.riskLevel, + locale, + }); } } diff --git a/src/contexts/beach/adapter/out/persistence/guide.kysely-query.ts b/src/contexts/beach/adapter/out/persistence/guide.kysely-query.ts index b5f54a3..745701e 100644 --- a/src/contexts/beach/adapter/out/persistence/guide.kysely-query.ts +++ b/src/contexts/beach/adapter/out/persistence/guide.kysely-query.ts @@ -4,6 +4,7 @@ import { RiskLevel } from '@shared/kernel/risk-level'; import { GuideTargetType } from '../../../domain/beach-enums'; import { StaticGuideView } from '../../../domain/static-guide'; import { GuideListFilter, GuideQueryPort } from '../../../application/port/out/guide-query.port'; +import { GuideTranslation, localizeGuide } from '../../../domain/guide-translation'; /** * 안내/고지 문구 조회 어댑터 (Kysely, G-006). @@ -33,14 +34,55 @@ export class GuideKyselyQuery implements GuideQueryPort { .orderBy('g.id', 'asc') .execute(); - return rows.map((row) => ({ - id: Number(row.id), - guideCode: row.guideCode, - targetType: row.targetType as GuideTargetType, - riskLevel: (row.riskLevel as RiskLevel | null) ?? null, - title: row.title ?? null, - body: row.body, - displayOrder: Number(row.displayOrder), - })); + // 한국어면 번역을 읽을 이유가 없다. 원문이 곧 답이다. + const translations = + filter.locale === 'ko' || rows.length === 0 + ? [] + : await this.db + .selectFrom('static_guide_translations as t') + .select([ + 't.guide_id as guideId', + 't.locale as locale', + 't.title as title', + 't.body as body', + 't.source_hash as sourceHash', + ]) + .where( + 't.guide_id', + 'in', + rows.map((r) => Number(r.id)), + ) + .where('t.locale', '=', filter.locale) + .execute(); + + const byGuide = new Map(); + for (const t of translations) { + const id = Number(t.guideId); + byGuide.set(id, [ + ...(byGuide.get(id) ?? []), + { locale: t.locale, title: t.title ?? null, body: t.body, sourceHash: t.sourceHash }, + ]); + } + + return rows.map((row) => { + const id = Number(row.id); + // 번역이 없거나 **원문이 바뀐 뒤의 번역**이면 한국어로 떨어진다(도메인 주석 참고). + const localized = localizeGuide( + { title: row.title ?? null, body: row.body }, + byGuide.get(id) ?? [], + filter.locale, + ); + + return { + id, + guideCode: row.guideCode, + targetType: row.targetType as GuideTargetType, + riskLevel: (row.riskLevel as RiskLevel | null) ?? null, + title: localized.title, + body: localized.body, + locale: localized.locale, + displayOrder: Number(row.displayOrder), + }; + }); } } diff --git a/src/contexts/beach/application/port/out/guide-query.port.ts b/src/contexts/beach/application/port/out/guide-query.port.ts index a32399c..d4b8fe6 100644 --- a/src/contexts/beach/application/port/out/guide-query.port.ts +++ b/src/contexts/beach/application/port/out/guide-query.port.ts @@ -1,4 +1,5 @@ import { RiskLevel } from '@shared/kernel/risk-level'; +import { Locale } from '@shared/i18n/locale'; import { GuideTargetType } from '../../../domain/beach-enums'; import { StaticGuideView } from '../../../domain/static-guide'; @@ -6,6 +7,13 @@ import { StaticGuideView } from '../../../domain/static-guide'; export interface GuideListFilter { targetType?: GuideTargetType; riskLevel?: RiskLevel; + /** + * 표시 언어. **선택이 아니라 필수다.** + * + * 기본값을 주면 새로 부르는 쪽이 언어를 잊어도 조용히 한국어가 나간다 — 이 엔드포인트가 + * 언어를 따르지 않던 원인이 정확히 그런 종류였다(#94). 부르는 쪽이 매번 정하게 한다. + */ + locale: Locale; } /** diff --git a/src/contexts/beach/domain/guide-translation.spec.ts b/src/contexts/beach/domain/guide-translation.spec.ts new file mode 100644 index 0000000..ff68cfa --- /dev/null +++ b/src/contexts/beach/domain/guide-translation.spec.ts @@ -0,0 +1,84 @@ +import { guideSourceHash, localizeGuide } from './guide-translation'; + +/** + * 안내 문구 언어 선택. + * + * ⚠️ 여기서 지키는 것은 "번역을 보여준다" 가 아니라 **"틀릴 수 있는 번역을 안 보여준다"** 다. + * 응급대처법은 지침이 바뀌면 원문이 갱신되는데(seed 가 FIRST_AID 를 강제로 덮어쓴다), + * 그때 옛 번역을 그대로 내보내면 외국인 방문객은 **현행과 반대되는 처치**를 안내받는다. + * 옛 지침은 식초·알코올을 권했고, 지금은 그게 자포 발사를 촉진한다고 본다. + */ +describe('localizeGuide', () => { + const source = { title: '해파리 응급대처법', body: '수돗물로 씻지 마세요.' }; + const hash = guideSourceHash(source.title, source.body); + + const en = { + locale: 'en', + title: 'Jellyfish first aid', + body: 'Do not rinse with tap water.', + sourceHash: hash, + }; + + it('번역이 있으면 그 언어로 준다', () => { + const result = localizeGuide(source, [en], 'en'); + + expect(result.locale).toBe('en'); + expect(result.body).toBe('Do not rinse with tap water.'); + }); + + it('한국어 요청이면 원문을 그대로 준다', () => { + expect(localizeGuide(source, [en], 'ko')).toMatchObject({ locale: 'ko', body: source.body }); + }); + + it('그 언어 번역이 없으면 한국어로 떨어진다 — 빈 값을 내보내면 안내가 사라진다', () => { + const result = localizeGuide(source, [en], 'ja'); + + expect(result.locale).toBe('ko'); + expect(result.body).toBe(source.body); + }); + + it('⚠️ 원문이 바뀌면 옛 번역을 버리고 한국어(현행)를 준다', () => { + // 지침이 개정된 상황. 번역은 아직 옛 문구다. + const revised = { title: source.title, body: '바닷물로 씻으세요. (개정)' }; + + const result = localizeGuide(revised, [en], 'en'); + + expect(result.locale).toBe('ko'); + // 읽을 수 있지만 틀릴 수 있는 글보다, 기계번역으로라도 돌려 읽을 수 있는 현행 원문이 낫다. + expect(result.body).toBe('바닷물로 씻으세요. (개정)'); + }); + + it('제목만 바뀌어도 번역을 버린다', () => { + const revised = { title: '해파리 응급처치 (개정)', body: source.body }; + + expect(localizeGuide(revised, [en], 'en').locale).toBe('ko'); + }); + + it('제목이 없는 문구도 다룬다', () => { + const noTitle = { title: null, body: '본문만 있는 안내' }; + const tr = { + locale: 'en', + title: null, + body: 'Body only', + sourceHash: guideSourceHash(null, noTitle.body), + }; + + expect(localizeGuide(noTitle, [tr], 'en')).toMatchObject({ locale: 'en', body: 'Body only' }); + }); +}); + +describe('guideSourceHash', () => { + it('같은 원문이면 같은 해시다', () => { + expect(guideSourceHash('가', '나')).toBe(guideSourceHash('가', '나')); + }); + + it('⚠️ 제목과 본문의 경계가 흔들려도 다른 해시다', () => { + // 구분자가 없으면 ('AB','C') 와 ('A','BC') 가 같은 해시가 되어, 경계만 바뀐 개정을 + // "안 바뀐 것" 으로 보게 된다. + expect(guideSourceHash('AB', 'C')).not.toBe(guideSourceHash('A', 'BC')); + }); + + it('제목 없음과 빈 제목을 같게 본다 — DB 에서 NULL 과 빈 문자열이 오간다', () => { + expect(guideSourceHash(null, '본문')).toBe(guideSourceHash('', '본문')); + }); +}); diff --git a/src/contexts/beach/domain/guide-translation.ts b/src/contexts/beach/domain/guide-translation.ts new file mode 100644 index 0000000..cefb3df --- /dev/null +++ b/src/contexts/beach/domain/guide-translation.ts @@ -0,0 +1,71 @@ +import { createHash } from 'node:crypto'; +import { Locale } from '@shared/i18n/locale'; + +/** + * 안내/고지 문구의 언어 선택. + * + * ── ⚠️ 왜 번역이 있어도 안 쓸 때가 있나 ────────────────────────────────────────────── + * `seed.ts` 는 FIRST_AID(응급대처법)를 **재시드마다 강제로 덮어쓴다.** 국립수산과학원이 + * 지침을 바꿨는데 옛 문구가 남아 있으면 사람이 다칠 수 있기 때문이다. 실제로 옛 지침은 + * 식초·알코올을 권했는데, 그건 자포 발사를 촉진할 수 있어 지금은 반대로 안내한다. + * + * 번역을 붙이면 그 위험이 그대로 옮겨온다 — 한국어만 갱신되고 영어가 옛 지침으로 남으면 + * **외국인 방문객은 현행과 반대되는 응급처치를 안내받는다.** 읽을 수 있다는 점이 오히려 + * 피해를 키운다. + * + * 그래서 번역 시점의 원문 해시를 함께 저장하고, 읽을 때 **원문이 바뀌었으면 그 번역을 + * 버린다.** 읽을 수 있지만 틀릴 수 있는 글보다, 기계번역으로라도 스스로 돌려 읽을 수 있는 + * 현행 원문이 낫다. + * + * ⚠️ 이 장치는 **번역이 낡았다는 것을 알려주지는 않는다.** 조용히 한국어로 떨어질 뿐이다. + * 원문을 고친 사람이 번역도 고쳐야 한다는 것은 여전히 운영 규칙이다. + */ + +/** 원문(제목 + 본문)의 해시. 번역이 어느 원문에 대응하는지를 이 값으로 잇는다. */ +export function guideSourceHash(title: string | null, body: string): string { + // 제목과 본문 사이에 구분자를 넣는다. 없으면 ("AB", "C") 와 ("A", "BC") 가 같은 해시가 된다. + return createHash('sha256').update(`${title ?? ''}\u0000${body}`).digest('hex'); +} + +export interface GuideSource { + title: string | null; + body: string; +} + +export interface GuideTranslation { + locale: string; + title: string | null; + body: string; + sourceHash: string; +} + +export interface LocalizedGuide { + title: string | null; + body: string; + /** 실제로 내보낸 언어. 번역이 없거나 낡아 한국어로 떨어지면 'ko'. */ + locale: Locale; +} + +/** + * 요청 언어로 문구를 고른다. 없거나 원문과 어긋나면 **한국어 원문**으로 떨어진다. + * + * 빈 값을 내려보내지 않는 것이 이 함수의 계약이다 — 화면에서 안내가 통째로 사라지면 + * 번역이 없는 것보다 나쁘다. + */ +export function localizeGuide( + source: GuideSource, + translations: GuideTranslation[], + locale: Locale, +): LocalizedGuide { + if (locale === 'ko') return { ...source, locale: 'ko' }; + + const hash = guideSourceHash(source.title, source.body); + const match = translations.find((t) => t.locale === locale); + + // 원문이 바뀐 뒤의 번역은 쓰지 않는다(위 주석). + if (match === undefined || match.sourceHash !== hash) { + return { ...source, locale: 'ko' }; + } + + return { title: match.title, body: match.body, locale }; +} diff --git a/src/contexts/beach/domain/static-guide.ts b/src/contexts/beach/domain/static-guide.ts index 1f5f2d1..4442d0b 100644 --- a/src/contexts/beach/domain/static-guide.ts +++ b/src/contexts/beach/domain/static-guide.ts @@ -1,4 +1,5 @@ import { Id } from '@shared/kernel/id'; +import { Locale } from '@shared/i18n/locale'; import { RiskLevel } from '@shared/kernel/risk-level'; import { GuideTargetType } from './beach-enums'; @@ -14,5 +15,13 @@ export interface StaticGuideView { riskLevel: RiskLevel | null; title: string | null; body: string; + /** + * 실제로 내보낸 언어. + * + * 요청 언어와 다를 수 있다 — 번역이 없거나 **원문이 바뀐 뒤의 낡은 번역**이면 한국어로 + * 떨어진다. 화면이 "번역본입니다/원문입니다" 를 구분해 보여줄 수 있어야 하고, 무엇보다 + * 조용히 떨어진 것을 나중에 추적할 수 있어야 한다. + */ + locale: Locale; displayOrder: number; } diff --git a/src/shared/persistence/kysely/database.types.ts b/src/shared/persistence/kysely/database.types.ts index 6e47742..27da48a 100644 --- a/src/shared/persistence/kysely/database.types.ts +++ b/src/shared/persistence/kysely/database.types.ts @@ -467,6 +467,20 @@ export type StaticGuide = { created_at: Generated; updated_at: Timestamp; }; +export type StaticGuideTranslation = { + id: Generated; + guide_id: number; + locale: string; + title: string | null; + body: string; + /** + * 번역 시점의 원문(제목+본문) 해시. 원문이 바뀌면 달라져 **이 번역은 무시된다** — + * 한국어만 갱신된 상태에서 옛 응급처치를 외국인에게 내보내지 않기 위한 장치다. + */ + source_hash: string; + created_at: Generated; + updated_at: Timestamp; +}; export type StingIncident = { id: Generated; beach_id: number; @@ -580,6 +594,7 @@ export type DB = { risk_recommendations: RiskRecommendation; risk_rule_configs: RiskRuleConfig; risk_scores: RiskScore; + static_guide_translations: StaticGuideTranslation; static_guides: StaticGuide; sting_incidents: StingIncident; subscription_areas: SubscriptionArea; diff --git a/test/persistence.smoke-spec.ts b/test/persistence.smoke-spec.ts index 6842f0b..4188159 100644 --- a/test/persistence.smoke-spec.ts +++ b/test/persistence.smoke-spec.ts @@ -1,4 +1,5 @@ import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { GUIDE_QUERY, GuideQueryPort } from '@contexts/beach/application/port/out/guide-query.port'; import { Test } from '@nestjs/testing'; import request from 'supertest'; import { App } from 'supertest/types'; @@ -2333,6 +2334,70 @@ describe('영속성 스모크', () => { * 저녁에 남긴 기록이 내일 것으로 잡혀, 운영자는 채웠는데 목록이 비지 않는다. * 그 어긋남은 DB 의 시각 비교에서만 드러난다. */ + /** + * 안내/고지 문구의 언어 (#94). + * + * ⚠️ 실 DB 로 봐야 하는 이유 — 이 기능의 핵심은 **번역을 버리는 조건**이고, 그 판단은 + * DB 에 저장된 원문 해시와 현재 원문을 맞춰 보는 것이다. 단위 테스트는 해시 비교만 + * 확인할 뿐, 시드가 해시를 제대로 심었는지는 확인하지 못한다. + */ + describe('안내 문구 다국어', () => { + let guides: GuideQueryPort; + + beforeAll(() => { + guides = app.get(GUIDE_QUERY); + }); + + it('요청 언어의 번역을 준다', async () => { + const rows = await guides.list({ targetType: 'public', locale: 'en' }); + const firstAid = rows.find((g) => g.guideCode === 'FIRST_AID'); + + expect(firstAid?.locale).toBe('en'); + // 응급처치의 핵심 금지사항이 번역에 남아 있어야 한다. + expect(firstAid?.body).toContain('tap water'); + }); + + it('한국어 요청은 원문을 준다', async () => { + const rows = await guides.list({ targetType: 'public', locale: 'ko' }); + + expect(rows.find((g) => g.guideCode === 'FIRST_AID')?.locale).toBe('ko'); + }); + + it('빈 값을 내보내지 않는다 — 안내가 사라지면 번역이 없는 것보다 나쁘다', async () => { + for (const locale of ['ko', 'en', 'zh', 'ja'] as const) { + const rows = await guides.list({ targetType: 'public', locale }); + + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) expect(row.body.length).toBeGreaterThan(0); + } + }); + + it('⚠️ 원문이 바뀌면 옛 번역을 버리고 한국어(현행)를 준다', async () => { + const before = await prisma.staticGuide.findFirstOrThrow({ + where: { guideCode: 'FIRST_AID' }, + select: { id: true, body: true }, + }); + + // 지침 개정을 흉내 낸다. 번역은 옛 원문에 대응한 채 남아 있다. + await prisma.staticGuide.update({ + where: { id: before.id }, + data: { body: `${before.body} +(지침 개정 스모크)` }, + }); + + try { + const rows = await guides.list({ targetType: 'public', locale: 'en' }); + const firstAid = rows.find((g) => g.guideCode === 'FIRST_AID'); + + expect(firstAid?.locale).toBe('ko'); + // 그리고 **갱신된** 원문이어야 한다. 옛 한국어로 떨어지면 그것도 낡은 안내다. + expect(firstAid?.body).toContain('지침 개정 스모크'); + } finally { + await prisma.staticGuide.update({ where: { id: before.id }, data: { body: before.body } }); + } + }); + }); + describe('정답 데이터 수집 체크리스트', () => { let coverage: ObservationCoverageQueryPort; let recordObservation: RecordFieldObservationUseCase;