租房项目开发实战(三)

「C++ 40 周年」主题征文大赛(有机会与C++之父现场交流!) 10w+人浏览 735人参与

房间管理

房间管理共有六个接口,下面逐一实现

首先在RoomController​中注入RoomInfoService​,如下

@Tag(name = "房间信息管理")
@RestController
@RequestMapping("/admin/room")
public class RoomController {

    @Autowired
    private RoomInfoService service;
}

1. 保存或更新房间信息

  • 查看数据返回结构

@Data
@Schema(description = "房间信息")
public class RoomSubmitVo extends RoomInfo {

    @Schema(description = "图片列表")
    private List<GraphVo> graphVoList;

    @Schema(description = "属性信息列表")
    private List<Long> attrValueIds;

    @Schema(description = "配套信息列表")
    private List<Long> facilityInfoIds;

    @Schema(description = "标签信息列表")
    private List<Long> labelInfoIds;

    @Schema(description = "支付方式列表")
    private List<Long> paymentTypeIds;

    @Schema(description = "可选租期列表")
    private List<Long> leaseTermIds;

}
  • 编写Controller层

    @Operation(summary = "保存或更新房间信息")
    @PostMapping("saveOrUpdate")
    public Result saveOrUpdate(@RequestBody RoomSubmitVo roomSubmitVo) {
        service.saveOrUpdateRoom(roomSubmitVo);
        return Result.ok();
    }
  • 编写Service层

//RoomInfoService.java
public interface RoomInfoService extends IService<RoomInfo> {

    void saveOrUpdateRoom(RoomSubmitVo roomSubmitVo);
}

//RoomInfoServiceImpl.java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import java.util.ArrayList;
import java.util.List;
import org.springframework.util.CollectionUtils;

@Service
public class RoomInfoServiceImpl extends ServiceImpl<RoomInfoMapper, RoomInfo>
        implements RoomInfoService {

    // 注入与Room关联的其他服务
    @Autowired
    private GraphInfoService graphInfoService;
    @Autowired
    private RoomAttrValueService roomAttrValueService;
    @Autowired
    private RoomFacilityService roomFacilityService;
    @Autowired
    private RoomLabelService roomLabelService;
    @Autowired
    private RoomPaymentTypeService roomPaymentTypeService;
    @Autowired
    private RoomLeaseTermService roomLeaseTermService;

    /**
     * 保存或更新房间信息,并同步处理其关联数据。
     * <p>
     * 这是一个典型的“全量更新”或“UPSERT”(插入或更新)场景。
     * 为了保证数据一致性,采用了“先删除,后插入”的策略来处理关联表数据。
     *
     * @param roomSubmitVo 包含房间基本信息和所有关联数据的提交对象
     */
    @Override
    // 建议:在实际生产环境中,此方法应添加 @Transactional 注解,
    // 以确保房间主表和所有关联表的操作在同一个事务中完成,要么全部成功,要么全部失败。
    // @Transactional(rollbackFor = Exception.class)
    public void saveOrUpdateRoom(RoomSubmitVo roomSubmitVo) {
        // 1. 判断是更新操作还是新增操作
        // 如果传入的VO对象包含id,则视为更新;否则视为新增。
        boolean isUpdate = roomSubmitVo.getId() != null;

        // 2. 保存或更新房间主表信息
        // ServiceImpl 提供的 saveOrUpdate 方法会根据实体是否有id来自动选择执行INSERT或UPDATE。
        // 注意:这里假设 RoomSubmitVo 继承自或可以被MyBatis-Plus正确映射到 RoomInfo 实体。
        // 如果字段名不一致,可能需要手动转换或使用 MapStruct 等工具。
        super.saveOrUpdate(roomSubmitVo);

        // --- 核心逻辑:处理关联数据 ---
        // 对于更新操作,最稳妥的方式是先删除该房间之前所有的关联数据,
        // 然后再根据传入的新数据重新创建。
        // 这样可以避免处理“哪些数据被新增、哪些被删除、哪些被修改”的复杂逻辑。
        if (isUpdate) {
            // 确保在更新关联数据时,roomId已经存在(新增操作时ID是在save后才生成的)
            Long roomId = roomSubmitVo.getId();

            // 3. 批量删除旧的关联数据
            // 使用LambdaQueryWrapper可以获得类型安全,避免字符串硬编码字段名。

            // 3.1 删除与房间关联的图片信息
            LambdaQueryWrapper<GraphInfo> graphQueryWrapper = new LambdaQueryWrapper<>();
            graphQueryWrapper.eq(GraphInfo::getItemType, ItemType.ROOM); // 业务类型为房间
            graphQueryWrapper.eq(GraphInfo::getItemId, roomId);         // 关联的房间ID
            graphInfoService.remove(graphQueryWrapper);

            // 3.2 删除与房间关联的属性值
            LambdaQueryWrapper<RoomAttrValue> attrQueryMapper = new LambdaQueryWrapper<>();
            attrQueryMapper.eq(RoomAttrValue::getRoomId, roomId);
            roomAttrValueService.remove(attrQueryMapper);

            // 3.3 删除与房间关联的设施
            LambdaQueryWrapper<RoomFacility> facilityQueryWrapper = new LambdaQueryWrapper<>();
            facilityQueryWrapper.eq(RoomFacility::getRoomId, roomId);
            roomFacilityService.remove(facilityQueryWrapper);

            // 3.4 删除与房间关联的标签
            LambdaQueryWrapper<RoomLabel> labelQueryWrapper = new LambdaQueryWrapper<>();
            labelQueryWrapper.eq(RoomLabel::getRoomId, roomId);
            roomLabelService.remove(labelQueryWrapper);

            // 3.5 删除与房间关联的支付方式
            LambdaQueryWrapper<RoomPaymentType> paymentQueryWrapper = new LambdaQueryWrapper<>();
            paymentQueryWrapper.eq(RoomPaymentType::getRoomId, roomId);
            roomPaymentTypeService.remove(paymentQueryWrapper);

            // 3.6 删除与房间关联的租赁期限
            LambdaQueryWrapper<RoomLeaseTerm> termQueryWrapper = new LambdaQueryWrapper<>();
            termQueryWrapper.eq(RoomLeaseTerm::getRoomId, roomId);
            roomLeaseTermService.remove(termQueryWrapper);
        }

        // 4. 批量插入新的关联数据
        // 无论是新增还是更新操作,最后都需要将VO中的关联数据保存到数据库。
        // 在更新场景下,这是在删除旧数据之后执行的“重新创建”步骤。

        // 4.1 处理并保存图片信息列表
        List<GraphVo> graphVoList = roomSubmitVo.getGraphVoList();
        // 使用 CollectionUtils.isEmpty 进行空判断,是更安全和规范的写法
        if (!CollectionUtils.isEmpty(graphVoList)) {
            ArrayList<GraphInfo> graphInfoList = new ArrayList<>();
            // 将前端传来的 GraphVo 转换为数据库实体 GraphInfo
            for (GraphVo graphVo : graphVoList) {
                GraphInfo graphInfo = new GraphInfo();
                graphInfo.setItemType(ItemType.ROOM);
                // 关键:这里的 roomSubmitVo.getId() 在 saveOrUpdate 之后一定有值(新增时会被回填)
                graphInfo.setItemId(roomSubmitVo.getId());
                graphInfo.setName(graphVo.getName());
                graphInfo.setUrl(graphVo.getUrl());
                graphInfoList.add(graphInfo);
            }
            // 使用 saveBatch 进行批量保存,性能远高于循环单条插入
            graphInfoService.saveBatch(graphInfoList);
        }

        // 4.2 处理并保存属性值列表 (多对多关系)
        List<Long> attrValueIds = roomSubmitVo.getAttrValueIds();
        if (!CollectionUtils.isEmpty(attrValueIds)) {
            List<RoomAttrValue> roomAttrValueList = new ArrayList<>();
            // 为每个选中的属性值ID创建一个中间表记录
            for (Long attrValueId : attrValueIds) {
                // 使用建造者模式(Builder)创建对象,代码更简洁易读
                RoomAttrValue roomAttrValue = RoomAttrValue.builder()
                        .roomId(roomSubmitVo.getId())
                        .attrValueId(attrValueId)
                        .build();
                roomAttrValueList.add(roomAttrValue);
            }
            roomAttrValueService.saveBatch(roomAttrValueList);
        }

        // 4.3 处理并保存设施列表 (多对多关系)
        List<Long> facilityInfoIds = roomSubmitVo.getFacilityInfoIds();
        if (!CollectionUtils.isEmpty(facilityInfoIds)) {
            List<RoomFacility> roomFacilityList = new ArrayList<>();
            for (Long facilityInfoId : facilityInfoIds) {
                RoomFacility roomFacility = RoomFacility.builder()
                        .roomId(roomSubmitVo.getId())
                        .facilityId(facilityInfoId)
                        .build();
                roomFacilityList.add(roomFacility);
            }
            roomFacilityService.saveBatch(roomFacilityList);
        }

        // 4.4 处理并保存标签列表 (多对多关系)
        List<Long> labelInfoIds = roomSubmitVo.getLabelInfoIds();
        if (!CollectionUtils.isEmpty(labelInfoIds)) {
            ArrayList<RoomLabel> roomLabelList = new ArrayList<>();
            for (Long labelInfoId : labelInfoIds) {
                RoomLabel roomLabel = RoomLabel.builder()
                        .roomId(roomSubmitVo.getId())
                        .labelId(labelInfoId)
                        .build();
                roomLabelList.add(roomLabel);
            }
            roomLabelService.saveBatch(roomLabelList);
        }

        // 4.5 处理并保存支付方式列表 (多对多关系)
        List<Long> paymentTypeIds = roomSubmitVo.getPaymentTypeIds();
        if (!CollectionUtils.isEmpty(paymentTypeIds)) {
            ArrayList<RoomPaymentType> roomPaymentTypeList = new ArrayList<>();
            for (Long paymentTypeId : paymentTypeIds) {
                RoomPaymentType roomPaymentType = RoomPaymentType.builder()
                        .roomId(roomSubmitVo.getId())
                        .paymentTypeId(paymentTypeId)
                        .build();
                roomPaymentTypeList.add(roomPaymentType);
            }
            roomPaymentTypeService.saveBatch(roomPaymentTypeList);
        }

        // 4.6 处理并保存租赁期限列表 (多对多关系)
        List<Long> leaseTermIds = roomSubmitVo.getLeaseTermIds();
        if (!CollectionUtils.isEmpty(leaseTermIds)) {
            ArrayList<RoomLeaseTerm> roomLeaseTerms = new ArrayList<>();
            for (Long leaseTermId : leaseTermIds) {
                RoomLeaseTerm roomLeaseTerm = RoomLeaseTerm.builder()
                        .roomId(roomSubmitVo.getId())
                        .leaseTermId(leaseTermId)
                        .build();
                roomLeaseTerms.add(roomLeaseTerm);
            }
            roomLeaseTermService.saveBatch(roomLeaseTerms);
        }
    }
}

