从理论到实战:图结构在仓库关联业务中的落地(小白→中级,附完整代码)

AgenticCoding·十二月创作之星挑战赛 10w+人浏览 403人参与

从理论到实战:图结构在仓库关联业务中的落地(小白→中级,附完整代码)

很多开发者在大学数据结构课上都学过这种数据结构,但大多停留在“知道节点、边、BFS/DFS这些概念”的层面,很少有机会在实际业务中落地。本文将以仓库关联关系维护(后续用于库存上限关联合并的前置需求)为场景,从图的基础概念讲起,由浅入深地讲解如何将图结构与实际业务结合,解决“无向连通图下仓库关联关系维护”的问题。即使你只听过图的名字,也能跟着本文完成从理论到代码的落地。

一、先补基础:图结构的核心概念(小白友好)

在开始业务代码前,我们先花几分钟回顾图的核心概念,并用仓库场景做类比,让抽象的概念变具体。

1. 图的基本组成

图(Graph)由**节点(Vertex)边(Edge)**组成:

  • 节点:代表一个实体。比如仓库场景中,每个仓库(如仓库35、36)就是一个节点。
  • :代表节点之间的关系。比如仓库35关联仓库36,这条关联关系就是一条边。

2. 图的常见分类

(1)有向图 vs 无向图
  • 有向图:边有方向。比如“仓库A是仓库B的上游”,这条边是单向的。
  • 无向图:边无方向。比如“仓库A和仓库B关联”,等同于“仓库B和仓库A关联”(本文的仓库场景就是无向图)。
(2)连通图 vs 非连通图
  • 连通图:任意两个节点之间都有路径相连(直接或间接)。比如仓库35关联36,36关联39,那么35和39通过36间接相连,这三个节点构成连通图。
  • 非连通图:存在两个节点之间没有路径相连。比如仓库35的关联网络和仓库40的关联网络完全独立。
(3)连通分量

连通分量是指非连通图中的最大连通子图。比如仓库35、36、39是一个连通分量,仓库40、41是另一个连通分量。

3. 图的遍历算法(核心)

遍历图的目的是从一个节点出发,访问所有可达的节点(直接+间接),常用的有两种算法:

  • BFS(广度优先搜索):按层级遍历,先访问当前节点的直接邻居,再访问邻居的邻居(比如先找仓库35的直接关联仓库,再找这些仓库的关联仓库)。
  • DFS(深度优先搜索):先深入遍历一条路径,再回溯(比如仓库35→36→39,再回溯到35→37)。

这两个算法是处理图业务的基础,后面会详细实现。

二、业务场景与数据表设计

1. 核心业务需求

先明确我们的业务目标(这是后续库存上限关联合并的前置需求):

  1. 关联维护:前端传递一个主仓库ID,以及该仓库需要关联的其他仓库列表,后端需要同步数据库中的关联关系(新增缺失的,删除多余的)。
  2. 关联查询:从任意仓库节点出发,能快速获取所有关联的仓库(直接+间接),为后续库存上限合并做准备。
  3. 无向关联:仓库A关联B,等同于B关联A。
  4. 数据可追溯:支持逻辑删除,保留创建/修改时间、操作人等信息。

2. 数据表设计(warehouse_ext)

我们先采用边存储的方式设计表(这是小白最容易想到的方式,后续会分析其优缺点),表名改为warehouse_ext(见名知意),用于存储仓库之间的直接关联边:

DROP TABLE IF EXISTS `warehouse_ext`;
CREATE TABLE IF NOT EXISTS `warehouse_ext`
(
    `id`               bigint       NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `warehouse_id`     bigint       null comment '仓库ID(节点A)',
    `warehouse`        varchar(128) null comment '仓库名称/编码',
    `warehouse_ext_id` bigint       DEFAULT NULL COMMENT '衍生仓库ID(节点B)',
    `warehouse_ext`    varchar(128) DEFAULT NULL COMMENT '衍生仓库名称/编码',
    `creator`          bigint       DEFAULT NULL COMMENT '创建者ID',
    `updater`          bigint       DEFAULT NULL COMMENT '修改者ID',
    `ct`               datetime(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
    `ut`               datetime(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
    `is_deleted`       tinyint(1)   NULL DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除',

    PRIMARY KEY (`id`) USING BTREE,
    -- 唯一索引:避免同一对仓库重复存储直接关联(A-B和B-A视为不同?后续会处理)
    UNIQUE KEY `uk_warehouse_ext` (`warehouse_id`, `warehouse_ext_id`, `is_deleted`) USING BTREE,
    -- 业务查询索引:加速根据仓库ID查询关联关系
    INDEX idx_warehouse_id (`warehouse_id`, `is_deleted`),
    INDEX idx_warehouse_ext_id (`warehouse_ext_id`, `is_deleted`),
    -- 时间索引:支持时间范围查询
    INDEX idx_ct (`ct`),
    INDEX idx_ut (`ut`)

) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_unicode_ci
    COMMENT = '仓库关联表(存储节点间的直接边,双向关联)';

表设计关键点说明

  • warehouse_idwarehouse_ext_id:分别代表关联的两个仓库节点(边的两个端点)。
  • 唯一索引uk_warehouse_ext:防止同一对仓库重复存储直接关联(比如多次插入35-36的记录)。
  • 逻辑删除is_deleted:保留数据溯源能力,而非物理删除。
  • 索引优化:为常用查询字段(仓库ID、时间)建立索引,提升查询性能。

三、小白版实现:边存储+简单维护(踩坑前奏)

作为基础开发者,首先会想到“前端传什么,我就存什么”,通过对比前端传递的关联列表和数据库中的直接关联列表,进行新增/删除操作。我们先实现这个版本,再看会遇到什么问题。

1. 技术栈准备

本文使用Spring Boot 2.7.x + MyBatis + MySQL + Lombok,先配置核心依赖和配置文件。

(1)Maven依赖(pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version>
        <relativePath/>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>warehouse-graph-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>warehouse-graph-demo</name>
    <description>图结构在仓库关联中的应用</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <!-- Spring Boot Web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- MyBatis Spring Boot Starter -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.3.0</version>
        </dependency>

        <!-- MySQL驱动 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- 测试依赖(可选) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
(2)配置文件(application.yml)
server:
  port: 8080 # 端口号

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    # 替换为你的数据库地址和名称
    url: jdbc:mysql://localhost:3306/warehouse_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
    username: root # 你的数据库用户名
    password: root # 你的数据库密码

mybatis:
  mapper-locations: classpath:mybatis/mapper/*.xml # Mapper XML文件路径
  type-aliases-package: com.example.warehouse.entity # 实体类别名包
  configuration:
    map-underscore-to-camel-case: true # 开启下划线转驼峰命名
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印SQL日志(开发环境用)

2. 核心代码实现

(1)实体类与DTO

实体类(WarehouseExt.java):对应数据库表warehouse_ext

package com.example.warehouse.entity;

import lombok.Data;
import java.time.LocalDateTime;

/**
 * 仓库关联表实体类(存储直接边)
 */
@Data
public class WarehouseExt {
    /**
     * 主键ID
     */
    private Long id;

    /**
     * 仓库ID(节点A)
     */
    private Long warehouseId;

    /**
     * 仓库名称/编码
     */
    private String warehouse;

    /**
     * 衍生仓库ID(节点B)
     */
    private Long warehouseExtId;

    /**
     * 衍生仓库名称/编码
     */
    private String warehouseExt;

    /**
     * 创建者ID
     */
    private Long creator;

    /**
     * 修改者ID
     */
    private Long updater;

    /**
     * 创建时间
     */
    private LocalDateTime ct;

    /**
     * 更新时间
     */
    private LocalDateTime ut;

    /**
     * 是否删除:0-未删除,1-已删除
     */
    private Integer isDeleted;
}

DTO(WarehouseRelationDto.java):接收前端传递的参数

package com.example.warehouse.dto;

import lombok.Data;
import java.util.List;

/**
 * 前端传递的仓库关联数据DTO
 */
@Data
public class WarehouseRelationDto {
    /**
     * 主仓库ID
     */
    private Long warehouseId;

    /**
     * 主仓库名称/编码
     */
    private String warehouse;

    /**
     * 关联的衍生仓库列表
     */
    private List<Item> items;

    /**
     * 衍生仓库子项
     */
    @Data
    public static class Item {
        /**
         * 衍生仓库名称/编码
         */
        private String warehouseExt;

        /**
         * 衍生仓库ID
         */
        private Long warehouseExtId;
    }
}
(2)MyBatis Mapper接口与XML

Mapper接口(WarehouseExtMapper.java)

package com.example.warehouse.mapper;

import com.example.warehouse.entity.WarehouseExt;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Set;

/**
 * 仓库关联表Mapper接口
 */
public interface WarehouseExtMapper {

    /**
     * 根据仓库ID,查询其直接关联的所有仓库ID(双向)
     * @param warehouseId 仓库ID
     * @return 直接关联的仓库ID列表
     */
    List<Long> selectRelatedWarehouseIds(@Param("warehouseId") Long warehouseId);

    /**
     * 查询两个仓库之间是否存在直接关联(双向)
     * @param mainId 仓库A
     * @param extId 仓库B
     * @return 关联记录ID(不存在则返回null)
     */
    Long selectRelationIdByTwoWarehouse(@Param("mainId") Long mainId, @Param("extId") Long extId);

    /**
     * 批量插入仓库关联记录
     * @param list 关联记录列表
     * @return 影响行数
     */
    int batchInsert(@Param("list") List<WarehouseExt> list);

    /**
     * 逻辑删除两个仓库之间的直接关联(双向)
     * @param mainId 仓库A
     * @param extId 仓库B
     * @return 影响行数
     */
    int logicDeleteRelation(@Param("mainId") Long mainId, @Param("extId") Long extId);

    /**
     * 查询指定仓库已有的直接关联仓库ID列表(未删除)
     * @param warehouseId 仓库ID
     * @return 直接关联的仓库ID集合
     */
    Set<Long> selectExistingRelatedIds(@Param("warehouseId") Long warehouseId);
}

Mapper XML(WarehouseExtMapper.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.example.warehouse.mapper.WarehouseExtMapper">

    <!-- 查询指定仓库的直接关联仓库ID(双向) -->
    <select id="selectRelatedWarehouseIds" resultType="java.lang.Long">
        SELECT
            CASE
                WHEN warehouse_id = #{warehouseId} THEN warehouse_ext_id
                ELSE warehouse_id
            END AS related_id
        FROM warehouse_ext
        WHERE is_deleted = 0
          AND (warehouse_id = #{warehouseId} OR warehouse_ext_id = #{warehouseId})
    </select>

    <!-- 查询两个仓库之间的直接关联记录ID(双向) -->
    <select id="selectRelationIdByTwoWarehouse" resultType="java.lang.Long">
        SELECT id
        FROM warehouse_ext
        WHERE is_deleted = 0
          AND (
              (warehouse_id = #{mainId} AND warehouse_ext_id = #{extId})
              OR (warehouse_id = #{extId} AND warehouse_ext_id = #{mainId})
          )
        LIMIT 1
    </select>

    <!-- 批量插入仓库关联记录 -->
    <insert id="batchInsert">
        INSERT INTO warehouse_ext (
            warehouse_id, warehouse, warehouse_ext_id, warehouse_ext,
            creator, updater, ct, ut, is_deleted
        ) VALUES
        <foreach collection="list" item="item" separator=",">
            (
                #{item.warehouseId}, #{item.warehouse},
                #{item.warehouseExtId}, #{item.warehouseExt},
                #{item.creator}, #{item.updater},
                NOW(3), NOW(3), 0
            )
        </foreach>
    </insert>

    <!-- 逻辑删除两个仓库之间的直接关联(双向) -->
    <update id="logicDeleteRelation">
        UPDATE warehouse_ext
        SET is_deleted = 1, ut = NOW(3)
        WHERE is_deleted = 0
          AND (
              (warehouse_id = #{mainId} AND warehouse_ext_id = #{extId})
              OR (warehouse_id = #{extId} AND warehouse_ext_id = #{mainId})
          )
    </update>

    <!-- 查询指定仓库已有的直接关联仓库ID列表 -->
    <select id="selectExistingRelatedIds" resultType="java.lang.Long">
        SELECT
            CASE
                WHEN warehouse_id = #{warehouseId} THEN warehouse_ext_id
                ELSE warehouse_id
            END AS related_id
        FROM warehouse_ext
        WHERE is_deleted = 0
          AND (warehouse_id = #{warehouseId} OR warehouse_ext_id = #{warehouseId})
    </select>

</mapper>
(3)业务逻辑层(WarehouseRelationService.java)
package com.example.warehouse.service;

import com.example.warehouse.dto.WarehouseRelationDto;
import com.example.warehouse.entity.WarehouseExt;
import com.example.warehouse.mapper.WarehouseExtMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;

import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * 仓库关联关系业务服务(小白版:仅处理直接边)
 */
@Service
public class WarehouseRelationService {

    @Resource
    private WarehouseExtMapper warehouseExtMapper;

    /**
     * 维护仓库关联关系:新增缺失的直接边,删除多余的直接边
     * @param dto 前端传递的关联数据
     * @param operatorId 操作人ID(创建者/修改者)
     */
    @Transactional(rollbackFor = Exception.class)
    public void maintainWarehouseRelations(WarehouseRelationDto dto, Long operatorId) {
        // 1. 参数校验
        if (dto == null || dto.getWarehouseId() == null) {
            throw new IllegalArgumentException("主仓库ID不能为空");
        }
        Long mainWarehouseId = dto.getWarehouseId();
        String mainWarehouseName = dto.getWarehouse();

        // 2. 提取前端传递的关联仓库ID(去重、过滤空值)
        Set<Long> frontRelatedIds = new HashSet<>();
        List<WarehouseRelationDto.Item> items = dto.getItems();
        if (!CollectionUtils.isEmpty(items)) {
            frontRelatedIds = items.stream()
                    .filter(item -> item.getWarehouseExtId() != null)
                    .map(WarehouseRelationDto.Item::getWarehouseExtId)
                    .collect(Collectors.toSet());
        }

        // 3. 获取数据库中已有的直接关联仓库ID
        Set<Long> dbRelatedIds = warehouseExtMapper.selectExistingRelatedIds(mainWarehouseId);

        // 4. 计算需要新增和删除的直接边
        // 需要新增:前端有,数据库无
        Set<Long> needAddIds = new HashSet<>(frontRelatedIds);
        needAddIds.removeAll(dbRelatedIds);
        // 需要删除:数据库有,前端无
        Set<Long> needDeleteIds = new HashSet<>(dbRelatedIds);
        needDeleteIds.removeAll(frontRelatedIds);

        // 5. 执行新增操作
        if (!CollectionUtils.isEmpty(needAddIds)) {
            List<WarehouseExt> addList = new ArrayList<>();
            for (Long extId : needAddIds) {
                // 先判断是否已存在双向关联(避免违反唯一索引)
                Long relationId = warehouseExtMapper.selectRelationIdByTwoWarehouse(mainWarehouseId, extId);
                if (relationId == null) { // 不存在则新增
                    WarehouseExt entity = new WarehouseExt();
                    entity.setWarehouseId(mainWarehouseId);
                    entity.setWarehouse(mainWarehouseName);
                    // 从items中获取衍生仓库名称
                    String extWarehouseName = items.stream()
                            .filter(item -> extId.equals(item.getWarehouseExtId()))
                            .map(WarehouseRelationDto.Item::getWarehouseExt)
                            .findFirst()
                            .orElse("");
                    entity.setWarehouseExtId(extId);
                    entity.setWarehouseExt(extWarehouseName);
                    entity.setCreator(operatorId);
                    entity.setUpdater(operatorId);
                    addList.add(entity);
                }
            }
            if (!CollectionUtils.isEmpty(addList)) {
                warehouseExtMapper.batchInsert(addList);
            }
        }

        // 6. 执行逻辑删除操作
        if (!CollectionUtils.isEmpty(needDeleteIds)) {
            for (Long extId : needDeleteIds) {
                warehouseExtMapper.logicDeleteRelation(mainWarehouseId, extId);
            }
        }
    }
}
(4)控制器层(WarehouseRelationController.java)
package com.example.warehouse.controller;

import com.example.warehouse.dto.WarehouseRelationDto;
import com.example.warehouse.service.WarehouseRelationService;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.util.Map;

/**
 * 仓库关联关系接口控制器
 */
@RestController
@RequestMapping("/api/warehouse/relation")
public class WarehouseRelationController {

    @Resource
    private WarehouseRelationService warehouseRelationService;

    /**
     * 维护仓库关联关系
     * @param dto 前端传递的关联数据
     * @return 操作结果
     */
    @PostMapping("/maintain")
    public Map<String, Object> maintainRelations(@RequestBody WarehouseRelationDto dto) {
        try {
            // 实际项目中,operatorId从登录用户信息中获取,这里模拟为1L
            Long operatorId = 1L;
            warehouseRelationService.maintainWarehouseRelations(dto, operatorId);
            return Map.of("code", 200, "message", "关联关系维护成功", "data", null);
        } catch (IllegalArgumentException e) {
            return Map.of("code", 400, "message", e.getMessage(), "data", null);
        } catch (Exception e) {
            return Map.of("code", 500, "message", "服务器内部错误:" + e.getMessage(), "data", null);
        }
    }
}

3. 小白版的踩坑过程

我们执行三次操作,看看会遇到什么问题:

第一次操作:绑定节点(35关联36、37、38、39)

前端请求体

{
    "warehouseId": 35,
    "warehouse": "FBA-股份-美国",
    "items": [
        {"warehouseExt": "FBA-忻凯-日本", "warehouseExtId": 36},
        {"warehouseExt": "FBA-迅果-美国", "warehouseExtId": 37},
        {"warehouseExt": "FBA-云赫-美国", "warehouseExtId": 38},
        {"warehouseExt": "FBA-钰洲-美国", "warehouseExtId": 39}
    ]
}

数据库结果(符合预期,存储了35的4条直接边):

warehouse_idwarehousewarehouse_ext_idwarehouse_extis_deleted
35FBA-股份-美国36FBA-忻凯-日本0
35FBA-股份-美国37FBA-迅果-美国0
35FBA-股份-美国38FBA-云赫-美国0
35FBA-股份-美国39FBA-钰洲-美国0
第二次操作:错误传递(36关联35、39)

前端请求体(36的关联列表包含35和39,其中39是间接关联):

{
    "warehouseId": 36,
    "warehouse": "FBA-忻凯-日本",
    "items": [
        {"warehouseExt": "FBA-股份-美国", "warehouseExtId": 35},
        {"warehouseExt": "FBA-钰洲-美国", "warehouseExtId": 39}
    ]
}

数据库结果(出现问题:新增了36-39的直接边):

warehouse_idwarehousewarehouse_ext_idwarehouse_extis_deleted
35FBA-股份-美国36FBA-忻凯-日本0
35FBA-股份-美国37FBA-迅果-美国0
35FBA-股份-美国38FBA-云赫-美国0
35FBA-股份-美国39FBA-钰洲-美国0
36FBA-忻凯-日本39FBA-钰洲-美国0
第三次操作:删除节点(36的关联列表为空)

前端请求体

{
    "warehouseId": 36,
    "warehouse": "FBA-忻凯-日本",
    "items": []
}

数据库结果(连锁问题:35-36和36-39的边都被逻辑删除):

warehouse_idwarehousewarehouse_ext_idwarehouse_extis_deleted
35FBA-股份-美国36FBA-忻凯-日本1
35FBA-股份-美国37FBA-迅果-美国0
35FBA-股份-美国38FBA-云赫-美国0
35FBA-股份-美国39FBA-钰洲-美国0
36FBA-忻凯-日本39FBA-钰洲-美国1

4. 问题现象总结

  • 数据冗余:间接关联的节点(36和39)被错误存储为直接边,增加了不必要的数据。
  • 逻辑异常:冗余的直接边导致删除操作出现非预期结果,影响后续库存上限合并的准确性。
  • 性能下降:后续查询所有关联仓库时,需要遍历更多的边,降低查询效率。

四、问题根源分析(小白→中级的关键)

之所以会踩坑,核心是业务逻辑(无向连通图)与代码逻辑(仅处理直接边)的错位,具体有三个层面的原因:

1. 核心原因:未区分“直接关联”和“间接关联”

小白版代码只关注当前节点的直接边,却忽略了整个连通图的间接关联

  • 第二次操作时,代码只知道36的直接关联是35,却不知道36还通过35间接关联39。
  • 因此,代码将39视为“需要新增的直接边”,错误插入了36-39的记录。

2. 次要原因:无向边的双向性处理不彻底

表中的唯一索引uk_warehouse_ext(A,B)(B,A)视为不同的记录(比如35-36和36-35),虽然代码查询了双向关联,但无法阻止“间接关联转直接边”的错误。

3. 底层原因:边存储模型的局限性

边存储模型适合稀疏的、非连通的关联关系,但对于无向连通图的业务场景,这种模型存在天然的局限性:

  • 数据冗余:间接关联被转成直接边存储,增加了数据量。
  • 维护复杂:需要处理各种边界情况(如间接关联的过滤)。
  • 性能瓶颈:遍历所有关联节点需要递归/循环查询,数据量大时性能下降。

五、进阶版实现:结合图遍历+分组模型(解决问题)

针对无向连通图的业务场景,我们有两个解决方案:一是修复原有代码(边存储+间接过滤),二是重构为分组模型(连通分量)(推荐,因为后续库存上限合并需要高效的关联查询)。

方案一:修复原有代码(边存储+间接过滤)

核心思路:在维护关联关系前,先通过BFS/DFS遍历整个连通图,获取所有关联节点(包括直接和间接),然后过滤掉间接关联的节点,只处理真正需要新增的直接边。

1. 实现图的遍历算法(BFS/DFS)

WarehouseRelationService中添加遍历方法:

/**
 * BFS(广度优先搜索):获取当前仓库的所有关联节点(直接+间接)
 * @param startWarehouseId 起始仓库ID
 * @return 所有关联的仓库ID集合(包含起始节点)
 */
public Set<Long> traverseAllRelatedWarehousesByBFS(Long startWarehouseId) {
    if (startWarehouseId == null) {
        throw new IllegalArgumentException("起始仓库ID不能为空");
    }
    // 存储已访问的节点,避免循环遍历(比如A-B-A的环)
    Set<Long> visited = new HashSet<>();
    // 队列:存储待访问的节点(BFS的核心数据结构)
    java.util.Queue<Long> queue = new java.util.LinkedList<>();

    // 初始化:将起始节点加入队列和已访问集合
    queue.offer(startWarehouseId);
    visited.add(startWarehouseId);

    while (!queue.isEmpty()) {
        // 取出队首节点
        Long currentId = queue.poll();
        // 获取当前节点的直接关联节点
        List<Long> relatedIds = warehouseExtMapper.selectRelatedWarehouseIds(currentId);

        for (Long relatedId : relatedIds) {
            // 如果未访问过,则加入队列和已访问集合
            if (!visited.contains(relatedId)) {
                visited.add(relatedId);
                queue.offer(relatedId);
            }
        }
    }

    return visited;
}

/**
 * DFS(深度优先搜索):获取当前仓库的所有关联节点(直接+间接)
 * @param startWarehouseId 起始仓库ID
 * @return 所有关联的仓库ID集合(包含起始节点)
 */
public Set<Long> traverseAllRelatedWarehousesByDFS(Long startWarehouseId) {
    if (startWarehouseId == null) {
        throw new IllegalArgumentException("起始仓库ID不能为空");
    }
    Set<Long> visited = new HashSet<>();
    // 调用递归方法进行DFS遍历
    dfs(startWarehouseId, visited);
    return visited;
}

/**
 * DFS递归方法
 * @param currentId 当前仓库ID
 * @param visited 已访问的节点集合
 */
private void dfs(Long currentId, Set<Long> visited) {
    // 标记当前节点为已访问
    visited.add(currentId);
    // 获取当前节点的直接关联节点
    List<Long> relatedIds = warehouseExtMapper.selectRelatedWarehouseIds(currentId);

    for (Long relatedId : relatedIds) {
        // 如果未访问过,递归遍历
        if (!visited.contains(relatedId)) {
            dfs(relatedId, visited);
        }
    }
}
2. 修改维护逻辑(增加间接关联过滤)

修改maintainWarehouseRelations方法,在计算需要新增的节点时,过滤掉间接关联的节点:

@Transactional(rollbackFor = Exception.class)
public void maintainWarehouseRelations(WarehouseRelationDto dto, Long operatorId) {
    // 1. 参数校验(不变)
    if (dto == null || dto.getWarehouseId() == null) {
        throw new IllegalArgumentException("主仓库ID不能为空");
    }
    Long mainWarehouseId = dto.getWarehouseId();
    String mainWarehouseName = dto.getWarehouse();

    // 2. 提取前端传递的关联仓库ID(不变)
    Set<Long> frontRelatedIds = new HashSet<>();
    List<WarehouseRelationDto.Item> items = dto.getItems();
    if (!CollectionUtils.isEmpty(items)) {
        frontRelatedIds = items.stream()
                .filter(item -> item.getWarehouseExtId() != null)
                .map(WarehouseRelationDto.Item::getWarehouseExtId)
                .collect(Collectors.toSet());
    }

    // ====== 新增:获取当前节点的所有关联节点(直接+间接) ======
    Set<Long> allRelatedIds = traverseAllRelatedWarehousesByBFS(mainWarehouseId);

    // 3. 获取数据库中已有的直接关联仓库ID(不变)
    Set<Long> dbRelatedIds = warehouseExtMapper.selectExistingRelatedIds(mainWarehouseId);

    // ====== 修改:过滤间接关联节点,只处理真正需要的直接边 ======
    Set<Long> needAddIds = new HashSet<>();
    for (Long extId : frontRelatedIds) {
        // 需新增的条件:前端有 && 数据库无 && 不在间接关联中(避免间接转直接)
        if (!dbRelatedIds.contains(extId) && !allRelatedIds.contains(extId)) {
            needAddIds.add(extId);
        }
    }

    // 需删除的条件:数据库有 && 前端无(不变)
    Set<Long> needDeleteIds = new HashSet<>(dbRelatedIds);
    needDeleteIds.removeAll(frontRelatedIds);

    // 4. 执行新增和删除操作(不变)
    // ... 原有代码省略
}
3. 修复后的效果

第二次操作时,36的间接关联节点包含39,因此needAddIds为空,不会新增36-39的直接边,数据库保持正确。

方案二:进阶方案(推荐):连通分量分组模型

修复代码只是“治标”,而连通分量分组模型是“治本”——不再存储具体的直接边,而是为每个连通分量(关联的仓库组)分配一个分组ID,仓库节点与分组ID关联。这种模型更适合后续库存上限合并的需求,因为可以快速获取所有关联仓库。

1. 数据库表设计(新增分组表)
-- 仓库分组表:存储连通分量的分组信息
DROP TABLE IF EXISTS `warehouse_group`;
CREATE TABLE IF NOT EXISTS `warehouse_group` (
    `id` bigint NOT NULL AUTO_INCREMENT COMMENT '分组ID(连通分量标识)',
    `group_name` varchar(128) DEFAULT NULL COMMENT '分组名称(可自定义)',
    `creator` bigint DEFAULT NULL COMMENT '创建者ID',
    `updater` bigint DEFAULT NULL COMMENT '修改者ID',
    `ct` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
    `ut` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
    `is_deleted` tinyint(1) DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓库连通分量分组表';

-- 仓库-分组关联表:存储仓库与分组的归属关系
DROP TABLE IF EXISTS `warehouse_group_relation`;
CREATE TABLE IF NOT EXISTS `warehouse_group_relation` (
    `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `warehouse_id` bigint NOT NULL COMMENT '仓库ID',
    `warehouse` varchar(128) DEFAULT NULL COMMENT '仓库名称/编码',
    `group_id` bigint NOT NULL COMMENT '分组ID',
    `creator` bigint DEFAULT NULL COMMENT '创建者ID',
    `updater` bigint DEFAULT NULL COMMENT '修改者ID',
    `ct` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
    `ut` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
    `is_deleted` tinyint(1) DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除',
    -- 唯一索引:一个仓库只能属于一个未删除的分组
    UNIQUE KEY `uk_warehouse_id` (`warehouse_id`, `is_deleted`) USING BTREE,
    -- 分组查询索引:加速根据分组ID查询仓库
    INDEX `idx_group_id` (`group_id`, `is_deleted`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓库-分组关联表';
2. 核心业务逻辑(维护分组归属)
package com.example.warehouse.service;

import com.example.warehouse.dto.WarehouseRelationDto;
import com.example.warehouse.entity.WarehouseGroup;
import com.example.warehouse.entity.WarehouseGroupRelation;
import com.example.warehouse.mapper.WarehouseGroupMapper;
import com.example.warehouse.mapper.WarehouseGroupRelationMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;

import javax.annotation.Resource;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * 仓库分组业务服务(进阶版:连通分量分组模型)
 */
@Service
public class WarehouseGroupService {

    @Resource
    private WarehouseGroupMapper warehouseGroupMapper;

    @Resource
    private WarehouseGroupRelationMapper warehouseGroupRelationMapper;

    /**
     * 维护仓库的分组归属(核心方法)
     * 逻辑:前端传递的主仓库+items中的仓库,归属于同一个分组(连通分量)
     * @param dto 前端传递的关联数据
     * @param operatorId 操作人ID
     */
    @Transactional(rollbackFor = Exception.class)
    public void maintainWarehouseGroup(WarehouseRelationDto dto, Long operatorId) {
        // 1. 参数校验
        if (dto == null || dto.getWarehouseId() == null) {
            throw new IllegalArgumentException("主仓库ID不能为空");
        }
        Long mainWarehouseId = dto.getWarehouseId();
        String mainWarehouseName = dto.getWarehouse();

        // 2. 提取所有需要关联的仓库ID(主仓库+items中的仓库)
        Set<Long> warehouseIds = new HashSet<>();
        warehouseIds.add(mainWarehouseId);
        if (!CollectionUtils.isEmpty(dto.getItems())) {
            warehouseIds.addAll(dto.getItems().stream()
                    .filter(item -> item.getWarehouseExtId() != null)
                    .map(WarehouseRelationDto.Item::getWarehouseExtId)
                    .collect(Collectors.toSet()));
        }

        // 3. 查询这些仓库已有的分组ID(取第一个非空的分组ID,保证分组唯一)
        Long groupId = null;
        for (Long wid : warehouseIds) {
            groupId = warehouseGroupRelationMapper.selectGroupIdByWarehouseId(wid);
            if (groupId != null) {
                break;
            }
        }

        // 4. 若没有分组ID,新建分组
        if (groupId == null) {
            WarehouseGroup group = new WarehouseGroup();
            group.setGroupName("仓库分组_" + System.currentTimeMillis()); // 可自定义分组名
            group.setCreator(operatorId);
            group.setUpdater(operatorId);
            warehouseGroupMapper.insert(group);
            groupId = group.getId();
        }

        // 5. 为每个仓库设置分组归属(新增/更新)
        for (Long wid : warehouseIds) {
            String warehouseName = getWarehouseName(dto, wid, mainWarehouseId);
            // 查询仓库是否已有分组关联(未删除)
            Long relationId = warehouseGroupRelationMapper.selectRelationIdByWarehouseId(wid);
            if (relationId == null) {
                // 新增关联
                WarehouseGroupRelation relation = new WarehouseGroupRelation();
                relation.setWarehouseId(wid);
                relation.setWarehouse(warehouseName);
                relation.setGroupId(groupId);
                relation.setCreator(operatorId);
                relation.setUpdater(operatorId);
                warehouseGroupRelationMapper.insert(relation);
            } else {
                // 更新分组(若需要调整分组,此处可修改group_id)
                warehouseGroupRelationMapper.updateGroupId(wid, groupId, operatorId);
            }
        }

        // 6. 若前端传递items为空(删除节点),则逻辑删除该仓库的分组关联
        if (CollectionUtils.isEmpty(dto.getItems())) {
            warehouseGroupRelationMapper.logicDeleteByWarehouseId(mainWarehouseId, operatorId);
        }
    }

    /**
     * 查询仓库的所有关联节点(同分组的所有仓库)
     * @param warehouseId 仓库ID
     * @return 所有关联的仓库ID集合
     */
    public Set<Long> listRelatedWarehouses(Long warehouseId) {
        Long groupId = warehouseGroupRelationMapper.selectGroupIdByWarehouseId(warehouseId);
        if (groupId == null) {
            return new HashSet<>();
        }
        return warehouseGroupRelationMapper.selectWarehouseIdsByGroupId(groupId);
    }

    /**
     * 辅助方法:从DTO中获取仓库名称
     */
    private String getWarehouseName(WarehouseRelationDto dto, Long wid, Long mainWarehouseId) {
        if (wid.equals(mainWarehouseId)) {
            return dto.getWarehouse();
        }
        return dto.getItems().stream()
                .filter(item -> wid.equals(item.getWarehouseExtId()))
                .map(WarehouseRelationDto.Item::getWarehouseExt)
                .findFirst()
                .orElse("");
    }
}
3. 分组模型的优势(针对后续库存上限合并)
  • 查询高效:获取所有关联仓库只需查询同分组的仓库,无需遍历图,性能提升显著。
  • 数据简洁:无需存储大量的直接边,只存储仓库与分组的关联关系,数据量大幅减少。
  • 逻辑清晰:分组ID直接代表连通分量,后续库存上限合并时,只需按分组计算即可。

六、方案对比与选择建议

方案优点缺点适用场景
边存储+间接过滤无需重构表结构,小改代码即可修复性能略低(每次维护需遍历图),逻辑稍复杂已有系统无法大规模重构的场景
连通分量分组模型逻辑简单,性能高,彻底解决问题,适配后续库存计算需要重构表结构和原有代码新系统开发、无向连通图的核心场景

建议

  • 如果是已有系统,先使用边存储+间接过滤修复问题,后续逐步重构为分组模型。
  • 如果是新系统,直接使用连通分量分组模型,为后续库存上限合并等业务打下基础。

七、总结与拓展

1. 核心知识点回顾

  • 图的基础:节点、边、无向图、连通图、BFS/DFS是处理关联业务的基础。
  • 业务与算法结合:在实现业务代码前,要先明确业务模型(如无向连通图),再选择合适的技术方案。
  • 方案选择:边存储适合简单关联,分组模型适合连通图场景,尤其是后续有批量查询需求的业务。

2. 场景拓展

本文的思路不仅适用于仓库关联,还可以迁移到以下场景:

  • 社交关系:用户的好友关系(好友的好友属于间接关联)。
  • 部门关联:企业的部门之间的关联(同一个事业部的部门是连通的)。
  • 设备关联:物联网设备的组网关联(同一个网络的设备是连通的)。

3. 进阶优化建议

  • 缓存优化:对分组信息和关联仓库列表增加Redis缓存,提升查询性能。
  • 批量操作:对于大规模仓库关联,支持批量导入和批量维护。
  • 权限控制:在接口中添加用户权限校验,避免非法操作。
  • 监控告警:添加接口性能监控和数据异常告警,保障系统稳定。

希望本文能帮助你将图结构的理论知识落地到实际业务中,如果你觉得有用,欢迎点赞收藏~

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值