diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md
index 94ef877..6be15cb 100644
--- a/PROJECT_MEMORY.md
+++ b/PROJECT_MEMORY.md
@@ -69,3 +69,13 @@
- 기존 포럼 설정과 게시물은 삭제하지 않았다. 데이터베이스 구조 변경과 데이터 이전도 없다.
- 사용자 요청이 없어 백업을 만들지 않았다. 컨테이너 재시작도 하지 않았다.
- 실제 MIDI 장치를 이용한 서비스 동작 검사는 피아노 학습 서비스 자체의 장치 검증 작업으로 남는다.
+
+## 2026-10-15 운영 서버 한국어 번역 복구
+
+- 이전된 서버의 한국어 보완 플러그인 경로가 비어 있음을 확인했다. 전용 원본을 복구하고 포럼 컨테이너만 재시작했다.
+- 음성 대화방 입장 안내와 주변 메뉴 및 관리자 번역을 복구했다. 활성 플러그인 고유 번역 6,004개와 음성 기능 561개를 검사했다. 누락은 0개다.
+- 관리자 주소에 쓰이는 내부 플러그인 이름은 보존하도록 수정했다. 보드 메뉴 두 곳의 제목도 복구했다.
+- 실제 웹 번역과 보드 자산 해시 대조를 배포 검사에 추가했다. PC와 모바일의 안내창 및 관리자 메뉴 주소를 확인했다.
+- DB 구조 변경과 데이터 이전은 없다. 기존 글꼴과 게시물을 보존했다.
+- 백업: `/var/discourse/shared/standalone/backups/forum-korean-20261015-1150`
+- 상세 범위와 복구 방법: [한국어 복구 기록](docs/2026-10-15-localization-recovery.md)
diff --git a/README.md b/README.md
index 23177e9..db0ed29 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,9 @@ yarn deploy
DISCOURSE_CONTAINER=app DISCOURSE_URL=https://forum.itosm.co.kr yarn deploy
```
-배포 명령은 검사 통과 후 실행 파일만 `itosm-admin-korean` 플러그인 경로에 복사한다. 이후 대상 컨테이너만 다시 시작하고 HTTP 200 응답을 확인한다. 백업은 자동으로 만들지 않는다. 백업이 필요한 작업은 별도 승인을 받은 뒤 먼저 진행한다.
+배포는 영구 원본인 `/home/syskiller/discourse-korean-localization`에서 실행한다. 다른 설치 위치를 사용하는 경우 `DISCOURSE_PLUGIN_SOURCE`를 지정한다. 컨테이너 생성 설정에도 같은 원본을 `/var/www/discourse/plugins/itosm-korean-localization`로 연결해야 한다. 이전 `itosm-admin-korean` 플러그인과 중복 설치하지 않는다.
+
+배포 명령은 검사 통과 후 실행 파일을 복사한다. 별도 Rails 부팅에서 플러그인 로딩과 음성 대화 안내 및 활성 플러그인의 번역 누락을 검사한 뒤 `app`만 재시작한다. 마지막으로 사용자 화면과 관리자 화면에서 실제 제공하는 번역 묶음의 해시까지 대조한다. HTTP 200만으로 성공 처리하지 않는다. 원본과 설정 및 필요한 데이터는 배포 전에 작업별로 백업한다.
배포하지 않고 운영 상태만 확인하려면 다음 명령을 사용한다.
diff --git a/docs/2026-10-15-localization-recovery.html b/docs/2026-10-15-localization-recovery.html
new file mode 100644
index 0000000..f88bd8a
--- /dev/null
+++ b/docs/2026-10-15-localization-recovery.html
@@ -0,0 +1 @@
+
포럼 한국어 번역 복구
2026-10-15 · 운영 반영과 실제 화면 검증 완료
원인과 수정
보조 서버의 `app` 컨테이너에는 한국어 보완 플러그인 연결 경로만 남아 있었다. 연결된 호스트 디렉터리가 비어 있어 플러그인을 불러오지 못했다. 데이터베이스에 따로 저장된 번역 530개는 남아 있어 일부 화면만 영어로 보였다. 음성 대화방 입장 안내의 한국어 원문은 전용 저장소에 이미 있었다.
전용 저장소 원본을 `/home/syskiller/discourse-korean-localization`에 복구했다. 게임 프로젝트 경로에 새 파일이나 바로가기를 만들지 않았다. 실행 플러그인 이름과 경로는 `itosm-korean-localization`로 맞췄다. 컨테이너 생성 설정의 연결 경로도 새 원본으로 바꿨다. 현재 컨테이너에는 실행 파일을 복사하고 재시작했다. 새 연결 설정은 다음 컨테이너 재생성 때 사용된다.
관리자 메뉴 검수 중 플러그인 내부 이름까지 번역하면 주소가 잘못 만들어지는 문제를 확인했다. 내부 이름은 유지하고 표시 이름만 번역하도록 고쳤다. 보드 메뉴 두 곳에 번역 키가 그대로 표시되는 문제도 기존 한국어 제목으로 복구했다. 보드 자산을 컴파일하고 서버가 새 파일을 제공하는지 확인했다. 컨테이너 재빌드에는 같은 제목 보완을 적용하는 단계도 추가했다.
검사
| 항목 | 결과 |
|---|
| Ruby 문법과 한국어 YAML 11개 | 통과 |
| 활성 플러그인 번역 | 고유 키 6,004개 검사. 누락 0개 |
| 음성 대화 번역 | 561개 검사. 누락 0개 |
| 일반 코어 문구 | 사용자 문구 누락 없음. 문자 치환과 서수 처리 등 기반 기능 8개는 번역 대상에서 구분 |
| 실제 제공 자산 | 일반 화면과 관리자 화면 및 보드 자산의 내용 해시 일치 |
| 배포 실패 검출 | 재시작 전 예전 웹 번역을 제공할 때 검사 실패 확인 |
| PC 화면 | 입장 안내의 제목과 본문 및 확인란과 버튼 한국어 표시 확인 |
| 모바일 화면 | 390×844에서 같은 안내 확인. 가로 넘침 없음 |
| 관리자 화면 | 대시보드와 보드 설정 및 한국어 메뉴 확인. 보드 경로 정상 |
원문과 같은 값 중 제품명과 기술 식별자 및 서식 문자열은 유지했다. 음성 통화에 실제 참여하거나 마이크 권한을 요청하지 않았다. 게임 서버는 변경하거나 재시작하지 않았다. 앞서 추가한 원티드 산스와 기존 글꼴 선택지도 유지했다.
배포 검사 개선
`scripts/verify_deployment.sh`는 원본 파일 해시와 Rails에서의 플러그인 로딩 및 주요 안내 문구와 활성 플러그인의 번역 누락을 확인한다. 이후 실제 웹 서버가 제공하는 번역과 보드 자산을 대조한다. 파일 존재와 HTTP 200만으로 완료 처리하지 않는다.
`scripts/deploy.sh`는 지정된 영구 원본 경로에서만 실행한다. 재시작 전에 번역을 검사하고 대상 포럼 컨테이너만 재시작한다. 보드 원본 구조가 달라졌다면 제목 보완 단계에서 중단한다.
백업과 복구
- 일반 백업: `/var/discourse/shared/standalone/backups/forum-korean-20261015-1150`
- DB 덤프: `discourse-before.dump` 21,728,860바이트. 복원 목록 3,527줄 확인.
- 원본: `source-before.tar`, 보드 원본 두 파일과 `boards-generated-before`
- 비밀 설정 백업: `/home/syskiller/.local/share/discourse-backups/forum-korean-20261015-1150/app.yml` · 권한 600
- DB 구조 변경과 데이터 이전 없음. 일반 글과 계정 및 설정을 변경하지 않음.
문제 발생 시 변경 전 `app.yml`을 복원하고 새 플러그인 디렉터리를 로딩 대상에서 빼면 변경 전 상태로 돌아간다. 보드 원본과 생성 자산은 위 백업으로 복원한다. 이 절차는 번역이 빠진 이전 상태로 돌아가는 장애 대응용이다. DB 덤프는 별도 복원 시험을 하지 않았다.
검증 범위는 현재 활성 번역과 확인한 화면이다. 비활성 플러그인을 새로 켜거나 제품을 업데이트할 때는 번역 검사를 다시 실행한다.
\ No newline at end of file
diff --git a/docs/2026-10-15-localization-recovery.md b/docs/2026-10-15-localization-recovery.md
new file mode 100644
index 0000000..f288714
--- /dev/null
+++ b/docs/2026-10-15-localization-recovery.md
@@ -0,0 +1,45 @@
+# 포럼 한국어 번역 복구
+
+2026-10-15 · 이창현
+
+## 원인과 수정
+
+보조 서버의 `app` 컨테이너에는 한국어 보완 플러그인 연결 경로만 남아 있었다. 연결된 호스트 디렉터리가 비어 있어 플러그인을 불러오지 못했다. 데이터베이스에 따로 저장된 번역 530개는 남아 있어 일부 화면만 영어로 보였다. 음성 대화방 입장 안내의 한국어 원문은 전용 저장소에 이미 있었다.
+
+전용 저장소 원본을 `/home/syskiller/discourse-korean-localization`에 복구했다. 게임 프로젝트 경로에 새 파일이나 바로가기를 만들지 않았다. 실행 플러그인 이름과 경로는 `itosm-korean-localization`로 맞췄다. 컨테이너 생성 설정의 연결 경로도 새 원본으로 바꿨다. 현재 컨테이너에는 실행 파일을 복사하고 재시작했다. 새 연결 설정은 다음 컨테이너 재생성 때 사용된다.
+
+관리자 메뉴 검수 중 플러그인 내부 이름까지 번역하면 주소가 잘못 만들어지는 문제를 확인했다. 내부 이름은 유지하고 표시 이름만 번역하도록 고쳤다. 보드 메뉴 두 곳에 번역 키가 그대로 표시되는 문제도 기존 한국어 제목으로 복구했다. 보드 자산을 컴파일하고 서버가 새 파일을 제공하는지 확인했다. 컨테이너 재빌드에는 같은 제목 보완을 적용하는 단계도 추가했다.
+
+## 검사
+
+| 항목 | 결과 |
+|---|---|
+| Ruby 문법과 한국어 YAML 11개 | 통과 |
+| 활성 플러그인 번역 | 고유 키 6,004개 검사. 누락 0개 |
+| 음성 대화 번역 | 561개 검사. 누락 0개 |
+| 일반 코어 문구 | 사용자 문구 누락 없음. 문자 치환과 서수 처리 등 기반 기능 8개는 번역 대상에서 구분 |
+| 실제 제공 자산 | 일반 화면과 관리자 화면 및 보드 자산의 내용 해시 일치 |
+| 배포 실패 검출 | 재시작 전 예전 웹 번역을 제공할 때 검사 실패 확인 |
+| PC 화면 | 입장 안내의 제목과 본문 및 확인란과 버튼 한국어 표시 확인 |
+| 모바일 화면 | 390×844에서 같은 안내 확인. 가로 넘침 없음 |
+| 관리자 화면 | 대시보드와 보드 설정 및 한국어 메뉴 확인. 보드 경로 정상 |
+
+원문과 같은 값 중 제품명과 기술 식별자 및 서식 문자열은 유지했다. 음성 통화에 실제 참여하거나 마이크 권한을 요청하지 않았다. 게임 서버는 변경하거나 재시작하지 않았다. 앞서 추가한 원티드 산스와 기존 글꼴 선택지도 유지했다.
+
+## 배포 검사 개선
+
+`scripts/verify_deployment.sh`는 원본 파일 해시와 Rails에서의 플러그인 로딩 및 주요 안내 문구와 활성 플러그인의 번역 누락을 확인한다. 이후 실제 웹 서버가 제공하는 번역과 보드 자산을 대조한다. 파일 존재와 HTTP 200만으로 완료 처리하지 않는다.
+
+`scripts/deploy.sh`는 지정된 영구 원본 경로에서만 실행한다. 재시작 전에 번역을 검사하고 대상 포럼 컨테이너만 재시작한다. 보드 원본 구조가 달라졌다면 제목 보완 단계에서 중단한다.
+
+## 백업과 복구
+
+- 일반 백업: `/var/discourse/shared/standalone/backups/forum-korean-20261015-1150`
+- DB 덤프: `discourse-before.dump` 21,728,860바이트. 복원 목록 3,527줄 확인.
+- 원본: `source-before.tar`, 보드 원본 두 파일과 `boards-generated-before`
+- 비밀 설정 백업: `/home/syskiller/.local/share/discourse-backups/forum-korean-20261015-1150/app.yml` · 권한 600
+- DB 구조 변경과 데이터 이전 없음. 일반 글과 계정 및 설정을 변경하지 않음.
+
+문제 발생 시 변경 전 `app.yml`을 복원하고 새 플러그인 디렉터리를 로딩 대상에서 빼면 변경 전 상태로 돌아간다. 보드 원본과 생성 자산은 위 백업으로 복원한다. 이 절차는 번역이 빠진 이전 상태로 돌아가는 장애 대응용이다. DB 덤프는 별도 복원 시험을 하지 않았다.
+
+검증 범위는 현재 활성 번역과 확인한 화면이다. 비활성 플러그인을 새로 켜거나 제품을 업데이트할 때는 번역 검사를 다시 실행한다.
diff --git a/docs/README.md b/docs/README.md
index 986493f..b98c62b 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -19,3 +19,5 @@ Trilium의 `Ubuntu 서버 운영` 바로 아래에 만든 전용 분류다. 포
## 작성 기준
관련성이 분명한 기존 분류가 없으면 이 폴더 안에 새 문서를 만든다. 다른 프로젝트 기록이나 일반 서버 기록에 섞어 넣지 않는다. 문서는 마이그레이션 기록과 같은 수준으로 표지와 색상과 카드와 표와 제목 단계를 사용한다. 모바일 폭에서도 내용이 겹치거나 잘리지 않게 확인한다.
+
+- [포럼 한국어 번역 복구 · 2026-10-15](2026-10-15-localization-recovery.md) · Trilium `SUFRvrm6MX4L`
diff --git a/package.json b/package.json
index dd7b7ca..160df9d 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
},
"scripts": {
"test": "yarn check:ruby && yarn check:yaml",
- "check:ruby": "ruby -c plugin.rb",
+ "check:ruby": "ruby -c plugin.rb && ruby -c scripts/verify_runtime.rb && ruby -c scripts/verify_web.rb && ruby -c scripts/patch_boards.rb",
"check:yaml": "ruby scripts/validate_locales.rb",
"deploy": "bash scripts/deploy.sh",
"verify:deployment": "bash scripts/verify_deployment.sh"
diff --git a/plugin.rb b/plugin.rb
index dfe653b..d85089a 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
-# name: itosm-admin-korean
-# about: Discourse 관리자 설정 이름을 자연스러운 한국어로 표시합니다.
-# version: 0.1.0
+# name: itosm-korean-localization
+# about: Discourse 일반 화면과 관리자 화면의 한국어 번역을 보완합니다.
+# version: 0.1.1
# authors: 이창현
# url: https://gitea.itosm.co.kr/syskiller/discourse-korean-localization
@@ -50,6 +50,7 @@ module ::ItosmAdminKorean
"discourse-zendesk-plugin" => "Zendesk 연동",
"footnote" => "각주",
"itosm-admin-korean" => "관리자 한국어 번역",
+ "itosm-korean-localization" => "한국어 번역 보완",
"poll" => "설문",
"spoiler-alert" => "내용 숨기기",
"styleguide" => "스타일 가이드",
@@ -1897,10 +1898,8 @@ module ::ItosmAdminKorean
end
module AdminPluginNames
- def name
- ItosmAdminKorean::PLUGIN_NAMES[object.directory_name] || super
- end
-
+ # name은 관리자 주소를 구성하는 식별자이므로 번역하지 않습니다.
+ # 표시 이름만 바꿔야 한국어 메뉴를 눌러도 올바른 플러그인으로 이동합니다.
def humanized_name
ItosmAdminKorean::PLUGIN_NAMES[object.directory_name] || super
end
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
old mode 100755
new mode 100644
index d206a3c..1cdf586
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -3,7 +3,14 @@ set -euo pipefail
root_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
container="${DISCOURSE_CONTAINER:-app}"
-plugin_path="/var/www/discourse/plugins/itosm-admin-korean"
+plugin_path="/var/www/discourse/plugins/itosm-korean-localization"
+source_path="${DISCOURSE_PLUGIN_SOURCE:-/home/syskiller/discourse-korean-localization}"
+
+# 컨테이너만 고치면 재생성할 때 예전 번역으로 돌아갈 수 있으므로 영구 원본에서만 배포합니다.
+if [[ "$root_dir" != "$(realpath "$source_path")" ]]; then
+ echo "영구 원본 경로에서 배포해야 합니다: $source_path" >&2
+ exit 1
+fi
cd "$root_dir"
corepack yarn test
@@ -14,14 +21,24 @@ docker cp plugin.rb "$container:$plugin_path/plugin.rb"
docker cp config/locales/client.ko.yml "$container:$plugin_path/config/locales/client.ko.yml"
docker cp config/locales/server.ko.yml "$container:$plugin_path/config/locales/server.ko.yml"
docker cp assets/. "$container:$plugin_path/assets/"
+docker exec -i "$container" ruby < scripts/patch_boards.rb
+docker exec -u discourse -e RAILS_ENV=production -w /var/www/discourse "$container" \
+ bundle exec rake assets:precompile:build_plugins
+docker exec -u discourse -e RAILS_ENV=production -e SKIP_EMBER_CLI_COMPILE=1 \
+ -e DONT_PRECOMPILE_CSS=1 -w /var/www/discourse "$container" bundle exec rake assets:precompile
+
+# HTTP 응답만 검사하면 번역 플러그인이 빠져도 배포 성공으로 처리됩니다.
+# 웹 프로세스를 재시작하기 전에 별도 Rails 부팅으로 번역을 검사합니다.
+DISCOURSE_CONTAINER="$container" bash scripts/verify_deployment.sh --runtime-only
docker restart "$container" >/dev/null
-for attempt in 1 2 3 4 5 6 7 8 9 10; do
- if DISCOURSE_CONTAINER="$container" bash scripts/verify_deployment.sh; then
+for attempt in {1..30}; do
+ if curl --silent --fail "${DISCOURSE_URL:-https://forum.itosm.co.kr}" >/dev/null; then
+ DISCOURSE_CONTAINER="$container" bash scripts/verify_deployment.sh
echo "Discourse 한국어 보완 배포 완료"
exit 0
fi
- sleep 2
+ sleep 3
done
echo "컨테이너 재시작 뒤 서비스가 정상 상태로 돌아오지 않았습니다." >&2
diff --git a/scripts/patch_boards.rb b/scripts/patch_boards.rb
new file mode 100644
index 0000000..9ee9972
--- /dev/null
+++ b/scripts/patch_boards.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+# 번역 키를 그대로 출력하는 보드 메뉴 제목 두 곳만 바꿉니다.
+# 원본 구조가 바뀌면 전체 파일을 덮어쓰지 않고 중단해 다른 수정 사항을 보존합니다.
+root = ENV.fetch("DISCOURSE_ROOT", "/var/www/discourse")
+targets = {
+ "boards-board-viewer.gjs" => ["boards.board.controls", "보드 메뉴"],
+ "boards-column.gjs" => ["boards.board.column_controls", "열 메뉴"],
+}
+targets.each do |file, (key, title)|
+ path = File.join(root, "plugins/boards/assets/javascripts/discourse/components", file)
+ next unless File.exist?(path)
+ original = File.read(path)
+ needle = "@title=\"#{key}\""
+ replacement = "@title=\"#{title}\""
+ next if original.include?(replacement)
+ abort "보드 원본 구조가 달라졌습니다: #{file}" unless original.scan(needle).size == 1
+ File.write(path, original.sub(needle, replacement))
+end
+puts "보드 메뉴 한국어 제목 확인 완료"
diff --git a/scripts/verify_deployment.sh b/scripts/verify_deployment.sh
old mode 100755
new mode 100644
index a20b5cf..af9fcf8
--- a/scripts/verify_deployment.sh
+++ b/scripts/verify_deployment.sh
@@ -1,20 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
+root_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
container="${DISCOURSE_CONTAINER:-app}"
site_url="${DISCOURSE_URL:-https://forum.itosm.co.kr}"
-plugin_path="/var/www/discourse/plugins/itosm-admin-korean"
+plugin_path="/var/www/discourse/plugins/itosm-korean-localization"
+report="$(mktemp)"
+trap 'rm -f "$report"' EXIT
-docker inspect "$container" >/dev/null
-docker exec "$container" test -f "$plugin_path/plugin.rb"
-docker exec "$container" test -f "$plugin_path/config/locales/client.ko.yml"
-docker exec "$container" test -f "$plugin_path/config/locales/server.ko.yml"
+# 파일 존재뿐 아니라 저장소 원본과 실행 파일이 일치하는지 검사합니다.
+for file in plugin.rb config/locales/client.ko.yml config/locales/server.ko.yml; do
+ expected="$(sha256sum "$root_dir/$file" | cut -d ' ' -f 1)"
+ actual="$(docker exec "$container" sha256sum "$plugin_path/$file" | cut -d ' ' -f 1)"
+ [[ "$expected" == "$actual" ]] || { echo "운영 파일 불일치: $file" >&2; exit 1; }
+done
-status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "$site_url")"
-if [[ "$status" != "200" ]]; then
- echo "포럼 응답 확인 실패: HTTP $status" >&2
- exit 1
+docker exec -i -u discourse -e RAILS_ENV=production -w /var/www/discourse \
+ "$container" bundle exec rails runner - < "$root_dir/scripts/verify_runtime.rb" > "$report"
+
+if [[ "${1:-}" != "--runtime-only" ]]; then
+ # 별도 Rails 검사와 실제 웹 프로세스가 제공하는 번역 묶음을 함께 대조합니다.
+ # 재시작을 빠뜨려 예전 영어 번역을 계속 제공하는 경우도 실패로 처리합니다.
+ ruby "$root_dir/scripts/verify_web.rb" "$site_url" "$report"
fi
-
-echo "운영 플러그인 파일 확인 완료"
-echo "포럼 응답 확인 완료: HTTP 200"
+echo "운영 파일과 한국어 번역 검사 통과"
diff --git a/scripts/verify_runtime.rb b/scripts/verify_runtime.rb
new file mode 100644
index 0000000..e3da44f
--- /dev/null
+++ b/scripts/verify_runtime.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require "json"
+require "digest"
+require "yaml"
+
+abort "한국어 보완 플러그인을 불러오지 못했습니다." unless Discourse.plugins.any? { |plugin| plugin.name == "itosm-korean-localization" }
+abort "이전 한국어 플러그인이 중복 설치되어 있습니다." if Discourse.plugins.any? { |plugin| plugin.name == "itosm-admin-korean" }
+I18n.with_locale(:ko) do
+ boards = Discourse.plugins.find { |plugin| plugin.name == "boards" }
+ if boards
+ serialized = AdminPluginSerializer.new(boards, root: false).as_json
+ abort "플러그인 내부 이름이 번역되어 관리자 링크가 손상됩니다." unless serialized[:name] == "boards"
+ abort "보드 표시 이름 번역 누락" unless serialized[:humanized_name] == "보드"
+ end
+end
+
+expected = {
+ "title" => "대화방에 참여하기 전에 확인해 주세요.",
+ "join" => "대화방 참여",
+ "cancel" => "취소",
+ "dont_show_again" => "다시 표시하지 않기",
+}
+expected.each do |key, value|
+ actual = I18n.t("js.voice.mesh_privacy_warning.#{key}", locale: :ko, fallback: false)
+ abort "음성 대화방 번역 불일치: #{key}" unless actual == value
+end
+body = I18n.t("js.voice.mesh_privacy_warning.body", locale: :ko, fallback: false)
+abort "음성 대화방 개인정보 안내 번역 누락" unless body.include?("다른 참여자가 내 IP 주소를 확인할 수 있습니다.")
+
+def flatten_translations(hash, prefix = "", result = {})
+ hash.each do |key, value|
+ path = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
+ value.is_a?(Hash) ? flatten_translations(value, path, result) : result[path] = value
+ end
+ result
+end
+
+korean = flatten_translations(I18n.backend.send(:translations).fetch(:ko))
+TranslationOverride.where(locale: "ko").pluck(:translation_key, :value).each { |key, value| korean[key] = value }
+active_keys = Discourse.plugins.select(&:enabled?).flat_map do |plugin|
+ Dir.glob(Rails.root.join("plugins", plugin.directory_name, "config/locales/*.en.yml")).flat_map do |path|
+ flatten_translations(YAML.safe_load_file(path, aliases: true).fetch("en", {})).keys
+ end
+end.uniq
+
+# 한국어는 단수와 복수에 같은 표현을 사용하므로 other가 있으면 one도 충족합니다.
+missing = active_keys.reject do |key|
+ korean.key?(key) || (key.end_with?(".one") && korean.key?(key.sub(/\.one$/, ".other")))
+end
+abort "활성 플러그인 한국어 번역 누락 #{missing.size}개: #{missing.first(10).join(', ')}" unless missing.empty?
+
+# 영어 대체 표시를 정상 번역으로 오인하지 않도록 한국어 묶음의 내용 해시를 반환합니다.
+bundles = %w[main admin].to_h do |bundle|
+ content = ExtraLocalesController.bundle_js(bundle, locale: :ko)
+ [bundle, { url: ExtraLocalesController.url(bundle, locale: :ko), sha256: Digest::SHA256.hexdigest(content) }]
+end
+if Discourse.plugins.any? { |plugin| plugin.name == "boards" }
+ filename = Plugin::JsManager.read_manifest("boards").dig("main", "fileName")
+ board_js = File.read(Rails.root.join("app/assets/generated/boards/js/plugins", filename))
+ abort "보드 메뉴 제목 컴파일 누락" unless board_js.include?("보드 메뉴") && board_js.include?("열 메뉴")
+ bundles["boards"] = {url: "/assets/js/plugins/#{filename}", sha256: Digest::SHA256.hexdigest(board_js)}
+end
+puts JSON.generate(bundles: bundles, active_plugin_keys: active_keys.size, missing: missing.size)
diff --git a/scripts/verify_web.rb b/scripts/verify_web.rb
new file mode 100644
index 0000000..dd13df4
--- /dev/null
+++ b/scripts/verify_web.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require "json"
+require "digest"
+require "net/http"
+require "uri"
+
+base, report_path = ARGV
+report = JSON.parse(File.read(report_path))
+site = URI(base)
+home = Net::HTTP.get_response(site)
+abort "포럼 응답 확인 실패: HTTP #{home.code}" unless home.is_a?(Net::HTTPSuccess)
+
+report.fetch("bundles").each do |bundle, expected|
+ uri = URI.join(base, expected.fetch("url"))
+ abort "예상하지 않은 번역 서버 주소" unless uri.host == site.host
+ response = Net::HTTP.get_response(uri)
+ abort "#{bundle} 번역 요청 실패: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
+ abort "#{bundle} 웹 번역이 실행 원본과 다릅니다." unless Digest::SHA256.hexdigest(response.body) == expected.fetch("sha256")
+end
+puts "포럼 HTTP 200과 실제 사용자 화면 및 관리자 화면 번역 묶음 확인 완료"
diff --git a/수정기록.txt b/수정기록.txt
index c1a7aed..64c0c0c 100644
--- a/수정기록.txt
+++ b/수정기록.txt
@@ -67,3 +67,13 @@ Discourse 한국어 보완 수정 기록
- 보존 사항: 기존 포럼 설정과 카테고리와 게시물은 삭제하지 않았다.
- 백업: 사용자 요청이 없어 진행하지 않았다.
- 남은 제한: 실제 MIDI 장치를 이용한 서비스 동작 검사는 별도 장치 검증 작업으로 남는다.
+
+## 2026-10-15 운영 서버 한국어 번역 복구
+
+- 이전된 서버의 한국어 보완 플러그인 경로가 비어 있음을 확인했다. 전용 원본을 복구하고 포럼 컨테이너만 재시작했다.
+- 음성 대화방 입장 안내와 주변 메뉴 및 관리자 번역을 복구했다. 활성 플러그인 고유 번역 6,004개와 음성 기능 561개를 검사했다. 누락은 0개다.
+- 관리자 주소에 쓰이는 내부 플러그인 이름은 보존하도록 수정했다. 보드 메뉴 두 곳의 제목도 복구했다.
+- 실제 웹 번역과 보드 자산 해시 대조를 배포 검사에 추가했다. PC와 모바일의 안내창 및 관리자 메뉴 주소를 확인했다.
+- DB 구조 변경과 데이터 이전은 없다. 기존 글꼴과 게시물을 보존했다.
+- 백업: `/var/discourse/shared/standalone/backups/forum-korean-20261015-1150`
+- 상세 범위와 복구 방법: [한국어 복구 기록](docs/2026-10-15-localization-recovery.md)