当前位置: 首页 > news >正文

模板引擎驱动的动态计划书生成系统

在金融、保险、咨询等行业,为客户提供定制化的产品方案计划书是业务开展的关键环节。传统手工制作或静态模板修改的方式不仅效率低下,还容易出现数据不一致、格式混乱等问题。本文将系统讲解如何基于模板引擎构建一套支持多产品组合的动态计划书生成系统,通过 "模板定义 - 数据整合 - 动态渲染 - 多格式输出" 的全流程自动化,将计划书生成时间从小时级缩短至秒级,同时确保方案的专业性和准确性。

一、动态计划书系统的业务价值与技术挑战

动态计划书生成系统绝非简单的 "数据填充" 工具,而是业务与技术深度融合的产物。要构建一套实用的系统,首先需要理解其核心价值和面临的技术挑战。

1.1 业务痛点解析

传统计划书制作方式存在四大痛点:

  • 效率低下:一份复杂的保险组合方案需要经纪人手动计算数据、调整格式、核对条款,平均耗时 2-4 小时
  • 易出错:产品费率计算、利益演示表生成等环节人工操作错误率高达 15%
  • 不灵活:客户需求变化时,修改方案需要从头调整,难以快速响应
  • 标准化不足:不同经纪人制作的计划书风格、内容差异大,影响企业形象

某大型保险公司的调研数据显示,引入动态计划书系统后,经纪人的方案制作效率提升 80%,错误率降低至 0.5% 以下,客户方案接受率提高 23%。

1.2 核心技术需求

一套完善的动态计划书系统需要满足以下技术需求:

  • 模板多样化:支持 Word、PDF、HTML 等多种格式模板
  • 数据整合能力:对接产品库、客户信息系统、费率计算引擎等多数据源
  • 动态逻辑处理:模板中支持条件判断、循环、计算等复杂逻辑
  • 产品组合支持:灵活处理多个产品的组合方案,自动生成汇总信息
  • 高性能:复杂方案的生成时间控制在 3 秒以内
  • 易用性:业务人员可通过可视化工具维护模板,无需技术背景

1.3 模板引擎选型对比

目前主流的模板引擎各有特点,适用场景不同:

模板引擎优势劣势适用场景
Freemarker功能全面、语法灵活、社区成熟学习曲线较陡复杂文档生成、Web 页面
Thymeleaf自然模板、与 HTML 无缝集成处理复杂逻辑能力较弱Web 页面、简单文档
Velocity语法简单、轻量级已停止更新,功能有限简单模板场景
Apache POI直接操作 Office 文档代码复杂,不支持模板逻辑需精确控制格式的 Word/Excel
Docx4j强大的 Word 文档处理能力体积大,学习成本高复杂 Word 文档生成

经过综合评估,我们的系统采用Freemarker+Docx4j的组合方案:

  • 用 Freemarker 处理模板中的动态逻辑和数据填充
  • 用 Docx4j 实现 Word 文档的精确控制和格式转换
  • 同时支持 HTML 预览和 PDF 导出,满足不同场景需求

二、系统架构设计与核心模型

动态计划书系统需要兼顾灵活性和性能,合理的架构设计是系统成功的基础。我们采用分层架构,清晰划分各组件职责。

2.1 整体架构

系统采用经典的分层架构,从上到下分为:

  • 接入层:处理客户端请求,包含 API 网关、认证授权和请求路由
  • 应用层:核心业务服务实现,包括计划书生成、模板管理等
  • 领域层:核心领域逻辑,封装模板处理、数据加工和格式转换能力
  • 基础设施层:提供数据存储、文件管理和外部系统集成能力

这种分层架构的优势在于:

  1. 各层职责单一,便于维护和扩展
  2. 领域层与具体技术实现解耦,可灵活替换模板引擎
  3. 应用层专注业务流程编排,不涉及具体技术细节

2.2 核心业务流程

动态计划书生成的核心流程如下:

流程说明:

  1. 系统接收包含客户 ID、产品组合、模板 ID 等信息的生成请求
  2. 校验请求参数的完整性和合法性
  3. 从客户管理系统获取客户基本信息和需求
  4. 解析产品组合方案,确定包含的产品及参数
  5. 调用产品计算引擎,得到各产品的利益演示、费率等数据
  6. 根据模板 ID 获取对应的模板内容
  7. 将客户数据、产品数据整合成模板所需的数据集
  8. 使用模板引擎进行动态渲染,处理条件、循环等逻辑
  9. 根据需求生成 Word、PDF 或 HTML 格式的计划书
  10. 返回生成的计划书文件或访问地址

2.3 核心数据模型

基于业务需求,设计五大核心数据模型:

  1. 模板模型 (Template):存储模板基本信息和内容
  2. 产品模型 (Product):定义产品基本信息和计算规则
  3. 产品组合模型 (ProductPackage):描述多个产品的组合方案
  4. 客户模型 (Customer):记录客户基本信息和需求
  5. 计划书模型 (Proposal):存储生成的计划书信息

模型关系如下:

下面是具体的数据库表结构设计:

-- 模板表
CREATE TABLE template (id BIGINT NOT NULL AUTO_INCREMENT COMMENT '模板ID',template_code VARCHAR(64) NOT NULL COMMENT '模板编码',template_name VARCHAR(128) NOT NULL COMMENT '模板名称',template_type VARCHAR(32) NOT NULL COMMENT '模板类型:WORD, PDF, HTML',product_type VARCHAR(32) NOT NULL COMMENT '适用产品类型:INSURANCE, FINANCE, CONSULTING',content MEDIUMTEXT NOT NULL COMMENT '模板内容',preview_image VARCHAR(255) COMMENT '预览图URL',status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',created_by VARCHAR(64) NOT NULL COMMENT '创建人',created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',updated_by VARCHAR(64) COMMENT '更新人',updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (id),UNIQUE KEY uk_template_code (template_code),KEY idx_product_type (product_type),KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模板表';-- 产品表
CREATE TABLE product (id BIGINT NOT NULL AUTO_INCREMENT COMMENT '产品ID',product_code VARCHAR(64) NOT NULL COMMENT '产品编码',product_name VARCHAR(128) NOT NULL COMMENT '产品名称',product_type VARCHAR(32) NOT NULL COMMENT '产品类型',description TEXT COMMENT '产品描述',calculation_config JSON NOT NULL COMMENT '计算配置,JSON格式',status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-下架,1-在售',created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (id),UNIQUE KEY uk_product_code (product_code),KEY idx_product_type (product_type),KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品表';-- 产品组合表
CREATE TABLE product_package (id BIGINT NOT NULL AUTO_INCREMENT COMMENT '组合ID',package_code VARCHAR(64) NOT NULL COMMENT '组合编码',package_name VARCHAR(128) NOT NULL COMMENT '组合名称',product_type VARCHAR(32) NOT NULL COMMENT '产品类型',description TEXT COMMENT '组合描述',status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (id),UNIQUE KEY uk_package_code (package_code),KEY idx_product_type (product_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品组合表';-- 产品组合明细表
CREATE TABLE product_package_item (id BIGINT NOT NULL AUTO_INCREMENT COMMENT '明细ID',package_id BIGINT NOT NULL COMMENT '组合ID',product_id BIGINT NOT NULL COMMENT '产品ID',sort_order INT NOT NULL DEFAULT 0 COMMENT '排序号',config JSON COMMENT '产品在组合中的配置',created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',PRIMARY KEY (id),UNIQUE KEY uk_package_product (package_id, product_id),KEY idx_package_id (package_id),KEY idx_product_id (product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品组合明细表';-- 客户表
CREATE TABLE customer (id BIGINT NOT NULL AUTO_INCREMENT COMMENT '客户ID',customer_no VARCHAR(64) NOT NULL COMMENT '客户编号',name VARCHAR(64) NOT NULL COMMENT '客户姓名',gender VARCHAR(2) COMMENT '性别:男,女',birthday DATE COMMENT '出生日期',phone VARCHAR(20) COMMENT '手机号码',email VARCHAR(64) COMMENT '邮箱',address VARCHAR(255) COMMENT '地址',customer_type VARCHAR(32) COMMENT '客户类型',created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (id),UNIQUE KEY uk_customer_no (customer_no),KEY idx_phone (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户表';-- 计划书表
CREATE TABLE proposal (id BIGINT NOT NULL AUTO_INCREMENT COMMENT '计划书ID',proposal_no VARCHAR(64) NOT NULL COMMENT '计划书编号',customer_id BIGINT NOT NULL COMMENT '客户ID',template_id BIGINT NOT NULL COMMENT '模板ID',package_id BIGINT NOT NULL COMMENT '产品组合ID',params JSON NOT NULL COMMENT '生成参数',file_url VARCHAR(255) NOT NULL COMMENT '文件URL',file_type VARCHAR(32) NOT NULL COMMENT '文件类型:WORD, PDF, HTML',status VARCHAR(32) NOT NULL COMMENT '状态:GENERATING-生成中,SUCCESS-成功,FAIL-失败',created_by VARCHAR(64) NOT NULL COMMENT '创建人',created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (id),UNIQUE KEY uk_proposal_no (proposal_no),KEY idx_customer_id (customer_id),KEY idx_package_id (package_id),KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计划书表';

表设计要点:

  • 用 JSON 类型存储计算配置和生成参数,兼顾灵活性和查询效率
  • 产品组合采用主从表设计,支持任意数量的产品组合
  • 模板表按类型区分,便于管理和查询
  • 计划书表记录完整的生成信息,支持追溯和重新生成

三、模板引擎核心实现

模板引擎是动态计划书系统的核心组件,负责将静态模板与动态数据结合,生成最终的文档。我们将深入讲解如何基于 Freemarker 和 Docx4j 实现这一核心功能。

3.1 模板引擎接口设计

为了支持多种模板引擎的灵活切换,首先定义统一的模板引擎接口:

import java.util.Map;/*** 模板引擎接口* 定义模板渲染的标准方法,便于不同模板引擎的实现和切换** @author ken*/
public interface TemplateEngine {/*** 渲染模板** @param templateContent 模板内容* @param dataModel 数据模型* @return 渲染后的内容*/String render(String templateContent, Map<String, Object> dataModel);/*** 获取模板引擎类型** @return 模板引擎类型标识*/String getType();
}

这个接口定义了两个核心方法:

  • render:接收模板内容和数据模型,返回渲染后的内容
  • getType:返回模板引擎类型,用于区分不同实现

3.2 Freemarker 引擎实现

Freemarker 是一款功能强大的模板引擎,支持复杂的逻辑处理,非常适合动态计划书生成场景:

import freemarker.cache.StringTemplateLoader;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;import java.io.IOException;
import java.io.StringWriter;
import java.util.Map;/*** Freemarker模板引擎实现* 基于Freemarker实现模板渲染功能** @author ken*/
@Slf4j
@Component
public class FreemarkerTemplateEngine implements TemplateEngine {private final Configuration configuration;/*** 初始化Freemarker配置*/public FreemarkerTemplateEngine() {// 创建Freemarker配置实例configuration = new Configuration(Configuration.VERSION_2_3_32);// 配置模板加载器,使用字符串加载器StringTemplateLoader templateLoader = new StringTemplateLoader();configuration.setTemplateLoader(templateLoader);// 设置编码格式configuration.setDefaultEncoding("UTF-8");// 配置异常处理策略configuration.setTemplateExceptionHandler(freemarker.template.TemplateExceptionHandler.RETHROW_HANDLER);log.info("Freemarker模板引擎初始化完成,版本:{}", Configuration.VERSION_2_3_32);}@Overridepublic String render(String templateContent, Map<String, Object> dataModel) {log.info("开始使用Freemarker渲染模板,数据模型大小:{}", dataModel != null ? dataModel.size() : 0);// 参数校验if (!StringUtils.hasText(templateContent)) {log.error("模板内容为空,无法进行渲染");throw new IllegalArgumentException("模板内容不能为空");}if (dataModel == null) {log.warn("数据模型为空,将使用空模型进行渲染");}try (StringWriter writer = new StringWriter()) {// 创建模板Template template = new Template("dynamic-template", templateContent, configuration);// 渲染模板long startTime = System.currentTimeMillis();template.process(dataModel, writer);long endTime = System.currentTimeMillis();log.info("Freemarker模板渲染完成,耗时:{}ms", endTime - startTime);return writer.toString();} catch (TemplateException e) {log.error("模板语法错误", e);throw new RuntimeException("模板语法错误:" + e.getMessage(), e);} catch (IOException e) {log.error("模板渲染IO异常", e);throw new RuntimeException("模板渲染失败:" + e.getMessage(), e);}}@Overridepublic String getType() {return "FREEMARKER";}
}

这个实现类完成了 Freemarker 的初始化和模板渲染功能,关键点包括:

  1. 使用 StringTemplateLoader 加载模板内容,支持动态传入模板
  2. 配置 UTF-8 编码,避免中文乱码问题
  3. 设置异常处理策略,确保错误能够被正确捕获和处理
  4. 记录渲染时间,便于性能监控

3.3 Word 文档处理实现

对于 Word 格式的计划书,我们需要使用 Docx4j 进行处理,它能精确控制 Word 文档的各种元素:

import org.docx4j.Docx4J;
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
import org.docx4j.openpackaging.parts.WordprocessingML.MainDocumentPart;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;/*** Word文档处理器* 负责Word文档的生成、编辑和转换** @author ken*/
@Component
public class WordDocumentProcessor {/*** 将HTML内容转换为Word文档** @param htmlContent HTML内容* @return Word文档的字节数组*/public byte[] convertHtmlToWord(String htmlContent) {log.info("开始将HTML转换为Word文档,内容长度:{}", htmlContent != null ? htmlContent.length() : 0);if (!StringUtils.hasText(htmlContent)) {log.error("HTML内容为空,无法转换为Word文档");throw new IllegalArgumentException("HTML内容不能为空");}try {// 创建Word文档包WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.createPackage();MainDocumentPart mainDocumentPart = wordMLPackage.getMainDocumentPart();// 将HTML内容转换为Word文档内容InputStream inputStream = new ByteArrayInputStream(htmlContent.getBytes(StandardCharsets.UTF_8));mainDocumentPart.addAltChunk(inputStream, "text/html");// 将Word文档写入输出流ByteArrayOutputStream outputStream = new ByteArrayOutputStream();Docx4J.save(wordMLPackage, outputStream);log.info("HTML转换为Word文档成功,文档大小:{}字节", outputStream.size());return outputStream.toByteArray();} catch (Exception e) {log.error("HTML转换为Word文档失败", e);throw new RuntimeException("HTML转换为Word文档失败:" + e.getMessage(), e);}}/*** 将Word文档转换为PDF** @param wordBytes Word文档字节数组* @return PDF文档的字节数组*/public byte[] convertWordToPdf(byte[] wordBytes) {log.info("开始将Word文档转换为PDF,文档大小:{}字节", wordBytes != null ? wordBytes.length : 0);if (wordBytes == null || wordBytes.length == 0) {log.error("Word文档字节数组为空,无法转换为PDF");throw new IllegalArgumentException("Word文档不能为空");}try {// 加载Word文档InputStream inputStream = new ByteArrayInputStream(wordBytes);WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.load(inputStream);// 转换为PDFByteArrayOutputStream outputStream = new ByteArrayOutputStream();Docx4J.toPDF(wordMLPackage, outputStream);log.info("Word文档转换为PDF成功,PDF大小:{}字节", outputStream.size());return outputStream.toByteArray();} catch (Exception e) {log.error("Word文档转换为PDF失败", e);throw new RuntimeException("Word转换为PDF失败:" + e.getMessage(), e);}}
}

这个处理器实现了两个核心功能:

  1. 将 HTML 内容转换为 Word 文档,利用了 Word 对 HTML 的良好支持
  2. 将 Word 文档转换为 PDF,满足客户对最终交付物的格式需求

3.4 模板引擎工厂

为了根据模板类型选择合适的模板引擎,实现一个模板引擎工厂:

import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;import java.util.HashMap;
import java.util.List;
import java.util.Map;/*** 模板引擎工厂* 管理所有模板引擎实现,根据类型获取相应的引擎** @author ken*/
@Component
public class TemplateEngineFactory implements InitializingBean {@Autowiredprivate List<TemplateEngine> templateEngines;private final Map<String, TemplateEngine> engineMap = new HashMap<>();/*** 根据类型获取模板引擎** @param type 模板引擎类型* @return 对应的模板引擎,如果未找到则返回默认引擎*/public TemplateEngine getEngine(String type) {if (!StringUtils.hasText(type)) {log.warn("模板引擎类型为空,使用默认引擎");return engineMap.get("FREEMARKER");}TemplateEngine engine = engineMap.get(type);if (engine == null) {log.warn("未找到类型为[{}]的模板引擎,使用默认引擎", type);return engineMap.get("FREEMARKER");}return engine;}/*** 根据模板文件类型获取合适的引擎** @param templateType 模板文件类型(WORD, PDF, HTML)* @return 对应的模板引擎*/public TemplateEngine getEngineByTemplateType(String templateType) {// 目前所有模板类型都使用Freemarker引擎,未来可根据需要扩展return engineMap.get("FREEMARKER");}@Overridepublic void afterPropertiesSet() throws Exception {// 初始化引擎映射if (!CollectionUtils.isEmpty(templateEngines)) {for (TemplateEngine engine : templateEngines) {String type = engine.getType();if (engineMap.containsKey(type)) {throw new IllegalStateException("存在重复的模板引擎类型:" + type);}engineMap.put(type, engine);log.info("注册模板引擎,类型:{},实现类:{}", type, engine.getClass().getName());}}// 检查是否有默认引擎if (!engineMap.containsKey("FREEMARKER")) {throw new IllegalStateException("未配置Freemarker模板引擎,这是系统必需的组件");}log.info("模板引擎工厂初始化完成,共注册{}种模板引擎", engineMap.size());}
}

工厂类通过 Spring 的依赖注入获取所有模板引擎实现,并建立类型到引擎的映射。这种设计使得新增模板引擎时无需修改工厂代码,只需添加新的实现类并标注 @Component 即可,符合开闭原则。

四、数据模型与产品组合处理

动态计划书的核心价值在于将客户数据、产品数据与模板完美结合。本节将详细讲解数据模型的构建和产品组合的处理逻辑。

4.1 数据模型构建

模板渲染需要一个结构清晰、内容完整的数据模型。我们设计一个通用的数据模型构建器:

import com.google.common.collect.Maps;
import org.springframework.stereotype.Component;import java.util.Map;/*** 数据模型构建器* 负责构建模板渲染所需的数据模型** @author ken*/
@Component
public class DataModelBuilder {@Autowiredprivate CustomerService customerService;@Autowiredprivate ProductPackageService packageService;@Autowiredprivate ProductCalculationService calculationService;/*** 构建完整的数据模型** @param customerId 客户ID* @param packageId 产品组合ID* @param params 额外参数* @return 构建好的数据模型*/public Map<String, Object> build(Long customerId, Long packageId, Map<String, Object> params) {log.info("开始构建数据模型,客户ID:{},产品组合ID:{}", customerId, packageId);// 参数校验if (customerId == null) {throw new IllegalArgumentException("客户ID不能为空");}if (packageId == null) {throw new IllegalArgumentException("产品组合ID不能为空");}// 创建数据模型容器Map<String, Object> dataModel = Maps.newHashMap();// 1. 添加客户信息addCustomerData(dataModel, customerId);// 2. 添加产品组合数据addPackageData(dataModel, packageId, params);// 3. 添加额外参数if (params != null && !params.isEmpty()) {dataModel.putAll(params);log.info("添加了{}个额外参数到数据模型", params.size());}log.info("数据模型构建完成,包含客户信息和产品组合信息");return dataModel;}/*** 添加客户数据到模型** @param dataModel 数据模型* @param customerId 客户ID*/private void addCustomerData(Map<String, Object> dataModel, Long customerId) {Customer customer = customerService.getById(customerId);if (customer == null) {throw new RuntimeException("未找到ID为" + customerId + "的客户信息");}// 转换为Map并添加到数据模型Map<String, Object> customerMap = BeanMap.create(customer);dataModel.put("customer", customerMap);// 计算客户年龄等衍生信息if (customer.getBirthday() != null) {LocalDate birthday = customer.getBirthday();LocalDate now = LocalDate.now();int age = now.getYear() - birthday.getYear();if (now.getMonthValue() < birthday.getMonthValue() || (now.getMonthValue() == birthday.getMonthValue() && now.getDayOfMonth() < birthday.getDayOfMonth())) {age--;}dataModel.put("customerAge", age);}log.info("已添加客户信息到数据模型,客户ID:{}", customerId);}/*** 添加产品组合数据到模型** @param dataModel 数据模型* @param packageId 产品组合ID* @param params 额外参数*/private void addPackageData(Map<String, Object> dataModel, Long packageId, Map<String, Object> params) {// 获取产品组合信息ProductPackage productPackage = packageService.getById(packageId);if (productPackage == null) {throw new RuntimeException("未找到ID为" + packageId + "的产品组合");}dataModel.put("package", BeanMap.create(productPackage));// 获取组合中的产品列表List<ProductPackageItem> items = packageService.getPackageItems(packageId);if (CollectionUtils.isEmpty(items)) {log.warn("产品组合[ID:{}]中没有包含任何产品", packageId);dataModel.put("products", Lists.newArrayList());return;}// 计算每个产品的数据List<Map<String, Object>> productList = Lists.newArrayList();BigDecimal totalPremium = BigDecimal.ZERO;for (ProductPackageItem item : items) {Map<String, Object> productData = calculationService.calculateProductData(item.getProductId(), (Long) dataModel.get("customerId"),item.getConfig(),params);productList.add(productData);// 累加总保费if (productData.containsKey("premium")) {try {BigDecimal premium = new BigDecimal(productData.get("premium").toString());totalPremium = totalPremium.add(premium);} catch (Exception e) {log.warn("计算产品[ID:{}]保费失败", item.getProductId(), e);}}}// 添加产品列表和汇总信息到数据模型dataModel.put("products", productList);dataModel.put("totalPremium", totalPremium);log.info("已添加产品组合数据到模型,组合ID:{},包含{}个产品", packageId, productList.size());}
}

这个数据模型构建器完成了以下工作:

  1. 从客户服务获取客户基本信息
  2. 计算客户年龄等衍生数据
  3. 获取产品组合信息和包含的产品列表
  4. 调用产品计算服务获取每个产品的详细数据
  5. 计算总保费等汇总信息
  6. 将所有数据整合到一个统一的数据模型中

4.2 产品计算引擎

产品计算是生成计划书的核心环节,尤其是保险、金融类产品,需要复杂的费率计算和利益演示:

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.Period;
import java.util.HashMap;
import java.util.Map;/*** 产品计算服务* 负责计算产品的保费、利益演示等数据** @author ken*/
@Service
public class ProductCalculationService {@Autowiredprivate ProductMapper productMapper;@Autowiredprivate CustomerMapper customerMapper;/*** 计算产品数据** @param productId 产品ID* @param customerId 客户ID* @param itemConfig 组合中的产品配置* @param extraParams 额外参数* @return 产品计算结果*/public Map<String, Object> calculateProductData(Long productId, Long customerId, String itemConfig, Map<String, Object> extraParams) {log.info("开始计算产品数据,产品ID:{},客户ID:{}", productId, customerId);// 参数校验if (productId == null) {throw new IllegalArgumentException("产品ID不能为空");}if (customerId == null) {throw new IllegalArgumentException("客户ID不能为空");}// 获取产品信息Product product = productMapper.selectById(productId);if (product == null) {throw new RuntimeException("未找到ID为" + productId + "的产品");}// 获取客户信息Customer customer = customerMapper.selectById(customerId);if (customer == null) {throw new RuntimeException("未找到ID为" + customerId + "的客户");}// 解析产品配置JSONObject calculationConfig = JSON.parseObject(product.getCalculationConfig());if (calculationConfig == null) {throw new RuntimeException("产品[ID:" + productId + "]的计算配置为空");}// 解析组合中的产品配置JSONObject itemConfigJson = new JSONObject();if (StringUtils.hasText(itemConfig)) {try {itemConfigJson = JSON.parseObject(itemConfig);} catch (Exception e) {log.warn("解析产品组合配置失败,配置内容:{}", itemConfig, e);}}// 根据产品类型选择不同的计算逻辑String productType = product.getProductType();Map<String, Object> result;switch (productType) {case "LIFE_INSURANCE":result = calculateLifeInsurance(product, customer, calculationConfig, itemConfigJson, extraParams);break;case "HEALTH_INSURANCE":result = calculateHealthInsurance(product, customer, calculationConfig, itemConfigJson, extraParams);break;case "ANNUITY_INSURANCE":result = calculateAnnuityInsurance(product, customer, calculationConfig, itemConfigJson, extraParams);break;default:result = calculateGenericProduct(product, customer, calculationConfig, itemConfigJson, extraParams);}log.info("产品数据计算完成,产品ID:{},保费:{}", productId, result.getOrDefault("premium", "0"));return result;}/*** 计算寿险产品数据** @param product 产品信息* @param customer 客户信息* @param config 产品计算配置* @param itemConfig 组合配置* @param extraParams 额外参数* @return 计算结果*/private Map<String, Object> calculateLifeInsurance(Product product, Customer customer, JSONObject config, JSONObject itemConfig, Map<String, Object> extraParams) {Map<String, Object> result = new HashMap<>();result.put("productId", product.getId());result.put("productCode", product.getProductCode());result.put("productName", product.getProductName());// 获取基本参数Integer insuredAge = calculateAge(customer.getBirthday());result.put("insuredAge", insuredAge);// 保险金额,优先从组合配置获取,否则使用默认值BigDecimal sumInsured = itemConfig.getBigDecimal("sumInsured");if (sumInsured == null || sumInsured.compareTo(BigDecimal.ZERO) <= 0) {sumInsured = config.getBigDecimal("defaultSumInsured");}result.put("sumInsured", sumInsured);// 保险期间Integer insuranceTerm = itemConfig.getInteger("insuranceTerm");if (insuranceTerm == null || insuranceTerm <= 0) {insuranceTerm = config.getInteger("defaultInsuranceTerm");}result.put("insuranceTerm", insuranceTerm);// 缴费期间Integer paymentTerm = itemConfig.getInteger("paymentTerm");if (paymentTerm == null || paymentTerm <= 0) {paymentTerm = config.getInteger("defaultPaymentTerm");}result.put("paymentTerm", paymentTerm);// 计算保费BigDecimal premiumRate = getPremiumRate(config, "lifeTable", insuredAge, insuranceTerm, paymentTerm);BigDecimal annualPremium = sumInsured.multiply(premiumRate).setScale(2, RoundingMode.HALF_UP);result.put("annualPremium", annualPremium);// 月缴保费(年缴保费/12)BigDecimal monthlyPremium = annualPremium.divide(new BigDecimal("12"), 2, RoundingMode.HALF_UP);result.put("monthlyPremium", monthlyPremium);// 根据缴费方式选择保费String paymentMode = itemConfig.getString("paymentMode");if (!"MONTHLY".equals(paymentMode)) {result.put("premium", annualPremium);result.put("paymentMode", "ANNUAL");} else {result.put("premium", monthlyPremium);result.put("paymentMode", "MONTHLY");}// 计算总保费BigDecimal totalPremium = annualPremium.multiply(new BigDecimal(paymentTerm));result.put("totalPremium", totalPremium);// 生成利益演示表result.put("benefitIllustration", generateLifeInsuranceBenefitIllustration(insuredAge, sumInsured, annualPremium, insuranceTerm, paymentTerm));return result;}/*** 计算健康险产品数据* (实现逻辑与寿险类似,根据健康险特性调整)*/private Map<String, Object> calculateHealthInsurance(Product product, Customer customer, JSONObject config, JSONObject itemConfig, Map<String, Object> extraParams) {// 具体实现省略return new HashMap<>();}/*** 计算年金险产品数据* (实现逻辑与寿险类似,根据年金险特性调整)*/private Map<String, Object> calculateAnnuityInsurance(Product product, Customer customer, JSONObject config, JSONObject itemConfig, Map<String, Object> extraParams) {// 具体实现省略return new HashMap<>();}/*** 计算通用产品数据* 适用于没有特殊计算逻辑的产品*/private Map<String, Object> calculateGenericProduct(Product product, Customer customer, JSONObject config, JSONObject itemConfig, Map<String, Object> extraParams) {// 具体实现省略return new HashMap<>();}/*** 获取保费费率** @param config 产品配置* @param tableName 费率表名称* @param age 年龄* @param insuranceTerm 保险期间* @param paymentTerm 缴费期间* @return 保费费率*/private BigDecimal getPremiumRate(JSONObject config, String tableName, Integer age, Integer insuranceTerm, Integer paymentTerm) {// 从配置中获取费率表JSONObject rateTable = config.getJSONObject(tableName);if (rateTable == null) {log.error("费率表[{}]不存在", tableName);return BigDecimal.ZERO;}// 根据年龄、保险期间、缴费期间获取对应的费率// 实际应用中可能需要更复杂的查询逻辑String key = age + "_" + insuranceTerm + "_" + paymentTerm;BigDecimal rate = rateTable.getBigDecimal(key);if (rate == null) {log.warn("未找到年龄[{}]、保险期间[{}]、缴费期间[{}]对应的费率,使用默认费率",age, insuranceTerm, paymentTerm);rate = config.getBigDecimal("defaultRate");}return rate != null ? rate : BigDecimal.ZERO;}/*** 生成寿险利益演示表** @param insuredAge 被保人年龄* @param sumInsured 保险金额* @param annualPremium 年缴保费* @param insuranceTerm 保险期间* @param paymentTerm 缴费期间* @return 利益演示表数据*/private List<Map<String, Object>> generateLifeInsuranceBenefitIllustration(Integer insuredAge,BigDecimal sumInsured,BigDecimal annualPremium,Integer insuranceTerm,Integer paymentTerm) {List<Map<String, Object>> illustration = Lists.newArrayList();for (int year = 1; year <= insuranceTerm; year++) {Map<String, Object> yearData = Maps.newHashMap();// 保单年度yearData.put("policyYear", year);// 被保人年龄yearData.put("insuredAge", insuredAge + year - 1);// 当年保费if (year <= paymentTerm) {yearData.put("premium", annualPremium);} else {yearData.put("premium", BigDecimal.ZERO);}// 累计保费BigDecimal cumulativePremium = annualPremium.multiply(new BigDecimal(Math.min(year, paymentTerm)));yearData.put("cumulativePremium", cumulativePremium);// 身故保险金(示例计算方式)BigDecimal deathBenefit = sumInsured.multiply(new BigDecimal(1 + Math.min(year, 10) * 0.1)); // 前10年每年增加10%yearData.put("deathBenefit", deathBenefit);// 现金价值(示例计算方式)BigDecimal cashValue = calculateCashValue(year, paymentTerm, insuranceTerm, cumulativePremium);yearData.put("cashValue", cashValue);illustration.add(yearData);}return illustration;}/*** 计算现金价值* (示例计算逻辑,实际产品需根据条款调整)*/private BigDecimal calculateCashValue(int year, int paymentTerm, int insuranceTerm, BigDecimal cumulativePremium) {if (year < 2) {return BigDecimal.ZERO; // 前两年现金价值为0} else if (year <= paymentTerm) {// 缴费期内,现金价值为累计保费的一定比例double ratio = 0.3 + (year - 2) * 0.05; // 从30%开始,每年增加5%ratio = Math.min(ratio, 0.8); // 最高不超过80%return cumulativePremium.multiply(new BigDecimal(ratio)).setScale(2, RoundingMode.HALF_UP);} else {// 缴费期后,现金价值每年递增BigDecimal baseValue = cumulativePremium.multiply(new BigDecimal(0.8));int yearsAfterPayment = year - paymentTerm;return baseValue.multiply(new BigDecimal(1 + yearsAfterPayment * 0.03)).setScale(2, RoundingMode.HALF_UP);}}/*** 计算年龄** @param birthday 出生日期* @return 年龄,如果出生日期为空则返回0*/private Integer calculateAge(LocalDate birthday) {if (birthday == null) {return 0;}LocalDate now = LocalDate.now();Period period = Period.between(birthday, now);return period.getYears();}
}

这个产品计算服务实现了以下核心功能:

  1. 根据产品类型调用不同的计算方法
  2. 实现了寿险产品的详细计算逻辑,包括保费、现金价值、身故保险金等
  3. 生成利益演示表,展示各年度的保障和利益情况
  4. 支持从产品配置和组合配置中获取参数,兼顾通用性和灵活性

4.3 产品组合处理

产品组合是满足客户多样化需求的关键,系统需要灵活处理多个产品的组合方案:

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;import java.util.List;/*** 产品组合服务* 处理产品组合的查询和管理** @author ken*/
@Service
public class ProductPackageServiceImpl extends ServiceImpl<ProductPackageMapper, ProductPackage> implements ProductPackageService {@Autowiredprivate ProductPackageItemMapper packageItemMapper;@Autowiredprivate ProductMapper productMapper;/*** 获取产品组合包含的产品项** @param packageId 产品组合ID* @return 产品项列表*/@Overridepublic List<ProductPackageItem> getPackageItems(Long packageId) {if (packageId == null) {log.error("获取产品组合项时,组合ID为空");return Lists.newArrayList();}List<ProductPackageItem> items = packageItemMapper.selectList(new LambdaQueryWrapper<ProductPackageItem>().eq(ProductPackageItem::getPackageId, packageId).orderByAsc(ProductPackageItem::getSortOrder));log.info("获取产品组合[ID:{}]的产品项,共{}个", packageId, items.size());return items;}/*** 检查产品组合是否有效** @param packageId 产品组合ID* @return 有效返回true,否则返回false*/@Overridepublic boolean checkPackageValid(Long packageId) {if (packageId == null) {return false;}// 检查组合是否存在且启用ProductPackage productPackage = getById(packageId);if (productPackage == null || productPackage.getStatus() != 1) {log.warn("产品组合[ID:{}]不存在或已禁用", packageId);return false;}// 检查组合中的产品是否都有效List<ProductPackageItem> items = getPackageItems(packageId);if (CollectionUtils.isEmpty(items)) {log.warn("产品组合[ID:{}]中没有包含任何产品", packageId);return false;}for (ProductPackageItem item : items) {Product product = productMapper.selectById(item.getProductId());if (product == null || product.getStatus() != 1) {log.warn("产品组合[ID:{}]中的产品[ID:{}]不存在或已下架", packageId, item.getProductId());return false;}}return true;}/*** 获取产品组合的详细信息,包括包含的产品** @param packageId 产品组合ID* @return 包含产品详情的组合信息*/@Overridepublic Map<String, Object> getPackageDetail(Long packageId) {if (packageId == null) {throw new IllegalArgumentException("产品组合ID不能为空");}ProductPackage productPackage = getById(packageId);if (productPackage == null) {throw new RuntimeException("未找到ID为" + packageId + "的产品组合");}Map<String, Object> result = BeanMap.create(productPackage);List<ProductPackageItem> items = getPackageItems(packageId);if (!CollectionUtils.isEmpty(items)) {List<Map<String, Object>> productList = Lists.newArrayList();for (ProductPackageItem item : items) {Product product = productMapper.selectById(item.getProductId());if (product != null) {Map<String, Object> productMap = BeanMap.create(product);productMap.put("sortOrder", item.getSortOrder());productMap.put("config", item.getConfig());productList.add(productMap);}}result.put("products", productList);}return result;}
}

产品组合服务主要实现了以下功能:

  1. 查询产品组合包含的产品项
  2. 验证产品组合的有效性,确保所有产品都处于可用状态
  3. 获取包含产品详情的组合信息,用于展示和计算

五、核心服务与接口实现

有了模板引擎和数据处理的基础,我们可以实现动态计划书系统的核心服务和对外接口。这些组件将协调各个模块,完成从请求接收到文档生成的完整流程。

5.1 计划书生成服务

计划书生成服务是系统的核心,负责协调整个生成流程:

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;import java.io.ByteArrayInputStream;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;/*** 计划书生成服务* 协调各组件完成计划书的生成流程** @author ken*/
@Service
@Tag(name = "ProposalGenerationService", description = "计划书生成服务")
public class ProposalGenerationService {@Autowiredprivate TemplateMapper templateMapper;@Autowiredprivate ProposalMapper proposalMapper;@Autowiredprivate TemplateEngineFactory templateEngineFactory;@Autowiredprivate DataModelBuilder dataModelBuilder;@Autowiredprivate WordDocumentProcessor wordProcessor;@Autowiredprivate FileStorageService fileStorageService;@Autowiredprivate ProductPackageService packageService;@Value("${file.storage.base-url}")private String fileBaseUrl;/*** 生成计划书** @param request 计划书生成请求* @return 生成的计划书信息*/@Operation(summary = "生成计划书", description = "根据客户ID、产品组合ID和模板ID生成计划书")@ApiResponses(value = {@ApiResponse(responseCode = "200", description = "生成成功",content = @Content(schema = @Schema(implementation = Proposal.class))),@ApiResponse(responseCode = "400", description = "参数错误"),@ApiResponse(responseCode = "404", description = "模板或产品组合不存在")})@Transactional(rollbackFor = Exception.class)public Proposal generateProposal(@Parameter(description = "计划书生成请求", required = true)ProposalGenerationRequest request) {log.info("收到计划书生成请求,客户ID:{},产品组合ID:{},模板ID:{}",request.getCustomerId(), request.getPackageId(), request.getTemplateId());// 1. 参数校验validateRequest(request);// 2. 检查产品组合有效性boolean packageValid = packageService.checkPackageValid(request.getPackageId());if (!packageValid) {log.error("产品组合[ID:{}]无效,无法生成计划书", request.getPackageId());throw new RuntimeException("产品组合无效或包含不可用产品");}// 3. 获取模板Template template = templateMapper.selectById(request.getTemplateId());if (template == null || template.getStatus() != 1) {log.error("模板[ID:{}]不存在或已禁用", request.getTemplateId());throw new RuntimeException("模板不存在或已禁用");}// 4. 创建计划书记录Proposal proposal = createProposalRecord(request, template);try {// 5. 构建数据模型Map<String, Object> dataModel = dataModelBuilder.build(request.getCustomerId(),request.getPackageId(),request.getExtraParams());// 6. 渲染模板TemplateEngine engine = templateEngineFactory.getEngineByTemplateType(template.getTemplateType());String renderedContent = engine.render(template.getContent(), dataModel);// 7. 生成对应格式的文件byte[] fileBytes = generateFile(renderedContent, template.getTemplateType());// 8. 保存文件String fileName = generateFileName(proposal.getProposalNo(), template.getTemplateType());String fileUrl = fileStorageService.uploadFile(fileName, fileBytes, getContentType(template.getTemplateType()));// 9. 更新计划书记录proposal.setFileUrl(fileUrl);proposal.setStatus("SUCCESS");proposalMapper.updateById(proposal);log.info("计划书生成成功,编号:{},文件URL:{}", proposal.getProposalNo(), fileUrl);return proposal;} catch (Exception e) {log.error("计划书生成失败,编号:{}", proposal.getProposalNo(), e);// 更新状态为失败proposal.setStatus("FAIL");proposalMapper.updateById(proposal);throw new RuntimeException("计划书生成失败:" + e.getMessage(), e);}}/*** 验证生成请求参数** @param request 生成请求*/private void validateRequest(ProposalGenerationRequest request) {if (request.getCustomerId() == null) {throw new IllegalArgumentException("客户ID不能为空");}if (request.getPackageId() == null) {throw new IllegalArgumentException("产品组合ID不能为空");}if (request.getTemplateId() == null) {throw new IllegalArgumentException("模板ID不能为空");}if (!StringUtils.hasText(request.getCreatedBy())) {throw new IllegalArgumentException("创建人不能为空");}if (request.getFileType() == null) {// 默认生成Word格式request.setFileType("WORD");} else {// 检查文件类型是否支持String fileType = request.getFileType();if (!"WORD".equals(fileType) && !"PDF".equals(fileType) && !"HTML".equals(fileType)) {throw new IllegalArgumentException("不支持的文件类型:" + fileType);}}}/*** 创建计划书记录** @param request 生成请求* @param template 模板信息* @return 新建的计划书记录*/private Proposal createProposalRecord(ProposalGenerationRequest request, Template template) {Proposal proposal = new Proposal();// 生成计划书编号String proposalNo = generateProposalNo();proposal.setProposalNo(proposalNo);proposal.setCustomerId(request.getCustomerId());proposal.setTemplateId(request.getTemplateId());proposal.setPackageId(request.getPackageId());proposal.setFileType(request.getFileType());// 保存生成参数Map<String, Object> params = new HashMap<>();params.put("extraParams", request.getExtraParams());params.put("createdBy", request.getCreatedBy());proposal.setParams(JSON.toJSONString(params));proposal.setStatus("GENERATING");proposal.setCreatedBy(request.getCreatedBy());proposal.setCreatedTime(LocalDateTime.now());proposal.setUpdatedTime(LocalDateTime.now());proposalMapper.insert(proposal);log.info("创建计划书记录成功,编号:{}", proposalNo);return proposal;}/*** 生成计划书编号* 格式: PROP + 年月日时分秒 + 3位随机数** @return 计划书编号*/private String generateProposalNo() {DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");String dateStr = LocalDateTime.now().format(formatter);String randomStr = String.format("%03d", new Random().nextInt(1000));return "PROP" + dateStr + randomStr;}/*** 生成文件名称** @param proposalNo 计划书编号* @param fileType 文件类型* @return 文件名*/private String generateFileName(String proposalNo, String fileType) {String extension = switch (fileType) {case "WORD" -> "docx";case "PDF" -> "pdf";case "HTML" -> "html";default -> "docx";};return "proposal/" + proposalNo + "." + extension;}/*** 获取文件内容类型** @param fileType 文件类型* @return 内容类型*/private String getContentType(String fileType) {return switch (fileType) {case "WORD" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document";case "PDF" -> "application/pdf";case "HTML" -> "text/html";default -> "application/octet-stream";};}/*** 根据文件类型生成对应格式的文件** @param content 渲染后的内容* @param fileType 文件类型* @return 文件字节数组*/private byte[] generateFile(String content, String fileType) {log.info("开始生成文件,类型:{},内容长度:{}", fileType, content.length());try {switch (fileType) {case "WORD":return wordProcessor.convertHtmlToWord(content);case "PDF":byte[] wordBytes = wordProcessor.convertHtmlToWord(content);return wordProcessor.convertWordToPdf(wordBytes);case "HTML":return content.getBytes(StandardCharsets.UTF_8);default:log.warn("未知文件类型[{}],默认生成Word文件", fileType);return wordProcessor.convertHtmlToWord(content);}} catch (Exception e) {log.error("生成{}文件失败", fileType, e);throw new RuntimeException("生成" + fileType + "文件失败:" + e.getMessage(), e);}}/*** 根据计划书编号查询计划书** @param proposalNo 计划书编号* @return 计划书信息*/@Operation(summary = "查询计划书", description = "根据计划书编号查询计划书详情")public Proposal getProposalByNo(String proposalNo) {if (!StringUtils.hasText(proposalNo)) {throw new IllegalArgumentException("计划书编号不能为空");}return proposalMapper.selectOne(new LambdaQueryWrapper<Proposal>().eq(Proposal::getProposalNo, proposalNo));}/*** 重新生成计划书** @param proposalNo 计划书编号* @param operator 操作人* @return 重新生成的计划书*/@Operation(summary = "重新生成计划书", description = "根据已有计划书编号重新生成计划书")public Proposal regenerateProposal(String proposalNo, String operator) {if (!StringUtils.hasText(proposalNo)) {throw new IllegalArgumentException("计划书编号不能为空");}if (!StringUtils.hasText(operator)) {throw new IllegalArgumentException("操作人不能为空");}Proposal existing = getProposalByNo(proposalNo);if (existing == null) {throw new RuntimeException("未找到编号为" + proposalNo + "的计划书");}// 创建新的生成请求ProposalGenerationRequest request = new ProposalGenerationRequest();request.setCustomerId(existing.getCustomerId());request.setPackageId(existing.getPackageId());request.setTemplateId(existing.getTemplateId());request.setFileType(existing.getFileType());request.setCreatedBy(operator);// 解析原参数try {Map<String, Object> params = JSON.parseObject(existing.getParams());request.setExtraParams((Map<String, Object>) params.get("extraParams"));} catch (Exception e) {log.warn("解析计划书[{}]的参数失败,使用空参数重新生成", proposalNo, e);request.setExtraParams(new HashMap<>());}// 调用生成方法return generateProposal(request);}
}

这个核心服务实现了完整的计划书生成流程:

  1. 验证请求参数的完整性和合法性
  2. 检查产品组合和模板的有效性
  3. 创建计划书记录,设置初始状态
  4. 调用数据模型构建器准备渲染数据
  5. 根据模板类型选择合适的模板引擎进行渲染
  6. 生成指定格式的文件(Word、PDF 或 HTML)
  7. 保存文件到文件存储服务
  8. 更新计划书记录的状态和文件地址
  9. 提供查询和重新生成功能

5.2 文件存储服务

文件存储服务负责处理文件的上传和下载,支持本地存储和云存储:

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;/*** 文件存储服务* 负责文件的存储和访问** @author ken*/
@Service
public class FileStorageService {@Value("${file.storage.type:local}")private String storageType;@Value("${file.storage.local.base-path:/data/proposal-files}")private String localBasePath;@Value("${file.storage.base-url:http://localhost:8080/files/}")private String baseUrl;/*** 上传文件** @param fileName 文件名* @param content 文件内容字节数组* @param contentType 文件内容类型* @return 文件访问URL*/public String uploadFile(String fileName, byte[] content, String contentType) {log.info("开始上传文件,文件名:{},大小:{}字节,存储类型:{}",fileName, content.length, storageType);if (!StringUtils.hasText(fileName)) {throw new IllegalArgumentException("文件名不能为空");}if (content == null || content.length == 0) {throw new IllegalArgumentException("文件内容不能为空");}try {switch (storageType) {case "local":return uploadToLocal(fileName, content);case "oss":// 实际项目中可实现阿里云OSS等云存储return uploadToOss(fileName, content, contentType);default:log.warn("未知的存储类型[{}],使用本地存储", storageType);return uploadToLocal(fileName, content);}} catch (Exception e) {log.error("文件上传失败,文件名:{}", fileName, e);throw new RuntimeException("文件上传失败:" + e.getMessage(), e);}}/*** 上传到本地文件系统** @param fileName 文件名* @param content 文件内容* @return 文件访问URL* @throws IOException IO异常*/private String uploadToLocal(String fileName, byte[] content) throws IOException {// 创建基础目录File baseDir = new File(localBasePath);if (!baseDir.exists()) {boolean mkdirs = baseDir.mkdirs();if (!mkdirs) {throw new IOException("无法创建基础目录:" + localBasePath);}}// 构建文件路径Path filePath = Paths.get(localBasePath, fileName);// 创建父目录File parentDir = filePath.getParent().toFile();if (!parentDir.exists()) {parentDir.mkdirs();}// 写入文件Files.write(filePath, content);log.info("文件已保存到本地,路径:{}", filePath);// 构建访问URLString url = baseUrl + fileName;// 处理URL中的双斜杠问题url = url.replace("//", "/").replace("http:/", "http://");return url;}/*** 上传到OSS云存储* (实际项目中实现)*/private String uploadToOss(String fileName, byte[] content, String contentType) {// 示例实现,实际项目中需对接具体的OSS SDKlog.info("文件上传到OSS,文件名:{}", fileName);// 这里只是返回一个模拟的URLreturn baseUrl + fileName;}/*** 下载文件** @param fileUrl 文件URL* @return 文件内容字节数组*/public byte[] downloadFile(String fileUrl) {log.info("开始下载文件,URL:{}", fileUrl);if (!StringUtils.hasText(fileUrl)) {throw new IllegalArgumentException("文件URL不能为空");}try {// 从URL中提取文件名String fileName = extractFileNameFromUrl(fileUrl);switch (storageType) {case "local":return downloadFromLocal(fileName);case "oss":// 实际项目中实现从OSS下载return downloadFromOss(fileUrl);default:log.warn("未知的存储类型[{}],尝试从本地下载", storageType);return downloadFromLocal(fileName);}} catch (Exception e) {log.error("文件下载失败,URL:{}", fileUrl, e);throw new RuntimeException("文件下载失败:" + e.getMessage(), e);}}/*** 从本地文件系统下载文件** @param fileName 文件名* @return 文件内容* @throws IOException IO异常*/private byte[] downloadFromLocal(String fileName) throws IOException {Path filePath = Paths.get(localBasePath, fileName);if (!Files.exists(filePath)) {throw new IOException("文件不存在:" + filePath);}return Files.readAllBytes(filePath);}/*** 从OSS下载文件* (实际项目中实现)*/private byte[] downloadFromOss(String fileUrl) {// 示例实现,实际项目中需对接具体的OSS SDKlog.info("从OSS下载文件,URL:{}", fileUrl);// 这里只是返回一个空字节数组作为示例return new byte[0];}/*** 从URL中提取文件名** @param fileUrl 文件URL* @return 文件名*/private String extractFileNameFromUrl(String fileUrl) {if (fileUrl.startsWith(baseUrl)) {return fileUrl.substring(baseUrl.length());} else {// 处理其他情况int lastSlashIndex = fileUrl.lastIndexOf('/');if (lastSlashIndex >= 0 && lastSlashIndex < fileUrl.length() - 1) {return fileUrl.substring(lastSlashIndex + 1);} else {return fileUrl;}}}
}

文件存储服务设计为可扩展的架构:

  1. 支持本地存储和云存储(如 OSS)两种模式
  2. 提供统一的上传和下载接口,屏蔽底层存储细节
  3. 处理文件路径和 URL 的转换,确保访问正确

5.3 控制器实现

控制器提供 REST 接口,接收外部请求并返回结果:

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;/*** 计划书控制器* 提供计划书生成和查询的REST接口** @author ken*/
@RestController
@RequestMapping("/api/v1/proposals")
@Tag(name = "ProposalController", description = "计划书管理接口")
public class ProposalController {@Autowiredprivate ProposalGenerationService generationService;@Autowiredprivate FileStorageService fileStorageService;@Autowiredprivate ProductPackageService packageService;/*** 生成计划书** @param request 生成请求* @return 生成的计划书信息*/@PostMapping("/generate")@Operation(summary = "生成计划书", description = "根据客户、产品组合和模板生成计划书")@ApiResponses(value = {@ApiResponse(responseCode = "200", description = "生成成功",content = @Content(schema = @Schema(implementation = Proposal.class))),@ApiResponse(responseCode = "400", description = "参数错误"),@ApiResponse(responseCode = "404", description = "模板或产品组合不存在"),@ApiResponse(responseCode = "500", description = "服务器内部错误")})public ResponseEntity<Proposal> generateProposal(@Parameter(description = "计划书生成请求参数", required = true)@RequestBody ProposalGenerationRequest request) {try {Proposal proposal = generationService.generateProposal(request);return ResponseEntity.ok(proposal);} catch (IllegalArgumentException e) {log.error("生成计划书参数错误:{}", e.getMessage());return ResponseEntity.badRequest().body(null);} catch (RuntimeException e) {log.error("生成计划书失败:{}", e.getMessage(), e);if (e.getMessage().contains("不存在")) {return ResponseEntity.notFound().build();} else {return ResponseEntity.internalServerError().body(null);}}}/*** 查询计划书详情** @param proposalNo 计划书编号* @return 计划书详情*/@GetMapping("/{proposalNo}")@Operation(summary = "查询计划书", description = "根据计划书编号查询详情")@ApiResponses(value = {@ApiResponse(responseCode = "200", description = "查询成功",content = @Content(schema = @Schema(implementation = Proposal.class))),@ApiResponse(responseCode = "404", description = "计划书不存在")})public ResponseEntity<Proposal> getProposal(@Parameter(description = "计划书编号", required = true)@PathVariable String proposalNo) {try {Proposal proposal = generationService.getProposalByNo(proposalNo);if (proposal == null) {return ResponseEntity.notFound().build();}return ResponseEntity.ok(proposal);} catch (Exception e) {log.error("查询计划书失败,编号:{}", proposalNo, e);return ResponseEntity.internalServerError().body(null);}}/*** 下载计划书文件** @param proposalNo 计划书编号* @return 文件内容*/@GetMapping("/{proposalNo}/download")@Operation(summary = "下载计划书", description = "根据计划书编号下载文件")@ApiResponses(value = {@ApiResponse(responseCode = "200", description = "下载成功"),@ApiResponse(responseCode = "404", description = "计划书不存在或文件已删除")})public ResponseEntity<byte[]> downloadProposal(@Parameter(description = "计划书编号", required = true)@PathVariable String proposalNo) {try {Proposal proposal = generationService.getProposalByNo(proposalNo);if (proposal == null) {return ResponseEntity.notFound().build();}byte[] fileContent = fileStorageService.downloadFile(proposal.getFileUrl());// 构建响应头,设置文件名String fileName = proposalNo + "." + getFileExtension(proposal.getFileType());String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.name());HttpHeaders headers = new HttpHeaders();headers.add("Content-Disposition", "attachment; filename*=UTF-8''" + encodedFileName);headers.add("Content-Type", getContentType(proposal.getFileType()));return new ResponseEntity<>(fileContent, headers, HttpStatus.OK);} catch (Exception e) {log.error("下载计划书失败,编号:{}", proposalNo, e);return ResponseEntity.notFound().build();}}/*** 重新生成计划书** @param proposalNo 计划书编号* @param operator 操作人* @return 重新生成的计划书*/@PostMapping("/{proposalNo}/regenerate")@Operation(summary = "重新生成计划书", description = "根据已有计划书编号重新生成")@ApiResponses(value = {@ApiResponse(responseCode = "200", description = "生成成功"),@ApiResponse(responseCode = "404", description = "原计划书不存在")})public ResponseEntity<Proposal> regenerateProposal(@Parameter(description = "计划书编号", required = true)@PathVariable String proposalNo,@Parameter(description = "操作人", required = true)@RequestParam String operator) {try {Proposal proposal = generationService.regenerateProposal(proposalNo, operator);return ResponseEntity.ok(proposal);} catch (RuntimeException e) {log.error("重新生成计划书失败,编号:{}", proposalNo, e);if (e.getMessage().contains("未找到")) {return ResponseEntity.notFound().build();} else {return ResponseEntity.internalServerError().body(null);}}}/*** 获取产品组合详情** @param packageId 产品组合ID* @return 产品组合详情*/@GetMapping("/packages/{packageId}")@Operation(summary = "查询产品组合", description = "根据产品组合ID查询详情")@ApiResponses(value = {@ApiResponse(responseCode = "200", description = "查询成功"),@ApiResponse(responseCode = "404", description = "产品组合不存在")})public ResponseEntity<Map<String, Object>> getPackageDetail(@Parameter(description = "产品组合ID", required = true)@PathVariable Long packageId) {try {Map<String, Object> detail = packageService.getPackageDetail(packageId);if (detail == null) {return ResponseEntity.notFound().build();}return ResponseEntity.ok(detail);} catch (RuntimeException e) {log.error("查询产品组合失败,ID:{}", packageId, e);if (e.getMessage().contains("未找到")) {return ResponseEntity.notFound().build();} else {return ResponseEntity.internalServerError().body(null);}}}/*** 获取文件扩展名** @param fileType 文件类型* @return 扩展名*/private String getFileExtension(String fileType) {return switch (fileType) {case "WORD" -> "docx";case "PDF" -> "pdf";case "HTML" -> "html";default -> "docx";};}/*** 获取文件内容类型** @param fileType 文件类型* @return 内容类型*/private String getContentType(String fileType) {return switch (fileType) {case "WORD" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document";case "PDF" -> "application/pdf";case "HTML" -> "text/html";default -> "application/octet-stream";};}
}

控制器提供了完整的 REST 接口:

  1. 生成计划书接口,接收客户 ID、产品组合 ID 等参数
  2. 查询计划书详情接口,根据编号获取计划书信息
  3. 下载接口,支持获取生成的计划书文件
  4. 重新生成接口,便于在参数变化时更新计划书
  5. 产品组合查询接口,用于前端展示可选的产品组合

六、模板设计与管理

模板是动态计划书系统的核心资产,一套完善的模板管理功能可以提高系统的易用性和灵活性。本节将详细讲解模板的设计规范和管理功能实现。

6.1 模板设计规范

良好的模板设计可以提高渲染效率和文档质量。我们制定以下模板设计规范:

  1. 命名规范

    • 模板变量使用骆驼命名法,如、{products[0].premium}
    • 循环变量使用复数形式,如 <#list products as product>...</#list>
    • 条件判断使用明确的布尔变量,如 <#if product.hasGuarantee>...</#if>
  2. 结构规范

    • 模板应包含固定的页眉页脚,包含公司 Logo 和联系方式
    • 使用样式表统一控制字体、段落格式
    • 复杂表格使用 HTML 表格标签,避免使用复杂的 Word 表格样式
  3. 内容规范

    • 产品描述等静态内容应从产品库获取,避免硬编码在模板中
    • 法律条款等需要严格控制的内容可直接嵌入模板
    • 计算逻辑应在数据模型中完成,模板中只做展示

6.2 模板示例

以下是一个寿险产品组合计划书的模板示例:

<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><style>body { font-family: "SimSun", "宋体", serif; font-size: 12pt; }.title { text-align: center; font-weight: bold; margin: 20px 0; }.subtitle { font-weight: bold; margin: 15px 0 5px 0; }.content { line-height: 1.5; }.table { border-collapse: collapse; width: 100%; margin: 10px 0; }.table th, .table td { border: 1px solid #000; padding: 5px; text-align: center; }.footer { margin-top: 30px; font-size: 10pt; color: #666; text-align: center; }</style>
</head>
<body><div class="title"><h2>个人保险保障方案计划书</h2></div><div class="subtitle">一、客户信息</div><div class="content"><p>客户姓名:${customer.name}</p><p>性别:${customer.gender!"未知"}</p><p>年龄:${customerAge!"未知"}岁</p><p>联系方式:${customer.phone!"未提供"}</p></div><div class="subtitle">二、产品组合方案</div><div class="content"><p>组合名称:${package.packageName}</p><p>组合描述:${package.description!"无"}</p><table class="table"><tr><th>序号</th><th>产品名称</th><th>保险金额</th><th>保险期间</th><th>缴费期间</th><th>缴费方式</th><th>年缴保费</th></tr><#list products as product><tr><td>${product_index + 1}</td><td>${product.productName}</td><td>${product.sumInsured?string("###,##0.00")}元</td><td>${product.insuranceTerm}年</td><td>${product.paymentTerm}年</td><td>${product.paymentMode == "ANNUAL" ? "年缴" : "月缴"}</td><td>${product.annualPremium?string("###,##0.00")}元</td></tr></#list><tr><td colspan="6" style="text-align: right; font-weight: bold;">合计年缴保费:</td><td style="font-weight: bold;">${totalPremium?string("###,##0.00")}元</td></tr></table></div><#list products as product><div class="subtitle">三、${product.productName}利益说明</div><div class="content"><p>1. 产品简介:${product.description!"无"}</p><p>2. 利益演示:</p><table class="table"><tr><th>保单年度</th><th>被保人年龄</th><th>当年保费(元)</th><th>累计保费(元)</th><th>身故保险金(元)</th><th>现金价值(元)</th></tr><#list product.benefitIllustration as yearData><tr><td>${yearData.policyYear}</td><td>${yearData.insuredAge}</td><td>${yearData.premium?string("###,##0.00")}</td><td>${yearData.cumulativePremium?string("###,##0.00")}</td><td>${yearData.deathBenefit?string("###,##0.00")}</td><td>${yearData.cashValue?string("###,##0.00")}</td></tr></#list></table><#if product.specialClause?has_content><p>3. 特别约定:${product.specialClause}</p></#if></div></#list><div class="subtitle">四、风险提示</div><div class="content"><p>1. 本计划书仅为演示,具体以保险合同条款为准。</p><p>2. 产品利益演示基于公司精算假设,不代表公司的历史经营业绩,也不代表对公司未来经营业绩的预期。</p><p>3. 投保前请仔细阅读产品说明书和保险条款,充分了解产品特点和风险。</p></div><div class="footer"><p>计划书生成日期:${.now?string("yyyy年MM月dd日")}</p><p>服务热线:400-888-8888  公司网址:www.example-insurance.com</p><p>注:本计划书内容仅供参考,最终以正式保险合同为准。</p></div>
</body>
</html>

这个模板示例展示了:

  1. 使用 Freemarker 语法进行变量替换、循环和条件判断
  2. 通过 CSS 统一控制文档样式
  3. 展示客户信息、产品组合和各产品的利益演示
  4. 包含必要的风险提示和公司信息

6.3 模板管理服务

模板管理服务提供模板的 CRUD 操作,支持模板的版本控制和状态管理:

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;import java.io.IOException;
import java.util.List;/*** 模板管理服务* 负责模板的创建、查询、更新和删除** @author ken*/
@Service
public class TemplateServiceImpl extends ServiceImpl<TemplateMapper, Template> implements TemplateService {@Autowiredprivate FileStorageService fileStorageService;/*** 创建模板** @param template 模板信息* @param content 模板内容* @param previewImage 预览图字节数组* @return 创建的模板*/@Override@Transactional(rollbackFor = Exception.class)public Template createTemplate(Template template, String content, byte[] previewImage) {log.info("开始创建模板,编码:{},名称:{}", template.getTemplateCode(), template.getTemplateName());// 参数校验validateTemplate(template);// 检查编码是否已存在Template existing = getOne(new LambdaQueryWrapper<Template>().eq(Template::getTemplateCode, template.getTemplateCode()));if (existing != null) {throw new RuntimeException("模板编码已存在:" + template.getTemplateCode());}// 设置模板内容template.setContent(content);// 处理预览图if (previewImage != null && previewImage.length > 0) {String fileName = "template-preview/" + template.getTemplateCode() + ".png";String imageUrl = fileStorageService.uploadFile(fileName, previewImage, "image/png");template.setPreviewImage(imageUrl);}// 设置默认值if (template.getStatus() == null) {template.setStatus(1); // 默认启用}// 保存模板save(template);log.info("模板创建成功,ID:{},编码:{}", template.getId(), template.getTemplateCode());return template;}/*** 更新模板** @param id 模板ID* @param template 模板信息* @param content 模板内容(可为空,表示不更新)* @param previewImage 预览图字节数组(可为空,表示不更新)* @return 更新后的模板*/@Override@Transactional(rollbackFor = Exception.class)public Template updateTemplate(Long id, Template template, String content, byte[] previewImage) {log.info("开始更新模板,ID:{}", id);if (id == null) {throw new IllegalArgumentException("模板ID不能为空");}// 检查模板是否存在Template existing = getById(id);if (existing == null) {throw new RuntimeException("未找到ID为" + id + "的模板");}// 如果提供了编码,检查是否与其他模板冲突if (StringUtils.hasText(template.getTemplateCode()) && !template.getTemplateCode().equals(existing.getTemplateCode())) {Template codeExists = getOne(new LambdaQueryWrapper<Template>().eq(Template::getTemplateCode, template.getTemplateCode()));if (codeExists != null) {throw new RuntimeException("模板编码已存在:" + template.getTemplateCode());}}// 更新基本信息existing.setTemplateName(template.getTemplateName() != null ? template.getTemplateName() : existing.getTemplateName());existing.setTemplateType(template.getTemplateType() != null ? template.getTemplateType() : existing.getTemplateType());existing.setProductType(template.getProductType() != null ? template.getProductType() : existing.getProductType());existing.setStatus(template.getStatus() != null ? template.getStatus() : existing.getStatus());existing.setUpdatedBy(template.getUpdatedBy() != null ? template.getUpdatedBy() : existing.getUpdatedBy());// 更新模板内容if (content != null) {existing.setContent(content);}// 更新预览图if (previewImage != null && previewImage.length > 0) {String fileName = "template-preview/" + existing.getTemplateCode() + ".png";String imageUrl = fileStorageService.uploadFile(fileName, previewImage, "image/png");existing.setPreviewImage(imageUrl);}// 保存更新updateById(existing);log.info("模板更新成功,ID:{}", id);return existing;}/*** 分页查询模板** @param productType 产品类型(可为空)* @param templateType 模板类型(可为空)* @param status 状态(可为空)* @param pageNum 页码* @param pageSize 每页条数* @return 分页结果*/@Overridepublic IPage<Template> queryTemplates(String productType, String templateType, Integer status, Integer pageNum, Integer pageSize) {log.info("查询模板,产品类型:{},模板类型:{},状态:{},页码:{},每页条数:{}",productType, templateType, status, pageNum, pageSize);Page<Template> page = new Page<>(pageNum, pageSize);LambdaQueryWrapper<Template> queryWrapper = new LambdaQueryWrapper<>();if (StringUtils.hasText(productType)) {queryWrapper.eq(Template::getProductType, productType);}if (StringUtils.hasText(templateType)) {queryWrapper.eq(Template::getTemplateType, templateType);}if (status != null) {queryWrapper.eq(Template::getStatus, status);}queryWrapper.orderByDesc(Template::getUpdatedTime);return page(page, queryWrapper);}/*** 根据产品类型查询可用模板** @param productType 产品类型* @return 模板列表*/@Overridepublic List<Template> getAvailableTemplatesByProductType(String productType) {if (!StringUtils.hasText(productType)) {throw new IllegalArgumentException("产品类型不能为空");}return list(new LambdaQueryWrapper<Template>().eq(Template::getProductType, productType).eq(Template::getStatus, 1).orderByAsc(Template::getTemplateName));}/*** 验证模板信息** @param template 模板信息*/private void validateTemplate(Template template) {if (!StringUtils.hasText(template.getTemplateCode())) {throw new IllegalArgumentException("模板编码不能为空");}if (!StringUtils.hasText(template.getTemplateName())) {throw new IllegalArgumentException("模板名称不能为空");}if (!StringUtils.hasText(template.getTemplateType())) {throw new IllegalArgumentException("模板类型不能为空");}if (!StringUtils.hasText(template.getProductType())) {throw new IllegalArgumentException("适用产品类型不能为空");}if (!StringUtils.hasText(template.getCreatedBy())) {throw new IllegalArgumentException("创建人不能为空");}}
}

模板管理服务实现了以下功能:

  1. 创建新模板,包括基本信息、内容和预览图
  2. 更新现有模板,支持部分字段更新
  3. 分页查询模板,支持多条件筛选
  4. 根据产品类型查询可用模板,便于前端选择

七、系统配置与依赖

为了确保系统能够正确编译和运行,需要配置合适的依赖和系统参数。

7.1 Maven 依赖配置

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.2.0</version><relativePath/></parent><groupId>com.finance</groupId><artifactId>dynamic-proposal-system</artifactId><version>1.0.0</version><name>dynamic-proposal-system</name><description>Dynamic Proposal Generation System</description><properties><java.version>17</java.version><mybatis-plus.version>3.5.5</mybatis-plus.version><fastjson2.version>2.0.32</fastjson2.version><lombok.version>1.18.30</lombok.version><springdoc.version>2.1.0</springdoc.version><freemarker.version>2.3.32</freemarker.version><docx4j.version>11.4.9</docx4j.version><guava.version>32.1.3-jre</guava.version></properties><dependencies><!-- Spring Boot Core --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency><!-- Database --><dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency><!-- MyBatis-Plus --><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>${mybatis-plus.version}</version></dependency><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-extension</artifactId><version>${mybatis-plus.version}</version></dependency><!-- Template Engine --><dependency><groupId>org.freemarker</groupId><artifactId>freemarker</artifactId><version>${freemarker.version}</version></dependency><!-- Word Processing --><dependency><groupId>org.docx4j</groupId><artifactId>docx4j-JAXB-ReferenceImpl</artifactId><version>${docx4j.version}</version></dependency><dependency><groupId>org.docx4j</groupId><artifactId>docx4j-export-fo</artifactId><version>${docx4j.version}</version></dependency><!-- Lombok --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>${lombok.version}</version><scope>provided</scope></dependency><!-- JSON Processing --><dependency><groupId>com.alibaba.fastjson2</groupId><artifactId>fastjson2</artifactId><version>${fastjson2.version}</version></dependency><!-- Swagger 3 --><dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>${springdoc.version}</version></dependency><!-- Guava --><dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId><version>${guava.version}</version></dependency><!-- Testing --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter-test</artifactId><version>${mybatis-plus.version}</version><scope>test</scope></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><configuration><excludes><exclude><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></exclude></excludes></configuration></plugin></plugins></build>
</project>

这个 pom.xml 文件定义了项目的核心依赖,包括:

  • Spring Boot 核心组件
  • 数据库相关依赖
  • MyBatis-Plus 持久层框架
  • Freemarker 模板引擎
  • Docx4j 用于 Word 文档处理
  • Lombok 简化代码开发
  • Swagger 3 生成 API 文档
  • 其他工具类库

所有依赖都使用了最新的稳定版本,确保系统的安全性和兼容性。

7.2 应用配置

# 应用配置
spring:application:name: dynamic-proposal-system# 数据库配置datasource:url: jdbc:mysql://localhost:3306/proposal_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghaiusername: rootpassword: rootdriver-class-name: com.mysql.cj.jdbc.Driverhikari:maximum-pool-size: 10minimum-idle: 5idle-timeout: 300000connection-timeout: 20000# MyBatis-Plus配置
mybatis-plus:mapper-locations: classpath*:mapper/**/*.xmltype-aliases-package: com.finance.proposal.entityconfiguration:map-underscore-to-camel-case: truelog-impl: org.apache.ibatis.logging.slf4j.Slf4jImplglobal-config:db-config:id-type: autologic-delete-field: deletedlogic-delete-value: 1logic-not-delete-value: 0# 分页插件配置
mybatis-plus:configuration:# 其他配置...plugins:- com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor:- com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor:# 文件存储配置
file:storage:type: local # 存储类型:local-本地存储,oss-云存储base-url: http://localhost:8080/files/local:base-path: /data/proposal-files# 服务器配置
server:port: 8080servlet:context-path: /# 静态资源配置,用于文件下载
spring:web:resources:static-locations: file:${file.storage.local.base-path},classpath:/static/# Swagger配置
springdoc:api-docs:path: /api-docsswagger-ui:path: /swagger-ui.htmloperationsSorter: methodpackages-to-scan: com.finance.proposal.controller# 日志配置
logging:level:root: infocom.finance.proposal: debugpattern:console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"file:name: logs/dynamic-proposal-system.log

这个配置文件包含了系统的核心配置:

  • 应用名称和服务器端口
  • 数据库连接信息和连接池配置
  • MyBatis-Plus 的相关配置
  • 文件存储的配置,支持本地存储和云存储
  • 静态资源配置,用于文件下载
  • Swagger 文档的配置
  • 日志级别和输出格式

八、系统测试与性能优化

为了确保系统的可靠性和性能,需要进行全面的测试和优化。本节将介绍系统的测试方法和性能优化策略。

8.1 核心功能测试

模板渲染测试

import com.alibaba.fastjson2.JSON;
import com.finance.proposal.engine.TemplateEngine;
import com.finance.proposal.engine.TemplateEngineFactory;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;/*** 模板渲染测试** @author ken*/
@SpringBootTest
public class TemplateRenderTest {@Autowiredprivate TemplateEngineFactory engineFactory;/*** 测试简单模板渲染*/@Testpublic void testSimpleRender() {// 准备模板String template = "Hello, ${name}! You are ${age} years old.";// 准备数据模型Map<String, Object> data = new HashMap<>();data.put("name", "张三");data.put("age", 30);// 获取模板引擎并渲染TemplateEngine engine = engineFactory.getEngine("FREEMARKER");String result = engine.render(template, data);// 验证结果assertEquals("Hello, 张三! You are 30 years old.", result);}/*** 测试循环和条件渲染*/@Testpublic void testLoopAndConditionRender() {// 准备模板String template = "<#list products as product>" +"<#if product.price > 1000>" +"${product.name} is expensive: ${product.price}" +"<#else>" +"${product.name} is cheap: ${product.price}" +"</#if>" +"</#list>";// 准备数据模型Map<String, Object> data = new HashMap<>();Map<String, Object> product1 = new HashMap<>();product1.put("name", "产品A");product1.put("price", new BigDecimal("1500"));Map<String, Object> product2 = new HashMap<>();product2.put("name", "产品B");product2.put("price", new BigDecimal("800"));data.put("products", List.of(product1, product2));// 渲染模板TemplateEngine engine = engineFactory.getEngine("FREEMARKER");String result = engine.render(template, data);// 验证结果assertTrue(result.contains("产品A is expensive: 1500"));assertTrue(result.contains("产品B is cheap: 800"));}
}

计划书生成测试

import com.finance.proposal.entity.Proposal;
import com.finance.proposal.request.ProposalGenerationRequest;
import com.finance.proposal.service.ProposalGenerationService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;import java.util.HashMap;
import java.util.Map;import static org.junit.jupiter.api.Assertions.*;/*** 计划书生成测试** @author ken*/
@SpringBootTest
@Transactional
public class ProposalGenerationTest {@Autowiredprivate ProposalGenerationService generationService;/*** 测试完整的计划书生成流程*/@Testpublic void testFullProposalGeneration() {// 准备测试数据(实际测试中应提前插入测试数据)Long customerId = 1L; // 假设已存在ID为1的客户Long packageId = 1L;  // 假设已存在ID为1的产品组合Long templateId = 1L; // 假设已存在ID为1的模板// 创建生成请求ProposalGenerationRequest request = new ProposalGenerationRequest();request.setCustomerId(customerId);request.setPackageId(packageId);request.setTemplateId(templateId);request.setFileType("WORD");request.setCreatedBy("test_user");// 添加额外参数Map<String, Object> extraParams = new HashMap<>();extraParams.put("salesman", "李四");extraParams.put("branch", "北京分公司");request.setExtraParams(extraParams);// 执行生成Proposal proposal = generationService.generateProposal(request);// 验证结果assertNotNull(proposal);assertNotNull(proposal.getProposalNo());assertEquals("SUCCESS", proposal.getStatus());assertTrue(proposal.getFileUrl().startsWith("http://"));assertNotNull(proposal.getCreatedTime());// 验证生成的文件可以下载// (实际测试中可调用下载接口验证)}
}

8.2 性能优化策略

对常用模板进行缓存,避免重复读取和解析:

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;/*** 带缓存的模板服务** @author ken*/
@Service
public class CachedTemplateService extends TemplateServiceImpl {/*** 根据ID获取模板,并缓存** @param id 模板ID* @return 模板信息*/@Override@Cacheable(value = "templateCache", key = "#id", unless = "#result == null")public Template getById(Serializable id) {return super.getById(id);}/*** 根据产品类型获取可用模板,并缓存** @param productType 产品类型* @return 模板列表*/@Override@Cacheable(value = "templateCache", key = "'productType:' + #productType")

http://www.dtcms.com/a/446354.html

相关文章:

  • 网站建设与维护好学吗做网站优化有什么方法
  • BLDCPMSM电机控制器硬件设计工程(四)控制器功率模块IGBT和SIC MOS介绍及驱动方案
  • opencart做视频网站哪些php网站
  • 追踪 - 两张图片引发的地理位置暴露
  • 基于「YOLO目标检测 + 多模态AI分析」的光伏板缺陷检测分析系统(vue+flask+模型训练+AI算法)
  • 【Misc】CTFSHOW 入门 wp
  • 网站优化分析杭州网站建设公司
  • 每日一个C语言知识:C语言基础语法
  • 国内红酒网站建设wordpress创建登录页
  • 什么软件能把做的网站上传wordpress商品主图
  • Giants Shoulder - Samsung: LPDDR6 Key Architecture Share
  • 如何设计优秀的企业微信私域运营实战培训方案
  • 数据结构入门 (六):公平的艺术 —— 深入理解队列
  • 计算某字符出现次数
  • 智慧物流企业网站建设方案创意广告图片及文字解析
  • 医院网站建设思路太原制作响应式网站
  • ALiBi是否会替代YaRN?
  • java数据结构
  • 建设标准下载网站个人网站名称要求
  • Delphi Architect Crack
  • 网页设计与网站架设少儿编程平台
  • 广州网站开发创意设计网站上放个域名查询
  • MySQL索引特性
  • 网站建设中 英语公司装修费用可以一次性入账吗
  • 塑胶原料东莞网站建设课程建设类教学成果奖网站
  • 重庆房地产网站建设如何增加网站的流量
  • RT-Thread 移植教程 基于GD32F4XX
  • wordpress网站换主机网站设计形式
  • 音视频学习(六十八):视频采集原理
  • 实习小结。