SpringBoot+SSM项目实战 苍穹外卖(7)(Spring Cache)

继续上一节的内容,本节实现缓存菜品、缓存套餐、添加购物车、查看购物车和清空购物车功能。





缓存菜品

用户端小程序展示的菜品数据都是通过查询数据库获得,如果用户端访问量比较大,数据库访问压力随之增大。结果:系统响应慢、用户体验差

在这里插入图片描述

可以通过Redis来缓存菜品数据,减少数据库查询操作。缓存逻辑分析:每个分类下的菜品保存一份缓存数据;数据库中菜品数据有变更时清理缓存数据。

在这里插入图片描述

修改用户端接口 DishController 的 list 方法,加入缓存处理逻辑:

@Autowired
private RedisTemplate redisTemplate;
/**
 * 根据分类id查询菜品
 *
 * @param categoryId
 * @return
 */
@GetMapping("/list")
@ApiOperation("根据分类id查询菜品")
public Result<List<DishVO>> list(Long categoryId) {
    //构造redis中的key,规则:dish_分类id
    String key = "dish_" + categoryId;
    //查询redis中是否存在菜品数据
    List<DishVO> list = (List<DishVO>) redisTemplate.opsForValue().get(key);
    if(list != null && list.size() > 0){
        //如果存在,直接返回,无须查询数据库
        return Result.success(list);
    }

    
    Dish dish = new Dish();
    dish.setCategoryId(categoryId);
    dish.setStatus(StatusConstant.ENABLE);//查询起售中的菜品

    //如果不存在,查询数据库,将查询到的数据放入redis中
    list = dishService.listWithFlavor(dish);
    
    redisTemplate.opsForValue().set(key, list);

    return Result.success(list);
}

为了保证数据库和Redis中的数据保持一致,修改管理端接口 DishController 的相关方法,加入清理缓存逻辑。

需要改造的方法:新增菜品、修改菜品、批量删除菜品和起售、停售菜品。

在管理端DishController中添加清理缓存的方法,保证数据一致性:

/**
 * 清理缓存数据
 * @param pattern
 */
private void cleanCache(String pattern){
    Set keys = redisTemplate.keys(pattern);
    redisTemplate.delete(keys);
}

然后优化以前的方法:
1). 新增菜品优化

@PostMapping
@ApiOperation("新增菜品")
public Result save(@RequestBody DishDTO dishDTO) {
    log.info("新增菜品:{}", dishDTO);
    dishService.saveWithFlavor(dishDTO);

    //清理缓存数据
    String key = "dish_" + dishDTO.getCategoryId();
    cleanCache(key);
    return Result.success();
}

2). 菜品批量删除优化

@DeleteMapping
@ApiOperation("菜品批量删除")
public Result delete(@RequestParam List<Long> ids) {
    log.info("菜品批量删除:{}", ids);
    dishService.deleteBatch(ids);

    // 可能影响多个菜品分类,要知道影响哪些分类还需要等查询之后查数据库才知道比较麻烦
    // 所以直接将redis里所有的菜品缓存数据清理掉,删掉所有以dish_开头的key
    cleanCache("dish_*");
    return Result.success();
}

3). 修改菜品优化

@PutMapping
@ApiOperation("修改菜品")
public Result update(@RequestBody DishDTO dishDTO) {
    log.info("修改菜品:{}", dishDTO);
    dishService.updateWithFlavor(dishDTO);

    // 因为如果只是修改基础属性还好 但如果是修改菜品的分类那要影响俩个key
    // 也干脆将所有的菜品缓存数据清理掉,所有以dish_开头的key
    cleanCache("dish_*");
    return Result.success();
}

4). 菜品起售停售优化

@PostMapping("/status/{status}")
@ApiOperation("修改菜品销售状态")
public Result updateStatus(@PathVariable Integer status,Long id){
    log.info("根据分类id修改菜品销售状态:{}", status);
    dishService.updateStatusById(status,id);

    // 还需要去根据id查询分类id 比较麻烦
    // 也是将所有的菜品缓存数据清理掉,所有以dish_开头的key
    cleanCache("dish_*");
    return Result.success();
}

