功能设计


功能设计

在编程之中,独立负责项目时,设计其实很重要,代码只是设计思路的另一种展现形式。一个好的设计,不仅能够满足当下需求,后续的功能扩展也要考虑。因此好的设计,一定是灵活的。

但实际的工作之中,前期需求的不明确,开发时间的压缩,部门开发风格等因素,都会对设计的最终结果产生影响。甚至很多时候,需求初步明确后即进入写代码阶段。无论实际的情况如何,我还是觉得设计的能力不能丢。

文件在线生成

需求

指定模板文件的在线生成。

第一步

  1. 设置模板文件的名称。
  2. 选择生成文件的格式是 docx 还是 pdf。
  3. 上传制作好的模板文件。

第二步

制作模板文件的:变量名,编码,类型,属性,初始值,必填项,提示信息。

其中每个变量名对应的数据类型和属性为:

             1. 文本(单行文本,多行文本)
             2. 日期(数字日期,英文日期,中文日期,仅显示年,仅显示月,仅显示日)
             3. 表格
             4. 选择(单选,多选,下拉)
             5. 数字(阿拉伯数字,中文数字)
             6. 字典

第三步

每生成一次文件,都有对应的记录信息,在生成记录里,可以:下载文件,再一次生成,删除。

设计

实现上述需求,后端的设计核心为三个模块:

  1. 数据库设计。
  2. 业务流程设计。
  3. 接口设计。

这里重点列出数据库的设计,其业务流程设计可看作是对数据库表的增删改查,接口设计可看作是对增删改查的实现。

数据库设计

所有数据库表为:

模板文件表:t_template

模板文件列表:t_column

模板文件列填写值表:t_column_value

表格列数据表:t_table_column

初始值表:t_initial_value

日期列数据表:t_date_column

数字列数据表:t_number_column

选择列数据表:t_opion_column

字典列数据表:t_dict_column

生成文件记录表:t_record

  • 模板文件表:t_template
CREATE TABLE `t_template` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
    `user_id` bigint(20) DEFAULT NULL COMMENT '用户ID',
    `ac_number` varchar(30) DEFAULT NULL COMMENT '编号',
    `name` varchar(128) DEFAULT NULL COMMENT '模板名称',
    `number` varchar(30) DEFAULT NULL COMMENT '文档编码',
    `origin_name` varchar(255) DEFAULT NULL COMMENT '模板文件名',
    `origin_id` bigint(20) DEFAULT NULL COMMENT '模板文件id',
    `origin_url` varchar(255) DEFAULT NULL COMMENT '模板文件url',
    `code` varchar(30) DEFAULT NULL COMMENT '文档分享码',
    `type` varchar(30) DEFAULT NULL COMMENT '模板类型',
    `format` tinyint(2) DEFAULT NULL COMMENT '生成格式(1.docx; 2.pdf)',
    `edited` tinyint(4) DEFAULT '0' COMMENT '分享给对方是否可编辑(0不可编辑,1可编辑)',
    `create_by` varchar(32) DEFAULT '' COMMENT '创建人',
    `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_by` varchar(32) DEFAULT '' COMMENT '更新人',
    `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `remark` varchar(256) DEFAULT NULL COMMENT '备注',
    `deleted` tinyint(2) DEFAULT '0' COMMENT '记录状态(0正常,1删除)',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='模板文件表';
  • 模板文件列表:t_column
CREATE TABLE `t_column` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
    `template_id` bigint(20) DEFAULT NULL COMMENT '模板文件ID',
    `name` varchar(64) DEFAULT NULL COMMENT '列名',
    `code` varchar(256) DEFAULT NULL COMMENT '编码',
    `type` tinyint(2) DEFAULT '0' COMMENT '类型(1 文本,2 日期)',
    `property` tinyint(2) DEFAULT '0' COMMENT '属性(1 多行文本,2 单行文本,3 数字日期,4 仅显示年份,5 仅显示月份,6 仅显示日份,7 英文日期)',
    `required` tinyint(2) DEFAULT '1' COMMENT '是否必填(0必填,1非必填)',
    `draw` varchar(256) DEFAULT NULL COMMENT '提示',
    `create_by` varchar(32) DEFAULT '' COMMENT '创建人',
    `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_by` varchar(32) DEFAULT '' COMMENT '更新人',
    `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `remark` varchar(256) DEFAULT NULL COMMENT '备注',
    `source` varchar(256) DEFAULT NULL COMMENT '数据源,目前为OA那边回填的数据ID',
    `autofill` tinyint(2) DEFAULT '1' COMMENT '数据填充(1,填充;0,不填充)',
    `sorted` int(10) DEFAULT '0' COMMENT '排序字段',
    `dict_code` varchar(64) DEFAULT NULL COMMENT '数据字典编码',
    `deleted` tinyint(2) DEFAULT '0' COMMENT '记录状态(0正常,1删除)',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB  DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='模板文件列属性表';
  • 模板文件列填写值表:t_column_value
