From 7dcb094f4e72f8207c130524cab2e06260f95cb4 Mon Sep 17 00:00:00 2001 From: promise_cyf <15731060594@163.com> Date: Sat, 10 Oct 2026 14:24:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(juhe):=20=E5=A2=9E=E5=8A=A0Redis=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E4=B8=8E=E6=AF=8F=E6=97=A5=E8=B0=83=E7=94=A8=E9=99=90?= =?UTF-8?q?=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构聚合数据客户端为服务层,引入Redis缓存及每日调用上限50次,超限后降级使用缓存;调整默认激活prod并移除MySQL profile;处理静态资源404异常。 添加用户友好界面,增加京备案号 --- .dockerignore | 9 + Dockerfile | 9 + docker-compose.yml | 37 + pom.xml | 12 + .../common/GlobalExceptionHandler.java | 8 + .../devToolBox/controller/JuheController.java | 42 +- .../cyf/devToolBox/juhe/JuheProperties.java | 6 + .../juhe/exception/JuheApiException.java | 3 +- .../juhe/redis/JuheDailySupport.java | 28 + .../juhe/redis/JuheRedisClient.java | 111 +++ .../devToolBox/juhe/redis/JuheRedisKeys.java | 41 + .../devToolBox/juhe/soup/JuheSoupService.java | 71 ++ .../juhe/weather/JuheWeatherService.java | 82 ++ src/main/resources/application-dev.yml | 31 + src/main/resources/application-prod.yml | 29 + src/main/resources/application.yml | 38 +- src/main/resources/static/favicon.svg | 11 + src/main/resources/static/index.html | 766 ++++++++++++++++++ 18 files changed, 1273 insertions(+), 61 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/main/java/com/cyf/devToolBox/juhe/redis/JuheDailySupport.java create mode 100644 src/main/java/com/cyf/devToolBox/juhe/redis/JuheRedisClient.java create mode 100644 src/main/java/com/cyf/devToolBox/juhe/redis/JuheRedisKeys.java create mode 100644 src/main/java/com/cyf/devToolBox/juhe/soup/JuheSoupService.java create mode 100644 src/main/java/com/cyf/devToolBox/juhe/weather/JuheWeatherService.java create mode 100644 src/main/resources/application-dev.yml create mode 100644 src/main/resources/application-prod.yml create mode 100644 src/main/resources/static/favicon.svg create mode 100644 src/main/resources/static/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9bb275b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +target/ +.git/ +.gitignore +.idea/ +*.iml +.codebuddy/ +README.md +Dockerfile +docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8beae65 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +# 运行阶段:直接使用你本地已打好的 jar(放在本目录,与 Dockerfile / docker-compose.yml 同级) +# 用法:先本地执行 mvn package(JDK 21),把 target/devToolBox-1.0-SNAPSHOT.jar 复制到本目录 +FROM eclipse-temurin:21-jre +WORKDIR /app +ENV TZ=Asia/Shanghai +# 本目录下放且仅放一个 jar(.dockerignore 已排除 target/,避免误打包) +COPY *.jar app.jar +EXPOSE 8077 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1bb7f18 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +# 只部署应用本身;Redis 已在宿主机 Docker 中运行,MySQL 暂不需要。 +services: + app: + build: + context: . + dockerfile: Dockerfile + image: dev-toolbox:latest + container_name: dev-toolbox + restart: unless-stopped + ports: + - "8077:8077" + environment: + # 激活生产 profile(info 日志、关闭 H2 控制台) + SPRING_PROFILES_ACTIVE: prod + + # Redis:容器内通过 host.docker.internal 访问宿主机上已发布的 6379 + # 前提:宿主机运行的 Redis 容器已把端口映射出来(docker run 时带 -p 6379:6379) + REDIS_HOST: host.docker.internal + REDIS_PORT: "6379" + REDIS_PASSWORD: "" + REDIS_DATABASE: "0" + + # 项目暂未使用数据库,用 H2 内存库避免依赖 MySQL;将来接入 MySQL 时删掉这三行, + # 改用 MYSQL_HOST/MYSQL_PORT/MYSQL_DATABASE/MYSQL_USERNAME/MYSQL_PASSWORD 即可 + SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.h2.Driver + SPRING_DATASOURCE_URL: "jdbc:h2:mem:dev_toolbox;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE" + SPRING_DATASOURCE_USERNAME: sa + SPRING_DATASOURCE_PASSWORD: "" + + # 聚合数据 key:如需覆盖 yml 里的默认值,取消注释并填写(或写在同目录 .env 文件里) + # JUHE_WEATHER_KEY: "" + # JUHE_SOUP_KEY: "" + + TZ: Asia/Shanghai + # 让容器能通过 host.docker.internal 访问宿主机(Linux 上必需) + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/pom.xml b/pom.xml index c74f9fb..a277bd5 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,18 @@ spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.apache.commons + commons-pool2 + + com.baomidou diff --git a/src/main/java/com/cyf/devToolBox/common/GlobalExceptionHandler.java b/src/main/java/com/cyf/devToolBox/common/GlobalExceptionHandler.java index 7463f4e..ba1a481 100644 --- a/src/main/java/com/cyf/devToolBox/common/GlobalExceptionHandler.java +++ b/src/main/java/com/cyf/devToolBox/common/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import com.cyf.devToolBox.juhe.exception.JuheApiException; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; /** * 全局异常处理,保证异常时也返回统一的 {@link Result} 结构。 @@ -35,6 +36,13 @@ public class GlobalExceptionHandler { return Result.error(500, e.getMessage()); } + /** 静态资源不存在(如浏览器默认请求的 favicon.ico),按 404 处理,不记为系统异常 */ + @ExceptionHandler(NoResourceFoundException.class) + public Result handleNoResourceFoundException(NoResourceFoundException e) { + log.warn("静态资源不存在:{}", e.getResourcePath()); + return Result.error(404, "资源不存在:" + e.getResourcePath()); + } + /** 兜底 */ @ExceptionHandler(Exception.class) public Result handleException(Exception e) { diff --git a/src/main/java/com/cyf/devToolBox/controller/JuheController.java b/src/main/java/com/cyf/devToolBox/controller/JuheController.java index cd9cf60..87a8ebd 100644 --- a/src/main/java/com/cyf/devToolBox/controller/JuheController.java +++ b/src/main/java/com/cyf/devToolBox/controller/JuheController.java @@ -1,8 +1,8 @@ package com.cyf.devToolBox.controller; -import com.cyf.devToolBox.juhe.soup.JuheSoupClient; +import com.cyf.devToolBox.juhe.soup.JuheSoupService; import com.cyf.devToolBox.juhe.soup.model.JuheSoupResponse; -import com.cyf.devToolBox.juhe.weather.JuheWeatherClient; +import com.cyf.devToolBox.juhe.weather.JuheWeatherService; import com.cyf.devToolBox.juhe.weather.model.JuheWeatherResponse; import com.cyf.devToolBox.common.Result; import org.springframework.web.bind.annotation.GetMapping; @@ -16,23 +16,23 @@ import org.springframework.web.bind.annotation.RestController; *
  * GET /api/juhe/weather?city=丰台              查询实时天气(未来天数取配置默认值)
  * GET /api/juhe/weather?city=北京&futureNum=3  指定未来天气天数