个人觉得老师这部分的代码写得有些粗暴,大部分都选择了直接删除,但是可能这样在某种程度上也好,不用增加代码逻辑显得过于臃肿,因为本身修改菜品的情况就比较少。


但是新增菜品和删除菜品时感觉还是无需删除,因为新增菜品默认是停售状态,删除菜品时这些菜品必须是停售状态才能删除,只有修改这个菜品的销售状态为起售之后才会在用户端显示该菜品,所以这些地方根本不需要删除redis缓存,在起售停售里删除缓存就行。


然后感觉需要使用缓存的地方还是挺多的,如果大部分都是直接清理掉所有的缓存数据,那这部分的代码是不是用AOP来实现,在需要使用缓存清理的地方加上响应注解更加整洁呢?

下面进行功能测试,可以通过如下方式进行测试:查看控制台sql、前后端联调、查看Redis中的缓存数据

1). 加入缓存

当第一次查询某个分类的菜品时,会从数据为中进行查询,同时将查询的结果存储到Redis中,在后续的访问,若查询相同分类的菜品时,直接从Redis缓存中查询,不再查询数据库。登录小程序:选择蜀味牛蛙(id=17)

在这里插入图片描述

查看控制台sql:有查询语句,说明是从数据库中进行查询

在这里插入图片描述

查看Redis中的缓存数据:说明缓存成功

在这里插入图片描述

再次访问:选择蜀味牛蛙(id=17)

在这里插入图片描述

说明是从Redis中查询的数据。

2). 菜品修改

比如当在后台修改菜品数据时,为了保证Redis缓存中的数据和数据库中的数据时刻保持一致,当修改后,需要清空对应的缓存数据。用户再次访问时,还是先从数据库中查询,同时再把查询的结果存储到Redis中,这样,就能保证缓存和数据库的数据保持一致。
进入后台:修改蜀味牛蛙分类下的任意一个菜品,当前分类的菜品数据已在Redis中缓存。然后修改菜品,查看Redis中的缓存数据发现修改时,已清空缓存。

在这里插入图片描述

测试完毕提交代码到github。





缓存套餐(基于Spring Cache)

Spring Cache 是一个框架,实现了基于注解的缓存功能,只需要简单地加一个注解,就能实现缓存功能。其提供了一层抽象,底层可以切换不同的缓存实现,例如:EHCache、Caffeine、Redis(常用)

在SpringCache中提供了很多缓存操作的注解,常见的是以下的几个:

注解说明
@EnableCaching开启缓存注解功能,通常加在启动类上
@Cacheable在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据;如果没有缓存数据,调用方法并将方法返回值放到缓存中
@CachePut将方法的返回值放到缓存中
@CacheEvict将一条或多条数据从缓存中删除

在spring boot项目中,使用缓存技术只需在项目中导入相关缓存技术的依赖包,并在启动类上使用@EnableCaching开启缓存支持即可。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
    <version>2.7.3</version> 
</dependency>


@EnableCaching、@Cacheable、@CachePut和@CacheEvict

下面用一个入门案例来学习一下SpringCache注解的使用,导入老师提供的基础工程:底层已使用Redis缓存实现:

在这里插入图片描述

创建名为spring_cache_demo数据库,将springcachedemo.sql脚本直接导入数据库中。然后在引导类上加@EnableCaching:

@Slf4j
@SpringBootApplication
@EnableCaching//开启缓存注解功能
public class CacheDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(CacheDemoApplication.class,args);
        log.info("项目启动成功...");
    }
}


@CachePut注解

@CachePut 说明:作用: 将方法返回值,放入缓存。
​value: 缓存的名称, 每个缓存名称下面可以有很多key
key: 缓存的key ----------> 支持Spring的表达式语言SPEL语法