CREATE TABLE `t_column_value` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
    `column_id` bigint(20) DEFAULT NULL COMMENT '文件列属性表ID',
    `record_id` bigint(20) DEFAULT NULL COMMENT '记录表ID',
    `value` varchar(1200) DEFAULT NULL COMMENT '每一列填的数据',
    `create_by` varchar(32) DEFAULT '' COMMENT '创建人',
    `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_by` varchar(32) DEFAULT '' COMMENT '更新人',
    `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `remark` varchar(256) DEFAULT NULL COMMENT '备注',
    `deleted` tinyint(2) DEFAULT '0' COMMENT '记录状态(0正常,1删除)',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='模板文件列对应填写值';
  • 表格列数据表:t_table_column
CREATE TABLE `t_table_column` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
    `column_id` bigint(20) DEFAULT NULL COMMENT '文件列属性表ID',
    `value` varchar(256) DEFAULT NULL COMMENT '列值',
    `remark` varchar(256) DEFAULT NULL COMMENT '备注',
    `deleted` tinyint(2) DEFAULT '0' COMMENT '记录状态(0正常,1删除)',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='表格列数据';
  • 日期列数据表:t_date_column
CREATE TABLE `t_date_column` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
    `column_id` bigint(20) DEFAULT NULL COMMENT '文件列属性表ID',
    `code` varchar(256) DEFAULT NULL COMMENT '编码',
    `type` tinyint(2) DEFAULT NULL COMMENT '编码类型:3 数字日期,4 仅显示年份,5 仅显示月份,6 仅显示日份,7 英文日期,8 中文日期 ',
    `remark` varchar(256) DEFAULT NULL COMMENT '备注',
    `deleted` tinyint(2) DEFAULT '0' COMMENT '记录状态(0正常,1删除)',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='日期列数据';
  • 数字列数据表:t_number_column
CREATE TABLE `t_number_column` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
    `column_id` bigint(20) DEFAULT NULL COMMENT '文件列属性表ID',
    `code` varchar(256) DEFAULT NULL COMMENT '编码',
    `type` tinyint(2) DEFAULT NULL COMMENT '编码类型:9 中文数字, 13 英文数字 ',
    `remark` varchar(256) DEFAULT NULL COMMENT '备注',
    `deleted` tinyint(2) DEFAULT '0' COMMENT '记录状态(0正常,1删除)',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB  DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='数字列数据';
  • 选择列数据表:t_opion_column
CREATE TABLE `t_opion_column` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
    `column_id` bigint(20) DEFAULT NULL COMMENT '文件列属性表ID',
    `initial_id` bigint(20) DEFAULT NULL COMMENT '初始值ID',
    `code` varchar(256) DEFAULT NULL COMMENT '编码',
    `value` varchar(256) DEFAULT NULL COMMENT '值',
    `remark` varchar(256) DEFAULT NULL COMMENT '备注',
    `deleted` tinyint(2) DEFAULT '0' COMMENT '记录状态(0正常,1删除)',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=58 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='选择列数据';
  • 字典列数据表:t_dict_column
CREATE TABLE `t_dict_column` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
    `column_id` bigint(20) DEFAULT NULL COMMENT '文件列属性表ID',
    `code` varchar(256) DEFAULT NULL COMMENT '编码',
    `remark` varchar(256) DEFAULT NULL COMMENT '备注',
    `deleted` tinyint(2) DEFAULT '0' COMMENT '记录状态(0正常,1删除)',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='数据字典子编码';
  • 初始值表:t_initial_value
CREATE TABLE `t_initial_value` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
    `column_id` bigint(20) DEFAULT NULL COMMENT '文件列属性表ID',
    `table_id` bigint(20) DEFAULT NULL COMMENT '表格属性表ID',
    `value` varchar(1200) DEFAULT NULL COMMENT '初始值数据',
    `remark` varchar(256) DEFAULT NULL COMMENT '备注',
    `status` tinyint(2) DEFAULT '0' COMMENT '默认值状态,1,勾选;0不勾选',
    `deleted` tinyint(2) DEFAULT '0' COMMENT '记录状态(0正常,1删除)',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='初始值表';
  • 生成文件记录表:t_record
CREATE TABLE `t_record` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
    `template_id` bigint(20) DEFAULT NULL COMMENT '模板文件ID',
    `user_id` bigint(20) DEFAULT NULL COMMENT '用户ID',
    `number` varchar(30) DEFAULT NULL COMMENT '编号',
    `name` varchar(128) DEFAULT NULL COMMENT '模板名称',
    `target_name` varchar(255) DEFAULT NULL COMMENT '生成文件名',
    `target_id` bigint(20) DEFAULT NULL COMMENT '生成文件id',
    `target_url` varchar(255) DEFAULT NULL COMMENT '生成文件url',
    `status` tinyint(2) DEFAULT '0' COMMENT '记录状态(0成功,1失败, 2新增)',
    `create_by` varchar(32) DEFAULT '' COMMENT '创建人',
    `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_by` varchar(32) DEFAULT '' COMMENT '更新人',
    `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `remark` varchar(256) DEFAULT NULL COMMENT '备注',
    `md5` varchar(128) DEFAULT NULL COMMENT '文件的MD5',
    `deleted` tinyint(2) DEFAULT '0' COMMENT '记录状态(0正常,1删除)',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='文件生成记录表';

技术实现