2. 根据条件分页查询房间列表

  • 查看数据请求结构

@Schema(description = "房间查询实体")
@Data
public class RoomQueryVo {

    @Schema(description = "省份Id")
    private Long provinceId;

    @Schema(description = "城市Id")
    private Long cityId;

    @Schema(description = "区域Id")
    private Long districtId;

    @Schema(description = "公寓Id")
    private Long apartmentId;
}
  • 查看数据返回结构

@Data
@Schema(description = "房间信息")
public class RoomItemVo extends RoomInfo {

    @Schema(description = "租约结束日期")
    private Date leaseEndDate;

    @Schema(description = "当前入住状态")
    private Boolean isCheckIn;

    @Schema(description = "所属公寓信息")
    private ApartmentInfo apartmentInfo;
}
  • Controller层

    @Operation(summary = "根据条件分页查询房间列表")
    @GetMapping("pageItem")
    public Result<IPage<RoomItemVo>> pageItem(@RequestParam long current, @RequestParam long size, RoomQueryVo queryVo) {
        IPage<RoomItemVo> page = new Page<>(current, size);
        IPage<RoomItemVo> result = service.pageRoomItemByQuery(page, queryVo);
        return Result.ok(result);
    }
  • Service层

//RoomInfoService.java
public interface RoomInfoService extends IService<RoomInfo> {

    void saveOrUpdateRoom(RoomSubmitVo roomSubmitVo);

    IPage<RoomItemVo> pageRoomItemByQuery(IPage<RoomItemVo> page, RoomQueryVo queryVo);
}

// RoomInfoServiceImpl.java
    @Override
    public IPage<RoomItemVo> pageRoomItemByQuery(IPage<RoomItemVo> page, RoomQueryVo queryVo) {
        return roomInfoMapper.pageRoomItemByQuery(page, queryVo);
    }
  • Mapper层

//RoomInfoMapper.java
public interface RoomInfoMapper extends BaseMapper<RoomInfo> {

    IPage<RoomItemVo> pageRoomItemByQuery(IPage<RoomItemVo> page, RoomQueryVo queryVo);
}
//RoomInfoMapper.xml
<mapper namespace="com.yuhuan.lease.web.admin.mapper.RoomInfoMapper">
        <!--
          1. 定义结果集映射 (ResultMap)
          - id: "RoomItemVoMap",这个 ResultMap 的唯一标识符,在 select 标签中通过 resultMap 属性引用。
          - type: 指定最终映射到的 Java 对象类型,这里是自定义的视图对象 RoomItemVo。
          - autoMapping: "true",开启自动映射。MyBatis 会自动将查询结果中列名与 RoomItemVo 的属性名(驼峰命名法与下划线命名法会自动转换)一致的字段进行映射。
                        例如:SQL 中的 room_number 会自动映射到 RoomItemVo 的 roomNumber 属性。
        -->
        <resultMap id="RoomItemVoMap" type="com.yuhuan.lease.web.admin.vo.room.RoomItemVo" autoMapping="true">
            <!--
              <id> 标签用于指定主键字段。
              - property: RoomItemVo 中的属性名 "id"。
              - column: SQL 查询结果中的列名 "id" (来自 room_info 表)。
              作用:MyBatis 使用主键来标识唯一对象,尤其在处理嵌套结果集(如 association)时,能更准确地组装对象,避免重复数据。
            -->
            <id property="id" column="id"/>

            <!--
              <association> 标签用于处理“一对一”或“多对一”的关联关系。
              这里表示一个 RoomItemVo 对象内部包含一个 ApartmentInfo 对象。
              - property: RoomItemVo 中用于存储关联对象的属性名 "apartmentInfo"。
              - javaType: 关联对象的全类名,即 ApartmentInfo。
              - autoMapping: "true",同样开启自动映射,将查询到的公寓信息字段自动填充到 ApartmentInfo 对象中。
            -->
            <association property="apartmentInfo" javaType="com.yuhuan.lease.model.entity.ApartmentInfo" autoMapping="true">
                <!--
                  同样,为关联对象 ApartmentInfo 指定其主键映射。
                  - property: ApartmentInfo 的主键属性 "id"。
                  - column: SQL 查询结果中对应的别名 "apart_id"。
                  注意:这里必须使用别名,因为主表 room_info 也有一个 id 字段,避免混淆。
                -->
                <id property="id" column="apart_id"/>

                <!--
                  <result> 标签用于手动指定一个字段的映射关系,当自动映射无法满足时使用。
                  这里将 SQL 结果集中的别名 "apart_is_release" 映射到 ApartmentInfo 对象的 "isRelease" 属性。
                  虽然开启了 autoMapping,但由于 SQL 列名(apart_is_release)和 Java 属性名(isRelease)
                  之间的转换(下划线 to 驼峰)可能不直接匹配(apartIsRelease -> isRelease),所以手动指定更保险。
                -->
                <result property="isRelease" column="apart_is_release"/>
            </association>
        </resultMap>

        <!--
          2. 定义查询语句 (Select)
          - id: "pageRoomItemByQuery",对应 Mapper 接口中的方法名。
          - resultMap: "RoomItemVoMap",指定使用上面定义的 ResultMap 来解析查询结果。
        -->
        <select id="pageRoomItemByQuery" resultMap="RoomItemVoMap">
            select
            ri.id,
            ri.room_number,
            ri.rent,
            ri.apartment_id,
            ri.is_release,
            <!--
              这是一个巧妙的用法,用于判断房间当前是否有有效的租赁。
              - la.room_id is not null: 如果左连接到 lease_agreement 表且能找到记录,说明存在租赁。
              - is_check_in: 这是一个别名,会被映射到 RoomItemVo 的 isCheckIn 属性(布尔类型)。
            -->
            la.room_id is not null is_check_in,
            la.lease_end_date,
            <!--
              为公寓表的字段设置别名,以避免与房间表的字段(特别是 id)冲突。
              所有公寓相关的字段都以 "apart_" 为前缀。
            -->
            ai.id                  apart_id,
            ai.name,
            ai.introduction,
            ai.district_id,
            ai.district_name,
            ai.city_id,
            ai.city_name,
            ai.province_id,
            ai.province_name,
            ai.address_detail,
            ai.latitude,
            ai.longitude,
            ai.phone,
            ai.is_release          apart_is_release
            from room_info ri
            <!--
              左连接 (LEFT JOIN) 租赁协议表。
              - 目的:查询每个房间是否存在有效的租赁协议。
              - on 条件:
                - ri.id = la.room_id: 房间ID关联。
                - la.is_deleted = 0: 只查询未被逻辑删除的租赁协议。
                - la.status in (2,5): 只查询状态为“已入住”或“已续约”等有效状态的协议。
              即使没有找到匹配的租赁协议,房间信息也依然会被查询出来,此时 la 的相关字段会为 null。
            -->
            left join lease_agreement la
            on ri.id = la.room_id
            and la.is_deleted = 0
            and la.status in (2,5)
            <!--
              左连接公寓信息表。
              - 目的:将房间所属的公寓信息一并查询出来。
              - on 条件:
                - ri.apartment_id = ai.id: 通过 apartment_id 关联。
                - ai.is_deleted = 0: 只查询未被逻辑删除的公寓。
            -->
            left join apartment_info ai
            on ri.apartment_id = ai.id
            and ai.is_deleted = 0
            <!--
              <where> 标签用于动态构建 WHERE 子句。
              - 它会自动处理 AND/OR 逻辑,并在至少有一个内部条件成立时才生成 WHERE 关键字。
            -->
            <where>
                <!-- 基础条件:只查询未被逻辑删除的房间 -->
                ri.is_deleted = 0

                <!--
                  动态条件:根据传入的 queryVo 对象中的属性是否为 null 来决定是否添加该查询条件。
                  - test="queryVo.provinceId != null": OGNL 表达式,判断传入的查询参数对象中 provinceId 是否有值。
                  - 如果有值,则添加 "and ai.province_id = #{queryVo.provinceId}" 到 SQL 中。
                  - 注意:这里的条件是针对公寓表 (ai) 的,所以使用 ai.province_id。
                -->
                <if test="queryVo.provinceId != null">
                    and ai.province_id = #{queryVo.provinceId}
                </if>
                <if test="queryVo.cityId != null">
                    and ai.city_id = #{queryVo.cityId}
                </if>
                <if test="queryVo.districtId != null">
                    and ai.district_id = #{queryVo.districtId}
                </if>
                <if test="queryVo.apartmentId != null">
                    and ri.apartment_id = #{queryVo.apartmentId}
                </if>
            </where>
            <!--
              注意:一个完整的分页查询通常还需要 order by 和 limit 子句。
              例如:
              order by ${sortColumn} ${sortDirection}
              limit #{pageSize} offset #{offset}
              这些可以根据你的具体分页逻辑添加。
            -->
        </select>
</mapper>

3. 根据ID获取房间详细信息

  • 查看响应数据结构

@Schema(description = "房间信息")
@Data
public class RoomDetailVo extends RoomInfo {

    @Schema(description = "所属公寓信息")
    private ApartmentInfo apartmentInfo;

    @Schema(description = "图片列表")
    private List<GraphVo> graphVoList;

    @Schema(description = "属性信息列表")
    private List<AttrValueVo> attrValueVoList;

    @Schema(description = "配套信息列表")
    private List<FacilityInfo> facilityInfoList;

    @Schema(description = "标签信息列表")
    private List<LabelInfo> labelInfoList;

    @Schema(description = "支付方式列表")
    private List<PaymentType> paymentTypeList;

    @Schema(description = "可选租期列表")
    private List<LeaseTerm> leaseTermList;
}
  • Controller层

    @Operation(summary = "根据id获取房间详细信息")
    @GetMapping("getDetailById")
    public Result<RoomDetailVo> getDetailById(@RequestParam Long id) {
        RoomDetailVo roomInfo = service.getRoomDetailById(id);
        return Result.ok(roomInfo);
    }
  • Service层

public interface RoomInfoService extends IService<RoomInfo> {

    void saveOrUpdateRoom(RoomSubmitVo roomSubmitVo);

    IPage<RoomItemVo> pageRoomItemByQuery(IPage<RoomItemVo> page, RoomQueryVo queryVo);

