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 # 建表 + 种子数据