mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-06 08:15:57 +00:00
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:
parent
47adf3ebad
commit
cab627be02
6 changed files with 140 additions and 29 deletions
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=设备验证码已被使用
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue