From 4105b901f1fc7aa773041a8bd66ba782ebf85ab4 Mon Sep 17 00:00:00 2001 From: sans Date: Wed, 24 Jun 2026 13:49:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0redis=E7=9A=84=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E5=92=8C=E6=95=B0=E6=8D=AE=E6=B1=87=E6=80=BB=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 23 +- CLAUDE.md | 208 ++++++++++++----- docker-compose.yml | 210 +++++++++++++++++- system-admin/Dockerfile | 26 ++- .../dailydata/WeatherDataImportManager.java | 11 + .../dailydata/dao/WeatherDailyDataDao.java | 17 +- .../impl/WeatherDailyDataServiceImpl.java | 98 +++++++- .../task/WeatherSummarizeCacheTask.java | 65 ++++++ .../filescan/FileScanStartupRunner.java | 25 ++- .../src/main/resources/application-dev.yml | 20 -- .../src/main/resources/application.yml | 4 +- .../dailyweather/WeatherDailyDataDao.xml | 13 ++ system-api/Dockerfile | 26 ++- .../weather/common/aspect/RedisAspect.java | 2 +- .../com/weather/common/redis/RedisKeys.java | 14 ++ .../com/weather/common/redis/RedisUtils.java | 19 ++ 16 files changed, 662 insertions(+), 119 deletions(-) create mode 100644 system-admin/src/main/java/com/weather/modules/weather/dailydata/task/WeatherSummarizeCacheTask.java diff --git a/.gitignore b/.gitignore index 631d03f..26d3526 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,25 @@ target .idea # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* \ No newline at end of file +hs_err_pid* + +# ============================================================ +# Node.js / Frontend +# ============================================================ +node_modules/ +dist/ +.DS_Store +*.local + +# ============================================================ +# Environment +# ============================================================ +.env +.env.local +.env.*.local + +# ============================================================ +# Deploy +# ============================================================ +weather-data-deploy/ +weather-data-deploy.tar.gz \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index e85fae6..83d8ace 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,53 +2,58 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -> **See also** `AGENTS.md` for module breakdown, framework choices, conventions, and gotchas. This file supplements it with build commands and architectural patterns. +> **See also** `AGENTS.md` for module breakdown, framework choices, conventions, and gotchas. -## Build & Run +--- -```bash -# Full build (tests skipped by default per pom.xml true) -mvn clean install -DskipTests +## Project overview -# Build with tests -mvn clean install -DskipTests=false +**weather-data** is a full-stack meteorological data analysis platform: Java 17 / Spring Boot 3.5 multi-module backend + Vue 3 / Vite 5 / TypeScript frontend. -# Run single test class -mvn -pl system-admin -DskipTests=false -Dtest=YourTestClass test ``` - -**Run applications** from IntelliJ: -- **Admin backend**: `com.weather.AdminApplication` (`system-admin/`) — port 8080, context path `/system-admin` -- **API service**: `com.weather.ApiApplication` (`system-api/`) — port 8081 -- **Code generator**: `com.weather.GeneratorApplication` (`renren-generator/`) +weather-data/ +├── system-common/ → shared Java lib +├── system-admin/ → admin backend (port 8080, /system-admin) +├── system-api/ → external API service (port 8081) +├── system-dynamic-datasource → multi-DS support (placeholder) +├── renren-generator/ → code generator +└── weather-data-ui/ → Vue 3 SPA frontend +``` **Database**: `weather_data_system` (MySQL). Init from `system-admin/db/mysql.sql`. Default admin: `admin` / `admin`. -## Architecture +--- -### Multi-module Maven project (Java 17, Spring Boot 3.5.x) +## Backend +### Build & Run + +```bash +mvn clean install -DskipTests # full build (tests skipped by default) +mvn clean install -DskipTests=false # build with tests +mvn -pl system-admin -DskipTests=false -Dtest=YourTestClass test # single test ``` -weather-data (pom) -├── system-common → shared lib (all modules depend on this) -├── system-admin → main admin backend -├── system-api → external API service -├── system-dynamic-datasource → multi-DS support (placeholder) -└── renren-generator → code generator -``` + +Launch from IntelliJ: +- `AdminApplication` (`system-admin/`) → port 8080, context `/system-admin` +- `ApiApplication` (`system-api/`) → port 8081 +- `GeneratorApplication` (`renren-generator/`) ### Service layer pattern -All services extend one of two base classes from `system-common`: -- **`CrudService`** — generic CRUD with `page()`, `get()`, `save()`, `update()`, `delete()`. The DTO type param is used for query criteria wrapping. -- **`BaseService`** — lighter base without DTO generic. +Two base classes in `system-common`: -New modules follow this convention: +| Base | Purpose | +|---|---| +| `CrudService` | Generic CRUD: `page()`, `get()`, `save()`, `update()`, `delete()` | +| `BaseService` | Lighter base without DTO generic | + +Module convention: ``` modules// ├── controller/ → @RestController, returns Result ├── dao/ → extends BaseMapper (MyBatis-Plus) -├── dto/ → request/query DTOs (often extends BaseEntity for auto-fill) +├── dto/ → request/query DTOs (often extends BaseEntity) ├── entity/ → @TableName JPA entity ├── service/ → interface extends CrudService/BaseService │ └── impl/ → @Service implementation @@ -60,48 +65,129 @@ Mapper XMLs: `src/main/resources/mapper//**/*.xml` ### Key cross-cutting mechanisms -| Mechanism | How it works | +| Mechanism | How | |---|---| -| **Data permissions** | `@DataFilter` annotation on controller → `DataFilterAspect` → `DataFilterInterceptor` injects dept-based SQL filtering into MyBatis | -| **Auto-fill** | `FieldMetaObjectHandler` fills `creator`/`createDate`/`updater`/`updateDate` via MyBatis-Plus meta-object handler | -| **Scheduled jobs** | Quartz. Jobs in `schedule_job` table, implement `ITask`, annotated `@Component("beanName")`. `JobCommandLineRunner` auto-registers at startup | -| **File scanning** | Three-part weather module: `WatchService` (primary, `FileWatchServiceManager`) + Quartz fallback (`FileScanTask`) + startup scan (`FileScanStartupRunner`). Files served via `FileDownloadController` | -| **Excel import** | EasyExcel with async progress tracking via `WeatherDataImportManager` | -| **API responses** | Always wrapped in `Result` class (`system-common`) | -| **Validation** | Hibernate Validator on DTOs. XSS filter via `XssFilter` | +| **Data permissions** | `@DataFilter` on controller → `DataFilterAspect` → MyBatis interceptor injects dept-based SQL | +| **Auto-fill** | `FieldMetaObjectHandler` fills creator/date via MyBatis-Plus | +| **Scheduled jobs** | Quartz. `schedule_job` table, implements `ITask`, `@Component("beanName")` | +| **File scanning** | `WatchService` (primary) + Quartz fallback (`FileScanTask`) + startup runner | +| **Excel import** | EasyExcel + async progress via `WeatherDataImportManager` | +| **API responses** | Always wrapped in `Result` | +| **Validation** | Hibernate Validator. XSS filter via `XssFilter` | -### PK strategy +### PK & Auth -`ASSIGN_ID` (Snowflake via `IdUtil.getSnowflakeNextId()`), set globally in MyBatis-Plus config. All entities extend `BaseEntity` which declares the `id` field. +- PK: `ASSIGN_ID` (Snowflake). All entities extend `BaseEntity`. +- Auth: Apache Shiro 1.12 (Jakarta) + OAuth2 token. Login → `token` header. +- API module: `@Login` annotation + `AuthorizationInterceptor`. -### Auth flow +### Redis & Docs -- Apache Shiro 1.12 (Jakarta classifier) with OAuth2 token auth -- Login → get token → pass `token` header on subsequent requests -- API module (`system-api`) uses `@Login` annotation + `AuthorizationInterceptor` +- Redis: optional, `project-options.redis.open` (default `false` in dev). `RedisAspect`. +- API docs: Knife4j at `/doc.html`, **disabled by default** (`knife4j.enable: false`). -### Redis - -Optional, controlled by `project-options.redis.open` (default `false` in dev). Cache aspect: `RedisAspect`. - -### API docs - -Knife4j (Swagger UI) at `/doc.html`. **Disabled by default** (`knife4j.enable: false`). Enable only in dev profile. - -## Custom Weather Domain +### Weather domain (backend) Three sub-modules under `system-admin/.../modules/weather/`: -| Sub-module | Purpose | Key detail | -|---|---|---| -| `dailydata/` | Daily weather observations | Excel batch import (async), EasyExcel listener pattern | -| `station/` | Weather station CRUD | Linked to dept via `dept_id`, data-permission aware | -| `filescan/` | File monitoring & serving | WatchService → record → serve via `FileDownloadController` | +| Module | Purpose | +|---|---| +| `dailydata/` | Daily observations, Excel batch import (async), EasyExcel listener | +| `station/` | Weather station CRUD, linked to dept via `dept_id` | +| `filescan/` | File monitoring + serving. Format: `<地区>地区-<指标>.png` / `<地区>地区631信息.txt` | -File format convention (from `需求文档.md`): `<地区>地区-<指标>.png` for charts, `<地区>地区631信息.txt` for text data. `FileNameParser` extracts region/indicator keywords. +Parameter `scan_root_path` in `sys_params` controls file-scan base directory. -## Database +--- -Custom tables: `weather_daily_data`, `weather_station`, `weather_file_scan_record`. -Seed scripts in `system-admin/db/` (mysql.sql + Oracle/SQLServer/PostgreSQL/Dameng variants). -Parameter `scan_root_path` in `sys_params` controls the file-scan base directory. +## Frontend (`weather-data-ui/`) + +### Commands + +```bash +npm install # install dependencies +npm run dev # Vite dev server +npm run build / npm run build:prod # production build +npm run serve # preview production build +npm run lint # lint with autofix +npx vue-tsc --noEmit # type-check +``` + +### Stack + +- Vite 5 + Vue 3 + TypeScript SPA +- Element Plus + Element Plus Icons for UI +- `vue-router` with hash history +- Pinia for state management +- Axios via `src/utils/http.ts` + `src/service/baseService.ts` +- API base URL: `VITE_APP_API` env var, overridable at runtime by `window.SITE_CONFIG.apiURL` + +### Routing & state + +- `src/router/base.ts`: base routes (`/`, `/home`, `/login`, etc.) +- `src/router/index.ts`: `beforeEach` guard — auth check, dynamic route registration from backend menus, tab management +- `src/store/index.ts` (`useAppStore`): user, permissions, dicts, dynamic routes, tabs +- `src/store/importTasks.ts`: long-running import task state for header indicator +- `src/utils/router.ts`: converts backend menu records → Vue router records, flattens nested routes for keep-alive + +Layout is event-driven: `src/layout/` shell + `mitt` event bus (`src/utils/emits.ts`). Trace both the Pinia store and `mitt` events when changing navigation/sidebar/tabs/theme. + +### Weather frontend module + +The home dashboard (`src/views/home.vue`) uses a **composable-based architecture**. All domain logic is extracted from the SFC into `src/composables/`: + +| Composable | Responsibility | +|---|---| +| `useWeatherConstants.ts` | Rain levels, temperature thresholds, filter field definitions, `fmtVal()`, level/class helpers | +| `useWeatherFilter.ts` | Filter state, toggle/reset/match logic, `matchOp()` | +| `useWeatherStats.ts` | `computeStats()`, `buildStatCards()`, `buildSummary()`, `rainLevelDistribution`, `WeatherDataRow` type | +| `useWeatherChart.ts` | ECharts dynamic import, `buildChartOption()`, `ResizeObserver`, precise trigger key (not deep watch) | +| `useWeatherExport.ts` | PNG/PDF export with dynamic `html2canvas`/`jspdf` imports, loading indicator | + +Supporting utils: +- `src/utils/chartBuilder.ts` — chart option builders +- `src/utils/exportReport.ts` — shared `exportPNG()`/`exportPDF()` + +### Critical rules learned (must follow) + +#### 1. Null ≠ zero — missing data MUST be preserved as null +When mapping backend API responses to frontend models, **never** default missing numeric values to `0`. Rainfall of `0mm` means "no rain that day" (valid measurement); `null` means "no data available" (missing record). Use `: null` not `: 0` in data mapping, and display `"—"` for null values via `fmtVal()`. + +```typescript +// ✅ Correct +rainfall: row.rain2020 != null ? +row.rain2020 : null, + +// ❌ Wrong — confuses "no data" with "measured zero" +rainfall: row.rain2020 != null ? +row.rain2020 : 0, +``` + +All helper functions (`rainLevelLabel`, `tmaxValClass`, `tminValClass`, `rainValClass`) must accept `number | null` and return `"—"` or `""` for null. Stats computations (`computeStats`, `fStats`, `filterExtremes`) must skip null values in sums and extreme comparisons. ECharts will naturally render null as gaps in line/bar series. + +#### 2. Heavy libraries must use dynamic imports +`html2canvas`, `jspdf`, and `echarts` are NOT imported at module level. They are loaded via `await import()` only when the user triggers export or chart rendering. This keeps them out of the initial bundle (~600KB saved). + +#### 3. Google Fonts go in index.html, not scoped styles +Never use `@import url("https://fonts.googleapis.com/...")` inside Vue scoped styles — it blocks rendering. Instead, add `` + `` in `index.html`. + +#### 4. Export must show user feedback +When exporting images/PDFs, always show a loading indicator (`ElLoading.service` fullscreen) and a success/failure message (`ElMessage`). Disable the export button during rendering to prevent double-clicks. + +#### 5. Deep watchers on filter objects are banned +Never use `watch(filters, callback, { deep: true })`. Instead, derive a precise computed trigger key that only includes fields actually affecting the output (e.g., `dataHash`, `filteredHash`, `extremesVersion`) and watch that. + +### Common page pattern + +Admin CRUD pages use `src/hooks/useView.ts` for shared list-page workflow: query, paging, sorting, delete, export, permission checks, dictionary lookup. Check whether behavior comes from `useView` before refactoring these screens. + +### Other conventions + +- Reusable selector/tree controls: `src/components/sys-*`, registered globally in `main.ts` +- SVG icons: `vite-plugin-svg-icons` from `src/assets/icons/svg/` +- Tests: no test runner configured yet + +--- + +## Repository notes + +- `README.md` does not contain substantive guidance; operational context lives in this file and `AGENTS.md`. +- `weather-data-ui/CLAUDE.md` is superseded by this merged file — the root `CLAUDE.md` covers both frontend and backend. diff --git a/docker-compose.yml b/docker-compose.yml index f01e567..b432579 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,204 @@ -version: '2' +# ============================================================ +# Weather Data System - Docker Compose 部署编排 +# +# 前置条件: +# 1. 安装 Docker Engine 20.10+ 和 Docker Compose v2 +# 2. 复制 .env 文件并修改配置 +# 3. 确保 JAR 文件已构建到对应 target/ 目录 +# 4. 确保前端已构建到 weather-data-ui/dist/ +# +# 启动: docker compose up -d +# 停止: docker compose down +# 日志: docker compose logs -f [服务名] +# ============================================================ + services: - renren-admin: - image: renren/renren-admin - ports: - - "8080:8080" + + # ========================================================== + # MySQL 数据库 + # ========================================================== + mysql: + image: mysql:8.0 + container_name: weather-mysql + restart: unless-stopped environment: - - spring.profiles.active=dev - renren-api: - image: renren/renren-api + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE:-weather_data_system} + MYSQL_USER: ${MYSQL_USER:-weather} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + TZ: Asia/Shanghai ports: - - "8081:8081" + - "${MYSQL_PORT:-3306}:3306" + volumes: + - mysql-data:/var/lib/mysql + - ./deploy/mysql/init:/docker-entrypoint-initdb.d:ro # 初始化 SQL + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --default-time-zone=+08:00 + - --max-allowed-packet=128M + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 60s + networks: + - weather-net + + # ========================================================== + # Redis 缓存 + # ========================================================== + redis: + image: redis:7-alpine + container_name: weather-redis + restart: unless-stopped + command: + - redis-server + - --appendonly yes + - --requirepass ${REDIS_PASSWORD} + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - weather-net + + # ========================================================== + # 管理后台 (Spring Boot Admin) + # ========================================================== + admin: + build: + context: ./system-admin + dockerfile: Dockerfile + image: weather-data-admin:${APP_VERSION:-latest} + container_name: weather-admin + restart: unless-stopped + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy environment: - - spring.profiles.active=dev \ No newline at end of file + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod} + JAVA_OPTS: ${JAVA_OPTS:--Xms256m -Xmx512m -XX:+UseG1GC} + TZ: Asia/Shanghai + # 覆盖 application.yml 中的数据库/Redis 配置 + SPRING_DATASOURCE_DRUID_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-weather_data_system}?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true + SPRING_DATASOURCE_DRUID_USERNAME: ${MYSQL_USER:-weather} + SPRING_DATASOURCE_DRUID_PASSWORD: ${MYSQL_PASSWORD} + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 + SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD} + ports: + - "${ADMIN_PORT:-8080}:8080" + volumes: + - admin-logs:/app/logs + - admin-uploads:/app/upload + networks: + - weather-net + + # ========================================================== + # API 服务 + # ========================================================== + api: + build: + context: ./system-api + dockerfile: Dockerfile + image: weather-data-api:${APP_VERSION:-latest} + container_name: weather-api + restart: unless-stopped + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + environment: + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod} + JAVA_OPTS: ${JAVA_OPTS:--Xms256m -Xmx512m -XX:+UseG1GC} + TZ: Asia/Shanghai + SPRING_DATASOURCE_DRUID_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-weather_data_system}?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true + SPRING_DATASOURCE_DRUID_USERNAME: ${MYSQL_USER:-weather} + SPRING_DATASOURCE_DRUID_PASSWORD: ${MYSQL_PASSWORD} + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 + SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD} + ports: + - "${API_PORT:-8081}:8081" + volumes: + - api-logs:/app/logs + - api-uploads:/app/upload + networks: + - weather-net + + # ========================================================== + # 前端 UI + # ========================================================== + ui: + build: + context: ./weather-data-ui + dockerfile: ${UI_DOCKERFILE:-Dockerfile.offline} + image: weather-data-ui:${APP_VERSION:-latest} + container_name: weather-ui + restart: unless-stopped + depends_on: + - admin + - api + networks: + - weather-net + + # ========================================================== + # Gateway - 统一入口 Nginx 反向代理 + # ========================================================== + gateway: + image: nginx:alpine + container_name: weather-gateway + restart: unless-stopped + depends_on: + - ui + - admin + - api + ports: + - "${GATEWAY_PORT:-80}:80" + volumes: + - ./deploy/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - gateway-logs:/var/log/nginx + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost/"] + interval: 30s + timeout: 5s + retries: 3 + networks: + - weather-net + +# ============================================================ +# 数据卷 (持久化存储) +# ============================================================ +volumes: + mysql-data: + name: weather-mysql-data + redis-data: + name: weather-redis-data + admin-logs: + name: weather-admin-logs + admin-uploads: + name: weather-admin-uploads + api-logs: + name: weather-api-logs + api-uploads: + name: weather-api-uploads + gateway-logs: + name: weather-gateway-logs + +# ============================================================ +# 网络 +# ============================================================ +networks: + weather-net: + name: weather-net + driver: bridge diff --git a/system-admin/Dockerfile b/system-admin/Dockerfile index 9db81f6..0739902 100644 --- a/system-admin/Dockerfile +++ b/system-admin/Dockerfile @@ -1,7 +1,23 @@ -FROM java:8 +FROM eclipse-temurin:17-jre-alpine + +LABEL maintainer="weather-data" +LABEL description="Weather Data System - Admin Service" + +RUN apk add --no-cache tzdata curl && \ + cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ + echo "Asia/Shanghai" > /etc/timezone + +WORKDIR /app + EXPOSE 8080 -VOLUME /tmp -ADD renren-admin.jar /app.jar -RUN bash -c 'touch /app.jar' -ENTRYPOINT ["java","-jar","/app.jar"] +ARG JAR_FILE=target/system-admin.jar +COPY ${JAR_FILE} /app/app.jar + +ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC -Djava.security.egd=file:/dev/./urandom" +ENV SPRING_PROFILES_ACTIVE=prod + +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \ + CMD curl -f http://localhost:8080/system-admin/actuator/health || exit 1 + +ENTRYPOINT exec java ${JAVA_OPTS} -jar /app/app.jar diff --git a/system-admin/src/main/java/com/weather/modules/weather/dailydata/WeatherDataImportManager.java b/system-admin/src/main/java/com/weather/modules/weather/dailydata/WeatherDataImportManager.java index 0311410..290314a 100644 --- a/system-admin/src/main/java/com/weather/modules/weather/dailydata/WeatherDataImportManager.java +++ b/system-admin/src/main/java/com/weather/modules/weather/dailydata/WeatherDataImportManager.java @@ -9,6 +9,8 @@ import com.weather.modules.weather.dailydata.vo.WeatherExcelVO; import com.weather.modules.security.user.SecurityUser; import com.weather.modules.security.user.UserContextHolder; import com.weather.modules.security.user.UserDetail; +import com.weather.common.redis.RedisKeys; +import com.weather.common.redis.RedisUtils; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -27,6 +29,7 @@ import java.util.concurrent.ConcurrentHashMap; public class WeatherDataImportManager { private final WeatherDailyDataService weatherDailyDataService; + private final RedisUtils redisUtils; private final Map tasks = new ConcurrentHashMap<>(); public String submitImport(MultipartFile file) throws IOException { @@ -82,6 +85,14 @@ public class WeatherDataImportManager { progress.setStatus("COMPLETED"); log.info("导入任务 {} 完成,共导入 {} 行", taskId, progress.getProcessedRows().get()); + + // 导入完成后清除天气汇总缓存,等待下次定时任务刷新 + try { + redisUtils.deleteByPattern(RedisKeys.getWeatherSummarizePattern()); + log.info("已清除天气汇总缓存"); + } catch (Exception e) { + log.warn("清除天气汇总缓存失败", e); + } } public ImportProgress getProgress(String taskId) { diff --git a/system-admin/src/main/java/com/weather/modules/weather/dailydata/dao/WeatherDailyDataDao.java b/system-admin/src/main/java/com/weather/modules/weather/dailydata/dao/WeatherDailyDataDao.java index 7bb57dd..3a84bf9 100644 --- a/system-admin/src/main/java/com/weather/modules/weather/dailydata/dao/WeatherDailyDataDao.java +++ b/system-admin/src/main/java/com/weather/modules/weather/dailydata/dao/WeatherDailyDataDao.java @@ -1,5 +1,6 @@ package com.weather.modules.weather.dailydata.dao; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Constants; import com.weather.common.dao.BaseDao; @@ -18,7 +19,21 @@ import java.util.List; @Mapper public interface WeatherDailyDataDao extends BaseDao { - List selectSummarizeList(@Param(Constants.WRAPPER) QueryWrapper wrapper); + List selectSummarizeList(@Param(Constants.WRAPPER) LambdaQueryWrapper wrapper); + + /** + * 按站点 + 月-日 + 年份范围查询汇总数据 + * @param stationIds 站点 ID 列表 + * @param month 月份 1-12 + * @param day 日 1-31 + * @param startYear 起始年份 + * @param endYear 结束年份 + */ + List selectSummarizeByMonthDay(@Param("stationIds") List stationIds, + @Param("month") int month, + @Param("day") int day, + @Param("startYear") int startYear, + @Param("endYear") int endYear); int insertBatchMultiRow(@Param("list") List list); } \ No newline at end of file diff --git a/system-admin/src/main/java/com/weather/modules/weather/dailydata/service/impl/WeatherDailyDataServiceImpl.java b/system-admin/src/main/java/com/weather/modules/weather/dailydata/service/impl/WeatherDailyDataServiceImpl.java index 6e433d1..9ff69e8 100644 --- a/system-admin/src/main/java/com/weather/modules/weather/dailydata/service/impl/WeatherDailyDataServiceImpl.java +++ b/system-admin/src/main/java/com/weather/modules/weather/dailydata/service/impl/WeatherDailyDataServiceImpl.java @@ -3,11 +3,15 @@ package com.weather.modules.weather.dailydata.service.impl; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.weather.common.constant.Constant; import com.weather.common.page.PageData; +import com.weather.common.redis.RedisKeys; +import com.weather.common.redis.RedisUtils; import com.weather.common.service.impl.CrudServiceImpl; import com.weather.common.utils.ConvertUtils; import com.weather.common.utils.TimeUtils; @@ -23,14 +27,18 @@ import com.weather.modules.weather.dailydata.service.WeatherDailyDataService; import com.weather.modules.weather.station.dao.WeatherStationDao; import com.weather.modules.weather.station.entity.WeatherStationEntity; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.time.ZoneId; import java.util.*; import java.util.stream.Collectors; +import java.util.stream.Stream; +@Slf4j @Service @AllArgsConstructor public class WeatherDailyDataServiceImpl extends CrudServiceImpl implements WeatherDailyDataService { @@ -38,6 +46,7 @@ public class WeatherDailyDataServiceImpl extends CrudServiceImpl page(Map params) { @@ -61,7 +70,7 @@ public class WeatherDailyDataServiceImpl extends CrudServiceImpl getPrioritizedStationIds() { UserDetail user = SecurityUser.getUser(); - if (user == null || user.getDeptId() == null) return Collections.emptyList(); + if (ObjectUtil.isEmpty(user) || user.getDeptId() == null) return Collections.emptyList(); List deptIds = sysDeptService.getSubDeptIdList(user.getDeptId()); if (deptIds.isEmpty()) return Collections.emptyList(); List stations = weatherStationDao.selectList( @@ -70,8 +79,11 @@ public class WeatherDailyDataServiceImpl extends CrudServiceImpl { - try { return Long.valueOf(s.getStationCode()); } - catch (NumberFormatException e) { return null; } + try { + return Long.valueOf(s.getStationCode()); + } catch (NumberFormatException e) { + return null; + } }) .filter(Objects::nonNull) .collect(Collectors.toList()); @@ -110,7 +122,7 @@ public class WeatherDailyDataServiceImpl extends CrudServiceImpl stationDeptMap = new HashMap<>(); if (!stationIds.isEmpty()) { List stationCodeList = stationIds.stream().map(String::valueOf).collect(Collectors.toList()); @@ -134,7 +146,8 @@ public class WeatherDailyDataServiceImpl extends CrudServiceImpl wrapper = new QueryWrapper<>(); - wrapper.in("station_id", stationIds) - .between("observe_date", startTime, endTime); + // 尝试从缓存获取(仅"历年同月同日"查询可命中缓存) + List cached = getCachedSummarize(stationIds, startTime, endTime); + if (cached != null) { + return cached; + } - List list = weatherDailyDataDao.selectSummarizeList(wrapper); + // 缓存未命中,查询数据库 + return querySummarizeFromDb(stationIds, startTime, endTime); + } + + /** + * 尝试从 Redis 缓存获取汇总数据 + * + * @return 缓存命中时返回数据,未命中返回 null + */ + @SuppressWarnings("unchecked") + private List getCachedSummarize( + List stationIds, LocalDate start, LocalDate end) { + + // 仅当月-日相同的跨年查询可命中缓存 + if (start.getMonth() != end.getMonth() || start.getDayOfMonth() != end.getDayOfMonth()) { + return null; + } + + String key = RedisKeys.getWeatherSummarizeKey(end.getMonthValue(), end.getDayOfMonth()); + Object cached = redisUtils.get(key); + if (cached == null) { + return null; + } + + Map> grouped; + try { + grouped = (Map>) cached; + } catch (ClassCastException e) { + return null; + } + + // 验证所有请求站点都在缓存中,任一缺失则降级到 DB + for (Long sid : stationIds) { + if (!grouped.containsKey(String.valueOf(sid))) { + return null; + } + } + + // 从缓存中按站点过滤 + 限定年份范围(Key 为 String 避免 JSON 反序列化时 Long/Integer 类型丢失) + return stationIds.stream() + .flatMap(sid -> { + List stationData = grouped.get(String.valueOf(sid)); + return stationData != null ? stationData.stream() : Stream.empty(); + }) + .filter(d -> { + LocalDate obsDate = d.getObserveDate().toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate(); + return !obsDate.isBefore(start) && !obsDate.isAfter(end); + }) + .collect(Collectors.toList()); + } + + /** + * 直接查询数据库获取汇总数据 + *

