package com.zy.common.i18n; import com.core.common.Cools; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; import org.springframework.context.i18n.LocaleContextHolder; import java.io.File; import java.io.BufferedReader; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.text.MessageFormat; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; @Service public class I18nMessageService { private static final String MESSAGE_BUNDLE = "messages"; private static final String LEGACY_BUNDLE = "legacy"; private final Map cache = new ConcurrentHashMap<>(); @Autowired private I18nProperties properties; public Locale getCurrentLocale() { Locale locale = LocaleContextHolder.getLocale(); return locale == null ? I18nLocaleUtils.defaultLocale(properties) : locale; } public Locale resolveLocale(String requested) { return I18nLocaleUtils.resolveLocale(requested, properties); } public String getDefaultLocaleTag() { return properties.getDefaultLocale(); } public List getSupportedLocaleTags() { return properties.getSupportedLocales(); } public String getMessage(String key, Object... args) { return getMessage(key, getCurrentLocale(), args); } public String getMessage(String key, Locale locale, Object... args) { if (Cools.isEmpty(key)) { return ""; } Locale resolvedLocale = locale == null ? I18nLocaleUtils.defaultLocale(properties) : locale; String value = mergedBundle(resolvedLocale, MESSAGE_BUNDLE).get(key); if (value == null && !I18nLocaleUtils.isDefaultLocale(resolvedLocale, properties)) { value = mergedBundle(I18nLocaleUtils.defaultLocale(properties), MESSAGE_BUNDLE).get(key); } if (value == null) { return key; } return args == null || args.length == 0 ? value : MessageFormat.format(value, args); } public boolean hasMessage(String key, Locale locale) { return mergedBundle(locale == null ? I18nLocaleUtils.defaultLocale(properties) : locale, MESSAGE_BUNDLE).containsKey(key); } public Map getMessages(Locale locale) { return new LinkedHashMap<>(mergedBundle(locale, MESSAGE_BUNDLE)); } public Map getLegacyMessages(Locale locale) { return new LinkedHashMap<>(mergedBundle(locale, LEGACY_BUNDLE)); } public String translateLegacy(String text) { return translateLegacy(text, getCurrentLocale()); } public String translateLegacy(String text, Locale locale) { if (Cools.isEmpty(text)) { return text; } Locale resolvedLocale = locale == null ? I18nLocaleUtils.defaultLocale(properties) : locale; if (I18nLocaleUtils.isDefaultLocale(resolvedLocale, properties)) { return text; } Map bundle = mergedBundle(resolvedLocale, LEGACY_BUNDLE); String direct = directTranslate(text, bundle); if (!text.equals(direct)) { return direct; } String regex = regexTranslate(text, bundle); if (!text.equals(regex)) { return regex; } return fragmentTranslate(text, bundle); } public String resolveResourceText(String fallbackName, String code, Long id) { Locale locale = getCurrentLocale(); String key = resourceKey(code, id); if (hasMessage(key, locale)) { return getMessage(key, locale); } if (!I18nLocaleUtils.isDefaultLocale(locale, properties)) { String humanized = I18nLocaleUtils.humanizeCode(code); if (!Cools.isEmpty(humanized)) { return humanized; } } return fallbackName; } public String resolvePermissionText(String fallbackName, String action, Long id) { Locale locale = getCurrentLocale(); String key = permissionKey(action, id); if (hasMessage(key, locale)) { return getMessage(key, locale); } if (!I18nLocaleUtils.isDefaultLocale(locale, properties)) { String humanized = I18nLocaleUtils.humanizeCode(action); if (!Cools.isEmpty(humanized)) { return humanized; } } return fallbackName; } public String resourceKey(String code, Long id) { return "resource." + normalizeKey(code, id, "resource"); } public String permissionKey(String action, Long id) { return "permission." + normalizeKey(action, id, "permission"); } private Map mergedBundle(Locale locale, String bundleName) { Locale resolvedLocale = locale == null ? I18nLocaleUtils.defaultLocale(properties) : locale; LinkedHashMap merged = new LinkedHashMap<>(); Locale defaultLocale = I18nLocaleUtils.defaultLocale(properties); merged.putAll(loadBundle(defaultLocale, bundleName)); if (!I18nLocaleUtils.toTag(defaultLocale).equalsIgnoreCase(I18nLocaleUtils.toTag(resolvedLocale))) { merged.putAll(loadBundle(resolvedLocale, bundleName)); } return merged; } private Map loadBundle(Locale locale, String bundleName) { String localeTag = I18nLocaleUtils.toTag(locale); String cacheKey = localeTag + ":" + bundleName; CacheEntry cacheEntry = cache.get(cacheKey); File externalFile = externalBundle(localeTag, bundleName); long refreshMillis = Math.max(1, properties.getRefreshSeconds()) * 1000L; long externalLastModified = externalFile.exists() ? externalFile.lastModified() : -1L; if (cacheEntry != null && (System.currentTimeMillis() - cacheEntry.loadedAt) < refreshMillis && cacheEntry.externalLastModified == externalLastModified) { return cacheEntry.values; } synchronized (cache.computeIfAbsent(cacheKey, key -> new CacheEntry())) { CacheEntry latest = cache.get(cacheKey); if (latest != null && (System.currentTimeMillis() - latest.loadedAt) < refreshMillis && latest.externalLastModified == externalLastModified) { return latest.values; } LinkedHashMap values = new LinkedHashMap<>(); readClasspathBundle(values, localeTag, bundleName); readExternalBundle(values, externalFile); CacheEntry updated = new CacheEntry(); updated.values = values; updated.loadedAt = System.currentTimeMillis(); updated.externalLastModified = externalLastModified; cache.put(cacheKey, updated); return updated.values; } } private void readClasspathBundle(Map values, String localeTag, String bundleName) { ClassPathResource resource = new ClassPathResource("i18n/" + localeTag + "/" + bundleName + ".properties"); if (!resource.exists()) { return; } try (InputStream inputStream = resource.getInputStream()) { loadProperties(values, inputStream); } catch (IOException ex) { throw new IllegalStateException("Failed to load classpath i18n bundle: " + resource.getPath(), ex); } } private void readExternalBundle(Map values, File file) { if (!file.exists() || !file.isFile()) { return; } try (InputStream inputStream = new FileInputStream(file)) { loadProperties(values, inputStream); } catch (IOException ex) { throw new IllegalStateException("Failed to load external i18n bundle: " + file.getAbsolutePath(), ex); } } private void loadProperties(Map values, InputStream inputStream) throws IOException { try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { parsePropertyLine(values, line); } } } private void parsePropertyLine(Map values, String line) { if (line == null) { return; } String trimmed = line.trim(); if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("!")) { return; } int separatorIndex = findSeparator(line); String rawKey = separatorIndex >= 0 ? line.substring(0, separatorIndex) : line; String rawValue = separatorIndex >= 0 ? line.substring(separatorIndex + 1) : ""; while (!rawValue.isEmpty() && Character.isWhitespace(rawValue.charAt(0))) { rawValue = rawValue.substring(1); } String key = unescapePropertyToken(rawKey); String value = unescapePropertyToken(rawValue); if (!key.isEmpty()) { values.put(key, value); } } private int findSeparator(String line) { boolean escaping = false; for (int i = 0; i < line.length(); i++) { char ch = line.charAt(i); if (escaping) { escaping = false; continue; } if (ch == '\\') { escaping = true; continue; } if (ch == '=' || ch == ':') { return i; } } return -1; } private String unescapePropertyToken(String text) { StringBuilder builder = new StringBuilder(text.length()); boolean escaping = false; for (int i = 0; i < text.length(); i++) { char ch = text.charAt(i); if (!escaping) { if (ch == '\\') { escaping = true; } else { builder.append(ch); } continue; } switch (ch) { case 't': builder.append('\t'); break; case 'r': builder.append('\r'); break; case 'n': builder.append('\n'); break; case 'f': builder.append('\f'); break; case 'u': if (i + 4 < text.length()) { String hex = text.substring(i + 1, i + 5); try { builder.append((char) Integer.parseInt(hex, 16)); i += 4; break; } catch (NumberFormatException ex) { builder.append('u'); break; } } builder.append('u'); break; default: builder.append(ch); break; } escaping = false; } if (escaping) { builder.append('\\'); } return builder.toString(); } private File externalBundle(String localeTag, String bundleName) { return new File(properties.getPackPath(), localeTag + File.separator + bundleName + ".properties"); } private String directTranslate(String text, Map bundle) { String trimmed = text.trim(); String translated = bundle.get(trimmed); if (translated == null) { return text; } return preserveOuterWhitespace(text, translated); } private String regexTranslate(String text, Map bundle) { String trimmed = text == null ? "" : text.trim(); for (Map.Entry entry : bundle.entrySet()) { String key = entry.getKey(); if (Cools.isEmpty(key) || !key.startsWith("regex:")) { continue; } String patternText = key.substring("regex:".length()); if (Cools.isEmpty(patternText)) { continue; } try { Pattern pattern = Pattern.compile(patternText); if (!trimmed.isEmpty()) { Matcher trimmedMatcher = pattern.matcher(trimmed); if (trimmedMatcher.matches()) { return preserveOuterWhitespace(text, trimmedMatcher.replaceAll(entry.getValue())); } } Matcher matcher = pattern.matcher(text); if (matcher.find()) { return matcher.replaceAll(entry.getValue()); } } catch (PatternSyntaxException ex) { // Ignore invalid regex entries so a bad pack does not break all translations. } } return text; } private String fragmentTranslate(String text, Map bundle) { List> entries = new ArrayList<>(bundle.entrySet()); entries.sort((left, right) -> Integer.compare(right.getKey().length(), left.getKey().length())); String result = text; for (Map.Entry entry : entries) { if (Cools.isEmpty(entry.getKey()) || entry.getKey().length() < 2 || entry.getKey().startsWith("regex:") || entry.getKey().equals(entry.getValue())) { continue; } result = result.replace(entry.getKey(), entry.getValue()); } return result; } private String preserveOuterWhitespace(String original, String translated) { String trimmed = original == null ? "" : original.trim(); if (trimmed.isEmpty()) { return translated; } int leading = original.indexOf(trimmed); int trailing = original.length() - leading - trimmed.length(); StringBuilder builder = new StringBuilder(); if (leading > 0) { builder.append(original, 0, leading); } builder.append(translated); if (trailing > 0) { builder.append(original.substring(original.length() - trailing)); } return builder.toString(); } private String normalizeKey(String raw, Long id, String prefix) { if (Cools.isEmpty(raw)) { return prefix + "." + (id == null ? "unknown" : id); } String normalized = raw.replaceAll("\\.html", ""); normalized = normalized.replaceAll("[^A-Za-z0-9]+", "."); normalized = normalized.replaceAll("\\.+", "."); normalized = normalized.replaceAll("^\\.|\\.$", ""); if (Cools.isEmpty(normalized)) { return prefix + "." + (id == null ? "unknown" : id); } return normalized; } private static class CacheEntry { private Map values = new LinkedHashMap<>(); private long loadedAt; private long externalLastModified = -1L; } }