添加Redis缓存支持和Docker部署配置

- 新增Redis缓存功能,用于优化仪表板统计数据查询性能
- 添加Redis连接池管理和自动降级机制,无连接时自动跳过缓存
- 配置Dockerfile支持前后端容器化部署
- 添加docker-compose编排文件实现一键部署
- 更新README文档包含新的部署方式和架构说明
- 在入库出库操作后清除相关缓存以保证数据一致性
This commit is contained in:
2026-07-07 10:08:33 +08:00
parent 48007f536d
commit b764f6fa59
12 changed files with 245 additions and 6 deletions

4
backend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
target/
.idea/
*.iml
.env

View File

@@ -15,5 +15,10 @@ JWT_SECRET=change_me_to_a_random_base64_string_at_least_256_bits
JWT_EXPIRATION=86400000 JWT_EXPIRATION=86400000
JWT_REFRESH_EXPIRATION=604800000 JWT_REFRESH_EXPIRATION=604800000
# Redis可选不可用时自动跳过
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# 服务端口 # 服务端口
SERVER_PORT=8080 SERVER_PORT=8080

25
backend/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
# ============================================================
# 药品管理系统 — 后端 Dockerfile多阶段构建
# ============================================================
# ---- 构建阶段 ----
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml mvnw mvnw.cmd ./
COPY .mvn .mvn
# 缓存依赖
RUN chmod +x mvnw && ./mvnw dependency:go-offline -q
COPY src ./src
RUN ./mvnw package -DskipTests -q
# ---- 运行阶段 ----
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
ENV TZ=Asia/Shanghai
ENV SERVER_PORT=8080
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -81,6 +81,16 @@
<version>5.8.37</version> <version>5.8.37</version>
</dependency> </dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId> <artifactId>spring-boot-starter-jdbc</artifactId>

