# 📊 医药管理系统 - 项目文档 ## 📋 项目概述 ### 项目名称 **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. 数据库关系图 ```mermaid 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) ```java 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) ```java 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) ```java 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) ```java 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 4.0.0 org.springframework.boot spring-boot-starter-parent 3.1.5 com.pharma pharma-management 1.0.0 pharma-management 医药管理系统 17 3.5.3.1 0.11.5 2.0.38 5.8.21 org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-validation org.springframework.boot spring-boot-starter-aop mysql mysql-connector-java 8.0.33 com.baomidou mybatis-plus-boot-starter ${mybatis-plus.version} com.alibaba druid-spring-boot-starter 1.2.18 org.springframework.boot spring-boot-starter-data-redis io.jsonwebtoken jjwt-api ${jjwt.version} io.jsonwebtoken jjwt-impl ${jjwt.version} runtime io.jsonwebtoken jjwt-jackson ${jjwt.version} runtime org.projectlombok lombok true cn.hutool hutool-all ${hutool.version} com.alibaba.fastjson2 fastjson2 ${fastjson.version} org.springframework.boot spring-boot-devtools runtime true org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-maven-plugin org.projectlombok lombok ``` #### 配置文件 (application.yml) ```yaml # 主配置文件 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 ```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 ```typescript 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工具类 ```java 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 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配置 ```java 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. 药品管理模块 #### 药品服务实现 ```java 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 implements MedicineService { @Override public Page getMedicinePage(Integer pageNum, Integer pageSize, String name, Long categoryId) { Page page = new Page<>(pageNum, pageSize); LambdaQueryWrapper 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 medicinePage = this.page(page, queryWrapper); Page voPage = new Page<>(); BeanUtils.copyProperties(medicinePage, voPage, "records"); List 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 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 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 getLowStockList() { LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.lt(Medicine::getMedCount, Medicine::getReqCount); queryWrapper.orderByAsc(Medicine::getMedCount); List medicines = this.list(queryWrapper); return medicines.stream() .map(medicine -> { MedicineVO vo = new MedicineVO(); BeanUtils.copyProperties(medicine, vo); return vo; }) .collect(Collectors.toList()); } } ``` #### MyBatis Mapper接口 ```java 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 { @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> groupByCategory(); @Select("SELECT name, med_count " + "FROM tb_medicine " + "ORDER BY med_count ASC " + "LIMIT 10") List> getLowStockTop10(); } ``` ### 3. 销售管理模块 #### 销售服务实现 ```java 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 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> getSalesByDay(LocalDate startDate, LocalDate endDate) { return this.baseMapper.getDailySales(startDate, endDate); } @Override public List getRecentSales(Integer limit) { return this.baseMapper.selectRecentSales(limit); } } ``` ### 4. 前端实现示例 #### 药品列表页面 ```vue ``` #### 销售统计图表组件 ```vue ``` --- ## 📡 API接口文档 ### 认证模块 #### 1. 用户登录 ```http POST /api/auth/login Content-Type: application/json { "username": "admin", "password": "123456" } ``` #### 2. 获取当前用户信息 ```http GET /api/user/info Authorization: Bearer {token} ``` ### 药品模块 #### 1. 分页查询药品列表 ```http GET /api/medicine/list?pageNum=1&pageSize=10&name=感冒&categoryId=1 Authorization: Bearer {token} ``` #### 2. 新增药品 ```http 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. 更新药品库存 ```http PUT /api/medicine/stock/{id} Authorization: Bearer {token} Content-Type: application/json { "quantity": 10 // 正数增加,负数减少 } ``` ### 销售模块 #### 1. 药品销售 ```http POST /api/sale/create Authorization: Bearer {token} Content-Type: application/json { "medicineId": 1, "quantity": 2, "unitPrice": 25.50 } ``` #### 2. 销售统计 ```http GET /api/sale/statistics?startDate=2024-01-01&endDate=2024-01-31 Authorization: Bearer {token} ``` --- ## 🐳 Docker部署 ### Docker Compose配置 ```yaml 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 ```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 ```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. 单元测试示例 ```java 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) ```javascript // 登录测试 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周) - 基础框架搭建 - [x] 项目初始化 - [x] 数据库设计和初始化 - [x] Spring Boot + MyBatis Plus整合 - [x] Vue3 + Element Plus环境搭建 - [x] 用户认证模块 ### 第二阶段 (1周) - 核心功能实现 - [x] 药品管理模块 - [x] 分类管理模块 - [x] 销售管理模块 - [x] 库存管理模块 - [x] 基础前端页面 ### 第三阶段 (3-5天) - 高级功能 - [ ] 数据统计和图表 - [ ] 报表导出功能 - [ ] 库存预警系统 - [ ] 权限管理系统 - [ ] 系统日志管理 ### 第四阶段 (2-3天) - 优化部署 - [ ] 性能优化 - [ ] 安全加固 - [ ] Docker容器化 - [ ] 生产环境部署 - [ ] 监控告警配置 --- ## 🔍 故障排除 ### 常见问题及解决方案 1. **数据库连接失败** ```bash # 检查MySQL服务 sudo systemctl status mysql # 检查连接配置 # 确保application.yml中的数据库连接信息正确 ``` 2. **前端跨域问题** ```yaml # 检查后端CORS配置 # 确保SecurityConfig中的corsConfigurationSource配置正确 ``` 3. **JWT认证失败** ```java // 检查JWT配置 // 确保application.yml中的pharma.jwt.secret与前端保持一致 ``` 4. **MyBatis Plus分页失效** ```java // 添加分页拦截器配置 @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*