实现上述需求,决定采用 poi-tl 来完成文件的生成,documents4j 来完成文件从 docx 转成 pdf,pdfbox 来完成 pdf 的水印。

https://deepoove.com/poi-tl/#plugin-dynamic-table

https://documents4j.com/#/

  • pom.xml
<!--版本-->
<poi.version>5.2.2</poi.version>
<poitl.version>1.12.2</poitl.version>
<documents4j.version>1.0.3</documents4j.version>
<pdfbox.version>2.0.23</pdfbox.version>
<commons.version>2.11.0</commons.version>

<!--poi框架-->
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi</artifactId>
    <version>${poi.version}</version>
</dependency>

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>${poi.version}</version>
</dependency>

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-scratchpad</artifactId>
    <version>${poi.version}</version>
</dependency>

<!--注意对应poi的版本依赖:https://deepoove.com/poi-tl/ -->
<dependency>
    <groupId>com.deepoove</groupId>
    <artifactId>poi-tl</artifactId>
    <version>${poitl.version}</version>
</dependency>

<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>${commons.version}</version>
</dependency>

<!--word转pdf-->
<dependency>
    <groupId>com.documents4j</groupId>
    <artifactId>documents4j-client</artifactId>
    <version>${documents4j.version}</version>
</dependency>

<dependency>
    <groupId>com.documents4j</groupId>
    <artifactId>documents4j-transformer-msoffice-word</artifactId>
    <version>${documents4j.version}</version>
</dependency>

<dependency>
    <groupId>org.glassfish.jersey.inject</groupId>
    <artifactId>jersey-hk2</artifactId>
</dependency>

<!--pdf加水印-->
<dependency>
    <groupId>org.apache.pdfbox</groupId>
    <artifactId>pdfbox</artifactId>
    <version>${pdfbox.version}</version>
</dependency>

核心代码

  • 生成文件 DTO
/**
 * 生成文件 DTO
 */
@Data
public class RecordDTO {

    @NotNull(message = "模板文件ID不能为空")
    @ApiModelProperty("模板文件ID")
    private Long templateId;

    @NotEmpty(message = "ID和填写的数值不能为空")
    @ApiModelProperty("ID 和填写的数值")
    private LinkedHashMap<Long, Object> mapValue;

    @NotEmpty(message = "编码和填写的数值不能为空")
    @ApiModelProperty("编码和填写的数值")
    private LinkedHashMap<String, Object> mapCode;

    @ApiModelProperty("OA回填的数据")
    private LinkedHashMap<Long, String> mapUnion;
}
  • 生成文件
/**
* 替换 Word文件数据
* @param dto
* @param fileDTO
* @param template
* @param resUrl
* @param type
* @return
* @throws Exception
*/
@Override
public MultipartFile createXWPFTempAndConverter(RecordDTO dto, FileDTO fileDTO, TTemplate template, R<?> resUrl, Integer type) throws Exception {

    // 处理前端传过来的数据
    ConfigureBuilder builder = Configure.builder();
    LinkedHashMap<String, Object> mapCode = columnService.changeMap(dto.getMapCode(), dto.getTemplateId(), builder);
    log.info("传过来的参数:" + JSONObject.toJSON(dto));
    log.info("转换好的参数:" + JSONObject.toJSON(mapCode));

    // 把数据渲染到文件中去
    MultipartFile file = null;
    String name = DateUtil.format(new Date(), "yyyyMMdd") + String.format("%05d", new Random().nextInt(100000)) + ".docx";
    MultipartFile multipartFile = this.createMultipartFile(resUrl.getData().toString(), name, name);
    try (XWPFTemplate xwpfTemplate = XWPFTemplate.compile(multipartFile.getInputStream(), builder.build()).render(
        new HashMap<String, Object>() {{
            mapCode.forEach(this::put);
        }})) {

        // 将完成数据渲染的文档写入到输入流
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        xwpfTemplate.write(byteArrayOutputStream);

        // 将输入流转成 multipartFile
        file = this.createByteMultipartFile(byteArrayOutputStream, name, name);
    } catch (Exception e) {
        log.error("生成文件时发生异常: + " + e.getMessage());
        recordService.setRecord(dto, template, null, 1, "生成文件时发生异常: " + e.getMessage());
        throw new Exception("生成文件时发生异常: " + e.getMessage());
    }
    fileDTO.setName(name);
    if (ObjectUtils.isNotEmpty(type)) {
        if (type == 1) {
            return file;
        } else if (type == 2) {
            return getMultipartFile(dto, fileDTO, template, file);
        }
    }
    if (ObjectUtils.isNotEmpty(template.getFormat()) && template.getFormat() == 2) return getMultipartFile(dto, fileDTO, template, file);
    return file;
}
  • 转换数据类型
