Skip to content
This repository was archived by the owner on Sep 24, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 문안은 **원어민·의료 검토 전 초안**이다. 검토를 기다리는 동안 한국어만
나가는 것보다 낫다고 보고 넣었다.

## 알림 도달

| 채널 | 닿는 곳 | 비고 |
Expand Down Expand Up @@ -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건뿐이고 출처가 하나(국립수산과학원)라 먼저 손댈 수 있었다.
21 changes: 21 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
151 changes: 149 additions & 2 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { randomBytes, scryptSync } from 'node:crypto';
import { guideSourceHash } from '../src/contexts/beach/domain/guide-translation';
import { PrismaClient } from '@prisma/client';

/**
Expand Down Expand Up @@ -465,20 +466,166 @@ 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<string, Record<string, { title: string; body: string }>> = {
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 },
{ guideCode: 'DISCLAIMER_ADMIN', targetType: 'admin', title: '운영 판단 안내', body: 'AI 판별 결과는 관리자 확인 전 확정 데이터가 아닙니다. 운영기관 기준에 따라 최종 조치하세요.', displayOrder: 1 },
{ 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 — 원어민 검토 전 초안)`,
);
}

/**
Expand Down
48 changes: 48 additions & 0 deletions prisma/sql/009-static-guide-translations.sql
Original file line number Diff line number Diff line change
@@ -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 불일치로 무시된다';
15 changes: 15 additions & 0 deletions src/contexts/beach/adapter/in/web/dto/static-guide.response.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { SUPPORTED_LOCALES } from '@shared/i18n/locale';

/** G-006 안내/고지 문구 응답 (StaticGuideView 미러). */
export class StaticGuideResponse {
Expand All @@ -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;
}
Loading
Loading