포럼 한국어 번역 플러그인을 복구하고 배포 검사를 보강

비어 있던 운영 플러그인 경로를 전용 서비스 원본으로 복구했다.
음성 입장 안내와 주변 메뉴 및 관리자 번역을 실제 화면에서 확인했다.
관리자 주소용 내부 이름을 보존하고 보드 메뉴 두 곳의 제목을 복구했다.

검사: Ruby 문법과 YAML 11개 통과. 활성 번역 6004개와 음성 번역 561개 누락 0개.
일반 화면과 관리자 번역 및 보드 자산의 제공 해시를 확인했다.
PC와 390픽셀 모바일 안내창 및 관리자 메뉴 주소를 확인했다.
운영: app 컨테이너만 갱신. DB 구조 변경과 데이터 이전 없음.
백업: /var/discourse/shared/standalone/backups/forum-korean-20261015-1150
설정 백업은 별도 비밀 디렉터리의 권한 600 파일로 보관했다.
제한: DB 복원 시험과 다음 컨테이너 재생성은 이번 검사에 포함하지 않았다.
Trilium: Ubuntu 서버 운영 아래 Discourse 한국어 배포에 기록했다.
This commit is contained in:
2026-10-14 22:44:52 +00:00
parent ce3a2acee7
commit 5bad207526
13 changed files with 222 additions and 25 deletions
Executable → Regular
+21 -4
View File
@@ -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
+20
View File
@@ -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 "보드 메뉴 한국어 제목 확인 완료"
Executable → Regular
+18 -12
View File
@@ -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 "운영 파일과 한국어 번역 검사 통과"
+64
View File
@@ -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)
+21
View File
@@ -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과 실제 사용자 화면 및 관리자 화면 번역 묶음 확인 완료"