/**
* 拼接日期类型的编码和数字类似的数据
* @param map
* @return
*/
public LinkedHashMap<String, Object> changeMap(LinkedHashMap<String, Object> map, Long id, ConfigureBuilder builder) {
    LinkedHashMap<String, Object> newMap = new LinkedHashMap<>();
    map.forEach((k, v) -> {
        // 先处理日期格式的数据
        if (v instanceof String) {
            TColumn tColumnDate = this.getOne(new LambdaQueryWrapper<TColumn>().eq(TColumn::getTemplateId, id).eq(TColumn::getCode, k).eq(TColumn::getType, 2));
            if (ObjectUtils.isNotEmpty(tColumnDate) && ObjectUtils.isNotEmpty(v)) {
                List<TDateColumn> codeList = dateColumnService.list(new LambdaQueryWrapper<TDateColumn>().eq(TDateColumn::getColumnId, tColumnDate.getId()));
                codeList.forEach(c -> {
                    newMap.put(c.getCode(), LocalDataUtils.dateFormat(v.toString(), c.getType()));
                });
            }
        }
        newMap.put(k, v);

        // 在处理数字转大小写的
        if (v instanceof String) {
            TColumn tColumnNum = this.getOne(new LambdaQueryWrapper<TColumn>().eq(TColumn::getTemplateId, id).eq(TColumn::getCode, k).eq(TColumn::getType, 5));
            if (ObjectUtils.isNotEmpty(tColumnNum) && ObjectUtils.isNotEmpty(v)) {
                List<TNumberColumn> numList = numberColumnService.list(new LambdaQueryWrapper<TNumberColumn>().eq(TNumberColumn::getColumnId, tColumnNum.getId()));
                numList.forEach( m -> {
                    if (m.getType() == 9) {
                        newMap.put(m.getCode(), NumberToChineseUtils.toChinese(v.toString()));
                    } else {
                        newMap.put(m.getCode(), v.toString());
                    }
                });
            }
        }

        // 处理单选和多选数据
        if (v instanceof List) {
            List<TOpionColumn> opionList = Lists.newArrayList();
            TColumn columnMany = this.getOne(new LambdaQueryWrapper<TColumn>().eq(TColumn::getCode, k).eq(TColumn::getTemplateId, id).eq(TColumn::getType, 4).eq(TColumn::getProperty, 11));
            if (ObjectUtils.isNotEmpty(columnMany)) {
                opionList = opionColumnService.list(new LambdaQueryWrapper<TOpionColumn>().eq(TOpionColumn::getColumnId, columnMany.getId()));
            }

            // 多选为空
            if (((List) v).size() == 0) {
                opionList.forEach(c -> {
                    TextRenderData data = new TextRenderData();
                    data.setText("\u2610");
                    newMap.put(c.getCode(), data.getText());
                });
            } else {
                // 多选有数据
                opionList.stream().filter(c -> ((List) v).contains(c.getValue())).forEach(o -> {
                    TextRenderData data = new TextRenderData();
                    data.setText("\u2611");
                    newMap.put(o.getCode(), data.getText());
                });
                opionList.stream().filter(c -> !((List) v).contains(c.getValue())).forEach(o -> {
                    TextRenderData data = new TextRenderData();
                    data.setText("\u2610");
                    newMap.put(o.getCode(), data.getText());
                });

            }

            // 单选数据
            ((List) v).forEach(m -> {
                TColumn columnOnly = this.getOne(new LambdaQueryWrapper<TColumn>().eq(TColumn::getCode, k).eq(TColumn::getTemplateId, id).eq(TColumn::getType, 4).eq(TColumn::getProperty, 10));
                if (ObjectUtils.isNotEmpty(columnOnly)) {
                    newMap.put(k, m.toString());
                }
            });
        }

        // 处理表格数据
        if (v instanceof LinkedHashMap) {
            ((LinkedHashMap) v).forEach((s, x) -> {
                builder.bind(new DetailTablePolicy(), k);
            });
        }
    });
    return newMap;
}
  • 文件转换方法
/**
* 根据 ByteArrayOutputStream 转化文件为 MultipartFile
* @param outputStream
* @param name
* @param fileName
* @return
*/
@Override
public MultipartFile createByteMultipartFile(ByteArrayOutputStream outputStream, String name, String fileName) {
    return new MultipartFileUtils(name, fileName, MediaType.APPLICATION_OCTET_STREAM_VALUE, outputStream.toByteArray());
}
  • 保存数据
/**
* 保存对应的列和对应列的值
* @param dto
* @param id
*/
@Transactional(rollbackFor = Exception.class)
public void setColumeAndValue(RecordDTO dto, Long id) {

    // 保存每一列填写的值
    dto.getMapValue().forEach((key, value) -> {
        // 处理选择框的数据
        if (value instanceof List) {
            ((List) value).forEach(m -> {
                setColumValue(id, key, m);
            });
        }

        // 处理表格的数据
        if (value instanceof LinkedHashMap) {
            List<TTableColumn> tableList = tableColumnService.list(new LambdaQueryWrapper<TTableColumn>().eq(TTableColumn::getColumnId, key));
            ((LinkedHashMap) value).forEach((s, x) -> {
                tableList.forEach(t -> {
                    if (s.equals(t.getValue().trim().replace("!!", "<br>"))) {
                        ((List) x).forEach(m -> {
                            setColumValue(id, t.getId(), m);
                        });
                    }
                });
            });
        }

        // 处理普通列的数据
        if (value instanceof String) {
            setColumValue(id, key, value);
        }
    });

    // 处理OA回填数据
    if (ObjectUtils.isNotEmpty(dto.getMapUnion())) {
        // 先处理老数据
        List<TColumn> columnList = this.list(new LambdaQueryWrapper<TColumn>().eq(TColumn::getTemplateId, dto.getTemplateId()));
        columnList.forEach(c -> {
            c.setSource(null);
        });
        this.updateBatchById(columnList);
        dto.getMapUnion().forEach((key, value) -> {
            TColumn tColumn = this.getOne(new LambdaQueryWrapper<TColumn>().eq(TColumn::getId, key));
            tColumn.setSource(value);
            this.updateById(tColumn);
        });
    }
}

