File History: src/main/java/com/paymentlink/service/TranslationService.java

← View file content

File Content at Commit 4617f76

1 package com.paymentlink.service;
2
3 import com.github.benmanes.caffeine.cache.Cache;
4 import com.github.benmanes.caffeine.cache.Caffeine;
5 import com.paymentlink.model.dto.LocalizedProductDto;
6 import com.paymentlink.model.entity.Product;
7 import com.paymentlink.model.entity.Translation;
8 import com.paymentlink.repository.TranslationRepository;
9 import jakarta.annotation.PostConstruct;
10 import org.slf4j.Logger;
11 import org.slf4j.LoggerFactory;
12 import org.springframework.beans.factory.annotation.Value;
13 import org.springframework.stereotype.Service;
14 import org.springframework.transaction.annotation.Transactional;
15
16 import java.util.*;
17 import java.util.concurrent.TimeUnit;
18 import java.util.stream.Collectors;
19
20 @Service
21 public class TranslationService {
22
23 private static final Logger logger = LoggerFactory.getLogger(TranslationService.class);
24
25 private final TranslationRepository translationRepository;
26 private Cache<String, String> translationCache;
27
28 @Value("${i18n.cache.ttl:1800000}")
29 private long cacheTtl;
30
31 @Value("${i18n.default.language:en}")
32 private String defaultLanguage;
33
34 @Value("${i18n.supported.languages:en,es,fr,de,zh}")
35 private String supportedLanguages;
36
37 @Value("${i18n.fallback.enabled:true}")
38 private boolean fallbackEnabled;
39
40 public TranslationService(TranslationRepository translationRepository) {
41 this.translationRepository = translationRepository;
42 }
43
44 @PostConstruct
45 public void init() {
46 // Initialize local cache (30 min TTL)
47 translationCache = Caffeine.newBuilder()
48 .maximumSize(1000)
49 .expireAfterWrite(cacheTtl, TimeUnit.MILLISECONDS)
50 .build();
51
52 logger.info("TranslationService initialized with cache TTL: {}ms, default language: {}, supported: {}",
53 cacheTtl, defaultLanguage, supportedLanguages);
54 }
55
56 /**
57 * Get translation for a specific entity field
58 */
59 public String translate(String entityType, String entityId, String fieldName, String languageCode) {
60 // Return null if requesting default language
61 if (defaultLanguage.equalsIgnoreCase(languageCode)) {
62 return null; // Caller should use original text
63 }
64
65 // Build cache key
66 String cacheKey = buildCacheKey(entityType, entityId, fieldName, languageCode);
67
68 // Check cache first
69 String cached = translationCache.getIfPresent(cacheKey);
70 if (cached != null) {
71 return "".equals(cached) ? null : cached; // Empty string means no translation found
72 }
73
74 // Fetch from database
75 Optional<Translation> translationOpt = translationRepository
76 .findByEntityTypeAndEntityIdAndFieldNameAndLanguageCode(
77 entityType, entityId, fieldName, languageCode);
78
79 if (translationOpt.isPresent()) {
80 String translatedText = translationOpt.get().getTranslatedText();
81 translationCache.put(cacheKey, translatedText);
82 return translatedText;
83 }
84
85 // Try fallback to English if enabled
86 if (fallbackEnabled && !defaultLanguage.equalsIgnoreCase(languageCode)) {
87 logger.debug("No translation found for {}/{}/{}/{}, falling back to default",
88 entityType, entityId, fieldName, languageCode);
89 }
90
91 // Cache negative result to avoid repeated DB queries
92 translationCache.put(cacheKey, "");
93 return null;
94 }
95
96 /**
97 * Translate a single product
98 */
99 public LocalizedProductDto translateProduct(Product product, String languageCode, String targetCurrency) {
100 String translatedName = translate("product", String.valueOf(product.getId()), "name", languageCode);
101 String translatedDescription = translate("product", String.valueOf(product.getId()), "description", languageCode);
102
103 return LocalizedProductDto.builder()
104 .id(product.getId())
105 .name(translatedName != null ? translatedName : product.getName())
106 .description(translatedDescription != null ? translatedDescription : product.getDescription())
107 .price(product.getPrice()) // Will be converted by caller
108 .currency(targetCurrency != null ? targetCurrency : product.getCurrency())
109 .originalPrice(product.getPrice())
110 .originalCurrency(product.getCurrency())
111 .image(product.getImage())
112 .stock(product.getStock())
113 .category(product.getCategory())
114 .build();
115 }
116
117 /**
118 * Translate multiple products efficiently (bulk operation)
119 */
120 public List<LocalizedProductDto> bulkTranslate(List<Product> products, String languageCode, String targetCurrency) {
121 if (products == null || products.isEmpty()) {
122 return Collections.emptyList();
123 }
124
125 // If default language, no translation needed
126 if (defaultLanguage.equalsIgnoreCase(languageCode)) {
127 return products.stream()
128 .map(p -> translateProduct(p, languageCode, targetCurrency))
129 .collect(Collectors.toList());
130 }
131
132 // Fetch all translations for these products at once
133 List<String> productIds = products.stream()
134 .map(p -> String.valueOf(p.getId()))
135 .collect(Collectors.toList());
136
137 List<Translation> translations = translationRepository.findBulkTranslations(
138 "product", productIds, languageCode);
139
140 // Build a map for quick lookup
141 Map<String, Map<String, String>> translationMap = new HashMap<>();
142 for (Translation t : translations) {
143 translationMap
144 .computeIfAbsent(t.getEntityId(), k -> new HashMap<>())
145 .put(t.getFieldName(), t.getTranslatedText());
146 }
147
148 // Apply translations
149 return products.stream()
150 .map(product -> {
151 String productId = String.valueOf(product.getId());
152 Map<String, String> fields = translationMap.getOrDefault(productId, Collections.emptyMap());
153
154 return LocalizedProductDto.builder()
155 .id(product.getId())
156 .name(fields.getOrDefault("name", product.getName()))
157 .description(fields.getOrDefault("description", product.getDescription()))
158 .price(product.getPrice())
159 .currency(targetCurrency != null ? targetCurrency : product.getCurrency())
160 .originalPrice(product.getPrice())
161 .originalCurrency(product.getCurrency())
162 .image(product.getImage())
163 .stock(product.getStock())
164 .category(fields.getOrDefault("category", product.getCategory()))
165 .build();
166 })
167 .collect(Collectors.toList());
168 }
169
170 /**
171 * Save or update a translation
172 */
173 @Transactional
174 public Translation saveTranslation(String entityType, String entityId, String fieldName,
175 String languageCode, String translatedText) {
176 Optional<Translation> existingOpt = translationRepository
177 .findByEntityTypeAndEntityIdAndFieldNameAndLanguageCode(
178 entityType, entityId, fieldName, languageCode);
179
180 Translation translation;
181 if (existingOpt.isPresent()) {
182 translation = existingOpt.get();
183 translation.setTranslatedText(translatedText);
184 } else {
185 translation = Translation.builder()
186 .entityType(entityType)
187 .entityId(entityId)
188 .fieldName(fieldName)
189 .languageCode(languageCode)
190 .translatedText(translatedText)
191 .build();
192 }
193
194 translation = translationRepository.save(translation);
195
196 // Clear cache for this translation
197 String cacheKey = buildCacheKey(entityType, entityId, fieldName, languageCode);
198 translationCache.invalidate(cacheKey);
199
200 logger.info("Saved translation: {}/{}/{}/{}", entityType, entityId, fieldName, languageCode);
201 return translation;
202 }
203
204 /**
205 * Get all translations for an entity
206 */
207 public List<Translation> getTranslationsForEntity(String entityType, String entityId) {
208 return translationRepository.findByEntityTypeAndEntityId(entityType, entityId);
209 }
210
211 /**
212 * Delete a translation
213 */
214 @Transactional
215 public void deleteTranslation(Long translationId) {
216 Optional<Translation> translationOpt = translationRepository.findById(translationId);
217 if (translationOpt.isPresent()) {
218 Translation translation = translationOpt.get();
219 String cacheKey = buildCacheKey(
220 translation.getEntityType(),
221 translation.getEntityId(),
222 translation.getFieldName(),
223 translation.getLanguageCode()
224 );
225 translationCache.invalidate(cacheKey);
226 translationRepository.deleteById(translationId);
227 logger.info("Deleted translation: {}", translationId);
228 }
229 }
230
231 /**
232 * Get supported languages list
233 */
234 public List<String> getSupportedLanguages() {
235 return Arrays.asList(supportedLanguages.split(","));
236 }
237
238 /**
239 * Check if language is supported
240 */
241 public boolean isLanguageSupported(String languageCode) {
242 return getSupportedLanguages().contains(languageCode.toLowerCase());
243 }
244
245 /**
246 * Clear translation cache
247 */
248 public void clearCache() {
249 translationCache.invalidateAll();
250 logger.info("Translation cache cleared");
251 }
252
253 /**
254 * Build cache key for translation
255 */
256 private String buildCacheKey(String entityType, String entityId, String fieldName, String languageCode) {
257 return String.format("%s:%s:%s:%s", entityType, entityId, fieldName, languageCode);
258 }
259 }
260

Commits

Commit Author Date Message File SHA Actions
f0438c2 <f69e50@finnacloud.com> 1766443042 +0300 12/22/2025, 10:37:22 PM increment once more 89a8212 View
188fc92 <f69e50@finnacloud.com> 1766442998 +0300 12/22/2025, 10:36:38 PM increment 89a8212 View
4617f76 <f69e50@finnacloud.com> 1766442953 +0300 12/22/2025, 10:35:53 PM rename branch from main to master oops 89a8212 Hide
e6d1548 <f69e50@finnacloud.com> 1766442769 +0300 12/22/2025, 10:32:49 PM add initial test workflow file 89a8212 View
9c24ca4 <f69e50@finnacloud.com> 1766442705 +0300 12/22/2025, 10:31:45 PM add CI configuration and test script for Jenkins build 89a8212 View
690c1f6 <f69e50@finnacloud.com> 1766368110 +0300 12/22/2025, 1:48:30 AM initialize backend structure with controllers, DTOs, and configuration files 89a8212 View