UserController的save方法是用来保存用户信息的,我们希望在该用户信息保存到数据库的同时,也往缓存中缓存一份数据,我们可以在save方法上加上注解 @CachePut,用法如下:

/**
 * CachePut:将方法返回值放入缓存
 * value:缓存的名称,每个缓存名称下面可以有多个key
 * key:缓存的key
 */
 @PostMapping
 @CachePut(value = "userCache", key = "#user.id")
 public User save(@RequestBody User user){
     userMapper.insert(user);
     return user;
 }

上面这样写,假如方法运行完,生成的主键为1,那么在redis中存储的key是userCache::1,存储的value是方法返回值,存储的key是这个格式是因为指定了注解的两个属性
userCache为注解的value属性,然后自动加上::,后面跟着的key属性是可变参数,这个也是自己指定。key的写法如下:

#user.id : #user指的是方法形参的名称, id指的是user的id属性 , 也就是使用user的id属性作为key ;

#result.id : #result代表方法返回值,该表达式 代表以返回对象的id属性作为key ;

#p0.id:#p0指的是方法中的第一个参数,id指的是第一个参数的id属性,也就是使用第一个参数的id属性作为key ;
#a0.id:#a0指的是方法中的第一个参数,id指的是第一个参数的id属性,也就是使用第一个参数的id属性作为key ;
#root.args[0].id:#root.args[0]指的是方法中的第一个参数,id指的是第一个参数的id属性,也就是使用第一个参数的id属性作为key ;

后面三种其实是同一个意思,推荐使用第一种,因为比较直观。
启动服务,通过swagger接口文档测试,访问UserController的save()方法。因为id是自增,所以不需要设置id属性。访问完之后:

在这里插入图片描述

在这里插入图片描述

在这个工程里使用的是1号数据库所以在db1。然后我们发现redis种的key也是支持这种树形结构的,在userCache文件夹下有一个Empty文件夹,然后是userCache::1。这个层次是怎么划分出来的呢?为什么中间有个Empty呢?因为userCache::1中间有两个冒号,一个冒号代表一级。可以自己去redis命令行里试一下命令:set a:b:c:d test 就能看到四级的key



@Cacheable注解

​ 作用: 在方法执行前,spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中
value: 缓存的名称,每个缓存名称下面可以有多个key
​key: 缓存的key ----------> 支持Spring的表达式语言SPEL语法

在getById上加注解@Cacheable

/**
* Cacheable:在方法执行前spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中
* value:缓存的名称,每个缓存名称下面可以有多个key
* key:缓存的key
*/
@GetMapping
@Cacheable(cacheNames = "userCache",key="#id")
public User getById(Long id){
    User user = userMapper.getById(id);
    return user;
}

重启服务,通过swagger接口文档测试,访问UserController的getById()方法。第一次访问,会请求我们controller的方法,查询数据库。后面再查询相同的id,就直接从Redis中查询数据,不用再查询数据库了,就说明缓存生效了。

在这里插入图片描述



@CacheEvict注解注解

​ 作用: 清理指定缓存
value: 缓存的名称,每个缓存名称下面可以有多个key
​key: 缓存的key ----------> 支持Spring的表达式语言SPEL语法

在 delete 方法上加注解@CacheEvict

@DeleteMapping
@CacheEvict(cacheNames = "userCache",key = "#id")//删除某个key对应的缓存数据
public void deleteById(Long id){
    userMapper.deleteById(id);
}

@DeleteMapping("/delAll")
@CacheEvict(cacheNames = "userCache",allEntries = true)//删除userCache下所有的缓存数据
public void deleteAll(){
    userMapper.deleteAll();
}

重启服务,通过swagger接口文档测试,访问UserController的deleteAll()方法。查看user表:数据清空。查询Redis缓存数据,userCache下所有的缓存数据被清空。

在这里插入图片描述

在这里插入图片描述





Spring Cache实现缓存套餐

1). 导入Spring Cache和Redis相关maven坐标(已实现)

<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

