Files
PharmaManagementSystem/📊 医药管理系统 - 项目文档.md
2026-01-15 11:06:37 +08:00

58 KiB

📊 医药管理系统 - 项目文档

📋 项目概述

项目名称

PharmaManagement System - 医药管理系统

技术栈

  • 后端: Java 17 + Spring Boot 3.1 + MyBatis Plus 3.5 + MySQL 8.0
  • 前端: Vue 3 + TypeScript + Element Plus + Pinia + Vite
  • 安全: Spring Security + JWT
  • 工具: Maven + Node.js + Docker

系统功能模块

  1. 用户管理: 登录、权限控制、个人信息管理
  2. 药品管理: 药品CRUD、库存管理、分类管理
  3. 销售管理: 销售记录、销售统计、报表生成
  4. 库存预警: 库存不足预警、补货提醒
  5. 数据统计: 销售数据可视化、药品销量排行

📁 项目结构

后端项目结构

pharma-backend/
├── src/main/java/com/pharma/
│   ├── PharmaApplication.java              # 启动类
│   ├── common/                             # 通用组件
│   │   ├── config/                         # 配置类
│   │   │   ├── MyBatisConfig.java          # MyBatis配置
│   │   │   ├── SecurityConfig.java         # 安全配置
│   │   │   ├── CorsConfig.java             # 跨域配置
│   │   │   └── RedisConfig.java            # Redis配置
│   │   ├── constant/                       # 常量类
│   │   │   ├── CacheConstants.java         # 缓存常量
│   │   │   └── CommonConstants.java        # 通用常量
│   │   ├── exception/                      # 异常处理
│   │   │   ├── GlobalExceptionHandler.java # 全局异常处理
│   │   │   └── BusinessException.java      # 业务异常
│   │   ├── result/                         # 统一返回结果
│   │   │   ├── Result.java                 # 响应结果封装
│   │   │   └── ResultCode.java             # 响应状态码
│   │   ├── utils/                          # 工具类
│   │   │   ├── JwtUtil.java                # JWT工具
│   │   │   ├── SecurityUtil.java           # 安全工具
│   │   │   ├── DateUtil.java               # 日期工具
│   │   │   └── ExcelUtil.java              # Excel工具
│   │   └── web/                            # Web相关
│   │       ├── BaseController.java         # 控制器基类
│   │       └── LogAspect.java              # 日志切面
│   ├── module/                             # 业务模块
│   │   ├── auth/                           # 认证模块
│   │   │   ├── controller/                 # 控制器
│   │   │   ├── service/                    # 服务接口
│   │   │   ├── service/impl/               # 服务实现
│   │   │   ├── mapper/                     # Mapper接口
│   │   │   ├── entity/                     # 实体类
│   │   │   └── dto/                        # 数据传输对象
│   │   ├── user/                           # 用户模块
│   │   ├── medicine/                       # 药品模块
│   │   ├── category/                       # 分类模块
│   │   └── sale/                           # 销售模块
│   └── resources/
│       ├── application.yml                 # 主配置文件
│       ├── application-dev.yml             # 开发环境配置
│       ├── application-prod.yml            # 生产环境配置
│       ├── mapper/                         # XML映射文件
│       └── static/                         # 静态资源
├── pom.xml                                # Maven依赖
└── Dockerfile                             # Docker配置

前端项目结构

pharma-frontend/
├── public/                                # 静态资源
├── src/
│   ├── api/                              # API接口
│   │   ├── modules/                       # 模块接口
│   │   │   ├── auth.ts                   # 认证接口
│   │   │   ├── user.ts                   # 用户接口
│   │   │   ├── medicine.ts               # 药品接口
│   │   │   ├── category.ts               # 分类接口
│   │   │   └── sale.ts                   # 销售接口
│   │   └── index.ts                      # API统一导出
│   ├── assets/                           # 静态资源
│   │   ├── styles/                       # 样式文件
│   │   └── images/                       # 图片资源
│   ├── components/                       # 公共组件
│   │   ├── common/                       # 通用组件
│   │   │   ├── SearchBar.vue            # 搜索组件
│   │   │   ├── Pagination.vue           # 分页组件
│   │   │   └── UploadImage.vue          # 图片上传
│   │   ├── layout/                       # 布局组件
│   │   │   ├── Header.vue               # 头部
│   │   │   ├── Sidebar.vue              # 侧边栏
│   │   │   └── AppMain.vue              # 主内容
│   │   └── charts/                       # 图表组件
│   │       ├── SalesChart.vue           # 销售图表
│   │       └── InventoryChart.vue       # 库存图表
│   ├── router/                          # 路由配置
│   │   └── index.ts
│   ├── store/                           # Pinia状态管理
│   │   ├── modules/                     # 模块store
│   │   │   ├── user.ts                  # 用户store
│   │   │   ├── medicine.ts              # 药品store
│   │   │   └── common.ts                # 公共store
│   │   └── index.ts                     # store入口
│   ├── types/                           # TypeScript类型定义
│   │   ├── api.ts                       # API类型
│   │   ├── medicine.ts                  # 药品类型
│   │   ├── user.ts                      # 用户类型
│   │   └── index.ts                     # 类型导出
│   ├── utils/                           # 工具函数
│   │   ├── auth.ts                      # 认证工具
│   │   ├── request.ts                   # 请求封装
│   │   ├── validate.ts                  # 表单验证
│   │   └── date.ts                      # 日期处理
│   ├── views/                           # 页面组件
│   │   ├── login/                       # 登录页
│   │   ├── dashboard/                   # 仪表板
│   │   ├── medicine/                    # 药品管理
│   │   ├── category/                    # 分类管理
│   │   ├── sale/                        # 销售管理
│   │   ├── user/                        # 用户管理
│   │   └── report/                      # 报表统计
│   ├── App.vue                          # 根组件
│   └── main.ts                          # 入口文件
├── package.json                         # 项目依赖
├── tsconfig.json                        # TypeScript配置
├── vite.config.ts                       # Vite配置
└── Dockerfile                           # Docker配置