    RoomDetailVo getRoomDetailById(Long id);
}
    @Override
    public RoomDetailVo getRoomDetailById(Long id) {
        //1.查询RoomInfo
        RoomInfo roomInfo = roomInfoMapper.selectById(id);

        //2.查询所属公寓信息
        ApartmentInfo apartmentInfo = apartmentInfoMapper.selectById(roomInfo.getApartmentId());

        //3.查询graphInfoList
        List<GraphVo> graphVoList = graphInfoMapper.selectListByItemTypeAndId(ItemType.ROOM, id);

        //4.查询attrValueList
        List<AttrValueVo> attrvalueVoList = attrValueMapper.selectListByRoomId(id);

        //5.查询facilityInfoList
        List<FacilityInfo> facilityInfoList = facilityInfoMapper.selectListByRoomId(id);

        //6.查询labelInfoList
        List<LabelInfo> labelInfoList = labelInfoMapper.selectListByRoomId(id);

        //7.查询paymentTypeList
        List<PaymentType> paymentTypeList = paymentTypeMapper.selectListByRoomId(id);

        //8.查询leaseTermList
        List<LeaseTerm> leaseTermList = leaseTermMapper.selectListByRoomId(id);


        RoomDetailVo adminRoomDetailVo = new RoomDetailVo();
        BeanUtils.copyProperties(roomInfo, adminRoomDetailVo);

        adminRoomDetailVo.setApartmentInfo(apartmentInfo);
        adminRoomDetailVo.setGraphVoList(graphVoList);
        adminRoomDetailVo.setAttrValueVoList(attrvalueVoList);
        adminRoomDetailVo.setFacilityInfoList(facilityInfoList);
        adminRoomDetailVo.setLabelInfoList(labelInfoList);
        adminRoomDetailVo.setPaymentTypeList(paymentTypeList);
        adminRoomDetailVo.setLeaseTermList(leaseTermList);

        return adminRoomDetailVo;
    }
  • Mapper层

图片列表下的selectListByItemTypeAndId
public interface GraphInfoMapper extends BaseMapper<GraphInfo> {

    List<GraphVo> selectListByItemTypeAndId(ItemType itemType, Long id);
}
<mapper namespace="com.yuhuan.lease.web.admin.mapper.GraphInfoMapper">

    <select id="selectListByItemTypeAndId" resultType="com.yuhuan.lease.web.admin.vo.graph.GraphVo">
        select
            name,
            url
        from graph_info
        where is_deleted=0
          and item_type=#{itemType}
          and item_id=#{id}
    </select>
</mapper>
属性信息列表下的:selectListByRoomId
public interface AttrValueMapper extends BaseMapper<AttrValue> {

    List<AttrValueVo> selectListByRoomId(Long id);
}