2). 在启动类上加入@EnableCaching注解,开启缓存注解功能

@SpringBootApplication
@EnableTransactionManagement //开启注解方式的事务管理
@Slf4j
@EnableCaching // 开启缓存注解功能
public class SkyApplication {
	...
}

3). 在用户端接口SetmealController的 list 方法上加入@Cacheable注解

/**
 * 条件查询
 *
 * @param categoryId
 * @return
 */
@GetMapping("/list")
@ApiOperation("根据分类id查询套餐")
@Cacheable(cacheNames = "setmealCache",key = "#categoryId") //key可能为setmealCache::100
public Result<List<Setmeal>> list(Long categoryId) {
    Setmeal setmeal = new Setmeal();
    setmeal.setCategoryId(categoryId);
    setmeal.setStatus(StatusConstant.ENABLE);

    List<Setmeal> list = setmealService.list(setmeal);
    return Result.success(list);
}

4). 在管理端接口SetmealController的 save、delete、update、updateStatus等方法上加入CacheEvict注解

@PostMapping
@ApiOperation(value = "新增套餐")
@CacheEvict(cacheNames = "setmealCache",key = "#setmealDTO.categoryId")//key: setmealCache::100
public Result save(@RequestBody SetmealDTO setmealDTO){
    log.info("新增套餐:{}",setmealDTO);
    setmealServices.save(setmealDTO);
    return Result.success();
}

@DeleteMapping
@ApiOperation("套餐批量删除")
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
public Result delete(@RequestParam List<Long> ids) {
    log.info("套餐批量删除:{}", ids);
    setmealServices.deleteBatch(ids);
    return Result.success();
}

@PutMapping
@ApiOperation("修改套餐")
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
public Result update(@RequestBody SetmealVO setmealVO) {
    log.info("修改套餐:{}", setmealVO);
    setmealServices.updateWithDishes(setmealVO);
    return Result.success();
}

@PostMapping("/status/{status}")
@ApiOperation("修改套餐销售状态")
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
public Result updateStatus(@PathVariable Integer status,Long id){
    log.info("根据套餐id修改套餐销售状态:{}", status);
    setmealServices.updateStatusById(status,id);
    return Result.success();
}

这里解释一下为什么在修改套餐的时候不是去像新增套餐那样精确的根据分类id来删,而是全部删除。因为修改套餐的时候很有可能改变了套餐的分类。像根据套餐id修改销售状态那就属于是没办法精确删除,参数里就没有分类id。
但是这里因为新增套餐默认是停售,删除套餐必须套餐是停售才能删除,所以倒是感觉也没必要在这里清缓存了,前端根本看不到这些数据,你新增的时候清一下,然后用户前端查套餐的时候又得去数据库读了,感觉在修改套餐销售状态里清就行。感觉这个技术使用起来还是比较抽象的。

我看视频弹幕也有人问为什么不在管理端的新增套餐里使用@CachePut注解。
要记住我们在用户端的根据分类id查询套餐函数上加上了@Cacheable注解,所以用户在查的时候如果缓存里没有数据是自动会将查询到的数据存储到缓存的。用户端查询数据,所以设置缓存,而管理端的增删改会导致数据库和缓存数据不一致,那当然是要在管理端进行缓存删除了,要理清这个思路。

功能测试通过前后端联调方式来进行测试,同时观察redis中缓存的套餐数据。和缓存菜品功能测试基本一致,不再赘述。

测试完毕提交代码到github。





添加购物车

用户可以将菜品或者套餐添加到购物车。对于菜品来说,如果设置了口味信息,则需要选择规格后才能加入购物车;对于套餐来说,可以直接点击加号将当前套餐加入购物车。在购物车中可以修改菜品和套餐的数量,也可以清空购物车。

请添加图片描述

通过上述原型图,设计出对应的添加购物车接口。添加购物车时,有可能添加菜品,也有可能添加套餐。故传入参数要么是菜品id,要么是套餐id。

请添加图片描述