🗄️ 数据库设计详细说明

1. 数据库关系图

erDiagram
    tb_user ||--o{ tb_sale_detail : "operates"
    tb_medicine ||--o{ tb_sale_detail : "is_sold"
    tb_category ||--o{ tb_medicine : "categorizes"
    
    tb_user {
        int id PK "主键"
        varchar username "用户名"
        varchar password "密码"
        varchar name "姓名"
        char tel "手机号"
        datetime create_time "创建时间"
        datetime update_time "更新时间"
    }
    
    tb_medicine {
        int id PK "主键"
        varchar med_no UK "药品编码"
        varchar name "名称"
        varchar manufacturer "厂家"
        text description "描述"
        decimal price "单价"
        int med_count "库存数量"
        int req_count "补货阈值"
        varchar photo_path "图片路径"
        int category_id FK "分类ID"
        datetime create_time "创建时间"
        datetime update_time "更新时间"
    }
    
    tb_sale_detail {
        int id PK "主键"
        int medicine_id FK "药品ID"
        int operator_id FK "操作员ID"
        int quantity "销售数量"
        decimal unit_price "销售单价"
        datetime sale_time "销售时间"
        datetime create_time "创建时间"
        datetime update_time "更新时间"
    }
    
    tb_category {
        int id PK "主键"
        varchar name UK "分类名称"
        text description "描述"
        datetime create_time "创建时间"
        datetime update_time "更新时间"
    }

2. 实体类设计

用户实体 (User)

package com.pharma.module.user.entity;

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;

@Data
@TableName("tb_user")
public class User {
    @TableId(type = IdType.AUTO)
    private Long id;
    
    @TableField("username")
    private String username;
    
    @TableField("password")
    private String password;
    
    @TableField("name")
    private String name;
    
    @TableField("tel")
    private String tel;
    
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}

药品实体 (Medicine)

package com.pharma.module.medicine.entity;

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Data
@TableName("tb_medicine")
public class Medicine {
    @TableId(type = IdType.AUTO)
    private Long id;
    
    @TableField("med_no")
    private String medNo;
    
    @TableField("name")
    private String name;
    
    @TableField("manufacturer")
    private String manufacturer;
    
    @TableField("description")
    private String description;
    
    @TableField("price")
    private BigDecimal price;
    
    @TableField("med_count")
    private Integer medCount;
    
    @TableField("req_count")
    private Integer reqCount;
    
    @TableField("photo_path")
    private String photoPath;
    
    @TableField("category_id")
    private Long categoryId;
    
    @TableField(exist = false)
    private String categoryName;  // 关联查询字段
    
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}

销售明细实体 (SaleDetail)

package com.pharma.module.sale.entity;

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Data
@TableName("tb_sale_detail")
public class SaleDetail {
    @TableId(type = IdType.AUTO)
    private Long id;
    
    @TableField("medicine_id")
    private Long medicineId;
    
    @TableField("operator_id")
    private Long operatorId;
    
    @TableField("quantity")
    private Integer quantity;
    
    @TableField("unit_price")
    private BigDecimal unitPrice;
    
    @TableField("sale_time")
    private LocalDateTime saleTime;
    
    @TableField(exist = false)
    private String medicineName;  // 关联查询字段
    
    @TableField(exist = false)
    private String operatorName;  // 关联查询字段
    
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}

分类实体 (Category)

package com.pharma.module.category.entity;

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;

@Data
@TableName("tb_category")
public class Category {
    @TableId(type = IdType.AUTO)
    private Long id;
    
    @TableField("name")
    private String name;
    
    @TableField("description")
    private String description;
    
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}

🔧 环境配置

1. 后端环境配置

Maven依赖 (pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.5</version>
        <relativePath/>
    </parent>
    
    <groupId>com.pharma</groupId>
    <artifactId>pharma-management</artifactId>
    <version>1.0.0</version>
    <name>pharma-management</name>
    <description>医药管理系统</description>
    
    <properties>
        <java.version>17</java.version>
        <mybatis-plus.version>3.5.3.1</mybatis-plus.version>
        <jjwt.version>0.11.5</jjwt.version>
        <fastjson.version>2.0.38</fastjson.version>
        <hutool.version>5.8.21</hutool.version>
    </properties>
    
    <dependencies>
        <!-- Spring Boot 核心 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        
        <!-- 数据库相关 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.33</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.2.18</version>
        </dependency>
        
        <!-- Redis缓存 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        
        <!-- JWT认证 -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>
        
        <!-- 工具类 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>${hutool.version}</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>${fastjson.version}</version>
        </dependency>
        
        <!-- 开发工具 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

配置文件 (application.yml)

# 主配置文件
server:
  port: 8080
  servlet:
    context-path: /api
  tomcat:
    uri-encoding: UTF-8

spring:
  application:
    name: pharma-management
  profiles:
    active: dev
  
  # 数据库配置
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/pharma_mgmt_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
    type: com.alibaba.druid.pool.DruidDataSource
    druid:
      initial-size: 5
      min-idle: 5
      max-active: 20
      max-wait: 60000
      time-between-eviction-runs-millis: 60000
      min-evictable-idle-time-millis: 300000
      validation-query: SELECT 1 FROM DUAL
      test-while-idle: true
      test-on-borrow: false
      test-on-return: false
      pool-prepared-statements: true
      max-pool-prepared-statement-per-connection-size: 20
  
  # Redis配置
  redis:
    host: localhost
    port: 6379
    password: 
    database: 0
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0
  
  jackson:
    time-zone: GMT+8
    date-format: yyyy-MM-dd HH:mm:ss
  
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 20MB

# MyBatis Plus配置
mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      id-type: auto
      logic-delete-field: deleted
      logic-delete-value: 1
      logic-not-delete-value: 0
  mapper-locations: classpath*:/mapper/**/*.xml

# 自定义配置
pharma:
  jwt:
    secret: pharma-secret-key-2024-jwt-token-signature
    expiration: 86400000  # 24小时
    header: Authorization
  security:
    exclude-paths: /api/auth/login,/api/auth/register,/api/common/captcha,/swagger-ui/**,/webjars/**,/swagger-resources/**,/v2/api-docs,/doc.html
  upload:
    path: /upload/
    max-size: 10MB
    allowed-types: jpg,jpeg,png,gif

2. 前端环境配置

package.json

{
  "name": "pharma-frontend",
  "version": "1.0.0",
  "description": "医药管理系统前端",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
  },
  "dependencies": {
    "vue": "^3.3.0",
    "vue-router": "^4.2.0",
    "pinia": "^2.1.0",
    "axios": "^1.6.0",
    "element-plus": "^2.3.0",
    "@element-plus/icons-vue": "^2.2.0",
    "echarts": "^5.4.3",
    "vue-echarts": "^6.6.0",
    "dayjs": "^1.11.0",
    "lodash": "^4.17.21",
    "js-cookie": "^3.0.5",
    "vuedraggable": "^4.1.0",
    "print-js": "^1.6.0",
    "xlsx": "^0.18.5",
    "vite-plugin-svg-icons": "^2.0.1"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.5.0",
    "typescript": "^5.0.0",
    "vue-tsc": "^1.8.0",
    "vite": "^4.5.0",
    "@types/node": "^20.0.0",
    "@types/lodash": "^4.14.197",
    "@types/js-cookie": "^3.0.6",
    "@typescript-eslint/eslint-plugin": "^6.0.0",
    "@typescript-eslint/parser": "^6.0.0",
    "eslint": "^8.45.0",
    "eslint-plugin-vue": "^9.17.0",
    "unplugin-auto-import": "^0.16.7",
    "unplugin-vue-components": "^0.25.2",
    "@iconify/json": "^2.2.0"
  }
}

vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/auto-imports.d.ts',
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
    createSvgIconsPlugin({
      iconDirs: [resolve(process.cwd(), 'src/assets/icons')],
      symbolId: 'icon-[dir]-[name]',
    }),
  ],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
    },
  },
  server: {
    port: 3000,
    open: true,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: '@use "@/assets/styles/variables.scss" as *;',
      },
    },
  },
})

🚀 核心功能实现

1. 用户认证与授权

JWT工具类

package com.pharma.common.utils;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class JwtUtil {
    
    @Value("${pharma.jwt.secret}")
    private String secret;
    
    @Value("${pharma.jwt.expiration}")
    private Long expiration;
    
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes());
    }
    
    public String generateToken(String username) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", username);
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }
    
    public String getUsernameFromToken(String token) {
        Claims claims = getClaimsFromToken(token);
        return claims.getSubject();
    }
    
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
    
    private Claims getClaimsFromToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

Spring Security配置

package com.pharma.common.config;

import com.pharma.common.filter.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    
    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    "/api/auth/login",
                    "/api/auth/register",
                    "/api/common/captcha",
                    "/swagger-ui/**",
                    "/v3/api-docs/**"
                ).permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter, 
                UsernamePasswordAuthenticationFilter.class);
        
        return http.build();
    }
    
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setAllowCredentials(true);
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

2. 药品管理模块

药品服务实现

package com.pharma.module.medicine.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.pharma.common.exception.BusinessException;
import com.pharma.module.medicine.dto.MedicineDTO;
import com.pharma.module.medicine.entity.Medicine;
import com.pharma.module.medicine.mapper.MedicineMapper;
import com.pharma.module.medicine.service.MedicineService;
import com.pharma.module.medicine.vo.MedicineVO;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class MedicineServiceImpl extends ServiceImpl<MedicineMapper, Medicine> 
        implements MedicineService {
    
    @Override
    public Page<MedicineVO> getMedicinePage(Integer pageNum, Integer pageSize, 
                                            String name, Long categoryId) {
        Page<Medicine> page = new Page<>(pageNum, pageSize);
        LambdaQueryWrapper<Medicine> queryWrapper = new LambdaQueryWrapper<>();
        
        if (StringUtils.hasText(name)) {
            queryWrapper.like(Medicine::getName, name);
        }
        if (categoryId != null) {
            queryWrapper.eq(Medicine::getCategoryId, categoryId);
        }
        queryWrapper.orderByDesc(Medicine::getUpdateTime);
        
        Page<Medicine> medicinePage = this.page(page, queryWrapper);
        
        Page<MedicineVO> voPage = new Page<>();
        BeanUtils.copyProperties(medicinePage, voPage, "records");
        
        List<MedicineVO> voList = medicinePage.getRecords().stream()
            .map(medicine -> {
                MedicineVO vo = new MedicineVO();
                BeanUtils.copyProperties(medicine, vo);
                return vo;
            })
            .collect(Collectors.toList());
        
        voPage.setRecords(voList);
        return voPage;
    }
    
    @Override
    public void addMedicine(MedicineDTO dto) {
        // 检查药品编码是否重复
        LambdaQueryWrapper<Medicine> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Medicine::getMedNo, dto.getMedNo());
        if (this.count(queryWrapper) > 0) {
            throw new BusinessException("药品编码已存在");
        }
        
        Medicine medicine = new Medicine();
        BeanUtils.copyProperties(dto, medicine);
        this.save(medicine);
    }
    
    @Override
    public void updateMedicine(MedicineDTO dto) {
        Medicine medicine = this.getById(dto.getId());
        if (medicine == null) {
            throw new BusinessException("药品不存在");
        }
        
        // 检查药品编码是否与其他记录重复
        LambdaQueryWrapper<Medicine> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Medicine::getMedNo, dto.getMedNo())
                   .ne(Medicine::getId, dto.getId());
        if (this.count(queryWrapper) > 0) {
            throw new BusinessException("药品编码已存在");
        }
        
        BeanUtils.copyProperties(dto, medicine);
        this.updateById(medicine);
    }
    
    @Override
    public void updateStock(Long medicineId, Integer quantity) {
        Medicine medicine = this.getById(medicineId);
        if (medicine == null) {
            throw new BusinessException("药品不存在");
        }
        
        int newCount = medicine.getMedCount() + quantity;
        if (newCount < 0) {
            throw new BusinessException("库存不足");
        }
        
        medicine.setMedCount(newCount);
        this.updateById(medicine);
    }
    
    @Override
    public List<MedicineVO> getLowStockList() {
        LambdaQueryWrapper<Medicine> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.lt(Medicine::getMedCount, Medicine::getReqCount);
        queryWrapper.orderByAsc(Medicine::getMedCount);
        
        List<Medicine> medicines = this.list(queryWrapper);
        return medicines.stream()
            .map(medicine -> {
                MedicineVO vo = new MedicineVO();
                BeanUtils.copyProperties(medicine, vo);
                return vo;
            })
            .collect(Collectors.toList());
    }
}

MyBatis Mapper接口

package com.pharma.module.medicine.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pharma.module.medicine.entity.Medicine;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;

@Mapper
public interface MedicineMapper extends BaseMapper<Medicine> {
    
    @Select("SELECT m.*, c.name as category_name " +
            "FROM tb_medicine m " +
            "LEFT JOIN tb_category c ON m.category_id = c.id " +
            "WHERE m.id = #{id}")
    Medicine selectWithCategory(Long id);
    
    @Select("SELECT category_id, COUNT(*) as count, SUM(med_count) as total " +
            "FROM tb_medicine " +
            "GROUP BY category_id")
    List<Map<String, Object>> groupByCategory();
    
    @Select("SELECT name, med_count " +
            "FROM tb_medicine " +
            "ORDER BY med_count ASC " +
            "LIMIT 10")
    List<Map<String, Object>> getLowStockTop10();
}

3. 销售管理模块

销售服务实现

package com.pharma.module.sale.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.pharma.common.utils.SecurityUtil;
import com.pharma.module.medicine.service.MedicineService;
import com.pharma.module.sale.dto.SaleDTO;
import com.pharma.module.sale.entity.SaleDetail;
import com.pharma.module.sale.mapper.SaleDetailMapper;
import com.pharma.module.sale.service.SaleService;
import com.pharma.module.sale.vo.SaleVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

@Service
public class SaleServiceImpl extends ServiceImpl<SaleDetailMapper, SaleDetail> 
        implements SaleService {
    
    @Autowired
    private MedicineService medicineService;
    
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void saleMedicine(SaleDTO dto) {
        // 1. 扣减库存
        medicineService.updateStock(dto.getMedicineId(), -dto.getQuantity());
        
        // 2. 创建销售记录
        SaleDetail saleDetail = new SaleDetail();
        saleDetail.setMedicineId(dto.getMedicineId());
        saleDetail.setOperatorId(SecurityUtil.getCurrentUserId());
        saleDetail.setQuantity(dto.getQuantity());
        saleDetail.setUnitPrice(dto.getUnitPrice());
        saleDetail.setSaleTime(LocalDateTime.now());
        
        this.save(saleDetail);
    }
    
    @Override
    public BigDecimal getTodaySales() {
        LocalDate today = LocalDate.now();
        LocalDateTime startOfDay = today.atStartOfDay();
        LocalDateTime endOfDay = today.plusDays(1).atStartOfDay();
        
        BigDecimal total = this.baseMapper.getSalesTotalByTimeRange(
            startOfDay, endOfDay);
        return total != null ? total : BigDecimal.ZERO;
    }
    
    @Override
    public List<Map<String, Object>> getSalesByDay(LocalDate startDate, LocalDate endDate) {
        return this.baseMapper.getDailySales(startDate, endDate);
    }
    
    @Override
    public List<SaleVO> getRecentSales(Integer limit) {
        return this.baseMapper.selectRecentSales(limit);
    }
}

4. 前端实现示例

药品列表页面

<template>
  <div class="medicine-container">
    <!-- 搜索区域 -->
    <el-card shadow="never" class="search-card">
      <el-form :model="searchForm" inline>
        <el-form-item label="药品名称">
          <el-input
            v-model="searchForm.name"
            placeholder="请输入药品名称"
            clearable
          />
        </el-form-item>
        <el-form-item label="分类">
          <el-select
            v-model="searchForm.categoryId"
            placeholder="请选择分类"
            clearable
          >
            <el-option
              v-for="category in categoryList"
              :key="category.id"
              :label="category.name"
              :value="category.id"
            />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSearch">
            <el-icon><Search /></el-icon>搜索
          </el-button>
          <el-button @click="handleReset">
            <el-icon><Refresh /></el-icon>重置
          </el-button>
        </el-form-item>
      </el-form>
    </el-card>

    <!-- 操作区域 -->
    <el-card shadow="never" class="operation-card">
      <div class="operation-buttons">
        <el-button type="primary" @click="handleAdd">
          <el-icon><Plus /></el-icon>新增药品
        </el-button>
        <el-button type="warning" @click="handleExport">
          <el-icon><Download /></el-icon>导出Excel
        </el-button>
      </div>
    </el-card>

    <!-- 表格区域 -->
    <el-card shadow="never">
      <el-table
        :data="tableData"
        v-loading="loading"
        style="width: 100%"
        border
      >
        <el-table-column prop="medNo" label="药品编码" width="120" />
        <el-table-column prop="name" label="药品名称" width="180" />
        <el-table-column prop="categoryName" label="分类" width="120" />
        <el-table-column prop="manufacturer" label="生产厂家" width="180" />
        <el-table-column prop="price" label="单价(元)" width="100" align="right">
          <template #default="{ row }">
            {{ formatCurrency(row.price) }}
          </template>
        </el-table-column>
        <el-table-column prop="medCount" label="库存数量" width="100" align="right">
          <template #default="{ row }">
            <el-tag :type="getStockStatus(row.medCount, row.reqCount)">
              {{ row.medCount }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="reqCount" label="补货阈值" width="100" align="right" />
        <el-table-column prop="createTime" label="创建时间" width="180">
          <template #default="{ row }">
            {{ formatDate(row.createTime) }}
          </template>
        </el-table-column>
        <el-table-column label="操作" width="220" fixed="right">
          <template #default="{ row }">
            <el-button size="small" @click="handleView(row)">
              详情
            </el-button>
            <el-button type="primary" size="small" @click="handleEdit(row)">
              编辑
            </el-button>
            <el-button 
              type="success" 
              size="small" 
              @click="handleSale(row)"
              :disabled="row.medCount <= 0"
            >
              销售
            </el-button>
          </template>
        </el-table-column>
      </el-table>

      <!-- 分页 -->
      <div class="pagination-container">
        <el-pagination
          v-model:current-page="pagination.current"
          v-model:page-size="pagination.size"
          :total="pagination.total"
          :page-sizes="[10, 20, 50, 100]"
          layout="total, sizes, prev, pager, next, jumper"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </el-card>

    <!-- 新增/编辑对话框 -->
    <MedicineDialog
      v-model="dialogVisible"
      :type="dialogType"
      :data="currentRow"
      @success="handleDialogSuccess"
    />
    
    <!-- 销售对话框 -->
    <SaleDialog
      v-model="saleDialogVisible"
      :medicine="currentRow"
      @success="handleSaleSuccess"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Search, Refresh, Plus, Download } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import MedicineDialog from './components/MedicineDialog.vue'
import SaleDialog from './components/SaleDialog.vue'
import { useMedicineStore } from '@/store/modules/medicine'
import { useCategoryStore } from '@/store/modules/category'
import type { Medicine } from '@/types/medicine'
import { formatCurrency, formatDate } from '@/utils/format'

const medicineStore = useMedicineStore()
const categoryStore = useCategoryStore()

// 搜索表单
const searchForm = reactive({
  name: '',
  categoryId: null as number | null
})

// 表格数据
const tableData = ref<Medicine[]>([])
const loading = ref(false)

// 分页
const pagination = reactive({
  current: 1,
  size: 10,
  total: 0
})

// 分类列表
const categoryList = ref<any[]>([])

// 对话框控制
const dialogVisible = ref(false)
const saleDialogVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const currentRow = ref<Medicine | null>(null)

// 获取库存状态
const getStockStatus = (count: number, reqCount: number) => {
  if (count === 0) return 'danger'
  if (count <= reqCount) return 'warning'
  return 'success'
}

// 加载数据
const loadData = async () => {
  loading.value = true
  try {
    const params = {
      pageNum: pagination.current,
      pageSize: pagination.size,
      ...searchForm
    }
    const result = await medicineStore.getMedicineList(params)
    tableData.value = result.records || []
    pagination.total = result.total || 0
  } catch (error) {
    console.error('加载数据失败:', error)
  } finally {
    loading.value = false
  }
}

// 加载分类
const loadCategories = async () => {
  try {
    const result = await categoryStore.getCategoryList()
    categoryList.value = result || []
  } catch (error) {
    console.error('加载分类失败:', error)
  }
}

// 搜索
const handleSearch = () => {
  pagination.current = 1
  loadData()
}

// 重置
const handleReset = () => {
  searchForm.name = ''
  searchForm.categoryId = null
  handleSearch()
}

// 新增
const handleAdd = () => {
  dialogType.value = 'add'
  currentRow.value = null
  dialogVisible.value = true
}

// 编辑
const handleEdit = (row: Medicine) => {
  dialogType.value = 'edit'
  currentRow.value = { ...row }
  dialogVisible.value = true
}

// 查看详情
const handleView = (row: Medicine) => {
  ElMessageBox.alert(`
    <div style="line-height: 1.8;">
      <p><strong>药品编码:</strong>${row.medNo}</p>
      <p><strong>药品名称:</strong>${row.name}</p>
      <p><strong>生产厂家:</strong>${row.manufacturer}</p>
      <p><strong>分类:</strong>${row.categoryName}</p>
      <p><strong>单价:</strong>${formatCurrency(row.price)}</p>
      <p><strong>库存数量:</strong>${row.medCount}</p>
      <p><strong>补货阈值:</strong>${row.reqCount}</p>
      <p><strong>描述:</strong>${row.description}</p>
      <p><strong>创建时间:</strong>${formatDate(row.createTime)}</p>
    </div>
  `, '药品详情', {
    dangerouslyUseHTMLString: true,
    confirmButtonText: '确定'
  })
}

// 销售
const handleSale = (row: Medicine) => {
  currentRow.value = { ...row }
  saleDialogVisible.value = true
}

// 导出
const handleExport = async () => {
  try {
    await medicineStore.exportMedicine(searchForm)
    ElMessage.success('导出成功')
  } catch (error) {
    ElMessage.error('导出失败')
  }
}

// 分页大小改变
const handleSizeChange = (size: number) => {
  pagination.size = size
  pagination.current = 1
  loadData()
}

// 页码改变
const handleCurrentChange = (page: number) => {
  pagination.current = page
  loadData()
}

// 对话框成功回调
const handleDialogSuccess = () => {
  dialogVisible.value = false
  loadData()
}

// 销售成功回调
const handleSaleSuccess = () => {
  saleDialogVisible.value = false
  loadData()
}

// 初始化
onMounted(() => {
  loadData()
  loadCategories()
})
</script>

<style scoped lang="scss">
.medicine-container {
  padding: 20px;
  
  .search-card {
    margin-bottom: 20px;
  }
  
  .operation-card {
    margin-bottom: 20px;
    
    .operation-buttons {
      display: flex;
      gap: 10px;
    }
  }
  
  .pagination-container {
    margin-top: 20px;
    display: flex;
    justify-content: flex-end;
  }
}
</style>

销售统计图表组件

<template>
  <div class="sales-chart">
    <div class="chart-header">
      <h3>销售统计</h3>
      <div class="date-range">
        <el-date-picker
          v-model="dateRange"
          type="daterange"
          range-separator=""
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          @change="handleDateChange"
        />
      </div>
    </div>
    
    <div class="chart-container">
      <v-chart
        v-if="chartOption"
        :option="chartOption"
        :autoresize="true"
        style="height: 400px;"
      />
    </div>
    
    <div class="chart-footer">
      <el-row :gutter="20">
        <el-col :span="6">
          <div class="stat-card">
            <div class="stat-label">今日销售额</div>
            <div class="stat-value">{{ formatCurrency(todaySales) }}</div>
          </div>
        </el-col>
        <el-col :span="6">
          <div class="stat-card">
            <div class="stat-label">本月销售额</div>
            <div class="stat-value">{{ formatCurrency(monthSales) }}</div>
          </div>
        </el-col>
        <el-col :span="6">
          <div class="stat-card">
            <div class="stat-label">销售总量</div>
            <div class="stat-value">{{ totalQuantity }}</div>
          </div>
        </el-col>
        <el-col :span="6">
          <div class="stat-card">
            <div class="stat-label">平均单价</div>
            <div class="stat-value">{{ formatCurrency(avgPrice) }}</div>
          </div>
        </el-col>
      </el-row>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart } from 'echarts/charts'
import {
  TitleComponent,
  TooltipComponent,
  LegendComponent,
  GridComponent,
  ToolboxComponent
} from 'echarts/components'
import VChart from 'vue-echarts'
import { formatCurrency } from '@/utils/format'
import { useSaleStore } from '@/store/modules/sale'
import dayjs from 'dayjs'

use([
  CanvasRenderer,
  LineChart,
  BarChart,
  TitleComponent,
  TooltipComponent,
  LegendComponent,
  GridComponent,
  ToolboxComponent
])

const saleStore = useSaleStore()

// 日期范围
const dateRange = ref([
  dayjs().subtract(30, 'day').toDate(),
  dayjs().toDate()
])

// 统计数据
const todaySales = ref(0)
const monthSales = ref(0)
const totalQuantity = ref(0)
const avgPrice = ref(0)

// 图表配置
const chartOption = ref<any>(null)

// 加载数据
const loadData = async () => {
  if (!dateRange.value || dateRange.value.length !== 2) return
  
  const [startDate, endDate] = dateRange.value
  
  try {
    // 获取销售统计数据
    const result = await saleStore.getSalesStatistics(
      dayjs(startDate).format('YYYY-MM-DD'),
      dayjs(endDate).format('YYYY-MM-DD')
    )
    
    // 更新统计数据
    todaySales.value = result.todaySales || 0
    monthSales.value = result.monthSales || 0
    totalQuantity.value = result.totalQuantity || 0
    avgPrice.value = result.avgPrice || 0
    
    // 更新图表
    updateChart(result.chartData)
  } catch (error) {
    console.error('加载销售数据失败:', error)
  }
}

// 更新图表
const updateChart = (chartData: any[]) => {
  if (!chartData || chartData.length === 0) {
    chartOption.value = {
      title: {
        text: '暂无数据',
        left: 'center',
        top: 'center',
        textStyle: {
          color: '#999'
        }
      }
    }
    return
  }
  
  const dates = chartData.map(item => item.date)
  const amounts = chartData.map(item => item.amount)
  const quantities = chartData.map(item => item.quantity)
  
  chartOption.value = {
    title: {
      text: '销售趋势图',
      left: 'center'
    },
    tooltip: {
      trigger: 'axis',
      axisPointer: {
        type: 'cross'
      }
    },
    legend: {
      data: ['销售额', '销售量'],
      top: 30
    },
    grid: {
      left: '3%',
      right: '4%',
      bottom: '3%',
      top: 80,
      containLabel: true
    },
    xAxis: {
      type: 'category',
      boundaryGap: false,
      data: dates
    },
    yAxis: [
      {
        type: 'value',
        name: '销售额(元)',
        position: 'left',
        axisLine: {
          show: true
        },
        axisLabel: {
          formatter: '{value}'
        }
      },
      {
        type: 'value',
        name: '销售量',
        position: 'right',
        axisLine: {
          show: true
        },
        axisLabel: {
          formatter: '{value}'
        }
      }
    ],
    series: [
      {
        name: '销售额',
        type: 'line',
        yAxisIndex: 0,
        data: amounts,
        smooth: true,
        itemStyle: {
          color: '#409EFF'
        },
        areaStyle: {
          color: {
            type: 'linear',
            x: 0,
            y: 0,
            x2: 0,
            y2: 1,
            colorStops: [{
              offset: 0, color: 'rgba(64, 158, 255, 0.3)'
            }, {
              offset: 1, color: 'rgba(64, 158, 255, 0.05)'
            }]
          }
        }
      },
      {
        name: '销售量',
        type: 'bar',
        yAxisIndex: 1,
        data: quantities,
        itemStyle: {
          color: '#67C23A'
        }
      }
    ]
  }
}

// 日期改变
const handleDateChange = () => {
  loadData()
}

// 初始化
onMounted(() => {
  loadData()
})
</script>

<style scoped lang="scss">
.sales-chart {
  background: white;
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  
  .chart-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 20px;
    
    h3 {
      margin: 0;
      font-size: 18px;
      font-weight: 600;
      color: #303133;
    }
  }
  
  .chart-container {
    margin: 20px 0;
  }
  
  .chart-footer {
    margin-top: 30px;
    
    .stat-card {
      background: #f5f7fa;
      border-radius: 6px;
      padding: 20px;
      text-align: center;
      
      .stat-label {
        font-size: 14px;
        color: #909399;
        margin-bottom: 8px;
      }
      
      .stat-value {
        font-size: 24px;
        font-weight: 600;
        color: #303133;
      }
    }
  }
}
</style>

📡 API接口文档

认证模块

1. 用户登录

POST /api/auth/login
Content-Type: application/json

{
  "username": "admin",
  "password": "123456"
}

2. 获取当前用户信息

GET /api/user/info
Authorization: Bearer {token}

药品模块

1. 分页查询药品列表

GET /api/medicine/list?pageNum=1&pageSize=10&name=感冒&categoryId=1
Authorization: Bearer {token}

2. 新增药品

POST /api/medicine/add
Authorization: Bearer {token}
Content-Type: application/json

{
  "medNo": "MED1001",
  "name": "测试药品",
  "manufacturer": "测试药厂",
  "price": 25.50,
  "medCount": 100,
  "reqCount": 20,
  "categoryId": 1,
  "description": "测试药品描述"
}

3. 更新药品库存

PUT /api/medicine/stock/{id}
Authorization: Bearer {token}
Content-Type: application/json

{
  "quantity": 10  // 正数增加,负数减少
}

销售模块

1. 药品销售

POST /api/sale/create
Authorization: Bearer {token}
Content-Type: application/json

{
  "medicineId": 1,
  "quantity": 2,
  "unitPrice": 25.50
}

2. 销售统计

GET /api/sale/statistics?startDate=2024-01-01&endDate=2024-01-31
Authorization: Bearer {token}

🐳 Docker部署

Docker Compose配置

version: '3.8'

services:
  mysql:
    image: mysql:8.0
    container_name: pharma-mysql
    environment:
      MYSQL_ROOT_PASSWORD: root123456
      MYSQL_DATABASE: pharma_mgmt_db
      MYSQL_USER: pharma_user
      MYSQL_PASSWORD: pharma123
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    networks:
      - pharma-network
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci

  redis:
    image: redis:7-alpine
    container_name: pharma-redis
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    networks:
      - pharma-network
    command: redis-server --appendonly yes

  backend:
    build:
      context: ./pharma-backend
      dockerfile: Dockerfile
    container_name: pharma-backend
    depends_on:
      - mysql
      - redis
    environment:
      SPRING_PROFILES_ACTIVE: prod
      DATABASE_HOST: mysql
      REDIS_HOST: redis
    ports:
      - "8080:8080"
    volumes:
      - ./logs:/app/logs
      - ./upload:/app/upload
    networks:
      - pharma-network
    restart: unless-stopped

  frontend:
    build:
      context: ./pharma-frontend
      dockerfile: Dockerfile
    container_name: pharma-frontend
    depends_on:
      - backend
    ports:
      - "80:80"
    networks:
      - pharma-network
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    container_name: pharma-nginx
    depends_on:
      - backend
      - frontend
    ports:
      - "8000:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
      - ./nginx/conf.d:/etc/nginx/conf.d
    networks:
      - pharma-network

networks:
  pharma-network:
    driver: bridge

volumes:
  mysql_data:
  redis_data:
  upload:
  logs:

后端Dockerfile

FROM openjdk:17-jdk-slim

# 设置工作目录
WORKDIR /app

# 复制Maven构建文件
COPY target/*.jar app.jar

# 创建非root用户
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser

# 暴露端口
EXPOSE 8080

# 启动应用
ENTRYPOINT ["java", "-jar", "app.jar"]

前端Dockerfile

# 构建阶段
FROM node:18-alpine as build-stage

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .
RUN npm run build

# 生产阶段
FROM nginx:alpine as production-stage

COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

🧪 测试

1. 单元测试示例

package com.pharma.module.medicine.service;

import com.pharma.PharmaApplication;
import com.pharma.module.medicine.entity.Medicine;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest(classes = PharmaApplication.class)
@Transactional
class MedicineServiceTest {
    
    @Autowired
    private MedicineService medicineService;
    
    @Test
    void testAddMedicine() {
        Medicine medicine = new Medicine();
        medicine.setMedNo("TEST001");
        medicine.setName("测试药品");
        medicine.setPrice(new BigDecimal("25.50"));
        medicine.setMedCount(100);
        medicine.setReqCount(20);
        
        boolean result = medicineService.save(medicine);
        assertTrue(result);
        assertNotNull(medicine.getId());
    }
    
    @Test
    void testUpdateStock() {
        // 先创建测试数据
        Medicine medicine = new Medicine();
        medicine.setMedNo("TEST002");
        medicine.setName("测试药品2");
        medicine.setPrice(new BigDecimal("15.00"));
        medicine.setMedCount(50);
        medicine.setReqCount(10);
        medicineService.save(medicine);
        
        // 增加库存
        medicineService.updateStock(medicine.getId(), 10);
        Medicine updated = medicineService.getById(medicine.getId());
        assertEquals(60, updated.getMedCount());
        
        // 减少库存
        medicineService.updateStock(medicine.getId(), -20);
        updated = medicineService.getById(medicine.getId());
        assertEquals(40, updated.getMedCount());
    }
}

2. API测试示例 (Postman)

// 登录测试
pm.test("登录成功", function () {
    var jsonData = pm.response.json();
    pm.expect(jsonData.code).to.eql(200);
    pm.expect(jsonData.data.token).to.not.be.null;
});

// 药品列表测试
pm.test("获取药品列表成功", function () {
    var jsonData = pm.response.json();
    pm.expect(jsonData.code).to.eql(200);
    pm.expect(jsonData.data.records).to.be.an('array');
});

// 库存预警测试
pm.test("获取低库存药品", function () {
    var jsonData = pm.response.json();
    pm.expect(jsonData.code).to.eql(200);
    jsonData.data.forEach(function(item) {
        pm.expect(item.medCount).to.be.lessThan(item.reqCount);
    });
});

📊 项目进度规划

第一阶段 (1周) - 基础框架搭建

  • 项目初始化
  • 数据库设计和初始化
  • Spring Boot + MyBatis Plus整合
  • Vue3 + Element Plus环境搭建
  • 用户认证模块

第二阶段 (1周) - 核心功能实现

  • 药品管理模块
  • 分类管理模块
  • 销售管理模块
  • 库存管理模块
  • 基础前端页面

第三阶段 (3-5天) - 高级功能

  • 数据统计和图表
  • 报表导出功能
  • 库存预警系统
  • 权限管理系统
  • 系统日志管理

第四阶段 (2-3天) - 优化部署

  • 性能优化
  • 安全加固
  • Docker容器化
  • 生产环境部署
  • 监控告警配置

🔍 故障排除

常见问题及解决方案

  1. 数据库连接失败
# 检查MySQL服务
sudo systemctl status mysql

# 检查连接配置
# 确保application.yml中的数据库连接信息正确
  1. 前端跨域问题
# 检查后端CORS配置
# 确保SecurityConfig中的corsConfigurationSource配置正确
  1. JWT认证失败
// 检查JWT配置
// 确保application.yml中的pharma.jwt.secret与前端保持一致
  1. MyBatis Plus分页失效
// 添加分页拦截器配置
@Configuration
public class MyBatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        return interceptor;
    }
}

📚 学习资源

  1. Spring Boot官方文档: https://spring.io/projects/spring-boot
  2. MyBatis Plus官方文档: https://baomidou.com/
  3. Vue3官方文档: https://cn.vuejs.org/
  4. Element Plus文档: https://element-plus.org/zh-CN/
  5. MySQL文档: https://dev.mysql.com/doc/

📞 技术支持

如遇到问题,可通过以下方式获取支持:

  1. 查看日志文件: logs/application.log
  2. 检查数据库连接: 确认MySQL服务正常运行
  3. API调试: 使用Postman测试API接口
  4. 提交Issue: 在项目仓库提交问题
  5. 联系开发团队: 通过邮件或即时通讯工具

本文档最后更新于: 2024年1月
项目版本: v1.0.0
技术栈: Java 17 + Spring Boot 3.1 + Vue 3 + MySQL 8.0