<mapper namespace="com.yuhuan.lease.web.admin.mapper.AttrValueMapper">

    <select id="selectListByRoomId" resultType="com.yuhuan.lease.web.admin.vo.attr.AttrValueVo">
        select v.id,
               v.name,
               v.attr_key_id,
               k.name attr_key_name
        from attr_value v
                 join attr_key k on v.attr_key_id = k.id
        where v.is_deleted = 0
          and k.is_deleted = 0
          and v.id in (select attr_value_id
                       from room_attr_value
                       where is_deleted = 0
                         and room_id = #{id})
    </select>
</mapper>
配套信息列表下的:selectListByRoomId
public interface FacilityInfoMapper extends BaseMapper<FacilityInfo> {

    List<FacilityInfo> selectListByApartmentId(Long id);

    List<FacilityInfo> selectListByRoomId(Long id);
}
    <select id="selectListByRoomId" resultType="com.yuhuan.lease.model.entity.FacilityInfo">
        select id,
               type,
               name,
               icon
        from facility_info
        where is_deleted = 0
          and id in
              (select facility_id
               from room_facility
               where is_deleted = 0
                 and room_id = #{id})
    </select>
标签信息列表下的:selectListByRoomId
public interface LabelInfoMapper extends BaseMapper<LabelInfo> {

    List<LabelInfo> selectListByApartmentId(Long id);

    List<LabelInfo> selectListByRoomId(Long id);
}
    <select id="selectListByRoomId" resultType="com.yuhuan.lease.model.entity.LabelInfo">
        select id,
               type,
               name
        from label_info
        where is_deleted = 0
          and id in
              (select label_id
               from room_label
               where is_deleted = 0
                 and room_id = #{id})
    </select>
支付方式列表下的:selectListByRoomId
public interface PaymentTypeMapper extends BaseMapper<PaymentType> {

    List<PaymentType> selectListByRoomId(Long id);
}
    <select id="selectListByRoomId" resultType="com.yuhuan.lease.model.entity.PaymentType">
        select id,
               name,
               pay_month_count,
               additional_info
        from payment_type
        where is_deleted = 0
          and id in
              (select payment_type_id
               from room_payment_type
               where is_deleted = 0
                 and room_id = #{id})
    </select>
可选租期列表下的:selectListByRoomId
public interface LeaseTermMapper extends BaseMapper<LeaseTerm> {

    List<LeaseTerm> selectListByRoomId(Long id);
}
    <select id="selectListByRoomId" resultType="com.yuhuan.lease.model.entity.LeaseTerm">
        select id,
               month_count,
               unit
        from lease_term
        where is_deleted = 0
          and id in (select lease_term_id
                     from room_lease_term
                     where is_deleted = 0
                       and room_id = #{id})
    </select>

4. 根据ID删除房间信息

  • Controller层

    @Operation(summary = "根据id删除房间信息")
    @DeleteMapping("removeById")
    public Result removeById(@RequestParam Long id) {
        service.removeRoomById(id);
        return Result.ok();
    }
  • service层
public interface RoomInfoService extends IService<RoomInfo> {

    void saveOrUpdateRoom(RoomSubmitVo roomSubmitVo);

    IPage<RoomItemVo> pageRoomItemByQuery(IPage<RoomItemVo> page, RoomQueryVo queryVo);

    RoomDetailVo getRoomDetailById(Long id);

    void removeRoomById(Long id);
}

RoomInfoServiceImpl下的代码

    @Override
    public void removeRoomById(Long id) {
        //1.删除RoomInfo
        super.removeById(id);

        //2.删除graphInfoList
        LambdaQueryWrapper<GraphInfo> graphQueryWrapper = new LambdaQueryWrapper<>();
        graphQueryWrapper.eq(GraphInfo::getItemType, ItemType.ROOM);
        graphQueryWrapper.eq(GraphInfo::getItemId, id);
        graphInfoService.remove(graphQueryWrapper);

        //3.删除attrValueList
        LambdaQueryWrapper<RoomAttrValue> attrQueryWrapper = new LambdaQueryWrapper<>();
        attrQueryWrapper.eq(RoomAttrValue::getRoomId, id);
        roomAttrValueService.remove(attrQueryWrapper);

        //4.删除facilityInfoList
        LambdaQueryWrapper<RoomFacility> facilityQueryWrapper = new LambdaQueryWrapper<>();
        facilityQueryWrapper.eq(RoomFacility::getRoomId, id);
        roomFacilityService.remove(facilityQueryWrapper);

        //5.删除labelInfoList
        LambdaQueryWrapper<RoomLabel> labelQueryWrapper = new LambdaQueryWrapper<>();
        labelQueryWrapper.eq(RoomLabel::getRoomId, id);
        roomLabelService.remove(labelQueryWrapper);

        //6.删除paymentTypeList
        LambdaQueryWrapper<RoomPaymentType> paymentQueryWrapper = new LambdaQueryWrapper<>();
        paymentQueryWrapper.eq(RoomPaymentType::getRoomId, id);
        roomPaymentTypeService.remove(paymentQueryWrapper);

        //7.删除leaseTermList
        LambdaQueryWrapper<RoomLeaseTerm> termQueryWrapper = new LambdaQueryWrapper<>();
        termQueryWrapper.eq(RoomLeaseTerm::getRoomId, id);
        roomLeaseTermService.remove(termQueryWrapper);
    }

5. 根据id修改房间发布状态

  • controller层
    @Operation(summary = "根据id修改房间发布状态")
    @PostMapping("updateReleaseStatusById")
    public Result updateReleaseStatusById(Long id, ReleaseStatus status) {
        LambdaUpdateWrapper<RoomInfo> updateWrapper = new LambdaUpdateWrapper<>();
        updateWrapper.eq(RoomInfo::getId, id);
        updateWrapper.set(RoomInfo::getIsRelease, status);
        service.update(updateWrapper);
        return Result.ok();
    }

6. 根据公寓ID查询房间列表

  • controller层
    @GetMapping("listBasicByApartmentId")
    @Operation(summary = "根据公寓id查询房间列表")
    public Result<List<RoomInfo>> listBasicByApartmentId(Long id) {
        LambdaQueryWrapper<RoomInfo> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(RoomInfo::getApartmentId, id);
        queryWrapper.eq(RoomInfo::getIsRelease, ReleaseStatus.RELEASED);
        List<RoomInfo> roomInfoList = service.list(queryWrapper);
        return Result.ok(roomInfoList);
    }

看房预约管理

预约管理模块包含两个核心接口:

  1. 分页查询预约信息(支持条件筛选)
  2. 根据ID更新预约状态

实现步骤如下:
首先在ViewAppointmentController中注入ViewAppointmentService服务,代码如下:

@Tag(name = "预约看房管理")
@RequestMapping("/admin/appointment")
@RestController
public class ViewAppointmentController {

    @Autowired
    private ViewAppointmentService service;
}

1.分页查询预约信息(支持条件筛选)

  • 查看请求和响应的数据结构
@Data
@Schema(description = "预约看房查询实体")
public class AppointmentQueryVo {


    @Schema(description="预约公寓所在省份")
    private Long provinceId;

    @Schema(description="预约公寓所在城市")
    private Long cityId;

    @Schema(description="预约公寓所在区")
    private Long districtId;

    @Schema(description="预约公寓所在公寓")
    private Long apartmentId;

    @Schema(description="预约用户姓名")
    private String name;

    @Schema(description="预约用户手机号码")
    private String phone;

}
@Data
@Schema(description = "预约看房信息")
public class AppointmentVo extends ViewAppointment {

    @Schema(description = "预约公寓信息")
    private ApartmentInfo apartmentInfo;

}
  • controller层
    @Operation(summary = "分页查询预约信息")
    @GetMapping("page")
    public Result<IPage<AppointmentVo>> page(@RequestParam long current, @RequestParam long size, AppointmentQueryVo queryVo) {
        IPage<AppointmentVo> page = new Page<>(current, size);
        IPage<AppointmentVo> list = service.pageAppointmentByQuery(page, queryVo);
        return Result.ok(list);
    }
  • service层

ViewAppointmentService

public interface ViewAppointmentService extends IService<ViewAppointment> {

    IPage<AppointmentVo> pageAppointmentByQuery(IPage<AppointmentVo> page, AppointmentQueryVo queryVo);
}

ViewAppointmentServiceImpl

@Service
public class ViewAppointmentServiceImpl extends ServiceImpl<ViewAppointmentMapper, ViewAppointment>
        implements ViewAppointmentService {
    @Autowired
    private ViewAppointmentMapper viewAppointmentMapper;
    @Override
    public IPage<AppointmentVo> pageAppointmentByQuery(IPage<AppointmentVo> page, AppointmentQueryVo queryVo) {
        return viewAppointmentMapper.pageAppointmentByQuery(page, queryVo);
    }
}
  • Mapper层

public interface ViewAppointmentMapper extends BaseMapper<ViewAppointment> {

    IPage<AppointmentVo> pageAppointmentByQuery(IPage<AppointmentVo> page, AppointmentQueryVo queryVo);
}
    <!--
      1. 定义结果集映射 (ResultMap)
      - id: "AppointmentVoMap",此 ResultMap 的唯一标识符。
      - type: 指定查询结果最终要映射到的 Java 对象类型,即 AppointmentVo。
      - autoMapping: "true",开启自动映射。MyBatis 会自动将查询结果中列名
                     与 AppointmentVo 的属性名(遵循驼峰命名法与下划线命名法的转换规则)
                     一致的字段进行映射。例如,SQL 中的 user_id 会自动映射到 userId 属性。
    -->
    <resultMap id="AppointmentVoMap" type="com.yuhuan.lease.web.admin.vo.appointment.AppointmentVo" autoMapping="true">
        <!--
          <id> 标签用于指定主键字段。
          - property: AppointmentVo 中的主键属性名 "id"。
          - column: SQL 查询结果中对应的列名 "id"。
          作用:帮助 MyBatis 识别唯一的对象,尤其在处理嵌套结果时,可以避免重复数据,并能更高效地组装对象。
        -->
        <id property="id" column="id"/>

        <!--
          <association> 标签用于处理“一对一”或“多对一”的关联关系。
          这里表示每个 AppointmentVo 对象内部都包含一个 ApartmentInfo 对象。
          - property: AppointmentVo 中用于存储关联对象的属性名 "apartmentInfo"。
          - javaType: 关联对象的全类名,即 ApartmentInfo。
          - autoMapping: "true",对关联对象 ApartmentInfo 也开启自动映射。
        -->
        <association property="apartmentInfo" javaType="com.yuhuan.lease.model.entity.ApartmentInfo" autoMapping="true">
            <!--
              为关联对象 ApartmentInfo 指定其主键映射。
              - property: ApartmentInfo 的主键属性 "id"。
              - column: SQL 查询结果中对应的别名 "apartment_id"。
              使用别名是为了避免与主查询(view_appointment)中的其他字段(如 va.id)产生混淆。
            -->
            <id property="id" column="apartment_id"/>
            
            <!--
              <result> 标签用于手动指定一个字段的映射关系。
              这里将 SQL 结果集中的别名 "apartment_name" 映射到 ApartmentInfo 对象的 "name" 属性。
              虽然开启了 autoMapping,但由于查询的是视图 view_appointment,其列名可能直接就是 apartment_name,
              为了明确映射关系并增强代码可读性,这里手动指定。
            -->
            <result property="name" column="apartment_name"/>
        </association>
    </resultMap>

    <!--
      2. 定义查询语句 (Select)
      - id: "pageAppointmentByQuery",对应 Mapper 接口中的方法名。
      - resultMap: "AppointmentVoMap",指定使用上面定义好的 ResultMap 来解析查询结果。
    -->
    <select id="pageAppointmentByQuery" resultMap="AppointmentVoMap">
        select
            va.id,
            va.user_id,
            va.name,
            va.phone,
            va.appointment_time,
            va.additional_info,
            va.appointment_status,
            -- 为关联的公寓表字段设置别名,以避免字段名冲突,并与 ResultMap 中的配置对应
            ai.id   apartment_id,
            ai.name apartment_name,
            ai.district_id,
            ai.district_name,
            ai.city_id,
            ai.city_name,
            ai.province_id,
            ai.province_name
        -- 从预约视图 view_appointment 中查询,使用别名 va
        from view_appointment va
        --
        -- 左连接 (LEFT JOIN) 公寓信息表 apartment_info
        -- 目的:在查询预约信息的同时,一并获取该预约所关联的公寓详情。
        -- 即使某个预约没有关联到公寓(这种情况通常应避免),预约信息本身也依然会被查询出来。
        left join
            apartment_info ai
            -- 连接条件:预约记录中的 apartment_id 与公寓表的主键 id 相等。
            -- 并且只关联未被逻辑删除的公寓 (ai.is_deleted = 0)
            on va.apartment_id = ai.id and ai.is_deleted = 0
        <!--
          <where> 标签用于动态构建 WHERE 子句。
          - 它会自动处理 AND/OR 逻辑,并在至少有一个内部条件成立时才生成 WHERE 关键字。
          - 如果没有任何条件成立,则不会生成 WHERE 子句,避免了 SQL 语法错误。
        -->
        <where>
            -- 基础查询条件:只查询未被逻辑删除的预约记录
            va.is_deleted = 0

            <!--
              动态查询条件:根据传入的 queryVo 对象中的属性值来动态添加。
              - test: OGNL 表达式,判断 queryVo 中的 provinceId 是否不为 null。
              - 如果不为 null,则添加 "and ai.province_id = #{queryVo.provinceId}" 这个条件。
            -->
            <if test="queryVo.provinceId != null">
                and ai.province_id = #{queryVo.provinceId}
            </if>
            <if test="queryVo.cityId != null">
                and ai.city_id = #{queryVo.cityId}
            </if>
            <if test="queryVo.districtId != null">
                and ai.district_id = #{queryVo.districtId}
            </if>
            <if test="queryVo.apartmentId != null">
                and va.apartment_id = #{queryVo.apartmentId}
            </if>

            <!--
              模糊查询条件:判断 name 是否不为 null 且不为空字符串。
              - 使用 like 关键字和 concat 函数来拼接通配符 %,实现模糊搜索。
              - #{queryVo.name} 是参数占位符,MyBatis 会自动处理 SQL 注入问题。
            -->
            <if test="queryVo.name != null and queryVo.name != ''">
                and va.name like concat('%', #{queryVo.name}, '%')
            </if>
            <if test="queryVo.phone != null and queryVo.phone != ''">
                and va.phone like concat('%', #{queryVo.phone}, '%')
            </if>
        </where>
        <!--
          注意:一个完整的分页查询通常还需要添加排序和分页限制。
          例如:
          order by va.create_time desc
          limit #{pageSize} offset #{offset}
          这些可以根据你的具体业务逻辑添加到 SQL 语句的末尾。
        -->
    </select>

</mapper>
  • 优化相关

  • 时间格式化

ViewAppointment实体类中,appointmentTime字段采用Date类型。使用Jackson框架进行JSON序列化时,需特别注意日期格式和时区处理。以下是具体配置方案:

上面的列表里面预约时间        "appointmentTime": "2023-07-14T01:01:01.000+00:00", 不符合要求

单独配置

在指定字段增加@JsonFormat​注解,如下

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")
private Date appointmentTime;

全局配置

在application.yml​中增加如下内容

spring:
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: GMT+8

2. 根据ID更新预约状态

在ViewAppointmentController​中增加如下内容

    @Operation(summary = "根据id更新预约状态")
    @PostMapping("updateStatusById")
    public Result updateStatusById(@RequestParam Long id, @RequestParam AppointmentStatus status) {
        LambdaUpdateWrapper<ViewAppointment> updateWrapper = new LambdaUpdateWrapper<>();
        updateWrapper.eq(ViewAppointment::getId, id);
        updateWrapper.set(ViewAppointment::getAppointmentStatus, status);
        service.update(updateWrapper);
        return Result.ok();
    }

租约管理

租约管理需要实现五个核心接口,同时还需开发一个定时任务来检查租约到期状态并更新。具体实现步骤如下:首先在LeaseAgreementController中注入LeaseAgreementService,代码如下:

@Tag(name = "租约管理")
@RestController
@RequestMapping("/admin/agreement")
public class LeaseAgreementController {

    @Autowired
    private LeaseAgreementService service;
}

1. 保存获更新租约信息

在 LeaseAgreementController 中添加以下内容:

    @Operation(summary = "保存或修改租约信息")
    @PostMapping("saveOrUpdate")
    public Result saveOrUpdate(@RequestBody LeaseAgreement leaseAgreement) {
        service.saveOrUpdate(leaseAgreement);
        return Result.ok();
    }

2. 根据条件分页查询租约列表

  • 查看请求和响应的数据结构

请求数据结构

@Data
@Schema(description = "租约查询实体")
public class AgreementQueryVo {

    @Schema(description = "公寓所处省份id")
    private Long provinceId;

    @Schema(description = "公寓所处城市id")
    private Long cityId;

    @Schema(description = "公寓所处区域id")
    private Long districtId;

    @Schema(description = "公寓id")
    private Long apartmentId;

    @Schema(description = "房间号")
    private String roomNumber;

    @Schema(description = "用户姓名")
    private String name;

    @Schema(description = "用户手机号码")
    private String phone;

}

返回的数据结构

@Data
@Schema(description = "租约信息")
public class AgreementVo extends LeaseAgreement {

    @Schema(description = "签约公寓信息")
    private ApartmentInfo apartmentInfo;

    @Schema(description = "签约房间信息")
    private RoomInfo roomInfo;

    @Schema(description = "支付方式")
    private PaymentType paymentType;

    @Schema(description = "租期")
    private LeaseTerm leaseTerm;
}
  • Controller层

    @Operation(summary = "根据条件分页查询租约列表")
    @GetMapping("page")
    public Result<IPage<AgreementVo>> page(@RequestParam long current, @RequestParam long size, AgreementQueryVo queryVo) {
        IPage<AgreementVo> page = new Page<>();
        IPage<AgreementVo> list = service.pageAgreementByQuery(page, queryVo);
        return Result.ok(list);
    }
  • Service层

public interface LeaseAgreementService extends IService<LeaseAgreement> {

    IPage<AgreementVo> pageAgreementByQuery(IPage<AgreementVo> page, AgreementQueryVo queryVo);
}
@Service
public class LeaseAgreementServiceImpl extends ServiceImpl<LeaseAgreementMapper, LeaseAgreement>
        implements LeaseAgreementService {
    @Autowired
    private LeaseAgreementMapper leaseAgreementMapper;
    @Override
    public IPage<AgreementVo> pageAgreementByQuery(IPage<AgreementVo> page, AgreementQueryVo queryVo) {
        return leaseAgreementMapper.pageAgreementByQuery(page,queryVo);
    }
}
  • Mapper层

public interface LeaseAgreementMapper extends BaseMapper<LeaseAgreement> {

    IPage<AgreementVo> pageAgreementByQuery(IPage<AgreementVo> page, AgreementQueryVo queryVo);
}
    <!--
       1. 定义结果集映射 (ResultMap)
       - id: "agreementVoMap",此 ResultMap 的唯一标识符。
       - type: 指定查询结果最终要映射到的 Java 对象类型,即 AgreementVo。
       - autoMapping: "true",开启自动映射。MyBatis 会自动将查询结果中列名
                      与 AgreementVo 的属性名(遵循驼峰命名法与下划线命名法的转换规则)
                      一致的字段进行映射。例如,SQL 中的 lease_start_date 会自动映射到 leaseStartDate 属性。
     -->
    <resultMap id="agreementVoMap" type="com.yuhuan.lease.web.admin.vo.agreement.AgreementVo" autoMapping="true">
        <!--
          <id> 标签用于指定主键字段。
          - property: AgreementVo 中的主键属性名 "id"。
          - column: SQL 查询结果中对应的列名 "id" (来自 lease_agreement 表)。
          作用:帮助 MyBatis 识别唯一的对象,尤其在处理多个关联时,可以避免重复数据,并能更高效地组装对象。
        -->
        <id property="id" column="id"/>

        <!--
          <association> 标签用于处理“一对一”或“多对一”的关联关系。
          这里表示一个 AgreementVo 对象内部包含一个 ApartmentInfo 对象。
          - property: AgreementVo 中用于存储关联对象的属性名 "apartmentInfo"。
          - javaType: 关联对象的全类名。
          - autoMapping: "true",对关联对象也开启自动映射。
        -->
        <association property="apartmentInfo" javaType="com.yuhuan.lease.model.entity.ApartmentInfo" autoMapping="true">
            <id property="id" column="apartment_id"/>
            <result property="name" column="apartment_name"/>
        </association>

        <!-- 关联房间信息 -->
        <association property="roomInfo" javaType="com.yuhuan.lease.model.entity.RoomInfo" autoMapping="true">
            <id property="id" column="room_id"/>
        </association>

        <!-- 关联支付方式信息 -->
        <association property="paymentType" javaType="com.yuhuan.lease.model.entity.PaymentType" autoMapping="true">
            <id property="id" column="payment_type_id"/>
            <result property="name" column="payment_type_name"/>
        </association>

        <!-- 关联租赁期限信息 -->
        <association property="leaseTerm" javaType="com.yuhuan.lease.model.entity.LeaseTerm" autoMapping="true">
            <id property="id" column="lease_term_id"/>
        </association>
    </resultMap>

    <!--
      2. 定义查询语句 (Select)
      - id: "pageAgreementByQuery",对应 Mapper 接口中的方法名。
      - resultMap: "agreementVoMap",指定使用上面定义好的 ResultMap 来解析查询结果。
    -->
    <select id="pageAgreementByQuery" resultMap="agreementVoMap">
        select
        la.id,
        la.phone,
        la.name,
        la.identification_number,
        la.lease_start_date,
        la.lease_end_date,
        la.rent,
        la.deposit,
        la.status,
        la.source_type,
        la.additional_info,
        -- 为关联表的字段设置别名,以避免字段名冲突,并与 ResultMap 中的配置对应
        ai.id   apartment_id,
        ai.name apartment_name,
        ai.district_id,
        ai.district_name,
        ai.city_id,
        ai.city_name,
        ai.province_id,
        ai.province_name,
        ri.id   room_id,
        ri.room_number,
        pt.id   payment_type_id,
        pt.name payment_type_name,
        pt.pay_month_count,
        lt.id   lease_term_id,
        lt.month_count,
        lt.unit
        -- 从主表 lease_agreement 开始查询,使用别名 la
        from lease_agreement la
        -- 左连接公寓信息表,获取协议关联的公寓详情
        left join apartment_info ai
        on la.apartment_id = ai.id and ai.is_deleted = 0
        -- 左连接房间信息表,获取协议关联的房间详情
        left join room_info ri
        on la.room_id = ri.id and ri.is_deleted = 0
        -- 左连接支付方式表,获取协议关联的支付方式详情
        left join payment_type pt
        on la.payment_type_id = pt.id and pt.is_deleted = 0
        -- 左连接租赁期限表,获取协议关联的租赁期限详情
        left join lease_term lt
        on la.lease_term_id = lt.id and lt.is_deleted = 0

        <!--
          <where> 标签用于动态构建 WHERE 子句。
          - 它会自动处理 AND/OR 逻辑,并在至少有一个内部条件成立时才生成 WHERE 关键字。
        -->
        <where>
            -- 基础查询条件:只查询未被逻辑删除的租赁协议
            la.is_deleted = 0

            <!-- 动态查询条件:根据传入的 queryVo 对象中的属性值来动态添加 -->
            <if test="queryVo.provinceId != null">
                and ai.province_id = #{queryVo.provinceId}
            </if>
            <if test="queryVo.cityId != null">
                and ai.city_id = #{queryVo.cityId}
            </if>
            <if test="queryVo.districtId != null">
                and ai.district_id = #{queryVo.districtId}
            </if>
            <if test="queryVo.apartmentId != null">
                and la.apartment_id = #{queryVo.apartmentId}
            </if>
            <if test="queryVo.roomNumber != null and queryVo.roomNumber != ''">
                and ri.room_number like concat('%', #{queryVo.roomNumber}, '%')
            </if>
            <if test="queryVo.name != null and queryVo.name != ''">
                and la.name like concat('%', #{queryVo.name}, '%')
            </if>
            <if test="queryVo.phone != null and queryVo.phone != ''">
                and la.phone like concat('%', #{queryVo.phone}, '%')
            </if>
        </where>
    </select>

3. 根据ID查询租约信息

  • controller层
    @Operation(summary = "根据id查询租约信息")
    @GetMapping(name = "getById")
    public Result<AgreementVo> getById(@RequestParam Long id) {
        AgreementVo apartment = service.getAgreementById(id);
        return Result.ok(apartment);
    }
  • service层
public interface LeaseAgreementService extends IService<LeaseAgreement> {

    IPage<AgreementVo> pageAgreementByQuery(IPage<AgreementVo> page, AgreementQueryVo queryVo);

    AgreementVo getAgreementById(Long id);
}
    @Override
    public AgreementVo getAgreementById(Long id) {
        //1.查询租约信息
        LeaseAgreement leaseAgreement = leaseAgreementMapper.selectById(id);

        //2.查询公寓信息
        ApartmentInfo apartmentInfo = apartmentInfoMapper.selectById(leaseAgreement.getApartmentId());

        //3.查询房间信息
        RoomInfo roomInfo = roomInfoMapper.selectById(leaseAgreement.getRoomId());

        //4.查询支付方式
        PaymentType paymentType = paymentTypeMapper.selectById(leaseAgreement.getPaymentTypeId());

        //5.查询租期
        LeaseTerm leaseTerm = leaseTermMapper.selectById(leaseAgreement.getLeaseTermId());

        AgreementVo adminAgreementVo = new AgreementVo();
        BeanUtils.copyProperties(leaseAgreement, adminAgreementVo);
        adminAgreementVo.setApartmentInfo(apartmentInfo);
        adminAgreementVo.setRoomInfo(roomInfo);
        adminAgreementVo.setPaymentType(paymentType);
        adminAgreementVo.setLeaseTerm(leaseTerm);
        return adminAgreementVo;
    }

4. 根据ID删除租约信息

在LeaseAgreementController​中增加如下内容

    @Operation(summary = "根据id删除租约信息")
    @DeleteMapping("removeById")
    public Result removeById(@RequestParam Long id) {
        service.removeById(id);
        return Result.ok();
    }

5. 根据ID更新租约状态

后台管理系统需提供多个修改租约状态的接口,包括将租约状态改为已取消、已退租等。为避免代码重复,现将这些接口合并为一个。请注意:实际生产环境中应避免采用此写法。

在LeaseAgreementController中添加以下代码

    @Operation(summary = "根据id更新租约状态")
    @PostMapping("updateStatusById")
    public Result updateStatusById(@RequestParam Long id, @RequestParam LeaseStatus status) {
        LambdaUpdateWrapper<LeaseAgreement> updateWrapper = new LambdaUpdateWrapper<>();
        updateWrapper.eq(LeaseAgreement::getId, id);
        updateWrapper.set(LeaseAgreement::getStatus, status);
        service.update(updateWrapper);
        return Result.ok();
    }

    6. 定时检查租约状态

    本节内容是通过定时任务定时检查租约是否到期。SpringBoot内置了定时任务,具体实现如下。

    启用Spring Boot定时任务

    在Spring Boot启动类中添加@EnableScheduling注解,示例如下:

    @SpringBootApplication
    @EnableScheduling
    public class AdminWebApplication {
        public static void main(String[] args) {
            SpringApplication.run(AdminWebApplication.class, args);
        }
    }

    web-admin模块下创建com.yuhuan.lease.web.admin.schedule.ScheduledTasks类,具体内容如下:

    @Component
    public class ScheduledTasks {
    
        @Autowired
        private LeaseAgreementService leaseAgreementService;
    
        @Scheduled(cron = "0 0 0 * * *")
        public void checkLeaseStatus() {
            LambdaUpdateWrapper<LeaseAgreement> updateWrapper = new LambdaUpdateWrapper<>();
            Date now = new Date();
    
            // 优化2:清理冲突条件,只保留in条件(覆盖需要处理的两种状态)
            updateWrapper.le(LeaseAgreement::getLeaseEndDate, now) // 租赁结束日期 <= 当前时间(已到期)
                    .in(LeaseAgreement::getStatus, LeaseStatus.SIGNED, LeaseStatus.WITHDRAWING); // 状态为已签署或退租中
    
            // 优化3:添加更新字段(将状态改为「已到期」,假设枚举值为 EXPIRED)
            updateWrapper.set(LeaseAgreement::getStatus, LeaseStatus.EXPIRED);
    
            // 执行更新(添加返回值判断,便于排查问题)
            boolean success = leaseAgreementService.update(updateWrapper);
            if (success) {
                // 可添加日志记录(如更新成功的协议数量、执行时间等)
                // log.info("定时更新到期租赁协议状态成功,执行时间:{}", new Date());
            } else {
                // log.error("定时更新到期租赁协议状态失败,执行时间:{}", new Date());
            }
        }
    }

    用户管理

    用户管理功能包含两个接口:条件分页查询用户列表和根据ID更新用户状态。以下是具体实现步骤:首先,在UserInfoController中注入UserInfoService,代码如下:

        @Autowired
        private UserInfoService service;

    1. 获取用户列表(支持条件筛选与分页)

    • 查看请求的数据结构

    currentsize是分页参数,分别代表当前页码和每页显示的记录数。

    UserInfoQueryVo封装了用户查询条件,其数据结构如下:

    @Schema(description = "用户信息查询实体")
    @Data
    public class UserInfoQueryVo {
    
        @Schema(description = "用户手机号码")
        private String phone;
    
        @Schema(description = "用户账号状态")
        private BaseStatus status;
    }
    • controller层
    @Operation(summary = "分页查询用户信息")
    @GetMapping("page")
    public Result<IPage<UserInfo>> pageUserInfo(@RequestParam long current, @RequestParam long size, UserInfoQueryVo queryVo) {
    
        IPage<UserInfo> page = new Page<>(current, size);
        LambdaQueryWrapper<UserInfo> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.like(queryVo.getPhone() != null, UserInfo::getPhone, queryVo.getPhone());
        queryWrapper.eq(queryVo.getStatus() != null, UserInfo::getStatus, queryVo.getStatus());
        IPage<UserInfo> list = service.page(page, queryWrapper);
        return Result.ok(list);
    }

    知识点:

    password字段属于敏感信息,查询时需要过滤。可在UserInfo实体的password字段的@TableField注解中添加select=false参数实现此功能。

        @Schema(description = "密码")
        @TableField(value = "password",select = false)
        private String password;

    2. 根据ID更新用户状态

    在UserInfoController​中增加如下内容

        @Operation(summary = "根据用户id更新账号状态")
        @PostMapping("updateStatusById")
        public Result updateStatusById(@RequestParam Long id, @RequestParam BaseStatus status) {
            LambdaUpdateWrapper<UserInfo> updateWrapper = new LambdaUpdateWrapper<>();
            updateWrapper.eq(UserInfo::getId, id);
            updateWrapper.set(UserInfo::getStatus, status);
            service.update(updateWrapper);
            return Result.ok();
        }

    后台用户岗位管理

    后台用户岗位管理功能包含六个接口,具体实现步骤如下:首先在SystemPostController中注入SystemPostService,代码如下:

    @Autowired
    private SystemPostService service;

    1. 分页查询岗位信息

    在SystemPostController​中增加如下内容

        @Operation(summary = "分页获取岗位信息")
        @GetMapping("page")
        private Result<IPage<SystemPost>> page(@RequestParam long current, @RequestParam long size) {
            IPage<SystemPost> page = new Page<>(current, size);
            IPage<SystemPost> systemPostPage = service.page(page);
            return Result.ok(systemPostPage);
        }

    2. 保存或更新岗位信息

        @Operation(summary = "保存或更新岗位信息")
        @PostMapping("saveOrUpdate")
        public Result saveOrUpdate(@RequestBody SystemPost systemPost) {
            service.saveOrUpdate(systemPost);
            return Result.ok();
        }

    3. 根据ID删除岗位信息

        @DeleteMapping("deleteById")
        @Operation(summary = "根据id删除岗位")
        public Result removeById(@RequestParam Long id) {
            service.removeById(id);
            return Result.ok();
        }

    4. 获取全部岗位列表

        @Operation(summary = "获取全部岗位列表")
        @GetMapping("list")
        public Result<List<SystemPost>> list() {
            List<SystemPost> list = service.list();
            return Result.ok(list);
        }

    5. 根据ID获取岗位信息

        @GetMapping("getById")
        @Operation(summary = "根据id获取岗位信息")
        public Result<SystemPost> getById(@RequestParam Long id) {
            SystemPost systemPost = service.getById(id);
            return Result.ok(systemPost);
        }

    6. 根据ID修改岗位状态

        @Operation(summary = "根据岗位id修改状态")
        @PostMapping("updateStatusByPostId")
        public Result updateStatusByPostId(@RequestParam Long id, @RequestParam BaseStatus status) {
            LambdaUpdateWrapper<SystemPost> updateWrapper = new LambdaUpdateWrapper<>();
            updateWrapper.eq(SystemPost::getId, id);
            updateWrapper.set(SystemPost::getStatus, status);
            service.update(updateWrapper);
            return Result.ok();
        }

    后台用户信息管理

    在SystemUserController中注入SystemUserService,示例如下:

        @Autowired
        SystemUserService service;

    1. 根据条件分页查询后台用户列表

    • 请求的数据结构

    @Data
    @Schema(description = "员工查询实体")
    public class SystemUserQueryVo {
    
        @Schema(description= "员工姓名")
        private String name;
    
        @Schema(description= "手机号码")
        private String phone;
    }
    • 响应的数据结构

    @Data
    @Schema(description = "后台管理系统用户基本信息实体")
    public class SystemUserItemVo extends SystemUser {
    
        @Schema(description = "岗位名称")
        @TableField(value = "post_name")
        private String postName;
    
    }
    
    • controller层
        @Operation(summary = "根据条件分页查询后台用户列表")
        @GetMapping("page")
        public Result<IPage<SystemUserItemVo>> page(@RequestParam long current, @RequestParam long size, SystemUserQueryVo queryVo) {
            IPage<SystemUser> page = new Page<>(current, size);
            IPage<SystemUserItemVo> systemUserPage = service.pageSystemUserByQuery(page, queryVo);
            return Result.ok(systemUserPage);
        }
    • service层
    public interface SystemUserService extends IService<SystemUser> {
    
        IPage<SystemUserItemVo> pageSystemUserByQuery(IPage<SystemUser> page, SystemUserQueryVo queryVo);
    }
    @Service
    public class SystemUserServiceImpl extends ServiceImpl<SystemUserMapper, SystemUser>
            implements SystemUserService {
        @Autowired
        private SystemUserMapper systemUserMapper;
        @Override
        public IPage<SystemUserItemVo> pageSystemUserByQuery(IPage<SystemUser> page, SystemUserQueryVo queryVo) {
            return systemUserMapper.pageSystemUserByQuery(page,queryVo);
        }
    }
    • Mapper层逻辑

    public interface SystemUserMapper extends BaseMapper<SystemUser> {
    
        IPage<SystemUserItemVo> pageSystemUserByQuery(IPage<SystemUser> page, SystemUserQueryVo queryVo);
    }
    <mapper namespace="com.yuhuan.lease.web.admin.mapper.SystemUserMapper">
    
        <select id="pageSystemUserByQuery"
                resultType="com.yuhuan.lease.web.admin.vo.system.user.SystemUserItemVo">
            select su.id,
            username,
            su.name,
            type,
            phone,
            avatar_url,
            additional_info,
            post_id,
            su.status,
            sp.name post_name
            from system_user su
            left join system_post sp on su.post_id = sp.id and sp.is_deleted = 0
            <where>
                su.is_deleted = 0
                <if test="queryVo.name != null and queryVo.name != ''">
                    and su.name like concat('%',#{queryVo.name},'%')
                </if>
                <if test="queryVo.phone !=null and queryVo.phone != ''">
                    and su.phone like concat('%',#{queryVo.phone},'%')
                </if>
            </where>
        </select>
    </mapper>

      password​字段不要查询

      2. 根据ID查询后台用户信息

      • controller层
          @Operation(summary = "根据ID查询后台用户信息")
          @GetMapping("getById")
          public Result<SystemUserItemVo> getById(@RequestParam Long id) {
              SystemUserItemVo systemUser = service.getSystemUserById(id);
              return Result.ok(systemUser);
          }
      • service层
      public interface SystemUserService extends IService<SystemUser> {
      
          IPage<SystemUserItemVo> pageSystemUserByQuery(IPage<SystemUser> page, SystemUserQueryVo queryVo);
      
          SystemUserItemVo getSystemUserById(Long id);
      }
          @Override
          public SystemUserItemVo getSystemUserById(Long id) {
              SystemUser systemUser = systemUserMapper.selectById(id);
      
              SystemPost systemPost = systemPostMapper.selectById(systemUser.getPostId());
      
              SystemUserItemVo systemUserItemVo = new SystemUserItemVo();
              BeanUtils.copyProperties(systemPost, systemUserItemVo);
              systemUserItemVo.setPostName(systemUserItemVo.getPostName());
      
              return systemUserItemVo;
          }

      知识点

      ​system_user​表中的password​字段不应查询,需要在SystemUser​的password​字段的@TableField​注解中增加select=false​参数。

        3. 保存或更新后台用户信息

        这里涉及到存储用户密码我们 使用md5界面的方式 

        • 引入依赖

        common下的pom.xml,引入依赖

        <dependency>
            <groupId>commons-codec</groupId>
            <artifactId>commons-codec</artifactId>
        </dependency>

        这个依赖是 Apache Commons Codec 工具库,核心作用是为 Java 开发提供 通用的编码 / 解码工具类,简化日常开发中常见的 “数据格式转换” 需求(无需自己手写复杂的编码逻辑)。

        简单说:它是 Java 生态中处理「编码解码」的 “瑞士军刀”,覆盖了大部分开发中常用的编码场景,避免重复造轮子。

        • 不修改密码的情况

        修改用户信息的时候,没有修改到密码,这个时候密码为null,如果这样保存到数据库的话,会把原有密码修改成null,处理方案使用mybatisPlus的配置来处理 

        • 全局配置

        在application.yml​中配置如下参数

        mybatis-plus:
          global-config:
            db-config:
              update-strategy: <strategy>

        注:上述<strategy>​可选值有:ignore​、not_null​、not_empty​、never​,默认值为not_null​

        • ​ignore​:忽略空值判断,不管字段是否为空,都会进行更新
        • ​not_null​:进行非空判断,字段非空才会进行判断
        • ​not_empty​:进行非空判断,并进行非空串("")判断,主要针对字符串类型
        • ​never​:从不进行更新,不管该字段为何值,都不更新
        局部配置

        在实体类中的具体字段通过@TableField​注解进行配置,如下

        @Schema(description = "密码")
        @TableField(value = "password", updateStrategy = FieldStrategy.NOT_EMPTY)
        private String password;

        controller层的逻辑

            @Operation(summary = "保存或更新后台用户信息")
            @PostMapping("saveOrUpdate")
            public Result saveOrUpdate(@RequestBody SystemUser systemUser) {
                if(systemUser.getPassword() != null){
                    systemUser.setPassword(DigestUtils.md5Hex(systemUser.getPassword()));
                }
                service.saveOrUpdate(systemUser);
                return Result.ok();
            }

        密码处理

        用户的密码通常不会直接以明文的形式保存到数据库中,而是会先经过处理,然后将处理之后得到的"密文"保存到数据库,这样能够降低数据库泄漏导致的用户账号安全问题。

        密码通常会使用一些单向函数进行处理,如下图所示

        所以我们使用md5的方式,md5Hex是对输入字符串进行 MD5 哈希计算,并返回一个 32 位的小写十六进制字符串

        4. 判断后台用户名是否可用

            @Operation(summary = "判断后台用户名是否可用")
            @GetMapping("isUserNameAvailable")
            public Result<Boolean> isUsernameExists(@RequestParam String username) {
                LambdaQueryWrapper<SystemUser> queryWrapper = new LambdaQueryWrapper<>();
                queryWrapper.eq(SystemUser::getUsername, username);
                long count = service.count(queryWrapper);
                return Result.ok(count == 0);
            }

        5. 根据ID删除后台用户信息

            @DeleteMapping("deleteById")
            @Operation(summary = "根据ID删除后台用户信息")
            public Result removeById(@RequestParam Long id) {
                service.removeById(id);
                return Result.ok();
            }

        6. 根据ID修改后台用户状态

            @Operation(summary = "根据ID修改后台用户状态")
            @PostMapping("updateStatusByUserId")
            public Result updateStatusByUserId(@RequestParam Long id, @RequestParam BaseStatus status) {
                LambdaUpdateWrapper<SystemUser> updateWrapper = new LambdaUpdateWrapper<>();
                updateWrapper.eq(SystemUser::getId, id);
                updateWrapper.set(SystemUser::getStatus, status);
                service.update(updateWrapper);
                return Result.ok();
            }

        登录管理

        常见的认证方案主要有两种:Session认证和Token认证,下面将分别介绍这两种方式。

        基于Session

        方案特性

        1. 服务器内存存储登录信息

                  单节点架构下,随着访问量上升会导致服务器负载压力显著增加
        2. 集群扩展需求

                  当用户规模扩大需要部署集群时,必须解决跨服务器登录状态同步问题

        基于Token

        方案特点

        • 采用客户端存储登录状态,大幅降低服务器存储压力
        • 每个请求自动携带登录状态信息,天然支持集群部署
        • 无需额外处理登录状态共享问题,系统架构更简洁

        Token详解

        我们所说的Token通常指JWT(JSON Web Token)。这是一种轻量级的安全信息传输方式,用于在双方之间传递认证数据,常见于身份验证和信息交换场景。

        JWT(JSON Web Token)是一个由三部分组成的字符串,各部分之间通过点号(.)分隔。具体结构如下:

        • Header(头部)
        • Payload(负载)
        • Signature(签名)

        各组成部分功能说明:

        Header(头部)

        Header部分是通过对JSON对象进行base64url编码生成的。该JSON对象主要包含JWT的元数据信息,包括令牌类型(typ)和签名算法(alg)等。例如:

        {
          "alg": "HS256",
          "typ": "JWT"
        }

        Payload(有效载荷)

        又称Claims(声明),是通过base64url编码的JSON对象,用于存储具体的传输信息。JWT标准定义了7个官方字段:

        • iss (issuer):签发者
        • exp (expiration time):过期时间
        • sub (subject):主题
        • aud (audience):目标受众
        • nbf (Not Before):生效时间
        • iat (Issued At):签发时间
        • jti (JWT ID):唯一标识符

        除上述标准字段外,还支持自定义任意字段,例如

        {
          "sub": "1234567890",
          "name": "John Doe",
          "iat": 1516239022
        }

        签名(Signature)

        由消息头部、负载和密钥通过指定算法(在header中声明)计算生成的字符串,用于确保消息完整性,防止篡改。

        登录流程

        登录流程分析表明,管理系统需要三个核心接口:获取图形验证码、用户登录和获取个人信息。此外,所有受保护接口都需要通过HandlerInterceptor添加JWT验证功能来确保安全性。

        接口开发

        在LoginController中注入LoginService,具体实现如下

        @Tag(name = "后台管理系统登录管理")
        @RestController
        @RequestMapping("/admin")
        public class LoginController {
        
            @Autowired
            private LoginService service;
        }

        1. 获取图形验证码

        • 查看响应的数据结构
        @Data
        @Schema(description = "图像验证码")
        @AllArgsConstructor
        public class CaptchaVo {
        
            @Schema(description="验证码图片信息")
            private String image;
        
            @Schema(description="验证码key")
            private String key;
        }
        • 配置所需依赖

        验证码生成工具

        本项目采用开源验证码生成工具EasyCaptcha,该工具具有以下特点:

        • 支持多种验证码类型(包括GIF动画、中文、算术等)
        • 简单易用
        • 详细文档支持

        具体实现时,需要在common模块的pom.xml文件中添加以下依赖配置:

        <dependency>
            <groupId>com.github.whvcse</groupId>
            <artifactId>easy-captcha</artifactId>
        </dependency>

        Redis

        在common模块的pom.xml文件中添加以下配置:

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

        在application.yml​中增加如下配置

        spring:  
          data:
            redis:
              host: 192.168.200.128
              port: 6379
              database: 0
        • controller层

        在LoginController​中增加如下内容

            @Operation(summary = "获取图形验证码")
            @GetMapping("login/captcha")
            public Result<CaptchaVo> getCaptcha() {
                CaptchaVo captcha = service.getCaptcha();
                return Result.ok(captcha);
            }
        • service层
        public interface LoginService {
        
            CaptchaVo getCaptcha();
        }

        为便于统一管理,建议将Redis相关的配置值定义为常量,包括Key前缀、过期时间等。这些常量应集中存放在common模块下的com.yuhuan.lease.common.constant.RedisConstant类中。

        public class RedisConstant {
            public static final String ADMIN_LOGIN_PREFIX = "admin:login:";
            public static final Integer ADMIN_LOGIN_CAPTCHA_TTL_SEC = 60;
            public static final String APP_LOGIN_PREFIX = "app:login:";
            public static final Integer APP_LOGIN_CODE_RESEND_TIME_SEC = 60;
            public static final Integer APP_LOGIN_CODE_TTL_SEC = 60 * 10;
            public static final String APP_ROOM_PREFIX = "app:room:";
        }

        LoginServiceImpl​中增加如下内容

        @Service
        public class LoginServiceImpl implements LoginService {
            @Autowired
            private StringRedisTemplate redisTemplate;
            @Override
            public CaptchaVo getCaptcha() {
                SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);
                specCaptcha.setCharType(Captcha.TYPE_DEFAULT);
        
                String code = specCaptcha.text().toLowerCase();
                String key = RedisConstant.ADMIN_LOGIN_PREFIX + UUID.randomUUID();
                String image = specCaptcha.toBase64();
                redisTemplate.opsForValue().set(key, code, RedisConstant.ADMIN_LOGIN_CAPTCHA_TTL_SEC, TimeUnit.SECONDS);
        
                return new CaptchaVo(image, key);
            }
        }

          Redis Key 命名规范:

          • 采用三段式命名结构:项目名:功能模块名:其他标识
          • 示例:admin:login:123456

          项目集成说明: Spring Boot 已通过spring-boot-starter-data-redis自动配置了StringRedisTemplate,开发者可直接注入使用。

          2. 登录接口

          用户登录验证流程

          登录验证分为三个关键环节:验证码校验、用户状态检查和密码核对。具体步骤如下:

          1. 前端提交登录请求,包含以下字段:

            • username(用户名)
            • password(密码)
            • captchaKey(验证码标识)
            • captchaCode(验证码内容)
          2. 验证码校验流程:

            • 检查验证码是否为空 → 为空则返回"验证码为空"
            • 通过captchaKey查询Redis中存储的验证码 → 未查到则返回"验证码已过期"
            • 比对提交的验证码与存储的验证码 → 不一致则返回"验证码不正确"
          3. 用户状态检查:

            • 通过username查询用户记录 → 无结果则返回"账号不存在"
            • 检查用户状态 → 已禁用则返回"账号被禁"
          4. 密码核对:

            • 比对提交密码与数据库存储密码 → 不一致则返回"账号或密码错误"
          5. 验证通过后:

            • 生成JWT令牌
            • 返回令牌至客户端
          • 查看请求数据结构
          @Data
          @Schema(description = "后台管理系统登录信息")
          public class LoginVo {
          
              @Schema(description="用户名")
              private String username;
          
              @Schema(description="密码")
              private String password;
          
              @Schema(description="验证码key")
              private String captchaKey;
          
              @Schema(description="验证码code")
              private String captchaCode;
          }
          • 配置所需依赖

          登录接口需为成功登录的用户生成并返回JWT令牌。本项目采用开源的Java-JWT工具实现,具体配置如下(详细内容可参阅官方文档):

          在common模块的pom.xml文件中增加如下内容

          <dependency>
              <groupId>io.jsonwebtoken</groupId>
              <artifactId>jjwt-api</artifactId>
          </dependency>
          
          <dependency>
              <groupId>io.jsonwebtoken</groupId>
              <artifactId>jjwt-impl</artifactId>
              <scope>runtime</scope>
          </dependency>
          
          <dependency>
              <groupId>io.jsonwebtoken</groupId>
              <artifactId>jjwt-jackson</artifactId>
              <scope>runtime</scope>
          </dependency>
          • 创建JWT工具类

          在common模块中创建工具类:com.atguigu.lease.common.utils.JwtUtil,具体实现如下

          public class JwtUtil {
          
              private static long tokenExpiration = 60 * 60 * 1000L;
              private static SecretKey tokenSignKey = Keys.hmacShaKeyFor("M0PKKI6pYGVWWfDZw90a0lTpGYX1d4AQ".getBytes());
          
              public static String createToken(Long userId, String username) {
                  String token = Jwts.builder().
                          setSubject("USER_INFO").
                          setExpiration(new Date(System.currentTimeMillis() + tokenExpiration)).
                          claim("userId", userId).
                          claim("username", username).
                          signWith(tokenSignKey).
                          compact();
                  return token;
              }
          }
          • controller层
              @Operation(summary = "登录")
              @PostMapping("login")
              public Result<String> login(@RequestBody LoginVo loginVo) {
                  String token = service.login(loginVo);
                  return Result.ok(token);
              }
          • service层
          public interface LoginService {
          
              CaptchaVo getCaptcha();
          
              String login(LoginVo loginVo);
          }
          
              @Override
              public String login(LoginVo loginVo) {
                  //1.判断是否输入了验证码
                  if (!StringUtils.hasText(loginVo.getCaptchaCode())) {
                      throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_NOT_FOUND);
                  }
          
                  //2.校验验证码
                  String code = redisTemplate.opsForValue().get(loginVo.getCaptchaKey());
                  if (code == null) {
                      throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_EXPIRED);
                  }
          
                  if (!code.equals(loginVo.getCaptchaCode().toLowerCase())) {
                      throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_ERROR);
                  }
          
                  //3.校验用户是否存在
                  SystemUser systemUser = systemUserMapper.selectOneByUsername(loginVo.getUsername());
          
                  if (systemUser == null) {
                      throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_NOT_EXIST_ERROR);
                  }
          
                  //4.校验用户是否被禁
                  if (systemUser.getStatus() == BaseStatus.DISABLE) {
                      throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_DISABLED_ERROR);
                  }
          
                  //5.校验用户密码
                  if (!systemUser.getPassword().equals(DigestUtils.md5Hex(loginVo.getPassword()))) {
                      throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_ERROR);
                  }
          
                  //6.创建并返回TOKEN
                  return JwtUtil.createToken(systemUser.getId(), systemUser.getUsername());
              }
          • 编写Mapper层逻辑
          public interface SystemUserMapper extends BaseMapper<SystemUser> {
          
              IPage<SystemUserItemVo> pageSystemUserByQuery(IPage<SystemUser> page, SystemUserQueryVo queryVo);
          
              SystemUser selectOneByUsername(String username);
          }
              <select id="selectOneByUsername" resultType="com.yuhuan.lease.model.entity.SystemUser">
                  select id,
                         username,
                         password,
                         name,
                         type,
                         phone,
                         avatar_url,
                         additional_info,
                         post_id,
                         status
                  from system_user
                  where is_deleted = 0
                    and username = #{username}
              </select>
          • 编写HandlerInterceptor拦截器

          需要为所有受保护的接口添加JWT合法性校验逻辑。具体实现方案如下:

          在JwtUtil类中新增parseToken方法,实现代码如下:

              /**
               * 解析 JWT Token,获取其中的 Claims(负载信息)
               * 包含 Token 合法性校验、过期校验,校验失败会抛出对应业务异常
               *
               * @param token 待解析的 JWT Token 字符串
               * @return Claims:JWT 负载中的键值对数据(如用户ID、角色、过期时间等)
               * @throws LeaseException 自定义业务异常,包含 Token 为空、过期、无效等错误状态
               */
              public static Claims parseToken(String token) {
                  // 1. 校验 Token 是否为空
                  if (token == null) {
                      // 抛出「管理员登录认证失败」异常(Token 为空意味着未登录)
                      throw new LeaseException(ResultCodeEnum.ADMIN_LOGIN_AUTH);
                  }
          
                  try {
                      // 2. 构建 JWT 解析器,设置签名密钥(核心:确保 Token 未被篡改)
                      JwtParser jwtParser = Jwts.parserBuilder()
                              .setSigningKey(tokenSignKey) // 传入与生成 Token 相同的签名密钥
                              .build();
          
                      // 3. 解析 Token:
                      // - 验证签名完整性(防止 Token 被篡改)
                      // - 验证 Token 格式合法性
                      // parseClaimsJws() 返回包含 Header、Payload、Signature 的 Jws 对象
                      // getBody() 获取负载中的 Claims 数据(即业务自定义的键值对)
                      return jwtParser.parseClaimsJws(token).getBody();
          
                  } catch (ExpiredJwtException e) {
                      // 4. 捕获 Token 过期异常(JWT 内置的过期校验)
                      throw new LeaseException(ResultCodeEnum.TOKEN_EXPIRED); // 抛出「Token 已过期」业务异常
          
                  } catch (JwtException e) {
                      // 5. 捕获其他 JWT 相关异常(如签名不匹配、Token 格式错误、篡改等)
                      throw new LeaseException(ResultCodeEnum.TOKEN_INVALID); // 抛出「Token 无效」业务异常
                  }
              }

          在web-admin模块中创建com.yuhuan.lease.web.admin.custom.interceptor.AuthenticationInterceptor类,具体实现可参考以下代码。如需了解HandlerInterceptor的详细用法,请查阅官方文档。

          @Component
          public class AuthenticationInterceptor implements HandlerInterceptor {
          
              @Override
              public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
                  String token = request.getHeader("access-token");
                  JwtUtil.parseToken(token);
                  return true;
              }
          }

          双方约定,用户完成前端登录后,后续所有请求都需在HTTP请求头中附带JWT令牌,该令牌对应的请求头字段为"access-token"。

          • 注册HandlerInterceptor拦截器

            在web-admin模块的com.atguigu.lease.web.admin.custom.config.WebMvcConfiguration类中添加以下配置

              @Override
              public void addInterceptors(InterceptorRegistry registry) {
                  // 1. 注册身份认证拦截器
                  // 2. addPathPatterns:指定需要拦截的请求路径规则
                  //    "/admin/**" 表示拦截所有以 /admin/ 开头的请求(包括子路径,如 /admin/user/list、/admin/role/add 等)
                  // 3. excludePathPatterns:指定需要排除(不拦截)的请求路径规则
                  //    "/admin/login/**" 表示放行所有以 /admin/login/ 开头的请求(如登录接口 /admin/login、登录验证 /admin/login/check 等)
                  //    目的:登录相关接口不需要身份校验,否则会导致死循环(未登录 -> 拦截 -> 跳登录 -> 又拦截)
                  registry.addInterceptor(this.authenticationInterceptor)
                          .addPathPatterns("/admin/**") // 拦截所有管理员相关接口
                          .excludePathPatterns("/admin/login/**"); // 放行登录相关接口,允许匿名访问
              }
          • Knife4j配置

          先获取验证码

          通过验证码登录,拿到token

          添加上述拦截器后,若需继续调试其他接口,可获取长期有效的Token并配置到Knife4j全局参数中,操作示意如下:

          注意:每个接口分组需单独配置。

          刷新页面后,任意选择一个接口进行调试,系统将自动在请求中添加该header,效果如下图所示:

          然后要刷新界面,否则不生效

          3. 获取登录用户个人信息

          • 查看请求和响应的数据结构
          @Schema(description = "员工基本信息")
          @Data
          public class SystemUserInfoVo {
          
              @Schema(description = "用户姓名")
              private String name;
          
              @Schema(description = "用户头像")
              private String avatarUrl;
          }
          • 编写ThreadLocal工具类

          理论上我们可以在Controller方法中,使用@RequestHeader​获取JWT,然后在进行解析,如下

              @Operation(summary = "获取登陆用户个人信息")
              @GetMapping("info")
              public Result<SystemUserInfoVo> info(@RequestHeader("access-token") String token) {
                  Claims claims = JwtUtil.parseToken(token);
                  Long userId = claims.get("userId", Long.class);
                  SystemUserInfoVo userInfo = service.getLoginUserInfo(userId);
                  return Result.ok(userInfo);
              }

          这段代码逻辑本身没有问题,但存在JWT重复解析的问题(拦截器和方法内各解析一次)。通常的优化做法是在拦截器解析Token后,将结果存入ThreadLocal,这样就能在整个请求处理流程中共享使用。

          • ThreadLocal

          ThreadLocal 是 Java 中的一个线程本地存储工具类,核心作用是:为每个线程单独维护一份独立的变量副本,线程之间的变量互不干扰,实现线程级别的数据隔离。

          简单说:ThreadLocal 让变量 “线程私有”,就像每个线程有自己的 “专属容器”,只存自己的变量,不会和其他线程混用。

          在common模块中创建com.yuhuan.lease.common.login.LoginUser​类

          @Data
          @AllArgsConstructor
          public class LoginUser {
          
              private Long userId;
              private String username;
          }

          在common模块中创建com.yuhuan.lease.common.login.LoginUserHolder​工具类

          public class LoginUserHolder {
              public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();
          
              public static void setLoginUser(LoginUser loginUser) {
                  threadLocal.set(loginUser);
              }
          
              public static LoginUser getLoginUser() {
                  return threadLocal.get();
              }
          
              public static void clear() {
                  threadLocal.remove();
              }
          }
          • 修改AuthenticationInterceptor​拦截器
          @Component
          /**
           * 管理员身份认证拦截器
           * 作用:在请求进入控制器(Controller)前校验 JWT Token 有效性,
           * 并将当前登录用户信息存入 ThreadLocal(线程上下文),供后续链路复用;
           * 请求完成后清除 ThreadLocal 数据,避免内存泄漏
           */
          public class AuthenticationInterceptor implements HandlerInterceptor {
          
              @Override
              public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
                  // 1. 从请求头中获取 JWT Token(前端需将登录成功后的 Token 放入 access-token 头)
                  String token = request.getHeader("access-token");
          
                  // 2. 调用 JWT 工具类解析 Token:
                  // - Token 为空、过期、签名错误等情况会直接抛出对应业务异常(无需手动捕获,由全局异常处理器处理)
                  // - 解析成功返回 Token 中携带的 Claims(负载信息,包含 userId、username 等)
                  Claims claims = JwtUtil.parseToken(token);
          
                  // 3. 从 Claims 中提取用户核心信息(与 Token 生成时存入的字段对应)
                  Long userId = claims.get("userId", Long.class); // 用户ID(Long类型)
                  String username = claims.get("username", String.class); // 用户名(String类型)
          
                  // 4. 将用户信息封装为 LoginUser 对象,存入 ThreadLocal 上下文(LoginUserHolder 是 ThreadLocal 工具类)
                  // 后续 Controller/Service/Dao 可通过 LoginUserHolder 获取当前用户信息,无需层层传参
                  LoginUserHolder.setLoginUser(new LoginUser(userId, username));
          
                  // 5. Token 校验通过,放行请求,进入后续 Controller 处理
                  return true;
              }
          
              /**
               * 拦截器后置完成处理(请求完全结束后执行,包括视图渲染完成)
               * 核心职责:清除 ThreadLocal 中的用户信息,避免内存泄漏
               *
               * @param request  Http请求对象
               * @param response Http响应对象
               * @param handler  目标处理器(Controller 方法)
               * @param ex       请求处理过程中抛出的异常(若有)
               * @throws Exception 清理过程中可能出现的异常
               */
              @Override
              public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
                  // 关键操作:清除当前线程的 ThreadLocal 数据
                  // 原因:Web 容器(如 Tomcat)使用线程池复用线程,若不清除,线程复用时会残留上一次请求的用户信息,导致数据泄露
                  LoginUserHolder.clear();
              }
          }
          • controller层
              @Operation(summary = "获取登陆用户个人信息")
              @GetMapping("info")
              public Result<SystemUserInfoVo> info(@RequestHeader("access-token") String token) {
                  Claims claims = JwtUtil.parseToken(token);
                  Long userId = claims.get("userId", Long.class);
                  SystemUserInfoVo userInfo = service.getLoginUserInfo(userId);
                  return Result.ok(userInfo);
              }
          • service层
          public interface LoginService {
          
              CaptchaVo getCaptcha();
          
              String login(LoginVo loginVo);
          
              SystemUserInfoVo getLoginUserInfo(Long userId);
          }
              @Override
              public SystemUserInfoVo getLoginUserInfo(Long userId) {
                  SystemUser systemUser = systemUserMapper.selectById(userId);
                  SystemUserInfoVo systemUserInfoVo = new SystemUserInfoVo();
                  systemUserInfoVo.setName(systemUser.getName());
                  systemUserInfoVo.setAvatarUrl(systemUser.getAvatarUrl());
                  return systemUserInfoVo;
              }

          评论
          成就一亿技术人!
          拼手气红包6.0元
          还能输入1000个字符
           
          红包 添加红包
          表情包 插入表情
           条评论被折叠 查看
          添加红包

          请填写红包祝福语或标题

          红包个数最小为10个

          红包金额最低5元

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

          抵扣说明:

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

          余额充值