用户的购物车数据,也是需要保存在数据库中的,购物车对应的数据表为shopping_cart表,具体表结构如下:

字段名数据类型说明备注
idbigint主键自增
namevarchar(32)商品名称冗余字段
imagevarchar(255)商品图片路径冗余字段
user_idbigint用户id逻辑外键
dish_idbigint菜品id逻辑外键
setmeal_idbigint套餐id逻辑外键
dish_flavorvarchar(50)菜品口味
numberint商品数量
amountdecimal(10,2)商品单价冗余字段
create_timedatetime创建时间

购物车数据是关联用户的,在表结构中,我们需要记录,每一个用户的购物车数据是哪些
菜品列表展示出来的既有套餐,又有菜品,如果用户选择的是套餐,就保存套餐ID(setmeal_id),如果用户选择的是菜品,就保存菜品ID(dish_id)
对同一个菜品/套餐,如果选择多份不需要添加多条记录,增加数量number即可
在sky-pojo模块,ShoppingCartDTO.java已定义



Controller层

创建controller.user.ShoppingCartController:

@RestController
@RequestMapping("/user/shoppingCart")
@Slf4j
@Api(tags = "C端-购物车接口")
public class ShoppingCartController {

    @Autowired
    private ShoppingCartService shoppingCartService;

    /**
     * 添加购物车
     * @param shoppingCartDTO
     * @return
     */
    @PostMapping("/add")
    @ApiOperation("添加购物车")
    public Result<String> add(@RequestBody ShoppingCartDTO shoppingCartDTO){
        log.info("添加购物车:{}", shoppingCartDTO);
        shoppingCartService.addShoppingCart(shoppingCartDTO);//后绪步骤实现
        return Result.success();
    }
}

Service层实现类

ShoppingCartServiceImpl

@Service
public class ShoppingCartServiceImpl implements ShoppingCartService {

    @Autowired
    private ShoppingCartMapper shoppingCartMapper;
    @Autowired
    private DishMapper dishMapper;
    @Autowired
    private SetmealMapper setmealMapper;

    /**
     * 添加购物车
     *
     * @param shoppingCartDTO
     */
    public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {
        ShoppingCart shoppingCart = new ShoppingCart();
        BeanUtils.copyProperties(shoppingCartDTO, shoppingCart);
        //只能查询自己的购物车数据
        shoppingCart.setUserId(BaseContext.getCurrentId());

        //判断当前商品是否在购物车中 注意实现这个接口时如果是不同口味的同一个菜品 也需要单独插入 而不能在它基础上数量加1
        List<ShoppingCart> shoppingCartList = shoppingCartMapper.list(shoppingCart);

        if (shoppingCartList != null && shoppingCartList.size() == 1) {
            //如果已经存在,就更新数量,数量加1
            shoppingCart = shoppingCartList.get(0);
            shoppingCart.setNumber(shoppingCart.getNumber() + 1);
            shoppingCartMapper.updateNumberById(shoppingCart);
        } else {
            //如果不存在,插入数据,数量就是1

            //判断当前添加到购物车的是菜品还是套餐
            Long dishId = shoppingCartDTO.getDishId();
            if (dishId != null) {
                //添加到购物车的是菜品
                Dish dish = dishMapper.getById(dishId);
                shoppingCart.setName(dish.getName());
                shoppingCart.setImage(dish.getImage());
                shoppingCart.setAmount(dish.getPrice());
            } else {
                //添加到购物车的是套餐
                Setmeal setmeal = setmealMapper.getById(shoppingCartDTO.getSetmealId());
                shoppingCart.setName(setmeal.getName());
                shoppingCart.setImage(setmeal.getImage());
                shoppingCart.setAmount(setmeal.getPrice());
            }
            shoppingCart.setNumber(1);
            shoppingCart.setCreateTime(LocalDateTime.now());
            shoppingCartMapper.insert(shoppingCart);
        }
    }
}

Mapper层

ShoppingCartMapper

