diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..89ed517
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,4 @@
+target/
+.idea/
+*.iml
+.env
diff --git a/backend/.env.example b/backend/.env.example
index 7f32ee8..3594d18 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -15,5 +15,10 @@ JWT_SECRET=change_me_to_a_random_base64_string_at_least_256_bits
JWT_EXPIRATION=86400000
JWT_REFRESH_EXPIRATION=604800000
+# Redis(可选,不可用时自动跳过)
+REDIS_HOST=localhost
+REDIS_PORT=6379
+REDIS_PASSWORD=
+
# 服务端口
SERVER_PORT=8080
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..d1e23c2
--- /dev/null
+++ b/backend/Dockerfile
@@ -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"]
diff --git a/backend/pom.xml b/backend/pom.xml
index 676ebdd..51ee639 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -81,6 +81,16 @@
5.8.37
+
+ redis.clients
+ jedis
+
+
+
+ org.apache.commons
+ commons-pool2
+
+
org.springframework.boot
spring-boot-starter-jdbc
diff --git a/backend/src/main/java/com/kronecker/backend/service/impl/DashboardServiceImpl.java b/backend/src/main/java/com/kronecker/backend/service/impl/DashboardServiceImpl.java
index 7ab61f1..dfa24c9 100644
--- a/backend/src/main/java/com/kronecker/backend/service/impl/DashboardServiceImpl.java
+++ b/backend/src/main/java/com/kronecker/backend/service/impl/DashboardServiceImpl.java
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.kronecker.backend.entity.*;
import com.kronecker.backend.mapper.*;
import com.kronecker.backend.service.DashboardService;
+import com.kronecker.backend.utils.RedisCacheService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -21,16 +22,25 @@ public class DashboardServiceImpl implements DashboardService {
private final StockOutRecordMapper stockOutRecordMapper;
private final SupplierMapper supplierMapper;
private final CustomerMapper customerMapper;
+ private final RedisCacheService redisCache;
@Override
+ @SuppressWarnings("unchecked")
public Map getStatistics() {
+ // 尝试从缓存读取
+ Map cached = redisCache.getObject("dashboard:statistics", Map.class);
+ if (cached != null) return cached;
+
Map stats = new HashMap<>();
stats.put("medicineCount", medicineMapper.selectCount(null));
stats.put("stockBatchCount", stockMapper.selectCount(null));
stats.put("pendingStockIn", stockInRecordMapper.selectCount(
new LambdaQueryWrapper().eq(StockInRecord::getStatus, 0)));
stats.put("expiryWarningCount", stockMapper.selectCount(
- new LambdaQueryWrapper().le(Stock::getExpiryDate, LocalDate.now().plusDays(90))));
+ new LambdaQueryWrapper().gt(Stock::getQuantity, 0)
+ .le(Stock::getExpiryDate, LocalDate.now().plusDays(90))));
+ // 缓存 60 秒
+ redisCache.setObject("dashboard:statistics", stats, 60);
return stats;
}
diff --git a/backend/src/main/java/com/kronecker/backend/service/impl/StockInServiceImpl.java b/backend/src/main/java/com/kronecker/backend/service/impl/StockInServiceImpl.java
index 51b9dbd..35c5880 100644
--- a/backend/src/main/java/com/kronecker/backend/service/impl/StockInServiceImpl.java
+++ b/backend/src/main/java/com/kronecker/backend/service/impl/StockInServiceImpl.java
@@ -6,6 +6,7 @@ import com.kronecker.backend.dto.StockInCreateDTO;
import com.kronecker.backend.entity.*;
import com.kronecker.backend.mapper.*;
import com.kronecker.backend.service.StockInService;
+import com.kronecker.backend.utils.RedisCacheService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -20,6 +21,7 @@ public class StockInServiceImpl implements StockInService {
private final StockInRecordMapper recordMapper;
private final StockInDetailMapper detailMapper;
private final StockMapper stockMapper;
+ private final RedisCacheService redisCache;
@Override
@Transactional
@@ -65,6 +67,7 @@ public class StockInServiceImpl implements StockInService {
}
record.setStatus(1);
recordMapper.updateById(record);
+ redisCache.delete("dashboard:statistics");
// 更新库存
var details = detailMapper.selectList(
diff --git a/backend/src/main/java/com/kronecker/backend/service/impl/StockOutServiceImpl.java b/backend/src/main/java/com/kronecker/backend/service/impl/StockOutServiceImpl.java
index b94591d..884ad5c 100644
--- a/backend/src/main/java/com/kronecker/backend/service/impl/StockOutServiceImpl.java
+++ b/backend/src/main/java/com/kronecker/backend/service/impl/StockOutServiceImpl.java
@@ -6,6 +6,7 @@ import com.kronecker.backend.dto.StockOutCreateDTO;
import com.kronecker.backend.entity.*;
import com.kronecker.backend.mapper.*;
import com.kronecker.backend.service.StockOutService;
+import com.kronecker.backend.utils.RedisCacheService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -20,6 +21,7 @@ public class StockOutServiceImpl implements StockOutService {
private final StockOutRecordMapper recordMapper;
private final StockOutDetailMapper detailMapper;
private final StockMapper stockMapper;
+ private final RedisCacheService redisCache;
@Override
@Transactional
@@ -92,5 +94,6 @@ public class StockOutServiceImpl implements StockOutService {
record.setStatus(1);
recordMapper.updateById(record);
+ redisCache.delete("dashboard:statistics");
}
}
diff --git a/backend/src/main/java/com/kronecker/backend/utils/RedisCacheService.java b/backend/src/main/java/com/kronecker/backend/utils/RedisCacheService.java
new file mode 100644
index 0000000..fe55ac0
--- /dev/null
+++ b/backend/src/main/java/com/kronecker/backend/utils/RedisCacheService.java
@@ -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 getObject(String key, Class 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());
+ }
+ }
+}
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..dafa699
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+.env
+.env.local
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..9c87f28
--- /dev/null
+++ b/frontend/Dockerfile
@@ -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;"]
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..f5d3c15
--- /dev/null
+++ b/frontend/nginx.conf
@@ -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;
+ }
+}
diff --git a/readme.md b/readme.md
index 5060099..89843e7 100644
--- a/readme.md
+++ b/readme.md
@@ -18,8 +18,9 @@
| ORM | MyBatis-Plus | 3.5.11 |
| 安全认证 | Spring Security + JWT (jjwt 0.13.0) | — |
| 数据库 | MySQL 8.0 (JDBC + HikariCP) | — |
+| 缓存 | Redis 7 + Jedis(可选,无连接自动跳过) | — |
| 工具库 | Hutool 5.8 / Lombok | — |
-| 构建 | Maven 3.9+ | — |
+| 构建 / 部署 | Maven 3.9+ / Docker + Docker Compose | — |
---
@@ -37,8 +38,10 @@ graph TB
end
DB["🐬 MySQL 8.0
InnoDB · utf8mb4"]
+ Cache["⚡ Redis 7
可选 · 自动降级"]
Client -->|"HTTPS + JWT Token"| Security
+ Service --> Cache
Security --> Controller
Controller --> Service
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+
-### 1. 初始化数据库
+#### 1. 初始化数据库
```bash
mysql -u root -p < backend/src/main/resources/database/init.sql
@@ -340,9 +360,12 @@ npm run dev
MedicineManagerSystem/
├── README.md
├── .env.example
+├── docker-compose.yml # Docker 编排
├── projectDesigning.md # 详细架构设计文档
│
├── frontend/ # Vue 3 SPA
+│ ├── Dockerfile
+│ ├── nginx.conf
│ └── src/
│ ├── api/ (11) # API 模块
│ ├── directives/ (1) # v-permission
@@ -350,10 +373,11 @@ MedicineManagerSystem/
│ ├── router/ (1) # 路由 + 动态菜单
│ ├── stores/ (2) # Pinia
│ ├── styles/ (1) # SCSS
-│ ├── utils/ (1) # Axios 封装
+│ ├── utils/ (2) # Axios · Redis 缓存
│ └── views/ (16) # 页面组件
│
└── backend/ # Spring Boot
+ ├── Dockerfile
└── src/main/
├── java/com/kronecker/backend/
│ ├── common/ (4) # Result · 异常
@@ -363,7 +387,8 @@ MedicineManagerSystem/
│ ├── entity/ (15) # 数据库实体
│ ├── mapper/ (15) # MP Mapper
│ ├── security/ (3) # JWT
- │ └── service/ (23) # 业务逻辑
+ │ ├── service/ (23) # 业务逻辑
+ │ └── utils/ (1) # Redis 缓存
└── resources/
├── application.yml
└── database/init.sql # 建表 + 种子数据