用 MONTH/DAY 定位具体日期 + YEAR BETWEEN 限定年份范围,避免日期 BETWEEN 全量问题

+ */ + private List querySummarizeFromDb( + List stationIds, LocalDate startTime, LocalDate endTime) { + + List list = weatherDailyDataDao.selectSummarizeByMonthDay( + stationIds, + startTime.getMonthValue(), + startTime.getDayOfMonth(), + startTime.getYear(), + endTime.getYear()); return BeanUtil.copyToList(list, DailyWeatherSummarizeDto.class); } diff --git a/system-admin/src/main/java/com/weather/modules/weather/dailydata/task/WeatherSummarizeCacheTask.java b/system-admin/src/main/java/com/weather/modules/weather/dailydata/task/WeatherSummarizeCacheTask.java new file mode 100644 index 0000000..bf4eb91 --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/weather/dailydata/task/WeatherSummarizeCacheTask.java @@ -0,0 +1,65 @@ +package com.weather.modules.weather.dailydata.task; + +import cn.hutool.core.bean.BeanUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.weather.common.redis.RedisKeys; +import com.weather.common.redis.RedisUtils; +import com.weather.modules.job.task.ITask; +import com.weather.modules.weather.dailydata.dao.WeatherDailyDataDao; +import com.weather.modules.weather.dailydata.dto.DailyWeatherSummarizeDto; +import com.weather.modules.weather.dailydata.entity.WeatherDailyDataEntity; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 天气汇总缓存预热任务 + *