View File

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.kronecker.backend.entity.*; import com.kronecker.backend.entity.*;
import com.kronecker.backend.mapper.*; import com.kronecker.backend.mapper.*;
import com.kronecker.backend.service.DashboardService; import com.kronecker.backend.service.DashboardService;
import com.kronecker.backend.utils.RedisCacheService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -21,16 +22,25 @@ public class DashboardServiceImpl implements DashboardService {
private final StockOutRecordMapper stockOutRecordMapper; private final StockOutRecordMapper stockOutRecordMapper;
private final SupplierMapper supplierMapper; private final SupplierMapper supplierMapper;
private final CustomerMapper customerMapper; private final CustomerMapper customerMapper;
private final RedisCacheService redisCache;
@Override @Override
@SuppressWarnings("unchecked")
public Map<String, Object> getStatistics() { public Map<String, Object> getStatistics() {
// 尝试从缓存读取
Map<String, Object> cached = redisCache.getObject("dashboard:statistics", Map.class);
if (cached != null) return cached;
Map<String, Object> stats = new HashMap<>(); Map<String, Object> stats = new HashMap<>();
stats.put("medicineCount", medicineMapper.selectCount(null)); stats.put("medicineCount", medicineMapper.selectCount(null));
stats.put("stockBatchCount", stockMapper.selectCount(null)); stats.put("stockBatchCount", stockMapper.selectCount(null));
stats.put("pendingStockIn", stockInRecordMapper.selectCount( stats.put("pendingStockIn", stockInRecordMapper.selectCount(
new LambdaQueryWrapper<StockInRecord>().eq(StockInRecord::getStatus, 0))); new LambdaQueryWrapper<StockInRecord>().eq(StockInRecord::getStatus, 0)));
stats.put("expiryWarningCount", stockMapper.selectCount( stats.put("expiryWarningCount", stockMapper.selectCount(
new LambdaQueryWrapper<Stock>().le(Stock::getExpiryDate, LocalDate.now().plusDays(90)))); new LambdaQueryWrapper<Stock>().gt(Stock::getQuantity, 0)
.le(Stock::getExpiryDate, LocalDate.now().plusDays(90))));
// 缓存 60 秒
redisCache.setObject("dashboard:statistics", stats, 60);
return stats; return stats;
} }

View File

@@ -6,6 +6,7 @@ import com.kronecker.backend.dto.StockInCreateDTO;
import com.kronecker.backend.entity.*; import com.kronecker.backend.entity.*;
import com.kronecker.backend.mapper.*; import com.kronecker.backend.mapper.*;
import com.kronecker.backend.service.StockInService; import com.kronecker.backend.service.StockInService;
import com.kronecker.backend.utils.RedisCacheService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -20,6 +21,7 @@ public class StockInServiceImpl implements StockInService {
private final StockInRecordMapper recordMapper; private final StockInRecordMapper recordMapper;
private final StockInDetailMapper detailMapper; private final StockInDetailMapper detailMapper;
private final StockMapper stockMapper; private final StockMapper stockMapper;
private final RedisCacheService redisCache;
@Override @Override
@Transactional @Transactional
@@ -65,6 +67,7 @@ public class StockInServiceImpl implements StockInService {
} }
record.setStatus(1); record.setStatus(1);
recordMapper.updateById(record); recordMapper.updateById(record);
redisCache.delete("dashboard:statistics");
// 更新库存 // 更新库存
var details = detailMapper.selectList( var details = detailMapper.selectList(

View File

@@ -6,6 +6,7 @@ import com.kronecker.backend.dto.StockOutCreateDTO;
import com.kronecker.backend.entity.*; import com.kronecker.backend.entity.*;
import com.kronecker.backend.mapper.*; import com.kronecker.backend.mapper.*;
import com.kronecker.backend.service.StockOutService; import com.kronecker.backend.service.StockOutService;
import com.kronecker.backend.utils.RedisCacheService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -20,6 +21,7 @@ public class StockOutServiceImpl implements StockOutService {
private final StockOutRecordMapper recordMapper; private final StockOutRecordMapper recordMapper;
private final StockOutDetailMapper detailMapper; private final StockOutDetailMapper detailMapper;
private final StockMapper stockMapper; private final StockMapper stockMapper;
private final RedisCacheService redisCache;
@Override @Override
@Transactional @Transactional
@@ -92,5 +94,6 @@ public class StockOutServiceImpl implements StockOutService {
record.setStatus(1); record.setStatus(1);
recordMapper.updateById(record); recordMapper.updateById(record);
redisCache.delete("dashboard:statistics");
} }
} }

View File

@@ -0,0 +1,111 @@
package com.kronecker.backend.utils;
import cn.hutool.json.JSONUtil;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
@Slf4j
@Component
public class RedisCacheService {
@Value("${REDIS_HOST:localhost}")
private String host;
@Value("${REDIS_PORT:6379}")
private int port;
@Value("${REDIS_PASSWORD:}")
private String password;
@Value("${REDIS_TIMEOUT:2000}")
private int timeout;
private JedisPool pool;
private boolean available = false;
@PostConstruct
public void init() {
try {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(8);
config.setMaxIdle(4);
config.setMinIdle(1);
pool = (password != null && !password.isEmpty())
? new JedisPool(config, host, port, timeout, password)
: new JedisPool(config, host, port, timeout);
// 测试连接
try (Jedis jedis = pool.getResource()) {
jedis.ping();
}
available = true;
log.info("Redis 连接成功: {}:{}", host, port);
} catch (Exception e) {
available = false;
pool = null;
log.warn("Redis 不可用 ({}:{}),跳过缓存: {}", host, port, e.getMessage());
}
}
@PreDestroy
public void destroy() {
if (pool != null) pool.close();
}
public boolean enabled() {
return available && pool != null;
}
public void set(String key, String value, long seconds) {
if (!enabled()) return;
try (Jedis jedis = pool.getResource()) {
jedis.setex(key, seconds, value);
} catch (Exception e) {
log.warn("Redis set {} 失败: {}", key, e.getMessage());
}
}
public String get(String key) {
if (!enabled()) return null;
try (Jedis jedis = pool.getResource()) {
return jedis.get(key);
} catch (Exception e) {
log.warn("Redis get {} 失败: {}", key, e.getMessage());
return null;
}
}
// === Object 序列化方法 ===
public void setObject(String key, Object value, long seconds) {
if (value == null) return;
set(key, JSONUtil.toJsonStr(value), seconds);
}
public <T> T getObject(String key, Class<T> clazz) {
String json = get(key);
if (json == null) return null;
try {
return JSONUtil.toBean(json, clazz);
} catch (Exception e) {
log.warn("Redis 反序列化 {} 失败: {}", key, e.getMessage());
return null;
}
}
public void delete(String key) {
if (!enabled()) return;
try (Jedis jedis = pool.getResource()) {
jedis.del(key);
} catch (Exception e) {
log.warn("Redis del {} 失败: {}", key, e.getMessage());
}
}
}

4
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
.env.local

20
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# ============================================================
# 药品管理系统 — 前端 Dockerfile多阶段构建
# ============================================================
# ---- 构建阶段 ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- 运行阶段 ----
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

19
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,19 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# API 代理到后端
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Vue SPA 路由
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -18,8 +18,9 @@
| ORM | MyBatis-Plus | 3.5.11 | | ORM | MyBatis-Plus | 3.5.11 |
| 安全认证 | Spring Security + JWT (jjwt 0.13.0) | — | | 安全认证 | Spring Security + JWT (jjwt 0.13.0) | — |
| 数据库 | MySQL 8.0 (JDBC + HikariCP) | — | | 数据库 | MySQL 8.0 (JDBC + HikariCP) | — |
| 缓存 | Redis 7 + Jedis可选无连接自动跳过 | — |
| 工具库 | Hutool 5.8 / Lombok | — | | 工具库 | Hutool 5.8 / Lombok | — |
| 构建 | Maven 3.9+ | — | | 构建 / 部署 | Maven 3.9+ / Docker + Docker Compose | — |
--- ---
@@ -37,8 +38,10 @@ graph TB
end end
DB["🐬 MySQL 8.0<br/>InnoDB · utf8mb4"] DB["🐬 MySQL 8.0<br/>InnoDB · utf8mb4"]
Cache["⚡ Redis 7<br/>可选 · 自动降级"]
Client -->|"HTTPS + JWT Token"| Security Client -->|"HTTPS + JWT Token"| Security
Service --> Cache
Security --> Controller Security --> Controller
Controller --> Service Controller --> Service
Service --> Mapper Service --> Mapper
@@ -278,11 +281,28 @@ sequenceDiagram
## 快速开始 ## 快速开始
### 环境要求 ### 方式一Docker Compose推荐
```bash
# 1. 配置环境变量
cp .env.example .env
# 编辑 .env 设置 DB_PASSWORD 和 JWT_SECRET
# 2. 一键启动MySQL + Redis + 后端 + 前端)
docker-compose up -d
# 3. 访问
open http://localhost
```
> 首次启动 MySQL 会自动执行 `init.sql` 建表和数据。Redis 不可用时后端自动降级跳过缓存。
### 方式二:手动启动
**环境要求**
- JDK 21+ · Node.js 18+ · MySQL 8.0+ · Maven 3.9+ - JDK 21+ · Node.js 18+ · MySQL 8.0+ · Maven 3.9+
### 1. 初始化数据库 #### 1. 初始化数据库
```bash ```bash
mysql -u root -p < backend/src/main/resources/database/init.sql mysql -u root -p < backend/src/main/resources/database/init.sql
@@ -340,9 +360,12 @@ npm run dev
MedicineManagerSystem/ MedicineManagerSystem/
├── README.md ├── README.md
├── .env.example ├── .env.example
├── docker-compose.yml # Docker 编排
├── projectDesigning.md # 详细架构设计文档 ├── projectDesigning.md # 详细架构设计文档
├── frontend/ # Vue 3 SPA ├── frontend/ # Vue 3 SPA
│ ├── Dockerfile
│ ├── nginx.conf
│ └── src/ │ └── src/
│ ├── api/ (11) # API 模块 │ ├── api/ (11) # API 模块
│ ├── directives/ (1) # v-permission │ ├── directives/ (1) # v-permission
@@ -350,10 +373,11 @@ MedicineManagerSystem/
│ ├── router/ (1) # 路由 + 动态菜单 │ ├── router/ (1) # 路由 + 动态菜单
│ ├── stores/ (2) # Pinia │ ├── stores/ (2) # Pinia
│ ├── styles/ (1) # SCSS │ ├── styles/ (1) # SCSS
│ ├── utils/ (1) # Axios 封装 │ ├── utils/ (2) # Axios · Redis 缓存
│ └── views/ (16) # 页面组件 │ └── views/ (16) # 页面组件
└── backend/ # Spring Boot └── backend/ # Spring Boot
├── Dockerfile
└── src/main/ └── src/main/
├── java/com/kronecker/backend/ ├── java/com/kronecker/backend/
│ ├── common/ (4) # Result · 异常 │ ├── common/ (4) # Result · 异常
@@ -363,7 +387,8 @@ MedicineManagerSystem/
│ ├── entity/ (15) # 数据库实体 │ ├── entity/ (15) # 数据库实体
│ ├── mapper/ (15) # MP Mapper │ ├── mapper/ (15) # MP Mapper
│ ├── security/ (3) # JWT │ ├── security/ (3) # JWT
── service/ (23) # 业务逻辑 ── service/ (23) # 业务逻辑
│ └── utils/ (1) # Redis 缓存
└── resources/ └── resources/
├── application.yml ├── application.yml
└── database/init.sql # 建表 + 种子数据 └── database/init.sql # 建表 + 种子数据