/**
* 保存填写的数据
* @param key
* @param value
*/
private void setColumValue(Long id, Long key, Object value) {
    TColumnValue tColumnValue = new TColumnValue();
    tColumnValue.setColumnId(key);
    tColumnValue.setValue(value.toString());
    tColumnValue.setRecordId(id);
    columnValueService.save(tColumnValue);
}
  • 再一次 DTO
/**
 * 生成文件 DTO
 */
@EqualsAndHashCode(callSuper = true)
@Data
public class RecordAgainDTO extends RecordDTO{

}
  • 再一次核心方法
/**
* 封装再一次接口数据
* @param record
* @return
*/
@Override
public RecordAgainDTO packRecordAgain(TRecord record) {

    // 先封装模板ID
    LinkedHashMap<String, Object> mapCode = new LinkedHashMap<String, Object>();
    LinkedHashMap<Long, Object> mapValue = new LinkedHashMap<Long, Object>();
    RecordAgainDTO againDTO = new RecordAgainDTO();
    againDTO.setTemplateId(record.getTemplateId());

    // 查询列数据
    List<TColumn> columnlist = this.list(new LambdaQueryWrapper<TColumn>().eq(TColumn::getTemplateId, record.getTemplateId()));
    columnlist.forEach(item -> {
        if (ObjectUtils.isNotEmpty(item.getType())) {

            // 处理表格属性的数据
            if (item.getType() == 3) {
                LinkedHashMap<String, List<String>> map = new LinkedHashMap<>();
                List<TTableColumn> tableColumns = tableColumnService.list(new LambdaQueryWrapper<TTableColumn>().eq(TTableColumn::getColumnId, item.getId()));
                tableColumns.forEach(t -> {
                    List<String> value = Lists.newArrayList();
                    List<TColumnValue> columnValues = columnValueService.list(new LambdaQueryWrapper<TColumnValue>().eq(TColumnValue::getColumnId, t.getId()).eq(TColumnValue::getRecordId, record.getId()));
                    if (CollectionUtils.isNotEmpty(columnValues)) {
                        columnValues.forEach(m -> {
                            value.add(m.getValue().trim());
                        });
                    } else {
                        value.add("");
                    }
                    map.put(t.getValue().trim(), value);
                });
                mapCode.put(item.getCode(), map);
                mapValue.put(item.getId(), map);
            } else if (item.getType() == 4) {

                // 处理选择框数据
                List<String> value = Lists.newArrayList();
                List<TColumnValue> columnValues = columnValueService.list(new LambdaQueryWrapper<TColumnValue>().eq(TColumnValue::getColumnId, item.getId()).eq(TColumnValue::getRecordId, record.getId()));
                if (CollectionUtils.isNotEmpty(columnValues)) {
                    columnValues.forEach(m -> {
                        value.add(m.getValue());
                    });
                } else {
                    value.add("");
                }
                mapCode.put(item.getCode(), value);
                mapValue.put(item.getId(), value);
            } else if (item.getType() == 6) {

                // 处理字典类型数据
                List<TDictColumn> dictList = dictColumnService.list(new LambdaQueryWrapper<TDictColumn>().eq(TDictColumn::getColumnId, item.getId()));
                dictList.forEach(m -> {
                    TColumnValue dictValue = columnValueService.getOne(new LambdaQueryWrapper<TColumnValue>().eq(TColumnValue::getColumnId, m.getId()).eq(TColumnValue::getRecordId, record.getId()));
                    if (ObjectUtils.isNotEmpty(dictValue)) {
                        mapCode.put(m.getCode(), dictValue.getValue());
                        mapValue.put(m.getId(), dictValue.getValue());
                    }
                });
            } else {

                // 处理普通列对应的值
                List<TColumnValue> columnValues = columnValueService.list(new LambdaQueryWrapper<TColumnValue>().eq(TColumnValue::getColumnId, item.getId()).eq(TColumnValue::getRecordId, record.getId()));
                columnValues.forEach(m -> {
                    mapCode.put(item.getCode(), m.getValue());
                    mapValue.put(item.getId(), m.getValue());
                });
            }
        }
    });

    // 封装最后的数据
    againDTO.setMapCode(mapCode);
    againDTO.setMapValue(mapValue);
    return againDTO;
}

备注

本地转换

在使用 Documents4j 进行文件转换的时候,使用架包:

<dependency>
    <groupId>com.documents4j</groupId>
    <artifactId>documents4j-local</artifactId>
    <version>1.0.3</version>
</dependency>

部署到 Linux 上服务器上是不生效的。

主要原因是该架包为本地服务架包,而 Documents4j 进行文件转换,使用的是 Win 的 Office 服务。而 Linux 服务器上没有对应转换所需要的服务。

