第一次参加线上多人项目总结以及一些碎碎念

这份文档完成于11月中旬,前面一个月左右基本都是写项目

一些代码是参考以及询问某些佬的写法,本苦手尤为感谢大佬们的帮助

项目无前端代码demo

下面就是正片了,主要还是个人存档使用,可能其他人参考不到,见谅

第一次大项目总结

包括学习的一些代码方法以及对项目的总结

茼蒿(车万)进销存(笑)

首先,这是一个前后端分离多语言开发(java与c++)的微服务架构项目,

项目介绍

《茼蒿进销存》是一款面向中小企业的高效、省心、高性价比的在线进销存(purchasing, sales, inventory)。旨在打通企业从采购、库存、销售等等到财务核算的核心业务流程,通过提供精准、实时、可视化的数据洞察,是促进企业发展的重要组成部分,是企业经营管理中的重要环节。

项目特点:操作简单、上网就能查库存、下销售单、采购管理、库存管理、库存管理/仓库管理等, 一应俱全;库存集中管理,管理员可以给不同的人员分配不同的数据权限和功能权限;智能补货, 保证库存充足,价格记忆,避免报价混乱,一键成本重算,解决多批次产品库存成本不同的问题;

作为一个相对比较符合市面上的web项目,以下是本次项目使用的架构:SpringCloud Alibaba微服务生态 + Vue3 + Docker(容器化)+ Redis(缓存)+ RocketMQ(消息代理)

个人任务

配置nacos服务注册中心与服务配置信息与线上服务器部署

首页界面的数据获取还有登录界面的数据获取等等(见下面)

-------------------

首页模块

总的来说都是crud,每个数据块都是获取来自库中的数据,大多数据都是使用dto的类进行返回,贴一个自己写的

@Data
@AllArgsConstructor
@ApiModel("首页-数据概览-收款单统计模型")
public class ImyCountsDTO {
    /**
     * 统计展示的日期
     */
    @ApiModelProperty(value = "日期",example = "2025-10-18")
    LocalDate date;
    /**
     每日所有收款的总金额
     */
    @ApiModelProperty(value = "每日所有收款的总金额",example = "114514.00000")
    BigDecimal value;
}

首页后端控制使用一个总的汇总信息的方法,调用各个展示的部分

最后前端从这个总和的dto中进行渲染

Login模块

包括菜单展示,获取用户,授权刷新退出登录5个方法,有点难度

菜单模块

本人负责的模块,最为熟悉,本质还是crud,之后对查询的数据先排序后换作树形结构

关于树形结构,返回数据的dto中包含了chidren节点的类

具体如下

public class MenuTreeVO{
    @ApiModelProperty(value = "序号", example = "001")
    private String id;
    @ApiModelProperty(value = "菜单名称", example = "主页")
    private String name;
    @ApiModelProperty(value = "路由路径", example = "/home")
    private String href;
    @ApiModelProperty(value = "菜单类型", example = "false")
    private String hasReport;
    @ApiModelProperty(value = "报表页面的路径", example = "/purchase-booking-report")
    private String reportHref;
    @ApiModelProperty(value = "节点包含的子节点")
    private List<MenuTreeVO> children;

    public MenuTreeVO(String id, String name, String href, String hasReport) {
        this.id = id;
        this.name = name;
        this.href = href;
        this.hasReport = hasReport;
        this.children = new ArrayList<MenuTreeVO>();
    }

    public void addChild(MenuTreeVO m){
        this.children.add(m);
    }
}

最后这个MenuTreeVo中会装载对应的子节点

关于排序

    private void sortMenuTree(List<MenuTreeVO> menuTree, Map<String, Menu> originalMenus) {
        // 对当前层级菜单按 sort 排序
        menuTree.sort((m1, m2) -> {
            Menu menu1 = originalMenus.get(m1.getId());
            Menu menu2 = originalMenus.get(m2.getId());
            return menu1.getSort().compareTo(menu2.getSort());
        });
        // 递归对子菜单排序
        for (MenuTreeVO menu : menuTree) {
            if (menu.getChildren() != null && !menu.getChildren().isEmpty()) {
                sortMenuTree(menu.getChildren(), originalMenus);
            }
        }
    }

上半部分的landom表达式中将Menu中的sort这个值取出,之后m1对m2进行比较,要是m1大于m2就返回正数,否则负数,之后的结果就是正序

其他地方都比较好理解了

授权登录

基于Spring Security OAuth2,外加验证码验证

