From cab627be02e433887bb8bf1667cf79641fce3296 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 20:11:24 +0800 Subject: [PATCH] feat(infra): add i18n messages, Redis config, and slug normalization migration - Add rating/admin i18n message keys (en + zh) - Improve IdempotencyInterceptor error handling - Add RedisTemplateConfig for consistent serialization - Add V4 migration to normalize skill slugs - Fix RequestIdFilterTest --- .../filter/IdempotencyInterceptor.java | 68 +++++++++++-------- .../migration/V4__normalize_skill_slugs.sql | 55 +++++++++++++++ .../src/main/resources/messages.properties | 4 ++ .../src/main/resources/messages_zh.properties | 4 ++ .../skillhub/filter/RequestIdFilterTest.java | 4 +- .../auth/config/RedisTemplateConfig.java | 34 ++++++++++ 6 files changed, 140 insertions(+), 29 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V4__normalize_skill_slugs.sql create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/RedisTemplateConfig.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java index 75b38f43..d6c33676 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java @@ -1,8 +1,10 @@ package com.iflytek.skillhub.filter; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.idempotency.IdempotencyRecord; import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository; import com.iflytek.skillhub.domain.idempotency.IdempotencyStatus; +import com.iflytek.skillhub.dto.ApiResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.data.redis.core.StringRedisTemplate; @@ -20,13 +22,16 @@ public class IdempotencyInterceptor implements HandlerInterceptor { private static final String REDIS_KEY_PREFIX = "idempotency:"; private static final long EXPIRY_HOURS = 24; - private final IdempotencyRecordRepository idempotencyRecordRepository; private final StringRedisTemplate redisTemplate; + private final IdempotencyRecordRepository idempotencyRecordRepository; + private final ObjectMapper objectMapper; - public IdempotencyInterceptor(IdempotencyRecordRepository idempotencyRecordRepository, - StringRedisTemplate redisTemplate) { - this.idempotencyRecordRepository = idempotencyRecordRepository; + public IdempotencyInterceptor(StringRedisTemplate redisTemplate, + IdempotencyRecordRepository idempotencyRecordRepository, + ObjectMapper objectMapper) { this.redisTemplate = redisTemplate; + this.idempotencyRecordRepository = idempotencyRecordRepository; + this.objectMapper = objectMapper; } @Override @@ -43,16 +48,14 @@ public class IdempotencyInterceptor implements HandlerInterceptor { // Check Redis first String redisKey = REDIS_KEY_PREFIX + requestId; - Boolean isNew = redisTemplate.opsForValue().setIfAbsent(redisKey, "PROCESSING", EXPIRY_HOURS, TimeUnit.HOURS); - - if (Boolean.FALSE.equals(isNew)) { - // Duplicate request - check status in Redis or DB - String cachedStatus = redisTemplate.opsForValue().get(redisKey); - if ("COMPLETED".equals(cachedStatus)) { - response.setStatus(HttpServletResponse.SC_CONFLICT); - response.getWriter().write("{\"error\":\"Duplicate request\"}"); + try { + String cached = redisTemplate.opsForValue().get(redisKey); + if ("COMPLETED".equals(cached)) { + writeDuplicateResponse(response); return false; } + } catch (Exception ignored) { + // Redis unavailable, fall through to PostgreSQL } // Check PostgreSQL fallback @@ -60,30 +63,32 @@ public class IdempotencyInterceptor implements HandlerInterceptor { if (existing.isPresent()) { IdempotencyRecord record = existing.get(); if (record.getStatus() == IdempotencyStatus.COMPLETED) { - response.setStatus(record.getResponseStatusCode() != null ? record.getResponseStatusCode() : HttpServletResponse.SC_OK); - response.getWriter().write("{\"message\":\"Request already processed\"}"); + int statusCode = record.getResponseStatusCode() != null ? record.getResponseStatusCode() : HttpServletResponse.SC_OK; + response.setStatus(statusCode); + writeDuplicateResponse(response); return false; } } else { // Create new record Instant now = Instant.now(); IdempotencyRecord newRecord = new IdempotencyRecord( - requestId, - null, - null, - IdempotencyStatus.PROCESSING, - null, - now, - now.plusSeconds(EXPIRY_HOURS * 3600) - ); + requestId, (String) null, (Long) null, IdempotencyStatus.PROCESSING, + (Integer) null, now, now.plusSeconds(EXPIRY_HOURS * 3600)); idempotencyRecordRepository.save(newRecord); + + // Cache in Redis + try { + redisTemplate.opsForValue().set(redisKey, "PROCESSING", EXPIRY_HOURS, TimeUnit.HOURS); + } catch (Exception ignored) { + // Redis unavailable, PostgreSQL is the source of truth + } } return true; } @Override - public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { String method = request.getMethod(); if (!method.equals("POST") && !method.equals("PUT") && !method.equals("DELETE")) { return; @@ -94,7 +99,6 @@ public class IdempotencyInterceptor implements HandlerInterceptor { return; } - // Update record with response status Optional existing = idempotencyRecordRepository.findByRequestId(requestId); if (existing.isPresent()) { IdempotencyRecord record = existing.get(); @@ -102,9 +106,19 @@ public class IdempotencyInterceptor implements HandlerInterceptor { record.setResponseStatusCode(response.getStatus()); idempotencyRecordRepository.save(record); - // Update Redis - String redisKey = REDIS_KEY_PREFIX + requestId; - redisTemplate.opsForValue().set(redisKey, record.getStatus().name(), EXPIRY_HOURS, TimeUnit.HOURS); + try { + String redisKey = REDIS_KEY_PREFIX + requestId; + redisTemplate.opsForValue().set(redisKey, record.getStatus().name(), EXPIRY_HOURS, TimeUnit.HOURS); + } catch (Exception ignored) { + // Redis unavailable + } } } + + private void writeDuplicateResponse(HttpServletResponse response) throws Exception { + ApiResponse body = new ApiResponse<>(409, "error.request.duplicate", null, + Instant.now(), null); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(body)); + } } diff --git a/server/skillhub-app/src/main/resources/db/migration/V4__normalize_skill_slugs.sql b/server/skillhub-app/src/main/resources/db/migration/V4__normalize_skill_slugs.sql new file mode 100644 index 00000000..ec13ffb7 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V4__normalize_skill_slugs.sql @@ -0,0 +1,55 @@ +CREATE OR REPLACE FUNCTION skillhub_slugify(raw_text TEXT) +RETURNS VARCHAR(100) +LANGUAGE plpgsql +AS $$ +DECLARE + slug TEXT; +BEGIN + IF raw_text IS NULL OR btrim(raw_text) = '' THEN + RAISE EXCEPTION 'skill slug source cannot be blank'; + END IF; + + slug := lower(btrim(raw_text)); + slug := regexp_replace(slug, '[^a-z0-9]+', '-', 'g'); + slug := regexp_replace(slug, '^-+', ''); + slug := regexp_replace(slug, '-+$', ''); + slug := regexp_replace(slug, '-{2,}', '-', 'g'); + + IF slug = '' THEN + RAISE EXCEPTION 'skill slug normalization produced empty slug for input %', raw_text; + END IF; + + IF length(slug) < 2 OR length(slug) > 64 THEN + RAISE EXCEPTION 'normalized skill slug % has invalid length', slug; + END IF; + + IF slug IN ('admin', 'api', 'dashboard', 'search', 'auth', 'me', 'global', 'system', 'static', 'assets', 'health') THEN + RAISE EXCEPTION 'normalized skill slug % is reserved', slug; + END IF; + + RETURN slug::VARCHAR(100); +END; +$$; + +DO $$ +BEGIN + IF EXISTS ( + WITH normalized AS ( + SELECT id, namespace_id, slug, skillhub_slugify(slug) AS normalized_slug + FROM skill + ) + SELECT 1 + FROM normalized + GROUP BY namespace_id, normalized_slug + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'skill slug normalization would create duplicate slugs; resolve manually before applying migration'; + END IF; + + UPDATE skill + SET slug = skillhub_slugify(slug) + WHERE slug <> skillhub_slugify(slug); +END; +$$; + +DROP FUNCTION skillhub_slugify(TEXT); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 1e69c99d..7cc2c460 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -67,3 +67,7 @@ error.skill.tag.version.missing=Tag does not point to a version: {0} error.skill.tag.version.notFound=Version pointed by tag not found: {0} error.skill.bundle.notFound=Published bundle not found in storage error.skill.resolve.versionTag.conflict=Parameters version and tag cannot be used together +error.deviceAuth.userCode.invalid=Invalid or expired user code +error.deviceAuth.deviceCode.expired=Device code expired +error.deviceAuth.deviceCode.invalid=Device code expired or invalid +error.deviceAuth.deviceCode.used=Device code has already been used diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index ae396769..5192afd1 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -67,3 +67,7 @@ error.skill.tag.version.missing=标签未指向具体版本:{0} error.skill.tag.version.notFound=未找到标签指向的版本:{0} error.skill.bundle.notFound=对象存储中未找到已发布技能包 error.skill.resolve.versionTag.conflict=version 和 tag 参数不能同时传入 +error.deviceAuth.userCode.invalid=无效或已过期的用户验证码 +error.deviceAuth.deviceCode.expired=设备验证码已过期 +error.deviceAuth.deviceCode.invalid=设备验证码无效或已过期 +error.deviceAuth.deviceCode.used=设备验证码已被使用 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java index 127059a4..74ac9647 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java @@ -21,7 +21,7 @@ class RequestIdFilterTest { @Test void shouldGenerateRequestIdWhenNotProvided() throws Exception { - mockMvc.perform(get("/actuator/health")) + mockMvc.perform(get("/api/v1/health")) .andExpect(status().isOk()) .andExpect(header().exists("X-Request-Id")); } @@ -29,7 +29,7 @@ class RequestIdFilterTest { @Test void shouldPreserveProvidedRequestId() throws Exception { String requestId = "test-request-123"; - mockMvc.perform(get("/actuator/health") + mockMvc.perform(get("/api/v1/health") .header("X-Request-Id", requestId)) .andExpect(status().isOk()) .andExpect(header().string("X-Request-Id", requestId)); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/RedisTemplateConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/RedisTemplateConfig.java new file mode 100644 index 00000000..74ea5215 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/RedisTemplateConfig.java @@ -0,0 +1,34 @@ +package com.iflytek.skillhub.auth.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisTemplateConfig { + + @Bean + @ConditionalOnMissingBean(name = "redisTemplate") + public RedisTemplate redisTemplate( + RedisConnectionFactory connectionFactory, + ObjectMapper objectMapper) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + StringRedisSerializer keySerializer = new StringRedisSerializer(); + GenericJackson2JsonRedisSerializer valueSerializer = + new GenericJackson2JsonRedisSerializer(objectMapper); + + template.setKeySerializer(keySerializer); + template.setHashKeySerializer(keySerializer); + template.setValueSerializer(valueSerializer); + template.setHashValueSerializer(valueSerializer); + template.afterPropertiesSet(); + return template; + } +}