远程转换

使用架包:

<dependency>
    <groupId>com.documents4j</groupId>
    <artifactId>documents4j-client</artifactId>
    <version>1.0.3</version>
</dependency>

然后通过远程接口调用的形式即可完成转换。

Win 系统和 Win Service 系统

一开始 Documents4j 部署在一台 Win10 系统的笔记本上,对应的 Win Office 版本 2016,但在项目运行的过程之中,会偶发性报错,导致项目不稳定。

最后把 Documents4j 部署在一台 Win Server 服务器系统上,对应的 Win Office 版本 2016,这个时候项目运行稳定。

依赖

Documents4j 的运行依赖 JDK,部署 Documents4j 前先部署好对应的 JDK。

Documents4j 运行启动的命令:

@echo off
%1 mshta vbscript:CreateObject("WScript.Shell").Run("%~s0 ::",0,FALSE)(window.close)&&exit
java -jar C:\documents4j\documents4j-server-standalone-1.1.8-shaded.jar http://192.168.xxxx:9998 --level debug > C:\documents4j\documents4j.log 2>&1 &
exit

每转换一次文件,服务器会打开一个 Word 进程,该进程需要手动关闭,否则时间久了,服务器内存会被占满。可使用下面脚本集合Win 定时任务去执行对应操作。

taskkill /f /t /im winword.exe 

后端解析富文本编码

前端传过来的数据是富文本形式的,再次给前端的时候,需要给文本。那么如何过滤掉对应的富文本标签。

  • 引入架包
  <dependency>
      <groupId>org.jsoup</groupId>
      <artifactId>jsoup</artifactId>
      <version>1.16.1</version>
</dependency>
  • 定义方法
/**
    * 解析富文本数据
    * @param richText
    * @return
    */
public static String parseText(String richText) {
    if (StringUtils.isBlank(richText)) {
        return "";
    }
    Document doc = Jsoup.parse(richText);
    Elements mentions = doc.select("span.mention");
    for (Element mention : mentions) {
        String value = mention.attr("data-value");
        mention.text("@" + value);
    }
    return doc.text();
}

获取全球地理编码

项目中需要计算两地之间的距离,前端输入地名,后端计算出数值。(这个需求如何把地名转换成经纬度才是关键,第三方接口收费比较贵,只能思考免费的方案。)

自己维护全球主要地点的经纬度是一个不错的方案。如何获取这批数据?

地理数据库:GeoNames

  • 访问下载地址,下载 allCountries.zip 文件,然后解压下载后的文件,得到 allCountries.txt 文件。

https://download.geonames.org/export/dump/

  • 创建数据库表