@Resource
    private CaptchaBusinessService captchaBusinessService;


    @ApiOperation(value = "授权登录")
    @PostMapping("auth-login")
    @Override
    public JsonVO<Oauth2TokenDTO> authLogin(LoginDTO loginDTO) {
            // TODO:未实现验证码验证,注意:接入验证码后需要加一个启动或关闭验证码验证功能的开关,此开关可以在Nacos配置中心中动态的调整
        // 验证码二次校验
        if (captchaEnabled){
            //校验LoginDTO
            if(StrUtil.isBlank(loginDTO.getCode())){
                JsonVO.create(null, ResultStatus.FAIL.getCode(), "验证码不能为空");
            }
            //校验验证码
            ResponseModel responseModel = captchaBusinessService.verification(loginDTO.getCode());
            if (!responseModel.isSuccess()) {
                // TODO: 可能错误码冲突
                return JsonVO.create(null,Integer.parseInt(responseModel.getRepCode()),responseModel.getRepMsg());
            }
              //验证码缓存删除 源码已经实现 详情见BlockPuzzleCaptchaServiceImpl
              //二次校验也是同理详情见BlockPuzzleCaptchaServiceImpl和DefaultCaptchaServiceImpl
//            redisTemplate.delete(RedisConstant.RUNNING_CAPTCHA + )
        }
        // 账号密码认证
        Map<String, String> params = new HashMap<>(5);
        params.put("grant_type", "password");//密码授权方式
        params.put("client_id", clientId);//客户端id
        params.put("client_secret", clientPassword);//客户端密钥
        params.put("username", loginDTO.getUsername());
        params.put("password", loginDTO.getPassword());
        Oauth2Token oauth2Token = oAuthService.postAccessToken(params);

        // 认证失败
        if (oauth2Token.getErrorMsg() != null) {
            return JsonVO.create(null, ResultStatus.FAIL.getCode(), oauth2Token.getErrorMsg());
        }

        // TODO:未实现认证成功后如何实现注销凭证(如记录凭证到内存数据库)
        Oauth2TokenDTO tokenDTO = BeanUtil.toBean(oauth2Token, Oauth2TokenDTO.class);//转换对象
        //缓存token到白名单
        redisTemplate.opsForValue().set(
                RedisConstant.LOGOUT_TOKEN_PREFIX + tokenDTO.getToken(),
                RedisConstant.TOKEN_STATUS_ACTIVE,
                oauth2Token.getExpiresIn(),
                TimeUnit.SECONDS
        );

        // 响应认证成功数据
        return JsonVO.success(tokenDTO);
    }

总体逻辑就是通过CaptchaBusinessService(防刷,区别人机,保护等等)保证二次验证码通过,之后将信息传输到OAuthService中进行OAuth2令牌获取,之后如果认证成功缓存token到(redis实现)白名单(这里没有实现黑名单,默认不在白名单的就是在黑名单1)

补充:oauth2Token中的认证实际上就是判断是否有错误,否则就返回null(都null了,那我也没意见了)

刷新登录

    @ApiOperation(value = "刷新登录")
    @PostMapping("refresh-token")
    @Override
    public JsonVO<Oauth2TokenDTO> refreshToken(RefreshTokenDTO refreshTokenDTO) {
        // TODO:未实现注销凭证验证
        // 注销凭证验证
        try {
            jwtComponent.defaultRsaVerify(refreshTokenDTO.getToken());
        }catch (Exception e){
            if (!(e instanceof JwtExpiredException))
                return JsonVO.create(null, ResultStatus.FAIL.getCode(),e.getMessage());
        }

        // 刷新凭证
        Map<String, String> params = new HashMap<>(4);
        params.put("grant_type", "refresh_token");
        params.put("client_id", clientId);
        params.put("client_secret", clientPassword);
        params.put("refresh_token", refreshTokenDTO.getRefreshToken());
        Oauth2Token oauth2Token = oAuthService.postAccessToken(params);
        // 刷新失败
        if (oauth2Token.getErrorMsg() != null) {
            return JsonVO.create(null, ResultStatus.FAIL.getCode(), oauth2Token.getErrorMsg());
        }

        Oauth2TokenDTO tokenDTO = BeanUtil.toBean(oauth2Token, Oauth2TokenDTO.class);
        // TODO:未实现刷新成功后如何刷新注销凭证(如删除与更新内存数据库)
        // 刷新注销凭证(如删除与更新内存数据库),更新accessToken
        redisTemplate.opsForValue().getOperations()
                .delete(RedisConstant.LOGOUT_TOKEN_PREFIX+refreshTokenDTO.getToken());
        redisTemplate.opsForValue().set(
                RedisConstant.LOGOUT_TOKEN_PREFIX+oauth2Token.getToken()
                ,RedisConstant.TOKEN_STATUS_ACTIVE,
                oauth2Token.getExpiresIn(), TimeUnit.SECONDS);

        // 响应刷新成功数据
        return JsonVO.success(tokenDTO);
    }

通过传入一个token,首先先注销对应的旧的信息(jwt令牌),之后再跟重新登录一样再加入一个新的令牌(同样还是使用redis,实现就是除旧迎新)