+ * 每日凌晨 1:00 执行,将历年当月-当日的气象数据 + * 按站点分组写入 Redis,供 export-sum 接口查询。 + * + * @author 2333 123 + * @since 1.0.0 2026-06-24 + */ +@Slf4j +@Component("weatherSummarizeCacheTask") +public class WeatherSummarizeCacheTask implements ITask { + + @Resource + private WeatherDailyDataDao weatherDailyDataDao; + + @Resource + private RedisUtils redisUtils; + + @Override + public void run(String params) { + LocalDate today = LocalDate.now(); + int month = today.getMonthValue(); + int day = today.getDayOfMonth(); + + log.info("开始刷新天气汇总缓存,日期: {}-{}", month, day); + + try { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.apply("MONTH(observe_date) = {0} AND DAY(observe_date) = {1}", month, day); + + List list = weatherDailyDataDao.selectSummarizeList(wrapper); + + Map> grouped = list.stream() + .map(e -> BeanUtil.copyProperties(e, DailyWeatherSummarizeDto.class)) + .collect(Collectors.groupingBy(dto -> String.valueOf(dto.getStationId()))); + + String key = RedisKeys.getWeatherSummarizeKey(month, day); + redisUtils.set(key, grouped, RedisUtils.NOT_EXPIRE); + + log.info("天气汇总缓存刷新完成,共 {} 条记录,{} 个站点", list.size(), grouped.size()); + } catch (Exception e) { + log.error("天气汇总缓存刷新失败", e); + } + } +} diff --git a/system-admin/src/main/java/com/weather/modules/weather/filescan/FileScanStartupRunner.java b/system-admin/src/main/java/com/weather/modules/weather/filescan/FileScanStartupRunner.java index 732c282..7787b7b 100644 --- a/system-admin/src/main/java/com/weather/modules/weather/filescan/FileScanStartupRunner.java +++ b/system-admin/src/main/java/com/weather/modules/weather/filescan/FileScanStartupRunner.java @@ -7,7 +7,9 @@ import com.weather.modules.sys.entity.SysDeptEntity; import com.weather.modules.sys.service.SysParamsService; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import java.io.IOException; @@ -15,18 +17,33 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; +import java.util.concurrent.TimeUnit; @Slf4j @Component @AllArgsConstructor -public class FileScanStartupRunner implements CommandLineRunner { +public class FileScanStartupRunner { + + /** + * 启动后延迟扫描时间(秒),给应用留出充分的初始化时间 + */ + private static final long STARTUP_DELAY_SECONDS = 30; private final SysParamsService sysParamsService; private final SysDeptDao sysDeptDao; private final FileWatchServiceManager fileWatchServiceManager; - @Override - public void run(String... args) { + @Async + @EventListener(ApplicationReadyEvent.class) + public void onApplicationReady() { + log.info("应用已启动,{} 秒后开始文件扫描目录初始化...", STARTUP_DELAY_SECONDS); + try { + TimeUnit.SECONDS.sleep(STARTUP_DELAY_SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("文件扫描延迟等待被中断,直接开始初始化"); + } + String rootPath = sysParamsService.getValue(Constant.FILE_SCAN_ROOT_PATH); if (StrUtil.isBlank(rootPath)) { log.warn("FILE_SCAN_ROOT_PATH 未配置,跳过文件扫描目录初始化"); diff --git a/system-admin/src/main/resources/application-dev.yml b/system-admin/src/main/resources/application-dev.yml index 5ce6347..bf2a777 100644 --- a/system-admin/src/main/resources/application-dev.yml +++ b/system-admin/src/main/resources/application-dev.yml @@ -6,26 +6,6 @@ spring: url: jdbc:mysql://localhost:3306/weather_data_system?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true username: root password: root - #达梦8 -# driver-class-name: dm.jdbc.driver.DmDriver -# url: jdbc:dm://192.168.10.10:5236/renren_security?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true -# username: renren_security -# password: 12345678 - # #Oracle - # driver-class-name: oracle.jdbc.OracleDriver - # url: jdbc:oracle:thin:@192.168.10.10:1521:xe - # username: renren_security - # password: 123456 - # #SQLServer - # driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver - # url: jdbc:sqlserver://localhost:1433;DatabaseName=renren_security - # username: sa - # password: 123456 - # #postgresql - # driver-class-name: org.postgresql.Driver - # url: jdbc:postgresql://192.168.10.10:5432/postgres - # username: postgres - # password: 123456 initial-size: 10 max-active: 100 min-idle: 10 diff --git a/system-admin/src/main/resources/application.yml b/system-admin/src/main/resources/application.yml index cc42588..f89170c 100644 --- a/system-admin/src/main/resources/application.yml +++ b/system-admin/src/main/resources/application.yml @@ -40,7 +40,7 @@ spring: data: redis: database: 0 - host: 192.168.10.10 + host: 127.0.0.1 port: 6379 password: # 密码(默认为空) timeout: 6000ms # 连接超时时长(毫秒) @@ -54,7 +54,7 @@ spring: # 是否开启redis缓存 true开启 false关闭 project-options: redis: - open: false + open: true #mybatis mybatis-plus: diff --git a/system-admin/src/main/resources/mapper/dailyweather/WeatherDailyDataDao.xml b/system-admin/src/main/resources/mapper/dailyweather/WeatherDailyDataDao.xml index ee6af68..14319e8 100644 --- a/system-admin/src/main/resources/mapper/dailyweather/WeatherDailyDataDao.xml +++ b/system-admin/src/main/resources/mapper/dailyweather/WeatherDailyDataDao.xml @@ -42,6 +42,19 @@ ORDER BY station_id, observe_date + + INSERT INTO weather_daily_data ( id, station_id, observe_date, diff --git a/system-api/Dockerfile b/system-api/Dockerfile index 251b7fc..f99b91a 100644 --- a/system-api/Dockerfile +++ b/system-api/Dockerfile @@ -1,7 +1,23 @@ -FROM java:8 +FROM eclipse-temurin:17-jre-alpine + +LABEL maintainer="weather-data" +LABEL description="Weather Data System - API Service" + +RUN apk add --no-cache tzdata curl && \ + cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ + echo "Asia/Shanghai" > /etc/timezone + +WORKDIR /app + EXPOSE 8081 -VOLUME /tmp -ADD renren-api.jar /app.jar -RUN bash -c 'touch /app.jar' -ENTRYPOINT ["java","-jar","/app.jar"] +ARG JAR_FILE=target/system-api.jar +COPY ${JAR_FILE} /app/app.jar + +ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC -Djava.security.egd=file:/dev/./urandom" +ENV SPRING_PROFILES_ACTIVE=prod + +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \ + CMD curl -f http://localhost:8081/renren-api/actuator/health || exit 1 + +ENTRYPOINT exec java ${JAVA_OPTS} -jar /app/app.jar diff --git a/system-common/src/main/java/com/weather/common/aspect/RedisAspect.java b/system-common/src/main/java/com/weather/common/aspect/RedisAspect.java index 1de2bed..0199253 100644 --- a/system-common/src/main/java/com/weather/common/aspect/RedisAspect.java +++ b/system-common/src/main/java/com/weather/common/aspect/RedisAspect.java @@ -23,7 +23,7 @@ public class RedisAspect { /** * 是否开启redis缓存 true开启 false关闭 */ - @Value("${weather.redis.open: false}") + @Value("${project-options.redis.open: false}") private boolean open; @Around("execution(* com.weather.common.redis.RedisUtils.*(..))") diff --git a/system-common/src/main/java/com/weather/common/redis/RedisKeys.java b/system-common/src/main/java/com/weather/common/redis/RedisKeys.java index b1882e5..7c4ba5b 100644 --- a/system-common/src/main/java/com/weather/common/redis/RedisKeys.java +++ b/system-common/src/main/java/com/weather/common/redis/RedisKeys.java @@ -55,4 +55,18 @@ public class RedisKeys { public static String getUserPermissionsKey(Long userId){ return "sys:user:permissions:" + userId; } + + /** + * 天气汇总缓存Key(按站点分组,月-日维度) + */ + public static String getWeatherSummarizeKey(int month, int day) { + return "weather:summarize:" + month + ":" + day; + } + + /** + * 天气汇总缓存匹配模式 + */ + public static String getWeatherSummarizePattern() { + return "weather:summarize:*"; + } } diff --git a/system-common/src/main/java/com/weather/common/redis/RedisUtils.java b/system-common/src/main/java/com/weather/common/redis/RedisUtils.java index 0cb85a1..cf41b11 100644 --- a/system-common/src/main/java/com/weather/common/redis/RedisUtils.java +++ b/system-common/src/main/java/com/weather/common/redis/RedisUtils.java @@ -116,4 +116,23 @@ public class RedisUtils { public Object rightPop(String key) { return redisTemplate.opsForList().rightPop(key); } + + /** + * 按模式删除缓存 + * @param pattern 匹配模式,如 "weather:summarize:*" + */ + public void deleteByPattern(String pattern) { + java.util.Set keys = redisTemplate.keys(pattern); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } + + /** + * 判断缓存是否存在 + */ + public boolean exists(String key) { + Boolean result = redisTemplate.hasKey(key); + return Boolean.TRUE.equals(result); + } }