- * GET /api/juhe/weather/raw?city=北京          返回原始 JSON,便于排查
  * GET /api/juhe/soup                           查询每日一句
- * GET /api/juhe/soup/raw                       返回原始 JSON,便于排查
  * 
* + *

天气与每日一句均带 Redis 缓存与每日调用限流,详见对应 Service。

+ * * @author cyf */ @RestController @RequestMapping("/api/juhe") public class JuheController { - private final JuheWeatherClient juheWeatherClient; - private final JuheSoupClient juheSoupClient; + private final JuheWeatherService juheWeatherService; + private final JuheSoupService juheSoupService; - public JuheController(JuheWeatherClient juheWeatherClient, JuheSoupClient juheSoupClient) { - this.juheWeatherClient = juheWeatherClient; - this.juheSoupClient = juheSoupClient; + public JuheController(JuheWeatherService juheWeatherService, JuheSoupService juheSoupService) { + this.juheWeatherService = juheWeatherService; + this.juheSoupService = juheSoupService; } /** @@ -45,19 +45,7 @@ public class JuheController { @GetMapping("/weather") public Result weather(@RequestParam String city, @RequestParam(required = false) Integer futureNum) { - JuheWeatherResponse.Result data = futureNum == null - ? juheWeatherClient.query(city) - : juheWeatherClient.query(city, futureNum); - return Result.success(data); - } - - /** - * 天气接口原始 JSON,便于排查问题。 - */ - @GetMapping("/weather/raw") - public Result weatherRaw(@RequestParam String city, - @RequestParam(required = false) Integer futureNum) { - return Result.success(juheWeatherClient.queryRaw(city, futureNum)); + return Result.success(juheWeatherService.query(city, futureNum)); } /** @@ -67,14 +55,6 @@ public class JuheController { */ @GetMapping("/soup") public Result soup() { - return Result.success(juheSoupClient.query()); - } - - /** - * 每日一句接口原始 JSON,便于排查问题。 - */ - @GetMapping("/soup/raw") - public Result soupRaw() { - return Result.success(juheSoupClient.queryRaw()); + return Result.success(juheSoupService.query()); } } diff --git a/src/main/java/com/cyf/devToolBox/juhe/JuheProperties.java b/src/main/java/com/cyf/devToolBox/juhe/JuheProperties.java index 8da4e36..7f5b951 100644 --- a/src/main/java/com/cyf/devToolBox/juhe/JuheProperties.java +++ b/src/main/java/com/cyf/devToolBox/juhe/JuheProperties.java @@ -35,6 +35,9 @@ public class JuheProperties { /** 读取超时时间 */ private Duration readTimeout = Duration.ofSeconds(5); + /** 每个接口每天允许真实调用上游的最大次数,超过后走 Redis 缓存降级 */ + private int dailyLimit = 50; + /** 天气查询配置 */ private Weather weather = new Weather(); @@ -55,6 +58,9 @@ public class JuheProperties { /** 默认查询的未来天气天数(1~7),为空表示只查询实时天气 */ private Integer futureNum; + + /** 天气缓存有效期(动态数据,默认 4 小时) */ + private Duration cacheTtl = Duration.ofHours(4); } /** diff --git a/src/main/java/com/cyf/devToolBox/juhe/exception/JuheApiException.java b/src/main/java/com/cyf/devToolBox/juhe/exception/JuheApiException.java index cdbc9d9..8d2b929 100644 --- a/src/main/java/com/cyf/devToolBox/juhe/exception/JuheApiException.java +++ b/src/main/java/com/cyf/devToolBox/juhe/exception/JuheApiException.java @@ -11,7 +11,8 @@ public class JuheApiException extends RuntimeException { private final int errorCode; public JuheApiException(int errorCode, String reason) { - super("聚合数据接口调用失败 [error_code=" + errorCode + "]: " + reason); +// super("聚合数据接口调用失败 [error_code=" + errorCode + "]: " + reason); + super("数据接口调用失败 [error_code=" + errorCode + "]: " + reason); this.errorCode = errorCode; } diff --git a/src/main/java/com/cyf/devToolBox/juhe/redis/JuheDailySupport.java b/src/main/java/com/cyf/devToolBox/juhe/redis/JuheDailySupport.java new file mode 100644 index 0000000..5f9e1ab --- /dev/null +++ b/src/main/java/com/cyf/devToolBox/juhe/redis/JuheDailySupport.java @@ -0,0 +1,28 @@ +package com.cyf.devToolBox.juhe.redis; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZonedDateTime; + +/** + * 「按自然日」维度的辅助工具:日期 key 片段与距 24 点的剩余时长。 + * + * @author cyf + */ +public final class JuheDailySupport { + + private JuheDailySupport() { + } + + /** 当前日期字符串(yyyy-MM-dd),作为当天维度的 key 片段。 */ + public static String today() { + return LocalDate.now().toString(); + } + + /** 距当天 24 点的剩余时长,用作计数 key 的过期时间。 */ + public static Duration tillMidnight() { + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime nextMidnight = now.toLocalDate().plusDays(1).atStartOfDay(now.getZone()); + return Duration.between(now, nextMidnight); + } +} diff --git a/src/main/java/com/cyf/devToolBox/juhe/redis/JuheRedisClient.java b/src/main/java/com/cyf/devToolBox/juhe/redis/JuheRedisClient.java new file mode 100644 index 0000000..eb92304 --- /dev/null +++ b/src/main/java/com/cyf/devToolBox/juhe/redis/JuheRedisClient.java @@ -0,0 +1,111 @@ +package com.cyf.devToolBox.juhe.redis; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Optional; + +/** + * Redis 操作的容错封装。 + * + *

所有方法内部捕获异常并降级,保证 Redis 不可用时不会影响主流程:

+ *
    + *
  • 读取失败:视为缓存未命中
  • + *
  • 写入失败:跳过缓存
  • + *
  • 计数失败:{@link #increment} 返回负数,调用方据此视为「不受限」照常调用上游
  • + *
+ * 使用 {@link StringRedisTemplate} 存字符串,对象与 JSON 的转换由 Jackson 完成。 + * + * @author cyf + */ +@Slf4j +@Component +public class JuheRedisClient { + + /** Redis 不可用(计数失败)时的哨兵值 */ + public static final long UNAVAILABLE = -1L; + + private final StringRedisTemplate redis; + private final ObjectMapper objectMapper; + + public JuheRedisClient(StringRedisTemplate redis, ObjectMapper objectMapper) { + this.redis = redis; + this.objectMapper = objectMapper; + } + + /** + * 读取并反序列化缓存,未命中或 Redis 异常时返回 {@link Optional#empty()}。 + */ + public Optional get(String key, Class type) { + try { + String json = redis.opsForValue().get(key); + if (json == null) { + return Optional.empty(); + } + return Optional.of(objectMapper.readValue(json, type)); + } catch (Exception e) { + log.warn("读取 Redis 失败,降级为缓存未命中。key={}, 原因={}", key, e.getMessage()); + return Optional.empty(); + } + } + + /** + * 写入缓存并设置过期时间。 + */ + public void set(String key, Object value, Duration ttl) { + try { + redis.opsForValue().set(key, objectMapper.writeValueAsString(value), ttl); + } catch (Exception e) { + log.warn("写入 Redis 失败,跳过缓存。key={}, 原因={}", key, e.getMessage()); + } + } + + /** + * 对当天计数进行自增,首次自增时设置过期时间。 + * + * @param key 计数 key + * @param ttl 过期时间(通常为距当天 24 点的剩余时长) + * @return 自增后的计数;Redis 不可用时返回 {@link #UNAVAILABLE}(负数) + */ + public long increment(String key, Duration ttl) { + try { + Long count = redis.opsForValue().increment(key); + if (count == null) { + return UNAVAILABLE; + } + if (count == 1L) { + redis.expire(key, ttl); + } + return count; + } catch (Exception e) { + log.warn("Redis 计数失败,降级为不受限。key={}, 原因={}", key, e.getMessage()); + return UNAVAILABLE; + } + } + + /** + * 向集合中添加元素(自动去重),永久存储。 + */ + public void addToSet(String key, String value) { + try { + redis.opsForSet().add(key, value); + } catch (Exception e) { + log.warn("写入 Redis 集合失败。key={}, 原因={}", key, e.getMessage()); + } + } + + /** + * 从集合中随机取一个元素,集合不存在或 Redis 异常时返回 {@link Optional#empty()}。 + */ + public Optional randomFromSet(String key) { + try { + return Optional.ofNullable(redis.opsForSet().randomMember(key)); + } catch (Exception e) { + log.warn("读取 Redis 集合失败。key={}, 原因={}", key, e.getMessage()); + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/cyf/devToolBox/juhe/redis/JuheRedisKeys.java b/src/main/java/com/cyf/devToolBox/juhe/redis/JuheRedisKeys.java new file mode 100644 index 0000000..78bf5d0 --- /dev/null +++ b/src/main/java/com/cyf/devToolBox/juhe/redis/JuheRedisKeys.java @@ -0,0 +1,41 @@ +package com.cyf.devToolBox.juhe.redis; + +/** + * 聚合数据相关的 Redis key 统一定义,避免散落在各处。 + * + *
+ * juhe:weather:{日期}:{城市}:{未来天数}   天气缓存(带 TTL)
+ * juhe:weather:call:{日期}                天气当天调用计数
+ * juhe:soup:pool                          每日一句缓存池(Set,永久)
+ * juhe:soup:call:{日期}                   每日一句当天调用计数
+ * 
+ * + * @author cyf + */ +public final class JuheRedisKeys { + + private static final String PREFIX = "juhe:"; + + private JuheRedisKeys() { + } + + /** 天气缓存 key,按「日期 + 城市 + 未来天数」维度缓存。 */ + public static String weather(String date, String city, Integer futureNum) { + return PREFIX + "weather:" + date + ":" + city + ":" + futureNum; + } + + /** 天气当天调用计数 key。 */ + public static String weatherCallCount(String date) { + return PREFIX + "weather:call:" + date; + } + + /** 每日一句缓存池 key(Set,永久存储,用于去重与随机返回)。 */ + public static String soupPool() { + return PREFIX + "soup:pool"; + } + + /** 每日一句当天调用计数 key。 */ + public static String soupCallCount(String date) { + return PREFIX + "soup:call:" + date; + } +} diff --git a/src/main/java/com/cyf/devToolBox/juhe/soup/JuheSoupService.java b/src/main/java/com/cyf/devToolBox/juhe/soup/JuheSoupService.java new file mode 100644 index 0000000..c285c80 --- /dev/null +++ b/src/main/java/com/cyf/devToolBox/juhe/soup/JuheSoupService.java @@ -0,0 +1,71 @@ +package com.cyf.devToolBox.juhe.soup; + +import com.cyf.devToolBox.juhe.JuheProperties; +import com.cyf.devToolBox.juhe.exception.JuheApiException; +import com.cyf.devToolBox.juhe.redis.JuheDailySupport; +import com.cyf.devToolBox.juhe.redis.JuheRedisClient; +import com.cyf.devToolBox.juhe.redis.JuheRedisKeys; +import com.cyf.devToolBox.juhe.soup.model.JuheSoupResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +/** + * 每日一句业务编排:调用结果永久缓存 + 每日调用限流。 + * + *

流程:

+ *
    + *
  1. 每天前 {@code juhe.daily-limit} 次真实调用上游,结果写入去重集合(永久)
  2. + *
  3. 超过上限后不再调用上游,从缓存池随机返回一条
  4. + *
+ * Redis 不可用时降级为每次直接调用上游。 + * + * @author cyf + */ +@Slf4j +@Service +public class JuheSoupService { + + /** 超过每日调用上限时返回的业务码 */ + private static final int CODE_OVER_LIMIT = 429; + + private final JuheSoupClient juheSoupClient; + private final JuheProperties properties; + private final JuheRedisClient redis; + + public JuheSoupService(JuheSoupClient juheSoupClient, + JuheProperties properties, + JuheRedisClient redis) { + this.juheSoupClient = juheSoupClient; + this.properties = properties; + this.redis = redis; + } + + /** + * 查询每日一句(超限则从缓存池随机返回)。 + */ + public JuheSoupResponse.Result query() { + String today = JuheDailySupport.today(); + long count = redis.increment(JuheRedisKeys.soupCallCount(today), JuheDailySupport.tillMidnight()); + + // 超过每日上限:不再调用上游,从缓存池随机返回 + if (count >= 0 && count > properties.getDailyLimit()) { + return randomFromPool(); + } + + JuheSoupResponse.Result data = juheSoupClient.query(); + if (data != null && StringUtils.hasText(data.getText())) { + redis.addToSet(JuheRedisKeys.soupPool(), data.getText()); + } + return data; + } + + private JuheSoupResponse.Result randomFromPool() { + String text = redis.randomFromSet(JuheRedisKeys.soupPool()) + .orElseThrow(() -> new JuheApiException(CODE_OVER_LIMIT, "今日每日一句额度已用完且暂无缓存,请明天再试")); + log.info("每日一句今日调用已达上限,从缓存池随机返回"); + JuheSoupResponse.Result result = new JuheSoupResponse.Result(); + result.setText(text); + return result; + } +} diff --git a/src/main/java/com/cyf/devToolBox/juhe/weather/JuheWeatherService.java b/src/main/java/com/cyf/devToolBox/juhe/weather/JuheWeatherService.java new file mode 100644 index 0000000..0708b81 --- /dev/null +++ b/src/main/java/com/cyf/devToolBox/juhe/weather/JuheWeatherService.java @@ -0,0 +1,82 @@ +package com.cyf.devToolBox.juhe.weather; + +import com.cyf.devToolBox.juhe.JuheProperties; +import com.cyf.devToolBox.juhe.exception.JuheApiException; +import com.cyf.devToolBox.juhe.redis.JuheDailySupport; +import com.cyf.devToolBox.juhe.redis.JuheRedisClient; +import com.cyf.devToolBox.juhe.redis.JuheRedisKeys; +import com.cyf.devToolBox.juhe.weather.model.JuheWeatherResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.Optional; + +/** + * 天气查询业务编排:缓存优先 + 每日调用限流。 + * + *

流程:

+ *
    + *
  1. 命中「城市 + 未来天数」缓存(TTL 4 小时)直接返回,不消耗当天额度
  2. + *
  3. 未命中则判断当天真实调用上游次数,超过 {@code juhe.daily-limit} 返回 429
  4. + *
  5. 正常调用上游后写入缓存
  6. + *
+ * Redis 不可用时降级为每次直接调用上游。 + * + * @author cyf + */ +@Slf4j +@Service +public class JuheWeatherService { + + /** 超过每日调用上限时返回的业务码 */ + private static final int CODE_OVER_LIMIT = 429; + + private final JuheWeatherClient juheWeatherClient; + private final JuheProperties properties; + private final JuheRedisClient redis; + + public JuheWeatherService(JuheWeatherClient juheWeatherClient, + JuheProperties properties, + JuheRedisClient redis) { + this.juheWeatherClient = juheWeatherClient; + this.properties = properties; + this.redis = redis; + } + + /** + * 查询指定城市的天气(缓存优先)。 + * + * @param city 城市名称 + * @param futureNum 未来天气天数,为 null 时使用配置默认值 + */ + public JuheWeatherResponse.Result query(String city, Integer futureNum) { + if (!StringUtils.hasText(city)) { + throw new IllegalArgumentException("城市名称不能为空"); + } + Integer effectiveFutureNum = futureNum == null ? properties.getWeather().getFutureNum() : futureNum; + String today = JuheDailySupport.today(); + + // 1. 命中缓存直接返回,不消耗当天额度 + String cacheKey = JuheRedisKeys.weather(today, city, effectiveFutureNum); + Optional cached = redis.get(cacheKey, JuheWeatherResponse.Result.class); + if (cached.isPresent()) { + log.debug("天气缓存命中:{}", cacheKey); + return cached.get(); + } + + // 2. 未命中:校验当天调用额度(计数失败=Redis 不可用,视为不受限) + long count = redis.increment(JuheRedisKeys.weatherCallCount(today), JuheDailySupport.tillMidnight()); + if (count >= 0 && count > properties.getDailyLimit()) { + log.warn("天气接口今日调用已达上限 {},city={}", properties.getDailyLimit(), city); + throw new JuheApiException(CODE_OVER_LIMIT, "今日天气查询额度已用完,请明天再试"); + } + + // 3. 调用上游并写入缓存 + JuheWeatherResponse.Result data = effectiveFutureNum == null + ? juheWeatherClient.query(city) + : juheWeatherClient.query(city, effectiveFutureNum); + redis.set(cacheKey, data, properties.getWeather().getCacheTtl()); + return data; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..c0ad2d9 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,31 @@ +# 开发环境:H2 内存库 + 本地 Redis,无需任何外部依赖即可启动 +spring: + # H2 内存库(MODE=MySQL 兼容 MySQL 语法) + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:dev_toolbox;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + username: sa + password: "" + + h2: + console: + enabled: true + path: /h2-console + + # Redis:天气缓存、每日一句缓存池与每日调用限流 + data: + redis: + host: localhost + port: 6379 + database: 0 + timeout: 3s + # password: 本地无密码时留空 + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + +logging: + level: + com.cyf.devToolBox: debug diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..aee8992 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,29 @@ +# 生产环境:MySQL + 独立 Redis,连接参数建议全部通过环境变量注入 +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:dev_toolbox}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true + username: ${MYSQL_USERNAME:root} + password: ${MYSQL_PASSWORD:root} + + # 生产关闭 H2 控制台 + h2: + console: + enabled: false + + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: ${REDIS_DATABASE:0} + timeout: 3s + lettuce: + pool: + max-active: 16 + max-idle: 16 + min-idle: 0 + +logging: + level: + com.cyf.devToolBox: info diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 117c19b..3d8f802 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,17 +5,9 @@ spring: application: name: dev-toolbox - # 默认使用 H2 内存库,本地无需任何外部依赖即可启动 - datasource: - driver-class-name: org.h2.Driver - url: jdbc:h2:mem:dev_toolbox;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE - username: sa - password: "" - - h2: - console: - enabled: true - path: /h2-console + # 默认激活 dev;生产可用环境变量 SPRING_PROFILES_ACTIVE=prod(或 --spring.profiles.active=prod)覆盖 + profiles: + active: ${SPRING_PROFILES_ACTIVE:prod} jackson: date-format: yyyy-MM-dd HH:mm:ss @@ -30,37 +22,25 @@ mybatis-plus: db-config: id-type: auto -# 聚合数据(每个接口使用各自的 key) +# 聚合数据(各环境共用,不随 dev/prod 变化;key 用环境变量覆盖) # 天气文档:https://www.juhe.cn/docs/api/id/73 # 每日一句文档:https://www.juhe.cn/docs/api/id/86 juhe: connect-timeout: 3s read-timeout: 5s + # 每个接口每天真实调用上游的最大次数,超过后走 Redis 缓存降级 + daily-limit: 50 # 天气查询 weather: base-url: https://apis.juhe.cn/simpleWeather/query # 建议改用环境变量 JUHE_WEATHER_KEY 覆盖,避免明文提交到仓库 key: ${JUHE_WEATHER_KEY:ca38eda68ea7242a3601e4e98afa8499} # 查询未来天气天数(1~7),不填写则只返回实时天气 - future-num: 5 + future-num: 7 + # 天气缓存有效期(动态数据,默认 4 小时) + cache-ttl: 4h # 每日一句 soup: base-url: https://apis.juhe.cn/fapig/soup/query # 每日一句接口的 key,请填入并建议用环境变量 JUHE_SOUP_KEY 覆盖 key: ${JUHE_SOUP_KEY:cf8695f97027c22b346a468697ba11db} - ---- -# 切换为 MySQL 时使用该 profile:mvn spring-boot:run -Dspring-boot.run.profiles=mysql -spring: - config: - activate: - on-profile: mysql - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://localhost:3306/dev_toolbox?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true - username: root - password: root - -logging: - level: - com.cyf.devToolBox: debug diff --git a/src/main/resources/static/favicon.svg b/src/main/resources/static/favicon.svg new file mode 100644 index 0000000..eaf3c28 --- /dev/null +++ b/src/main/resources/static/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + YF + diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..420e029 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,766 @@ + + + + + + 工具箱 · 聚合数据 + + + + +
+
+ +

🧰 工具箱

+

聚合数据 API · 实时调用演示

+
+ +
+ +
+
+
🌤
+ 天气查询 +
+
输入城市名查询实时天气,可选 1~7 天预报
+ +
+ 北京 + 上海 + 广州 + 深圳 + 杭州 + 丰台 +
+ +
+ + + +
+ +
+ 点击「查询」或选择城市 +
+
+ + +
+
+
✨
+ 每日一句 +
+
一句温暖的话,伴你度过今天
+ +
+ + +
+ +
+ 点击「换一句」开始 +
+
+
+
+ +
+ + + + + +