获取用户

@ApiOperation(value = "获取当前用户")
    @GetMapping("current-user")
    @Override
    public JsonVO<LoginVO> getCurrUser() {
        UserDTO currentUser;
        try {
            currentUser = userHolder.getCurrentUser();
        } catch (Exception e) {
            return JsonVO.create(null, ResultStatus.FAIL.getCode(), e.getMessage());
        }
        if (currentUser == null) {
            return JsonVO.fail(null);
        } else {
            // TODO:这里需要根据业务需求,重新实现
            LoginVO vo = new LoginVO();
            BeanUtil.copyProperties(currentUser, vo);
            return JsonVO.success(vo);
        }
    }

需要先登录之后才能获取,直接返回分装即可

这里可以;了解下如何获取用户

    @Resource
    JwtComponent jwtComponent;

    /**
     * 从请求头中获取用户信息
     * @return 用户信息
     * @throws Exception 解析失败抛出异常
     */
    @SuppressWarnings("MismatchedQueryAndUpdateOfCollection")
    public UserDTO getCurrentUser() throws Exception {
        // 从Header中获取用户信息
        ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (servletRequestAttributes == null) {
            return null;
        }
        HttpServletRequest request = servletRequestAttributes.getRequest();
        String userStr = request.getHeader("user");
        // 不是通过网关过来的,那么执行解析验证JWT
        if (userStr == null) {
            //从token中解析用户信息并设置到Header中去
            log.info("Authrization = "+ request.getHeader("Authorization"));
            String realToken = request.getHeader("Authorization").replace("Bearer ", "");
            userStr = jwtComponent.defaultRsaVerify(realToken);
        } else {
            userStr = UriEncoder.decode(userStr);
        }
        JSONObject userJsonObject = new JSONObject(userStr);

        // HARD_CODE 在没有办法使用token时候可以修改这里的代码伪造用户信息,注意伪造用户代码不要提交到仓库中
        /*userJsonObject = new JSONObject();
        userJsonObject.putOnce("id", 1);
        userJsonObject.putOnce("user_name", "王麻子");
        ArrayList<Object> roles = new ArrayList<>();
        roles.add("ROLE_ADMIN");
        userJsonObject.putOnce("authorities", roles);
        userJsonObject.putOnce("avatar","https://img95.699pic.com/photo/60112/3125.jpg_wh860.jpg")  ;
*/// FIXME: 如果要扩展用户信息,需要修改这里的代码
//  1.修改添加头像avatar 去除isenabled  -- ZGjie20
        return UserDTO.builder()
                .id(Convert.toStr(userJsonObject.get("id")))
                .username(userJsonObject.getStr("user_name"))
                //.isEnabled(Convert.toByte(1))
                .avatar(Convert.toStr(userJsonObject.get("avatar")))
                .roles(Convert.toList(String.class, userJsonObject.get("authorities")))
                .frameId(Convert.toStr(userJsonObject.get("frameId")))
                .frameName(Convert.toStr(userJsonObject.get("frameName")))
                .build();
    }

    /**
     * 从请求头中获取当前请求的token
     * @return 没有获取到返回null
     */
    public String getCurrentToken() {
        ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (servletRequestAttributes == null) {
            return null;
        }
        HttpServletRequest request = servletRequestAttributes.getRequest();
        String token = request.getHeader("Authorization");
        if (StringUtils.isEmpty(token)) {
            return null;
        }
        return token.replace("Bearer ", "");
    }

通过验证请求头携带的参数,再通过jwt令牌验证之后创建user对象,下面的token是一个道理

退出登录

@ApiOperation(value = "退出登录")
    @GetMapping("logout")
    @Override
    public JsonVO<String> logout() {
        // 获取当前请求的token
        String token = userHolder.getCurrentToken();
        if (token != null){
            // TODO:登出逻辑,需要配合登录逻辑实现
            // 构造 Redis 中存储 token 的 key
            String redisKey = RedisConstant.LOGOUT_TOKEN_PREFIX + token;
            // 删除 Redis 中的 token 记录
            redisTemplate.delete(redisKey);
            return JsonVO.success("退出成功");
        }
        return JsonVO.fail("获取凭证失败,退出失败");
    }

这里就使用到了获取token,之后删除redis中的数据就算退出登录了

系统配置

字典管理

这个部分包括两种查找,新增,修改,删除,新增