CREATE TABLE `allcountries` (
    `geonameid` int(10) unsigned NOT NULL COMMENT '地理位置的唯一ID',
    `name` varchar(200) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '地理位置名称(UTF-8)',
    `asciiname` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '地理位置的ASCII名称',
    `alternatenames` text CHARACTER SET utf8mb4 COMMENT '替代名称,逗号分隔',
    `latitude` decimal(10,7) DEFAULT NULL COMMENT '纬度(WGS84)',
    `longitude` decimal(10,7) DEFAULT NULL COMMENT '经度(WGS84)',
    `feature_class` char(1) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '特征类别,参见http://www.geonames.org/export/codes.html',
    `feature_code` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '特征代码,参见http://www.geonames.org/export/codes.html',
    `country_code` char(2) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'ISO-3166 2字母国家代码',
    `cc2` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备选国家代码,逗号分隔',
    `admin1_code` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一级行政区代码',
    `admin2_code` varchar(80) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '二级行政区代码',
    `admin3_code` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '三级行政区代码',
    `admin4_code` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '四级行政区代码',
    `population` bigint(20) DEFAULT NULL COMMENT '人口数量',
    `elevation` int(11) DEFAULT NULL COMMENT '海拔高度(米)',
    `dem` int(11) DEFAULT NULL COMMENT '数字高程模型,SRTM3或GTOPO30',
    `timezone` varchar(40) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '时区ID',
    `modification_date` date DEFAULT NULL COMMENT '最后修改日期(YYYY-MM-DD)',
    PRIMARY KEY (`geonameid`),
    KEY `idx_country_code` (`country_code`),
    KEY `idx_name` (`name`(50)),
    KEY `idx_feature` (`feature_class`,`feature_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='GeoNames地理位置数据表';
  • 数据库工具执行命令把文件里的数据导入到数据库
   LOAD DATA LOCAL INFILE '/path/to/allCountries.txt' -- 文件地址
   INTO TABLE AllCountries 
   CHARACTER SET utf8mb4
   FIELDS TERMINATED BY '\t' 
   (geonameid, name, asciiname, alternatenames, latitude, longitude, 
    feature_class, feature_code, country_code, cc2, admin1_code, 
    admin2_code, admin3_code, admin4_code, population, elevation, 
    dem, timezone, modification_date);
  • 执行SQL查询,查询出主要城市的数据信息
SELECT * FROM `allcountries` WHERE feature_code IN ('PPLC', 'PPLA', 'PPLA2', 'PPLA3', 'PPLA4');
  • 把查询出的数据导出,然后整理后导入到自己的业务库内。

计算两地之间的距离

计算两地之间的距离有两种方式:

  1. 计算直线距离。
  2. 计算曲面距离。

这里以计算曲面距离为例。

  • 定义实体类
@Data
public class Location {
    // 位置名称
    private String name;
    // 经度
    private double longitude;
    // 纬度
    private double latitude;

}
  • 定义计算方法
public class Calculate {

    // 地球半径(单位:千米)
    private static final double EARTH_RADIUS = 6371.0;

    /**
     * 计算两个位置之间的距离
     * @param location1 位置1
     * @param location2 位置2
     * @return 距离(单位:千米)
     */
    public double calculateDistance(Location location1, Location location2) {
        return calculateDistance(location1.getLatitude(), location1.getLongitude(), location2.getLatitude(), location2.getLongitude());
    }

    /**
     * 计算两个坐标之间的距离
     * @param lat1 位置1的纬度
     * @param lon1 位置1的经度
     * @param lat2 位置2的纬度
     * @param lon2 位置2的经度
     * @return 距离(单位:千米)
     */
    private double calculateDistance(double lat1, double lon1, double lat2, double lon2) {

        // 转换为弧度
        double radLat1 = Math.toRadians(lat1);
        double radLon1 = Math.toRadians(lon1);
        double radLat2 = Math.toRadians(lat2);
        double radLon2 = Math.toRadians(lon2);

        // 经度差
        double deltaLon = radLon2 - radLon1;

        // 纬度差
        double deltaLat = radLat2 - radLat1;

        // Haversine公式
        double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

        // 计算距离
        double distance = EARTH_RADIUS * c;

        // 保留两位小数
        return Math.round(distance * 100.0) / 100.0;
    }
}

记录用户的查询记录

系统列表页的查询,每次都需要记录不同用户最新一次的查询,以方便用户下次登录时能看到上次的查询数据。

实现这个需求,其实只需要把用户的查询记录存储在缓存即可。为了后期统计方便,在增加一个数据库表。

  • 创建数据库表
CREATE TABLE `t_query_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_id` bigint(20) DEFAULT NULL COMMENT '用户ID',
  `name` varchar(64) DEFAULT NULL COMMENT '用户名',
  `page_no` int(20) DEFAULT NULL COMMENT '查询当前页',
  `page_size` int(11) DEFAULT NULL COMMENT '当前页数大小',
  `params` varchar(64) DEFAULT NULL COMMENT '总的查询条件',
  `label` tinyint(2) DEFAULT NULL COMMENT '查询类型:使用数字区分不同的菜单',
  `remark` varchar(64) DEFAULT NULL COMMENT '备注',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted` tinyint(2) DEFAULT '0' COMMENT '表单状态(0-正常,1-删除)',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统查询记录表';
  • 定义接口方法
@Service
public class TQueryLogHandler {

    @Resource
    private ITQueryLogService queryLogService;

    @Resource
    private RedisClientUtils redisUtils;

    /**
     * 保存和更新查询记录
     * @param dto
     */
    public Boolean saveUpdate(QueryLogDTO dto) {
        long userId = LoginUserUtils.get().getId();
        String name = LoginUserUtils.get().getCnName();
        TQueryLog tQueryLog = queryLogService.getOne(new LambdaQueryWrapper<TQueryLog>().eq(TQueryLog::getUserId, userId).eq(TQueryLog::getLabel, dto.getLabel()));
        if (ObjectUtils.isEmpty(tQueryLog)) tQueryLog = new TQueryLog();
        tQueryLog.setUserId(userId);
        tQueryLog.setName(name);
        BeanUtils.copyProperties(dto, tQueryLog);
        queryLogService.saveOrUpdate(tQueryLog);
        String key = String.format("querylog:userId:%d:label:%d", userId, dto.getLabel());
        return redisUtils.set(key, tQueryLog);
    }

    /**
     * 查询更新记录
     * @param label
     * @return
     */
    public QueryLogVO get(Integer label) {
        QueryLogVO vo = new QueryLogVO();
        long userId = LoginUserUtils.get().getId();
        String key = String.format("querylog:userId:%d:label:%d", userId, label);
        TQueryLog queryLog = (TQueryLog) redisUtils.get(key);
        if (ObjectUtils.isEmpty(queryLog)) {
            queryLog = queryLogService.getOne(new LambdaQueryWrapper<TQueryLog>().eq(TQueryLog::getUserId, userId).eq(TQueryLog::getLabel, label));
            if (ObjectUtils.isEmpty(queryLog)) {
                vo.setPageNo(1);
                vo.setPageSize(10);
                return vo;
            }
            redisUtils.set(key, queryLog);
        }
        BeanUtils.copyProperties(queryLog, vo);
        return vo;
    }
}

数据库实现消息队列

前端过来一批任务,对应接口需要一个一个处理(业务需求)。

这里能想到的就是消息队列,但数据量不是很大,并且后期要做统计,因此使用数据库去实现。

  • 创建数据库表
CREATE TABLE `t_task_queue` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_id` bigint(20) DEFAULT NULL COMMENT '用户ID',
  `task_id` varchar(64) DEFAULT NULL COMMENT '任务ID',
  `batch_id` varchar(64) DEFAULT NULL COMMENT '批次ID',
  `url` varchar(800) DEFAULT NULL COMMENT 'URL',
  `status` tinyint(2) DEFAULT '1' COMMENT '状态:0-待处理,1-处理中,2-处理完成,3-处理失败',
  `create_by` varchar(32) DEFAULT '' COMMENT '创建人',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(32) DEFAULT '' COMMENT '更新人',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `remark` varchar(126) DEFAULT NULL COMMENT '备注',
  `deleted` tinyint(2) DEFAULT '0' COMMENT '记录状态(0正常,1删除)',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='任务列表';
  • 定义接口方法
/**
* 业务保持方法
*/
public R<?> save(InfoListDTO dtoList) {
    // 先过滤已经获取的数据
    boolean hasTask = taskQueueService.count(new LambdaQueryWrapper<TTaskQueue>().eq(TTaskQueue::getStatus, 0)) > 0;
    List<String> urlList = dtoList.getUrlList().stream().filter(url -> taskQueueService.count(new LambdaQueryWrapper<TTaskQueue>().eq(TTaskQueue::getUrl, url).eq(TTaskQueue::getDeleted, 0)) == 0).collect(Collectors.toList());
    if (CollectionUtil.isEmpty(urlList)) throw new RhxDataException("所有的连接都已经获取过,请勿重复获取!");

    // 为每个URL创建一个任务
    long userId = LoginUserUtils.get().getId();
    String batchId = UUID.randomUUID().toString();
    taskQueueService.saveBatch(urlList.stream()
                               .map(url -> TTaskQueue.builder()
                                    .userId(userId)
                                    .taskId(UUID.randomUUID().toString())
                                    .batchId(batchId)
                                    .url(url)
                                    .way(dtoList.getWay())
                                    .tag(dtoList.getTag())
                                    .status(0)
                                    .build())
                               .collect(Collectors.toList()));

    // 异步执行任务, 这样就能实现保存后立马执行。
    if (!hasTask) {
        CompletableFuture.runAsync(() -> {
            taskQueueService.processTask();
        });
    }
    log.info("用户{}的任务已加入队列,共{}个URL", userId, urlList.size());
    return R.ok();
}

保存后立刻执行

如果保存后立刻执行,那么需要添加这段代码。

// 异步执行任务
if (!hasTask) {
    CompletableFuture.runAsync(() -> {
        taskQueueService.processTask();
    });
}

保存后不立刻执行

如果不需要,那么采用定时器,需要在启动类上添加注解:@EnableScheduling。对应的方法上添加注解:@Scheduled(fixedDelay = 120000)

  • 任务处理方法
/**
  * 任务处理器
 */
private final AtomicBoolean isProcessing = new AtomicBoolean(false);
public void processTask() {
    if (!isProcessing.compareAndSet(false, true)) return;
    try {
        while (true) {
            TTaskQueue taskQueue = this.getOne(new LambdaQueryWrapper<TTaskQueue>().eq(TTaskQueue::getStatus, 0).orderByAsc(TTaskQueue::getCreateTime).last("LIMIT 1 FOR UPDATE"));
            if (taskQueue == null) return;

            taskQueue.setStatus(1);
            taskQueue.setUpdateTime(new Date());
            this.updateById(taskQueue);

            // 获取飞书记录
            String url = taskQueue.getUrl();
            String batchId = taskQueue.getBatchId();
            try {
                String record = getLarkRecord(url);
                log.info("处理任务{}, URL={}, 任务记录={}", taskQueue.getTaskId(), url, record);
                Long id = informationService.saveInitialColAndValue(taskQueue.getWay(), url, taskQueue.getTag(), record, batchId, taskQueue.getUserId());
                checkStatus(id, taskQueue);
            } catch (Exception e) {
                log.error("获取飞书记录失败, URL={}", url);
                finishTask(taskQueue, 3);
            }
        }
    } finally {
        isProcessing.set(false);
    }
}
  • 检查任务执行情况
/**
     * 检查任务状态
     * @param id
     */
private void checkStatus(Long id, TTaskQueue taskQueue) {
    try {
        LocalDateTime endTime = LocalDateTime.now().plusMinutes(2);
        while (LocalDateTime.now().isBefore(endTime)) {
            Thread.sleep(8000);
            TInformation info = informationService.getById(id);
            if (info == null || info.getStatus() != 3) {
                finishTask(taskQueue, 2);
                return;
            }
        }
        TInformation info = informationService.getById(id);
        if (info != null && info.getStatus() == 3) {
            info.setStatus(2);
            informationService.updateById(info);
        }
        finishTask(taskQueue, 2);

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        finishTask(taskQueue, 3);
    }
}
  • 统一处理未完成的任务
    /**
     * 统一处理任务完成的方法
     * @param taskQueue
     * @param status
     */
    private void finishTask(TTaskQueue taskQueue, Integer status) {
        try {
            taskQueue.setStatus(status);
            taskQueue.setUpdateTime(new Date());
            this.updateById(taskQueue);
        } catch (Exception e){
            log.error("更新任务状态失败:taskId={}, status={}", taskQueue.getTaskId(), status);
        }
    }

文章作者: L Q
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 L Q !
  目录