@Mapper
public interface ShoppingCartMapper {
    /**
     * 条件查询
     *
     * @param shoppingCart
     * @return
     */
    List<ShoppingCart> list(ShoppingCart shoppingCart);

    /**
     * 更新商品数量
     *
     * @param shoppingCart
     */
    @Update("update shopping_cart set number = #{number} where id = #{id}")
    void updateNumberById(ShoppingCart shoppingCart);

    /**
     * 插入购物车数据
     *
     * @param shoppingCart
     */
    @Insert("insert into shopping_cart (name, user_id, dish_id, setmeal_id, dish_flavor, number, amount, image, create_time) " +
            " values (#{name},#{userId},#{dishId},#{setmealId},#{dishFlavor},#{number},#{amount},#{image},#{createTime})")
    void insert(ShoppingCart shoppingCart);

}

ShoppingCartMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.ShoppingCartMapper">
    <select id="list" parameterType="ShoppingCart" resultType="ShoppingCart">
        select * from shopping_cart
        <where>
            <if test="userId != null">
                and user_id = #{userId}
            </if>
            <if test="dishId != null">
                and dish_id = #{dishId}
            </if>
            <if test="setmealId != null">
                and setmeal_id = #{setmealId}
            </if>
            <if test="dishFlavor != null">
                and dish_flavor = #{dishFlavor}
            </if>
        </where>
        order by create_time desc
    </select>
</mapper>

注意这里动态sql将口味也视为同一条购物车数据的判断之中,即若是不同的口味需要单独在购物车数据库中插入一条数据。

功能测试进入小程序,添加菜品加入购物车,查询数据库。因为现在没有实现查看购物车功能,所以只能在表中进行查看。在前后联调时,后台可通断点方式启动,查看运行的每一步。

在这里插入图片描述

测试成功,提交代码。





查看购物车

当用户添加完菜品和套餐后,可进入到购物车中,查看购物中的菜品和套餐。

在这里插入图片描述

请添加图片描述

Controller层

在ShoppingCartController中创建查看购物车的方法:

/**
 * 查看购物车
 * @return
 */
@GetMapping("/list")
@ApiOperation("查看购物车")
public Result<List<ShoppingCart>> list(){
    return Result.success(shoppingCartService.showShoppingCart());
}

Service层实现类

在ShoppingCartServiceImpl中实现查看购物车的方法:

/**
 * 查看购物车
 * @return
 */
public List<ShoppingCart> showShoppingCart() {
    return shoppingCartMapper.list(ShoppingCart.
                                   builder().
                                   userId(BaseContext.getCurrentId()).
                                   build());
}

功能测试,当进入小程序时,就会发起查看购物车的请求

在这里插入图片描述

在这里插入图片描述

测试成功,提交代码。





清空购物车

当点击清空按钮时,会把购物车中的数据全部清空。

在这里插入图片描述

在这里插入图片描述

Controller层

在ShoppingCartController中创建清空购物车的方法:

/**
 * 清空购物车商品
 * @return
 */
@DeleteMapping("/clean")
@ApiOperation("清空购物车商品")
public Result<String> clean(){
    shoppingCartService.cleanShoppingCart();
    return Result.success();
}

Service层实现类

在ShoppingCartServiceImpl中实现清空购物车的方法:

/**
 * 清空购物车商品
 */
public void cleanShoppingCart() {
    shoppingCartMapper.deleteByUserId(BaseContext.getCurrentId());
}

Mapper层

在ShoppingCartMapper接口中创建删除购物车数据的方法:

/**
 * 根据用户id删除购物车数据
 *
 * @param userId
 */
@Delete("delete from shopping_cart where user_id = #{userId}")
void deleteByUserId(Long userId);

功能测试,进入到购物车页面,点击清空。

在这里插入图片描述

在这里插入图片描述

数据库该用户的购物数据也被清空:

在这里插入图片描述

测试成功提交代码。

  • 21
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统 毕业设计项目:基于SpringBoot + SSM实现的医院信息管理系统

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值