查找本质都是crud

    <resultMap id="dictDTOResultMap" type="com.zeroone.star.project.dto.j1.sysconfig.DictDTO">
        <id column="id" property="id"/>
        <result column="tid" property="tid"/>
        <result column="name" property="name"/>
        <result column="value" property="value"/>
        <result column="remark" property="remark"/>
        <result column="type_name" property="type_name"/>
    </resultMap>
    <select id="selectByCondition" resultMap="dictDTOResultMap">
        SELECT
        d.id,
        d.tid,
        d.name,
        d.value,
        d.remark,
        dt.name AS type_name
        FROM
        dict d
        LEFT JOIN
        dict_type dt ON d.tid = dt.id
        <where>
            <if test="tid != null and tid != ''">
                AND d.tid = #{tid}
            </if>
            <if test="name != null and name != ''">
                AND d.name LIKE CONCAT('%', #{name}, '%')
            </if>
            <if test="value != null and value != ''">
                AND d.value LIKE CONCAT('%', #{value}, '%')
            </if>
        </where>
    </select>

有一部分需要进行分页,可以使用mq中的分页对象进行分页

    public PageDTO<DictDTO> selectByCondition(DictQuery condition){
        Page<DictDTO> page = new Page<>(condition.getPageIndex(), condition.getPageSize());
        baseMapper.selectPageByCondition(page, condition);
        PageDTO<DictDTO> pageDTO = PageDTO.create(page);
        System.out.println(pageDTO);//测试没来得及删的,可以换log
        return pageDTO;
    }

剩下的crud挑点重要的说说

修改和新增中,需要验证表中是否存在已存在过的字典,根据id啥的来判断,这里不做阐述

模板管理

主要说下有关fastdfs的地方

首先要知道这个是干啥的:分布式文件系统的文件的存储管理等等

这个用删除模板来举例子

@Override
public int deleteTemplate(String id) {
    TmplImport tmplImport=tmplImportMapper.selectById(id);
    if(tmplImport==null){
        return 0;
    }

    try{
        String group=tmplImport.getSavePath().split(",")[0];
        String storageId=tmplImport.getSavePath().split(",")[1];
        FastDfsFileInfo info=FastDfsFileInfo.builder().group(group).storageId(storageId).build();
        fastDfsClientComponent.deleteFile(info);
    }catch (Exception e){
        e.printStackTrace();
    }
    return tmplImportMapper.deleteById(id);
}

这里的fastdfs都是分装过的

@Component
@EnableFastdfsClient
public class FastDfsClientComponent {

    @Resource
    private FastdfsClientService remoteService;

    /**
     * 文件上传
     * @param fileName 文件全路径
     * @param extName  文件扩展名,不包含(.)
     * @return 上传结果信息
     * @throws Exception 存储失败异常
     */
    public FastDfsFileInfo uploadFile(String fileName, String extName) throws Exception {
        String[] info = remoteService.autoUpload(FileUtils.readFileToByteArray(new File(fileName)), extName);
        if (info != null) {
            return FastDfsFileInfo.builder()
                    .group(info[0])
                    .storageId(info[1])
                    .build();
        }
        return null;
    }

    /**
     * 文件上传
     * @param fileName 文件全路径
     * @return 上传结果信息
     * @throws Exception 存储失败异常
     */
    public FastDfsFileInfo uploadFile(String fileName) throws Exception {
        return uploadFile(fileName, null);
    }

    /**
     * 上传文件
     * @param fileContent 文件的内容,字节数组
     * @param extName     文件扩展名
     * @return 上传结果信息
     * @throws Exception 存储失败异常
     */
    public FastDfsFileInfo uploadFile(byte[] fileContent, String extName) throws Exception {
        String[] info = remoteService.autoUpload(fileContent, extName);
        if (info != null) {
            return FastDfsFileInfo.builder()
                    .group(info[0])
                    .storageId(info[1])
                    .build();
        }
        return null;
    }

    /**
     * 上传文件
     * @param fileContent 件的内容,字节数组
     * @return 上传结果信息 [0]:服务器分组,[1]:服务器ID
     * @throws Exception 存储失败异常
     */
    public FastDfsFileInfo uploadFile(byte[] fileContent) throws Exception {
        return uploadFile(fileContent, null);
    }

    /**
     * 文件下载
     * @param info 文件信息
     * @return 下载数据
     * @throws Exception 异常信息
     */
    public byte[] downloadFile(FastDfsFileInfo info) throws Exception {
        return remoteService.download(info.getGroup(), info.getStorageId());
    }

    /**
     * 删除文件
     * @param info 文件信息
     * @return 删除结果 0表示删除成功
     * @throws Exception 异常信息
     */
    public int deleteFile(FastDfsFileInfo info) throws Exception {
        return remoteService.delete(info.getGroup(), info.getStorageId());
    }

    /**
     * 解析成url地址
     * @param info      文件信息
     * @param urlPrefix 如:<a href="#">http://ip:port</a>
     * @param isToken   是否带防盗链
     * @return 获取失败返回null
     */
    public String fetchUrl(FastDfsFileInfo info, String urlPrefix, boolean isToken) {
        try {
            if (isToken) {
                return remoteService.autoDownloadWithToken(info.getGroup(), info.getStorageId(), urlPrefix);
            } else {
                return remoteService.autoDownloadWithoutToken(info.getGroup(), info.getStorageId(), urlPrefix);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

根据打包好的fastdfs中的内容来哦写还是比较简单的嘛

菜单管理

获取菜单跟登录界面的获取菜单有点像,也是获取本个节点之后向下递归或者向上获取节点,这里忽略

注意一下获取菜单需要使用到redis进行缓存,也就是保存界面

到这里也算是说完了我们小组负责的部分了,还是比较简单的,但是为啥实际实现却如此困难呢(笑)


仓库管理

d03f1d9e98b0870bbe337adf29df3ecc

5a96f7caefff089b643d584800f1771f

库存查询

    <!--查询商品的基本信息-->
    <select id="selectInventoryBaseList" resultType="com.zeroone.star.project.dto.j2.store.InventoryListDTO">
        SELECT
            g.id,
            g.name,
            g.threshold,
            g.stock,
            g.number,
            g.spec,
            g.brand,
            g.unit,
            g.code,
            g.`data` as remark,
            c.id as "categoryId",
            c.name as "categoryName",
            <!-- 使用子查询计算总库存 -->
            COALESCE((
                SELECT SUM(r2.nums)
                FROM room r2
                WHERE r2.goods = g.id
                <if test="query.warehouseId != null and query.warehouseId.size() > 0">
                    AND r2.warehouse IN
                    <foreach collection="query.warehouseId" item="id" open="(" close=")" separator=",">
                        #{id}
                    </foreach>
                </if>
            ), 0) as totalStock
        FROM goods g
        LEFT JOIN category c ON g.category = c.id
        <!--查询条件-->
        <where>
            <if test="query.goodsName != null and query.goodsName != ''">
                AND g.name LIKE CONCAT('%', #{query.goodsName},'%')
            </if>
            <if test="query.goodsNumber != null and query.goodsNumber != ''">
                AND g.number LIKE CONCAT('%', #{query.goodsNumber},'%')
            </if>
            <if test="query.goodsSpec != null and query.goodsSpec != ''">
                AND g.spec LIKE CONCAT('%',#{query.goodsSpec},'%')
            </if>
            <if test="query.goodsCategoryIds != null and query.goodsCategoryIds.size() > 0">
                AND g.category IN
                <foreach collection="query.goodsCategoryIds" item="categoryId" open="(" close=")" separator=",">
                    #{categoryId}
                </foreach>
            </if>
            <if test="query.goodsBrand != null and query.goodsBrand != ''">
                AND g.brand = #{query.goodsBrand}
            </if>
            <if test="query.goodsCode != null and query.goodsCode != ''">
                AND g.code LIKE CONCAT('%',#{query.goodsCode},'%')
            </if>
            <if test="query.goodsRemark != null and query.goodsRemark != ''">
                AND g.data LIKE CONCAT('%',#{query.goodsRemark},'%')
            </if>
        </where>
        ORDER BY g.id DESC
    </select>

sql如上,这里要介绍个字段:COALESCE(a,b)中的字段如果为null就转换成b

导出库存数据excel

/**
 * <p>
 * 描述:EasyExcel操作组件
 * </p>
 * <p>版权:&copy;01星球</p>
 * <p>地址:01星球总部</p>
 * @author 阿伟学长
 * @version 1.0.0
 */
@Component
public class EasyExcelComponent {
    /**
     * 定义每个页签存储的数据量
     */
    private static final int MAX_COUNT_PER_SHEET = 5000;

    /**
     * 生成 Excel
     * @param path      Excel 存储路径
     * @param sheetName sheet名称
     * @param clazz     存储的数据类型
     * @param dataList  存储数据集合
     * @param <T>       生成元素实体类类型
     */
    public <T> void generateExcel(String path, String sheetName, Class<T> clazz, List<T> dataList) {
        EasyExcel.write(path, clazz).sheet(sheetName).doWrite(dataList);
    }

    /**
     * 解析Excel
     * @param path  解析的Excel的路径
     * @param clazz 存储的数据类型
     * @param <T>   解析元素实体类类型
     * @return 解析后的数据集合
     */
    public <T> List<T> parseExcel(String path, Class<T> clazz) {
        ExcelReadListener<T> listener = new ExcelReadListener<>();
        //sheet()方法表示读取所有的sheet
        //doRead() 表示表示执行读取动作
        EasyExcel.read(path, clazz, listener).sheet().doRead();
        return listener.getDataList();
    }

    /**
     * 导出到输出流
     * @param sheetName sheet名称
     * @param os        输出流
     * @param clazz     导出数据类型
     * @param dataList  导出的数据集
     * @param <T>       生成元素实体类类型
     * @throws IOException IO异常
     */
    public <T> void export(String sheetName, OutputStream os, Class<T> clazz, List<T> dataList) throws IOException {
        ExcelWriterBuilder builder = EasyExcel.write(os, clazz);
        ExcelWriter writer = builder.build();
        //计算总页数
        int sheetCount = dataList.size() / MAX_COUNT_PER_SHEET;
        sheetCount = dataList.size() % MAX_COUNT_PER_SHEET == 0 ? sheetCount : sheetCount + 1;
        //循环构建分页
        for (int i = 0; i < sheetCount; i++) {
            //创建一个页签
            WriteSheet sheet = new WriteSheet();
            sheet.setSheetNo(i);
            sheet.setSheetName(sheetName + (i + 1));
            //设置数据起始位置
            int start = i * MAX_COUNT_PER_SHEET;
            int end = (i + 1) * MAX_COUNT_PER_SHEET;
            end = Math.min(end, dataList.size());
            //写入数据到页签
            writer.write(dataList.subList(start, end), sheet);
        }
        writer.finish();
        os.close();
    }
}

导出的具体代码

   /**
     * 导出库存列表数据Excel
     *
     * @param query
     * @return
     */
    @SneakyThrows
    @Override
    public ResponseEntity<byte[]> getListExport(InventoryQuery query) {
        // 定义输出流
        ByteArrayOutputStream out = new ByteArrayOutputStream();

        // 设置查询参数以获取所有数据(不分页)
        InventoryQuery allDataQuery = new InventoryQuery();
        // 复制原始查询条件
        BeanUtils.copyProperties(query, allDataQuery);
        // 设置分页参数为获取全部数据
        allDataQuery.setPageIndex(1);
        allDataQuery.setPageSize(Integer.MAX_VALUE);
        List<InventoryListDTO> inventoryListDTOS = getInventoryList(allDataQuery).getRows();

        // 将数据转换为导出DTO列表
        List<InventoryListExcelDTO> exportList = new ArrayList<>();
        for (InventoryListDTO inventory : inventoryListDTOS) {
            List<WarehouseStockDTO> warehouses = inventory.getGoodsWarehouses();
            
            // 如果没有仓库数据,创建一条基本记录
            if (warehouses == null || warehouses.isEmpty()) {
                InventoryListExcelDTO exportDTO = new InventoryListExcelDTO();
                BeanUtils.copyProperties(inventory, exportDTO);
                exportDTO.setWarehouseName("无仓库数据");
                exportDTO.setStockNum(BigDecimal.ZERO);
                exportList.add(exportDTO);
            } else {
                // 为每个仓库创建一条记录
                for (WarehouseStockDTO warehouse : warehouses) {
                    InventoryListExcelDTO exportDTO = new InventoryListExcelDTO();
                    BeanUtils.copyProperties(inventory, exportDTO);
                    BeanUtils.copyProperties(warehouse, exportDTO);
                    exportDTO.setWarehouseName(warehouse.getWarehouseName());
                    exportDTO.setStockNum(warehouse.getStockNum());
                    exportList.add(exportDTO);
                }
            }
        }
        
        // 生成Excel
        excel.export("库存列表", out, InventoryListExcelDTO.class, exportList);

        // 响应给前端
        HttpHeaders headers = new HttpHeaders();
        String filename = "库存列表" + DateTime.now().toString("yyyyMMddHHmmssS") + ".xlsx";
        try {
            // 对文件名进行URL编码,确保中文能正确显示
            filename = URLEncoder.encode(filename, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            // 编码失败时使用默认文件名
            filename = "inventory_detail_" + DateTime.now().toString("yyyyMMddHHmmssS") + ".xlsx";
        }
        headers.setContentDispositionFormData("attachment", filename);
        headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
        ResponseEntity<byte[]> res = new ResponseEntity<>(out.toByteArray(), headers, HttpStatus.CREATED);
        out.close();

        log.info("库存列表数据已导出");
        return res;
    }

注意点:注意向前端发送的字符集编码问题,空文件问题,excel是根据页面进行创建的,每个页面中的数据内容大小是写死的

如果是返回其他数据也是同理

分组代码技巧

            // 6. 按商品ID、属性名称、批次号进行分组
            Map<String, Map<String, Map<String, List<BatchDocumentDTO>>>> batchGroupMap = allBatchDocuments.stream()
                    .collect(Collectors.groupingBy(
                            BatchDocumentDTO::getGoodsId,
                            Collectors.groupingBy(
                                    dto -> {
                                        // 修正无属性商品的处理
                                        if (dto.getAttrName() == null || dto.getAttrName().isEmpty()) {
                                            return "NO_ATTR";
                                        }
                                        return dto.getAttrName();
                                    },
                                    Collectors.groupingBy(BatchDocumentDTO::getBatchNumber)
                            )
                    ));

多数据分组,使用stream进行,解释下关于分组:

这里使用的是多级分组,参数为(当前级别分组键,下游收集器),如果就单层只是用一个分组键即可

最外层分组(按 goodsId):遍历所有 BatchDocumentDTO 对象,提取每个对象的 goodsId 作为第一级键,对相同 goodsId 的对象应用下游收集器

中间层分组(处理后的 attrName):,在每个 goodsId 分组内,再次按属性名分组,空值安全处理:null/空属性名统一转为 "NO_ATTR",对相同处理后的属性名应用下游收集器

最内层分组(按 batchNumber):在每个属性名分组内,按批次号进行最终分组

建造者模式

OtherInListInfoDTO otherInListInfoDTO = OtherInListInfoDTO.builder()
        .goods(inventoryVerifyList.getGoodId())
        .attr(attrName)
        .unit(good.getUnit())
        .warehouse(inventoryVerifyList.getWarehouseId())
        .price(good.getBuy())
        .nums(inventoryVerifyList.getInventoryDifference())
        .total(inventoryVerifyList.getInventoryDifference().multiply(good.getBuy()))
        .build();

这样来创建一个对象挺好用的,看的比较清晰,毕竟就不需要写set地狱了(笑)

关于使用easyExcel

EasyExcel官方文档 - 基于Java的Excel处理工具 | Easy Excel 官网

可以作为对文档数据的输入输出的一种形式

通常我们会通过excel提前写一个模板来进行导入导出

这个部分的写法见下

写Excel | Easy Excel 官网

 /**
         * 导出库存盘点单(不需要参数,直接在数据库去查)
         *
         * @param
         * @return
         */
        @Override
        public ByteArrayOutputStream exportInventoryVerifyExcel () throws IOException {
            try {
                // 查询数据
                List<InventoryVerifyListDTO> inventoryVerifyList = inventoryVerifyMapper.selectInventoryVerifyListDTO();
                log.info("库存盘点单导出数据:{}", inventoryVerifyList);

                List<InventoryVerifyListDTO> inventoryVerifyListS = new ArrayList<>();

                // 组装数据
                //查看对象有有无属性attrName,如果有则将其包装成一个InventoryVerifyListDTO对象,并将其添加到inventoryVerifyListS中
                for (int i = 0; i < inventoryVerifyList.size(); i++) {

                    if (inventoryVerifyList.get(i).getAttrName() != null && !inventoryVerifyList.get(i).getAttrName().isEmpty()) {
                        if (i != 0 && inventoryVerifyList.get(i).getName().equals(inventoryVerifyList.get(i - 1).getName())) {
                            InventoryVerifyListDTO inventoryVerifyListDTO1 = new InventoryVerifyListDTO();
                            inventoryVerifyListDTO1.setName(inventoryVerifyList.get(i).getAttrName());
                            inventoryVerifyListS.add(inventoryVerifyListDTO1);
                        } else {
                            inventoryVerifyList.get(i).setAttrName(null);
                            inventoryVerifyListS.add(inventoryVerifyList.get(i));
                        }

                    } else {
                        inventoryVerifyListS.add(inventoryVerifyList.get(i));
                    }

                }

                log.info("组装后的库存盘点单数据:{}", inventoryVerifyListS);

                // 1. 准备字节输出流(用于存储Excel文件的字节数据)
                ByteArrayOutputStream baos = new ByteArrayOutputStream();

                // 2. 读取Excel模板文件(位于resources/templates目录下)
                InputStream is = null;
                try {
                    is = new ClassPathResource("templates/InventoryVerify.xlsx").getInputStream();
                } catch (IOException e) {
                    throw new RuntimeException(e); // 模板读取失败则抛异常
                }

                // 3. 初始化EasyExcel写入器(基于模板)
                ExcelWriter workBook = EasyExcel.write(baos, InventoryVerifyListDTO.class)
                        .withTemplate(is) // 关联模板
                        .build();

                // 4. 定义写入的sheet
                WriteSheet sheet = EasyExcel.writerSheet().build();

                // 6. 填充数据到模板(强制新增行,避免覆盖模板样式)
                FillConfig build = FillConfig.builder().forceNewRow(true).build();
                workBook.fill(inventoryVerifyListS, build, sheet); // 填充明细数据

                // 7. 完成写入并获取字节数组
                workBook.finish();

                // 8. 返回成功响应
                return baos;


            } catch (Exception e) {
                log.error("导出库存盘点单Excel失败", e);
                throw new IOException("导出库存盘点单Excel失败: " + e.getMessage(), e);
            }
        }

还有一个关于读入操作的代码

        ExcelReadListener<OtherInImportExcelDTO> listener = new ExcelReadListener<>();
        EasyExcel.read(is, OtherInImportExcelDTO.class, listener).sheet().headRowNumber(2).doRead();
        List<OtherInImportExcelDTO> dataList = listener.getDataList();
  • 创建监听器来收集解析的Excel数据

  • 从输入流is读取Excel,跳过前2行表头

  • 将每行数据映射为OtherInImportExcelDTO对象

  • 通过监听器获取所有解析的数据

就是通过监听器来获取信息

如果是web,用户需要下载关于excel之类的,后端需要准备

        workBook.fill(otherInEasyExportExcelDTOList, build, sheet);
        workBook.fill(map, build, sheet);
        workBook.finish();
        byte[] byteArray = baos.toByteArray();
        HttpHeaders headers = new HttpHeaders();
        String filename = "xxx"+ DateTime.now().toString("yyyyMMddHHmmssS") + ".xlsx";
        headers.setContentDispositionFormData("attachment", filename);
        headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);

	//之后可以将这个文件直接传出,这里就按具体分析了

return new ResponseEntity<>(byteArray, headers, HttpStatus.OK);

Excel 模板填充、生成文件并返回下载响应的完整流程

剩余的部分都是crud了

用模板化语言吹吹就行

系统参数模块

还是总结点比较有价值的代码

比较清爽的递归

    /**
     * 递归构建树形结构
     * @param allNodes 所有节点列表
     * @param parentId 父节点ID,根节点为null
     * @return 构建好的子树
     */
    private List<FrameDTO> buildTree(List<FrameDTO> allNodes, String parentId) {
        return allNodes.stream()
                // 过滤出当前父节点的直接子节点
                .filter(node -> Objects.equals(node.getPid(), parentId))
                // 递归构建子树
                .peek(node -> {
                    List<FrameDTO> children = buildTree(allNodes, node.getId());
                    node.setChildren(children);
                    node.setHasChildren(!children.isEmpty());
                })
                // 按sort字段排序
                .sorted(Comparator.comparing(FrameDTO::getSort))
                .collect(Collectors.toList());
    }

如果是我还是会这么写吧,问就是习惯(笑)

private List<FrameDTO> buildTree(List<FrameDTO> allNodes, String parentId) {
    List<FrameDTO> result = new ArrayList<>();
    // 找出当前父节点的所有直接子节点
    for (FrameDTO node : allNodes) {
        if (Objects.equals(node.getPid(), parentId)) {
            result.add(node);
        }
    }
    // 为每个节点递归构建子树
    for (FrameDTO node : result) {
        List<FrameDTO> children = buildTree(allNodes, node.getId());
        node.setChildren(children);
        node.setHasChildren(!children.isEmpty());
    }
    // 按sort字段排序
    result.sort(Comparator.comparing(FrameDTO::getSort));
    return result;
}

还有一个因人而异的玩意,我个人还是比较习惯xml形式的

    public PageDTO<CustomerDTO> listAll(CustomerQuery query) {
        // 构建分页查询对象
        Page<Customer> page = new Page<>(query.getPageIndex(), query.getPageSize());
        // 构建查询条件对象
        QueryWrapper<Customer> queryWrapper = new QueryWrapper<>();
        queryWrapper.like(!StringUtils.isEmpty(query.getName()), "name", query.getName());
        queryWrapper.like(!StringUtils.isEmpty(query.getNumber()), "number", query.getNumber());
        queryWrapper.like(!StringUtils.isEmpty(query.getCategory()), "category", query.getCategory());
        queryWrapper.like(!StringUtils.isEmpty(query.getGrade()), "grade", query.getGrade());
        queryWrapper.like(!StringUtils.isEmpty(query.getContactPerson()), "contacts", query.getContactPerson());
        queryWrapper.like(!StringUtils.isEmpty(query.getContactPhone()), "contacts", query.getContactPhone());
        queryWrapper.like(!StringUtils.isEmpty(query.getUser()), "user", query.getUser());
        queryWrapper.like(!StringUtils.isEmpty(query.getData()), "data", query.getData());
        //queryWrapper.orderBy(true, false, "IFNULL(`update_time`,`create_time`)");
        queryWrapper.orderBy(true, false, "id");
        // 分页查询
        Page<Customer> pageRes = baseMapper.selectPage(page, queryWrapper);
        System.out.println(pageRes);
        return PageDTO.create(pageRes, msCustomerMapper::customerToCustomerDTO);
    }

感觉还是xml形式比较容易写点?看个人习惯了

关于参数校验,springBoot中有提供一个

@Validated @RequestBody CustomerDTO customerDTO

@Validated就这个,可以提供比较基础的校验,当然还是自己写好点


(剩余模块就不是我参与了,略略略)

还能再加把劲点吧_

posted @ 2025-12-19 17:08  MRME39  阅读(0)  评论(0)    收藏  举报