第一次参加线上多人项目总结以及一些碎碎念
这份文档完成于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进行缓存,也就是保存界面
到这里也算是说完了我们小组负责的部分了,还是比较简单的,但是为啥实际实现却如此困难呢(笑)
仓库管理
库存查询
<!--查询商品的基本信息-->
<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>版权:©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提前写一个模板来进行导入导出
这个部分的写法见下
/**
* 导出库存盘点单(不需要参数,直接在数据库去查)
*
* @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就这个,可以提供比较基础的校验,当然还是自己写好点
(剩余模块就不是我参与了,略略略)
还能再加把劲点吧_

浙公网安备 33010602011771号