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
This commit is contained in:
vsxd 2026-03-12 20:11:24 +08:00
parent 47adf3ebad
commit cab627be02
6 changed files with 140 additions and 29 deletions

View file

@ -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<IdempotencyRecord> 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<Void> body = new ApiResponse<>(409, "error.request.duplicate", null,
Instant.now(), null);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(body));
}
}

View file

@ -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);

View file

@ -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

View file

@ -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=设备验证码已被使用

View file

@ -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));

View file

@ -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<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory,
ObjectMapper objectMapper) {
RedisTemplate<String, Object> 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;
}
}