reportEngine:dcp报告支持

This commit is contained in:
zhugongping 2026-10-06 10:02:50 +08:00
parent 2cb6237e37
commit a2f9285d29
21 changed files with 387 additions and 141 deletions

View File

@ -4,6 +4,7 @@ import com.fastbee.common.annotation.Log;
import com.fastbee.common.core.controller.BaseController;
import com.fastbee.common.core.domain.AjaxResult;
import com.fastbee.common.enums.BusinessType;
import com.fastbee.common.enums.ReportTemplate;
import com.fastbee.common.utils.SecurityUtils;
import com.fastbee.system.domain.LoginRedirectOption;
import com.fastbee.system.domain.SysUserSetting;
@ -92,7 +93,8 @@ public class SysUserSettingController extends BaseController {
public AjaxResult update(@RequestBody SysUserSetting setting) {
if (setting.getLoginRedirect() == null
&& setting.getQueryGatewayChild() == null
&& setting.getShowChild() == null) {
&& setting.getShowChild() == null
&& setting.getReportTemplate() == null) {
return AjaxResult.error("没有需要保存的配置项");
}
// 只有在本次确实要改「登录后显示页面」时才做白名单校验;
@ -100,11 +102,33 @@ public class SysUserSettingController extends BaseController {
if (setting.getLoginRedirect() != null && !isValidLoginRedirect(setting.getLoginRedirect())) {
return AjaxResult.error("登录后跳转页面不合法,请从系统菜单中选择");
}
// 报告模板版式只接受已注册的枚举编码,挡住脏数据/未发布的模板
if (setting.getReportTemplate() != null && !isValidReportTemplate(setting.getReportTemplate())) {
return AjaxResult.error("报告导出模板不合法,请从可选项中选择");
}
// 审计字段由服务端填充,不接受前端传入,避免越权写入他人操作名
userSettingService.updatePartial(SecurityUtils.getUserId(), setting, SecurityUtils.getUsername());
return AjaxResult.success();
}
/**
* 校验报告模板编码为已注册的模板版式
*
* <p>取值即 {@code ReportTemplate.code},未注册的一律拒绝(而非静默回退默认版式),
* 避免用户选到未发布的模板后导出行为与预期不符。</p>
*/
private boolean isValidReportTemplate(String code) {
if (code == null || code.trim().isEmpty()) {
return false;
}
for (ReportTemplate template : ReportTemplate.values()) {
if (template.getCode().equalsIgnoreCase(code.trim())) {
return true;
}
}
return false;
}
/**
* 校验跳转目标为系统内已注册的一级页签
*

View File

@ -233,12 +233,3 @@ map:
gaode_secret: d0561ad20142e74f8c59edc5deafdbe4
tianditu_ak: b920e6076a06aa279c974f5f76cf746e
# 设备监测报告导出配置(全局一刀切,test/prod 环境共用)
report:
# 模板编码(取值即 templates/ 下的模板名,V2 为按分类取默认的版本标记):
# 留空 或 V2 → 完整版,按监测分类走 DEVICE_{CATEGORY}_V2(共 10 节)
# DCPReport → DCP 平台专用三节版式(项目概况 / 测点数据 / 时程曲线),
# 测点表只输出 ThingsThreshold.is_primary=1 的主监测指标
# 默认保持完整版(V2):切换版式是交付格式变更,需现场确认后由运维显式改为 DCPReport
template-code: V2

View File

@ -1,5 +1,7 @@
package com.fastbee.common.constant;
import com.fastbee.common.enums.ReportTemplate;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@ -80,6 +82,17 @@ public class UserSettingConstants {
*/
public static final boolean DEFAULT_SHOW_CHILD = false;
/**
* 报告导出模板版式的默认值
*
* <p>对应 {@code sys_user_setting.report_template},取值即 {@code ReportTemplate.code}:
* 默认 {@code V2}(完整版,按监测分类取 {@code DEVICE_{CATEGORY}_V2})= 与引入本开关前完全一致。
* 用户可在「系统设置」页切换为 {@code DCPReport}(DCP 平台三节版式)等其它版式。
* <p>维度归属:报告导出由登录用户触发(异步线程已显式透传 SecurityContext,能拿到 userId),
* 故为 per-user 设置;无登录态不允许导出,不做全局兜底。
*/
public static final String DEFAULT_REPORT_TEMPLATE = ReportTemplate.FULL_VERSION.getCode();
private UserSettingConstants() {
}

View File

@ -0,0 +1,92 @@
package com.fastbee.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 设备监测报告导出模板版式
*
* <p>版式是现场交付物,每种版式对应一套固定的渲染策略(是否只出主指标、是否按分类拆分模板文件),
* 因此用枚举把「模板编码 ↔ 渲染行为」内聚在一起,新增版式只需新增枚举项 + 放置模板文件,
* 渲染层无需再散落字符串比较。</p>
*
* <p><b>为什么是枚举而不是自由字符串</b>:早期实现把「是否只出主指标」硬绑在
* {@code templateCode == "DEVICE_SUMMARY"} 上,一旦模板改名判定会静默失效(不报错,
* 辅助量列又冒出来)。枚举把编码与行为绑定,改名只动枚举,判定入口唯一。</p>
*
* <p><b>模板编码落库语义</b>:{@link #getCode()} 即 {@code sys_user_setting.report_template}
* 的存储值,同时是 {@code templates/} 下模板文件名的决定依据 ——
* {@link #isCategoryScoped()} 为 false 时直接用 code 拼文件名,为 true 时拼分类前缀。</p>
*
* @author FastBee
* @date 2026-10-06
*/
@Getter
@AllArgsConstructor
public enum ReportTemplate {
/**
* 完整版(V2):按监测分类取 {@code DEVICE_{CATEGORY}_V2},共 10 节
*
* <p>默认版式。测点表沿用「主指标完整四列 + 辅助量合并列」原口径,
* 辅助物理量(水温、电压、角度…)保留为「本次值(变化量)」一列用于分析影响量。</p>
*/
FULL_VERSION("V2", "完整版", false, true),
/**
* DCP 平台专用三节版式(项目概况 / 测点数据 / 时程曲线)
*
* <p>全分类共用一份 {@code templates/DCPReport.docx}(首行标题用
* {@code {{device.categoryDesc}}} 占位,故不按分类拆分)。测点表只输出
* {@code iot_things_threshold.is_primary=1} 的主监测指标,非主监测指标整列不出现。</p>
*
* <p>命名对齐 DCP 平台(平台实时数据推送,见
* {@code com.fastbee.iot.platform.adapter.dcp})要求的交付格式;注意与推送侧的
* DCP 适配器区分:本枚举属<b>报告导出</b>,彼处属<b>数据推送</b>。</p>
*/
DCP_REPORT("DCPReport", "DCP 三节版式", true, false);
/**
* 模板编码(存库值;非分类拆分时即 templates/ 下模板文件名)
*/
private final String code;
/**
* 模板版式中文说明(前端下拉展示用)
*/
private final String desc;
/**
* 测点表是否只输出主监测指标(is_primary=1)
*/
private final boolean primaryOnly;
/**
* 是否按监测分类拆分模板文件
*
* <p>true:模板文件名为 {@code DEVICE_{CATEGORY}_V2}(每分类一份);
* false:全分类共用一份 {@code {code}.docx}。</p>
*/
private final boolean categoryScoped;
/**
* 按编码解析模板版式
*
* <p>未知编码回退 {@link #FULL_VERSION}(完整版)而非抛异常:报告生成不能因为
* 一个脏配置就整体失败,回退到默认版式至少能产出报告,同时由调用方记录告警。</p>
*
* @param code 模板编码(可为 null / 空串)
* @return 匹配的模板版式;空值或未识别时返回 FULL_VERSION
*/
public static ReportTemplate fromCode(String code) {
if (code == null || code.trim().isEmpty()) {
return FULL_VERSION;
}
for (ReportTemplate template : values()) {
if (template.code.equalsIgnoreCase(code.trim())) {
return template;
}
}
return FULL_VERSION;
}
}

View File

@ -6,6 +6,7 @@ import com.fastbee.common.core.domain.AjaxResult;
import com.fastbee.common.core.page.TableDataInfo;
import com.fastbee.common.core.redis.RedisCache;
import com.fastbee.common.enums.MonitorCategory;
import com.fastbee.common.enums.ReportTemplate;
import com.fastbee.common.exception.ServiceException;
import com.fastbee.common.utils.StringUtils;
import com.fastbee.oss.service.OssClient;
@ -160,4 +161,21 @@ public class ReportController extends BaseController {
.collect(Collectors.toList());
return AjaxResult.success(list);
}
/**
* 报告导出模板版式列表(枚举驱动,供系统设置页下拉选择 per-user 模板)
*/
@ApiOperation("报告导出模板版式列表")
@GetMapping("/templates")
public AjaxResult templates() {
List<Map<String, String>> list = Arrays.stream(ReportTemplate.values())
.map(t -> {
Map<String, String> m = new HashMap<>();
m.put("code", t.getCode());
m.put("desc", t.getDesc());
return m;
})
.collect(Collectors.toList());
return AjaxResult.success(list);
}
}

View File

@ -38,6 +38,12 @@
<artifactId>fastbee-iot-data</artifactId>
</dependency>
<!-- 用户系统设置(报告模板版式 per-user 落库,读 sys_user_setting) -->
<dependency>
<groupId>com.fastbee</groupId>
<artifactId>fastbee-system-service</artifactId>
</dependency>
<!-- Word 模板渲染(poi-tl,兼容 poi-ooxml 4.1.2) -->
<dependency>
<groupId>com.deepoove</groupId>

View File

@ -1,73 +0,0 @@
package com.fastbee.report.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 报告导出配置
*
* <p>只控制「用哪一套模板」,全局一刀切。</p>
*
* <p><b>模板版式</b>(2026-10-04 起只有两种,不要按字面值硬判断):</p>
* <ul>
* <li><b>V2</b>:完整版,按监测分类取 {@code DEVICE_{CATEGORY}_V2},共 10 节;</li>
* <li><b>DCPReport</b>:DCP 平台专用的三节版式(项目概况 / 测点数据 / 时程曲线),
* 测点表只输出 {@code iot_things_threshold.is_primary=1} 的主监测指标。
* 全分类共用一份 {@code templates/DCPReport.docx},首行标题用
* {@code {{device.categoryDesc}}} 占位,因此不再按分类拆分。</li>
* </ul>
*
* <p><b>为什么叫 DCPReport</b>:这套三节版式是 DCP 平台(平台实时数据推送,见
* {@code com.fastbee.iot.platform.adapter.dcp})要求交付的报告格式,命名与用途对齐,
* 便于日后回溯「为什么报告有两套版式」。注意与推送侧的 DCP 适配器区分:本常量属
* <b>报告导出</b>,彼处属<b>数据推送</b>,两者是不同业务线。</p>
*
* <p><b>为什么不按分类/产品白名单</b>:报告模板是现场交付物,格式要求由业主一次性约定,
* 不会一部分产品要三节版、一部分要完整版。全局开关让「改配置即全量生效」,
* 也避免白名单长期无人维护、导致部分设备悄悄导出错版本。</p>
*
* @author FastBee
* @date 2026-10-04
*/
@Data
@Component
@ConfigurationProperties(prefix = "report")
public class ReportProperties {
/** 完整版模板的版本标记:配置值等于它(或为空)时按监测分类取默认模板 */
public static final String FULL_VERSION_CODE = "V2";
/**
* DCP 平台三节版式的模板编码,与 {@code templates/DCPReport.docx} 文件名严格一致
*/
public static final String DCP_REPORT_TEMPLATE_CODE = "DCPReport";
/**
* 报告模板编码
*
* <p>取值:空 / {@code V2} → 完整版;{@code DCPReport} → DCP 三节版式。</p>
*/
private String templateCode;
/**
* 是否启用 DCP 三节版式(只出项目概况、isPrimary 测点表、时程曲线)
*
* <p>唯一判定入口。装配器与渲染层都只依赖这个方法,不各自比对模板编码字符串 ——
* 早期版本把「是否只出主指标」硬绑在 {@code templateCode == "DEVICE_SUMMARY"} 上,
* 一旦模板改名,判定会静默失效(不报错,辅助量列又冒出来),是必须避免的耦合。</p>
*/
public boolean isDcpReportMode() {
return DCP_REPORT_TEMPLATE_CODE.equalsIgnoreCase(trimmed());
}
/**
* 实际生效的模板编码
*
* <p>用于 {@code ReportModel.templateCode} 落值与日志;非 DCP 版式返回 {@code V2},
* 由调用方拼分类前缀得到具体文件名。</p>
*/
public String trimmed() {
return templateCode == null ? "" : templateCode.trim();
}
}

View File

@ -1,12 +1,14 @@
package com.fastbee.report.core;
import com.fastbee.common.enums.ReportPeriod;
import com.fastbee.common.enums.ReportTemplate;
import com.fastbee.common.utils.DateUtils;
import com.fastbee.common.utils.StringUtils;
import com.fastbee.report.config.ReportProperties;
import com.fastbee.report.exception.ReportException;
import com.fastbee.report.model.AnalysisResult;
import com.fastbee.report.model.ReportModel;
import com.fastbee.report.model.Statistics;
import com.fastbee.system.service.ISysUserSettingService;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@ -23,7 +25,7 @@ import java.util.UUID;
public class ReportAssembler {
@Resource
private ReportProperties reportProperties;
private ISysUserSettingService userSettingService;
public ReportModel assemble(ReportContext context, AnalysisResult analysis) {
return assemble(context, analysis, 1);
@ -33,8 +35,8 @@ public class ReportAssembler {
* @param pointCount 参与分析的测点数(&gt;1 时为多测点报告,设备信息展示测点规模)
*/
public ReportModel assemble(ReportContext context, AnalysisResult analysis, int pointCount) {
// 模板选择:DCP 版式为全局配置(report.template-code),不拼分类后缀,全分类共用一份模板
String templateCode = resolveTemplateCode(context);
// 模板选择:per-user(sys_user_setting.report_template),必须能拿到 userId
ReportTemplate template = resolveTemplate(context);
ReportPeriod period = context.getTimeWindow().getPeriod();
String serialNumber = context.getDevice().getSerialNumber();
@ -68,7 +70,7 @@ public class ReportAssembler {
return ReportModel.builder()
.reportNo(context.getReportNo())
.templateCode(templateCode)
.templateCode(resolveTemplateFileName(template, context))
.fileName(fileName)
.periodDesc(context.getTimeWindow().getDescription())
.period(period)
@ -82,32 +84,45 @@ public class ReportAssembler {
.metricName(primary == null ? null : primary.getModelName())
.metricNames(metricNames)
.metricUnit(primary == null ? null : primary.getUnit())
// DCP 版式:测点表只出 is_primary=1 的主监测指标(未勾选时由渲染层输出提示行)
.primaryOnly(ReportProperties.DCP_REPORT_TEMPLATE_CODE.equals(templateCode))
// 精简版(DCP 三节版式):测点表只出 is_primary=1 的主监测指标
.primaryOnly(template.isPrimaryOnly())
.analysis(analysis)
.build();
}
/**
* 解析报告模板编码
* 解析报告模板版式(per-user)
*
* <p>取值优先级:</p>
* <ol>
* <li><b>配置 {@code report.template-code=DCPReport}</b> → DCP 平台三节版式,
* 全分类共用一份 {@code templates/DCPReport.docx},不拼分类后缀;</li>
* <li>配空或配成 {@code V2} → 按监测分类取完整版 {@code DEVICE_{CATEGORY}_V2}。</li>
* </ol>
* <p>报告导出由登录用户触发(异步线程已显式透传 SecurityContext,userId 落在
* {@code ReportContext.userId}),因此模板版式是 per-user 偏好,从
* {@code sys_user_setting.report_template} 读取。无登录态(userId 为 null)时直接失败:
* 报告导出本就不允许无登录态触发,不做全局兜底,避免后台任务用错版式。</p>
*
* <p><b>为什么不读 {@code request.templateCode}</b>:那是历史字段,从未被真正使用过
* (此前本行是硬编码),前端也从未传值。放开它等于给外部一个可任意指定 classpath
* 模板文件名的入口,而报告模板与渲染策略强耦合(动态表列、图表位、poi-tl 策略绑定),
* 传错编码会直接渲染失败或产出错版式。格式是现场交付物,由运维改配置决定即可。</p>
* 传错编码会直接渲染失败或产出错版式。版式由用户在系统设置页选定即可。</p>
*/
private String resolveTemplateCode(ReportContext context) {
if (reportProperties != null && reportProperties.isDcpReportMode()) {
return ReportProperties.DCP_REPORT_TEMPLATE_CODE;
private ReportTemplate resolveTemplate(ReportContext context) {
Long userId = context == null ? null : context.getUserId();
if (userId == null) {
throw new ReportException("无法确定报告导出人,请登录后重试");
}
return "DEVICE_" + context.getCategory().name() + "_" + ReportProperties.FULL_VERSION_CODE;
String code = userSettingService.selectByUserId(userId).getReportTemplate();
return ReportTemplate.fromCode(code);
}
/**
* 由版式与分类拼出模板文件名(解析器按它从 classpath 定位 {@code templates/{name}.docx})
*
* <p>按分类拆分的版式(完整版 V2)拼 {@code DEVICE_{CATEGORY}_V2};
* 全分类共用一份的版式(DCP 三节)直接用 {@code code} 拼 {@code DCPReport}。</p>
*/
private String resolveTemplateFileName(ReportTemplate template, ReportContext context) {
if (template.isCategoryScoped()) {
return "DEVICE_" + context.getCategory().name() + "_" + template.getCode();
}
return template.getCode();
}
/**

View File

@ -70,6 +70,9 @@ public class ReportContext {
/** 租户ID(从主线程传递) */
private Long tenantId;
/** 创建人用户ID(从主线程传递,报告模板版式 per-user 判定依赖它) */
private Long userId;
/**
* 该物模型是否被用户显式指定为主监测指标
*/

View File

@ -148,10 +148,12 @@ public class ReportContextBuilder {
// 从主线程获取用户信息,传递到异步线程
String username = null;
Long tenantId = null;
Long userId = null;
try {
LoginUser loginUser = SecurityUtils.getLoginUser();
if (loginUser != null) {
username = loginUser.getUsername();
userId = loginUser.getUserId();
if (loginUser.getUser() != null && loginUser.getUser().getDept() != null) {
tenantId = loginUser.getUser().getDept().getDeptUserId();
}
@ -161,6 +163,7 @@ public class ReportContextBuilder {
}
context.setCreateBy(username);
context.setTenantId(tenantId);
context.setUserId(userId);
return context;
}

View File

@ -48,7 +48,7 @@ public class AnalysisResult {
* {@code is_primary=1} 的物模型,<b>没配就是空,不做任何推断</b>。</li>
* </ul>
*
* <p>精简版报告({@code report.template-code=DCPReport})的测点表只输出本集合中的物模型,
* <p>精简版报告({@code ReportTemplate.DCP_REPORT},三节版式)的测点表只输出本集合中的物模型,
* 因此必须与用户配置严格一致 —— 用关键词推断的结果冒充实配会输出用户没勾的指标。</p>
*
* <p>多测点归并时取代表测点(最不利测点)的集合,与 {@link #primaryIdentifiers} 同源同口径。</p>

View File

@ -300,6 +300,12 @@ public class WordRenderer implements ReportRenderer {
analysisMap.put("quality", qualityMap);
analysisMap.put("warning", warningMap);
// 报告最上方「是否达到预警值」结论(DCP 三节版式头部用 {{warningStatus}}):
// 与正文预警章节同一份 warning 数据,但措辞直接回答「有无达到」,不落状态词
data.put("warningStatus", warning == null
? "未配置预警阈值,无法判定是否达到预警值"
: warningReachedText(warning.getStatus()));
// 添加项目概况总结(始终 put,空值兜底)
data.put("summary", StringUtils.isNotEmpty(analysis.getSummary())
? analysis.getSummary() : "统计期内无有效监测数据。");
@ -797,6 +803,29 @@ public class WordRenderer implements ReportRenderer {
return "正常";
}
/**
* 预警状态转「是否达到预警值」结论(报告头部 {{warningStatus}})
*
* <p>与 {@link #warningLevelText} 的区别:后者输出状态词(正常/黄色预警…),
* 前者直接回答「有无达到预警值」,并点明达到的是哪一档(预警值/报警值/控制值),
* 措辞与 {@code WarningAnalyzer} 的 description 口径一致(GB 50497-2019 双控)。</p>
*/
private String warningReachedText(String status) {
if (WarningAnalysis.STATUS_CONTROL.equals(status)) {
return "已达到红色预警(控制值)";
}
if (WarningAnalysis.STATUS_ALARM.equals(status)) {
return "已达到橙色预警(报警值)";
}
if (WarningAnalysis.STATUS_WARNING.equals(status)) {
return "已达到黄色预警(预警值)";
}
if (WarningAnalysis.STATUS_UNKNOWN.equals(status)) {
return "未配置预警阈值,无法判定是否达到预警值";
}
return "未达到预警值";
}
/** 安全等级转中文 */
private String safetyLevelText(String level) {
if ("DANGER".equals(level)) {

View File

@ -2,8 +2,8 @@ package com.fastbee.report.renderer;
import com.fastbee.common.enums.MonitorCategory;
import com.fastbee.common.enums.ReportPeriod;
import com.fastbee.common.enums.ReportTemplate;
import com.fastbee.report.chart.ChartGenerator;
import com.fastbee.report.config.ReportProperties;
import com.fastbee.report.core.ReportAssembler;
import com.fastbee.report.data.MetricData;
import com.fastbee.report.model.AnalysisResult;
@ -13,6 +13,8 @@ import com.fastbee.report.model.ReportModel;
import com.fastbee.report.model.Statistics;
import com.fastbee.report.model.TrendAnalysis;
import com.fastbee.report.model.WarningAnalysis;
import com.fastbee.system.domain.SysUserSetting;
import com.fastbee.system.service.ISysUserSettingService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
@ -60,7 +62,7 @@ public class WordRendererDcpReportTemplateTest {
}
@Test
public void testRenderDcpReportTemplate() {
public void testRenderDcpReportTemplate() throws Exception {
ReportModel model = buildModel(Arrays.asList("crackWidth"));
byte[] docx = newRenderer().render(model);
assertNotNull(docx);
@ -68,6 +70,11 @@ public class WordRendererDcpReportTemplateTest {
String magic = new String(new byte[]{docx[0], docx[1]}, StandardCharsets.ISO_8859_1);
assertTrue("PK".equals(magic), "渲染结果应为有效 docx(zip)");
// 头部「是否达到预警值」结论:主指标为正常状态 → 应输出「未达到预警值」
String xml = readDocumentXml(docx);
assertTrue(xml.contains("预警状态"), "DCP 头部应包含「预警状态」标签");
assertTrue(xml.contains("未达到预警值"), "正常状态下应输出「未达到预警值」");
try {
java.io.File out = new java.io.File("target/test-output-dcp-report.docx");
java.nio.file.Files.write(out.toPath(), docx);
@ -80,11 +87,11 @@ public class WordRendererDcpReportTemplateTest {
/** DCP 模板只需一份,服务全部监测分类(首行标题用 {{device.categoryDesc}} 占位) */
@Test
public void testDcpReportTemplateExists() {
// 常量必须与 templates/ 下的文件名严格一致:解析器按 templateCode 拼文件名找资源,
// 常量与文件任一侧改名都会在运行时抛「报告模板不存在」,这里提前挡住
// 枚举编码必须与 templates/ 下的文件名严格一致:解析器按 templateCode 拼文件名找资源,
// 编码与文件任一侧改名都会在运行时抛「报告模板不存在」,这里提前挡住
assertEquals("DCPReport.docx",
ReportProperties.DCP_REPORT_TEMPLATE_CODE + ".docx",
"常量与模板文件名不一致,改名时需同步");
ReportTemplate.DCP_REPORT.getCode() + ".docx",
"枚举编码与模板文件名不一致,改名时需同步");
try (InputStream in = getClass().getClassLoader()
.getResourceAsStream("templates/DCPReport.docx")) {
assertNotNull(in, "DCP 版式模板缺失: DCPReport.docx");
@ -93,13 +100,15 @@ public class WordRendererDcpReportTemplateTest {
}
}
/** 配置切到 DCPReport 时,装配出的模板编码与 primaryOnly 标记都正确 */
/** 用户设置选 DCPReport 时,装配出的模板编码与 primaryOnly 标记都正确 */
@Test
public void testAssemblerResolvesDcpReportTemplate() {
ReportAssembler assembler = new ReportAssembler();
ReflectionTestUtils.setField(assembler, "reportProperties", dcpReportProperties());
ReflectionTestUtils.setField(assembler, "userSettingService",
mockUserSettingService(ReportTemplate.DCP_REPORT.getCode()));
com.fastbee.report.core.ReportContext context = new com.fastbee.report.core.ReportContext();
context.setUserId(1L);
context.setCategory(MonitorCategory.CRACK_METER);
com.fastbee.iot.domain.Device device = new com.fastbee.iot.domain.Device();
device.setDeviceId(1L);
@ -116,19 +125,19 @@ public class WordRendererDcpReportTemplateTest {
context.setReportNo("REPORT-TEST-0001");
ReportModel model = assembler.assemble(context, buildAnalysis(Arrays.asList("crackWidth")), 1);
assertEquals(ReportProperties.DCP_REPORT_TEMPLATE_CODE, model.getTemplateCode());
assertEquals(ReportTemplate.DCP_REPORT.getCode(), model.getTemplateCode());
assertTrue(model.isPrimaryOnly(), "DCP 版式必须置 primaryOnly,测点表才只出主监测指标");
}
/** 配置切回 V2 时,仍按监测分类取完整版模板,且 primaryOnly=false(原行为不变) */
/** 用户设置默认 V2 时,仍按监测分类取完整版模板,且 primaryOnly=false(原行为不变) */
@Test
public void testAssemblerFallsBackToFullTemplate() {
ReportAssembler assembler = new ReportAssembler();
ReportProperties properties = new ReportProperties();
properties.setTemplateCode(ReportProperties.FULL_VERSION_CODE);
ReflectionTestUtils.setField(assembler, "reportProperties", properties);
ReflectionTestUtils.setField(assembler, "userSettingService",
mockUserSettingService(ReportTemplate.FULL_VERSION.getCode()));
com.fastbee.report.core.ReportContext context = new com.fastbee.report.core.ReportContext();
context.setUserId(1L);
context.setCategory(MonitorCategory.CRACK_METER);
com.fastbee.iot.domain.Device device = new com.fastbee.iot.domain.Device();
device.setDeviceId(1L);
@ -152,7 +161,7 @@ public class WordRendererDcpReportTemplateTest {
@Test
public void testDcpReportWithoutExplicitPrimaryShowsHint() {
ReportModel model = buildModel(new ArrayList<>());
model.setTemplateCode(ReportProperties.DCP_REPORT_TEMPLATE_CODE);
model.setTemplateCode(ReportTemplate.DCP_REPORT.getCode());
byte[] docx = newRenderer().render(model);
assertTrue(docx.length > 0, "未配置主监测指标时也应产出可读文档");
}
@ -207,10 +216,41 @@ public class WordRendererDcpReportTemplateTest {
throw new IllegalStateException("docx 中未找到 word/document.xml");
}
private ReportProperties dcpReportProperties() {
ReportProperties properties = new ReportProperties();
properties.setTemplateCode(ReportProperties.DCP_REPORT_TEMPLATE_CODE);
return properties;
/**
* 构造 mock 的 {@link ISysUserSettingService}:按入参模板编码返回对应设置对象
*/
private ISysUserSettingService mockUserSettingService(String reportTemplateCode) {
return new ISysUserSettingService() {
@Override
public SysUserSetting selectByUserId(Long userId) {
SysUserSetting setting = SysUserSetting.defaultOf(userId);
setting.setReportTemplate(reportTemplateCode);
return setting;
}
@Override
public int updatePartial(Long userId, SysUserSetting patch, String operator) {
return 1;
}
@Override
public List<com.fastbee.system.domain.LoginRedirectOption> listLoginRedirectOptions() {
return new ArrayList<>();
}
@Override
public void addPinnedTag(Long userId, String path, String operator) {
}
@Override
public void removePinnedTag(Long userId, String path, String operator) {
}
@Override
public List<String> getPinnedTags(Long userId) {
return new ArrayList<>();
}
};
}
/**
@ -221,7 +261,7 @@ public class WordRendererDcpReportTemplateTest {
AnalysisResult analysis = buildAnalysis(explicitPrimary);
return ReportModel.builder()
.reportNo("REPORT-20261004120000-0001")
.templateCode(ReportProperties.DCP_REPORT_TEMPLATE_CODE)
.templateCode(ReportTemplate.DCP_REPORT.getCode())
.fileName("裂缝计-监测分析报告-abc123.docx")
.periodDesc("2026-10-01 00:00:00 ~ 2026-10-04 00:00:00")
.period(ReportPeriod.CUSTOM)

View File

@ -71,6 +71,16 @@ public class SysUserSetting extends BaseEntity {
@ApiModelProperty(value = "设备管理列表是否包含下级机构数据")
private Boolean showChild;
/**
* 报告导出模板版式编码
*
* <p>取值即 {@link com.fastbee.common.enums.ReportTemplate#getCode()}:
* {@code V2} 完整版 / {@code DCPReport} DCP 三节版式,可扩展其它版式。
* 报告导出由登录用户触发,模板版式是 per-user 偏好,故落本表而非全局 {@code sys_config}。</p>
*/
@ApiModelProperty(value = "报告导出模板版式编码(V2完整版/DCPReport三节版式)")
private String reportTemplate;
public SysUserSetting() {
}
@ -87,6 +97,7 @@ public class SysUserSetting extends BaseEntity {
setting.setPinnedTags(UserSettingConstants.EMPTY_PINNED_TAGS);
setting.setQueryGatewayChild(UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD);
setting.setShowChild(UserSettingConstants.DEFAULT_SHOW_CHILD);
setting.setReportTemplate(UserSettingConstants.DEFAULT_REPORT_TEMPLATE);
return setting;
}
@ -138,6 +149,14 @@ public class SysUserSetting extends BaseEntity {
this.showChild = showChild;
}
public String getReportTemplate() {
return reportTemplate;
}
public void setReportTemplate(String reportTemplate) {
this.reportTemplate = reportTemplate;
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
@ -147,6 +166,7 @@ public class SysUserSetting extends BaseEntity {
.append("pinnedTags", getPinnedTags())
.append("queryGatewayChild", getQueryGatewayChild())
.append("showChild", getShowChild())
.append("reportTemplate", getReportTemplate())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
.append("updateBy", getUpdateBy())

View File

@ -51,20 +51,23 @@ public interface SysUserSettingMapper {
*
* <p>固定标签的增量写入要在一行上做「锁定读 → 改 → 写」,行必须先存在。
* 刻意不用 ON DUPLICATE 写其它列:那会覆盖
* login_redirect / query_gateway_child / show_child,把用户已保存的设置冲回默认值。
* login_redirect / query_gateway_child / show_child / report_template,
* 把用户已保存的设置冲回默认值。
*
* @param userId 用户ID
* @param loginRedirect 新行使用的默认登录跳转
* @param userId 用户ID
* @param loginRedirect 新行使用的默认登录跳转
* @param queryGatewayChild 新行使用的默认开关值
* @param showChild 新行使用的默认「包含下级机构数据」值
* @param operator 操作人用户名
* @param now 当前时间
* @param showChild 新行使用的默认「包含下级机构数据」值
* @param reportTemplate 新行使用的默认报告模板编码
* @param operator 操作人用户名
* @param now 当前时间
* @return 影响行数(插入 1 / 已存在 0)
*/
int insertDefaultIfAbsent(@Param("userId") Long userId,
@Param("loginRedirect") String loginRedirect,
@Param("queryGatewayChild") Boolean queryGatewayChild,
@Param("showChild") Boolean showChild,
@Param("reportTemplate") String reportTemplate,
@Param("operator") String operator,
@Param("now") Date now);

View File

@ -37,8 +37,9 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
*
* <p>用 \n 而不是常见的逗号/竖线:loginRedirect 是路由 path 不含换行,
* pinnedTags 是 JSON 数组(元素为带引号的字符串)也不含裸换行,
* showChild 与 queryGatewayChild 是布尔字面量,四段互不冲突。
* <p>字段顺序固定为:loginRedirect | pinnedTags | showChild | queryGatewayChild,
* showChild 与 queryGatewayChild 是布尔字面量,reportTemplate 是模板编码,
* 五段互不冲突。
* <p>字段顺序固定为:loginRedirect | pinnedTags | showChild | queryGatewayChild | reportTemplate,
* 与 {@link #parseCached} 一一对应,增删字段必须同步改这两处。
*/
private static final String CACHE_SEPARATOR = "\n";
@ -83,15 +84,17 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
setting.getLoginRedirect() + CACHE_SEPARATOR
+ setting.getPinnedTags() + CACHE_SEPARATOR
+ setting.getShowChild() + CACHE_SEPARATOR
+ setting.getQueryGatewayChild());
+ setting.getQueryGatewayChild() + CACHE_SEPARATOR
+ setting.getReportTemplate());
}
/**
* 解析缓存值,格式非法时返回 null 走查库
*
* <p>段数必须严格等于 4:历史版本只缓存了 2 段(loginRedirect + pinnedTags)、
* 后又扩到 3 段(追加 queryGatewayChild)。若按缺省值补齐,会把旧格式里
* 那个位置的字段当成 showChild 读出来,故段数不等一律判为未命中,由查库回填新格式。
* <p>段数必须严格等于 5:历史版本只缓存了 2 段(loginRedirect + pinnedTags)、
* 后又扩到 3 段(追加 queryGatewayChild)、4 段(追加 showChild)。
* 若按缺省值补齐,会把旧格式里那个位置的字段当成别的字段读出来,
* 故段数不等一律判为未命中,由查库回填新格式。
* 缓存无 TTL,但每个用户最多回填一次。
*
* @param cached 缓存字符串
@ -100,7 +103,7 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
*/
private SysUserSetting parseCached(String cached, Long userId) {
String[] parts = cached.split(CACHE_SEPARATOR, -1);
if (parts.length != 4) {
if (parts.length != 5) {
return null;
}
SysUserSetting setting = SysUserSetting.defaultOf(userId);
@ -108,6 +111,7 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
setting.setPinnedTags(parts[1]);
setting.setShowChild(Boolean.parseBoolean(parts[2]));
setting.setQueryGatewayChild(Boolean.parseBoolean(parts[3]));
setting.setReportTemplate(parts[4]);
return setting;
}
@ -119,6 +123,7 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
// 行必须先存在,否则后面的部分更新会 UPDATE 0 行、静默丢掉本次改动
userSettingMapper.insertDefaultIfAbsent(userId, UserSettingConstants.DEFAULT_LOGIN_REDIRECT,
UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD, UserSettingConstants.DEFAULT_SHOW_CHILD,
UserSettingConstants.DEFAULT_REPORT_TEMPLATE,
operator, now);
patch.setUserId(userId);
patch.setUpdateBy(operator);
@ -141,6 +146,7 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
// 行必须先存在,后面的锁定读才有东西可锁
userSettingMapper.insertDefaultIfAbsent(userId, UserSettingConstants.DEFAULT_LOGIN_REDIRECT,
UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD, UserSettingConstants.DEFAULT_SHOW_CHILD,
UserSettingConstants.DEFAULT_REPORT_TEMPLATE,
operator, now);
List<String> paths = parsePinnedTags(userSettingMapper.selectPinnedTagsForUpdate(userId));
if (paths.contains(path)) {

View File

@ -11,6 +11,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="pinnedTags" column="pinned_tags" />
<result property="queryGatewayChild" column="query_gateway_child" />
<result property="showChild" column="show_child" />
<result property="reportTemplate" column="report_template" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
@ -25,6 +26,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="selectUserSettingVo">
select setting_id, user_id, login_redirect, pinned_tags, query_gateway_child, show_child,
report_template,
create_by, create_time, update_by, update_time, remark
from sys_user_setting
</sql>
@ -49,6 +51,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="loginRedirect != null">login_redirect = #{loginRedirect},</if>
<if test="queryGatewayChild != null">query_gateway_child = #{queryGatewayChild},</if>
<if test="showChild != null">show_child = #{showChild},</if>
<if test="reportTemplate != null">report_template = #{reportTemplate},</if>
update_by = #{updateBy},
update_time = #{updateTime}
</set>
@ -64,9 +67,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
-->
<insert id="insertDefaultIfAbsent">
insert into sys_user_setting
(user_id, login_redirect, pinned_tags, query_gateway_child, show_child, create_by, create_time, update_by, update_time)
(user_id, login_redirect, pinned_tags, query_gateway_child, show_child, report_template, create_by, create_time, update_by, update_time)
values
(#{userId}, #{loginRedirect}, '[]', #{queryGatewayChild}, #{showChild}, #{operator}, #{now}, #{operator}, #{now})
(#{userId}, #{loginRedirect}, '[]', #{queryGatewayChild}, #{showChild}, #{reportTemplate}, #{operator}, #{now}, #{operator}, #{now})
on duplicate key update
user_id = user_id
</insert>

View File

@ -40,6 +40,10 @@
-- ADD COLUMN `show_child` tinyint(1) NOT NULL DEFAULT 0
-- COMMENT '设备管理列表是否包含下级机构数据(0否1是)'
-- AFTER `query_gateway_child`;
-- ALTER TABLE `sys_user_setting`
-- ADD COLUMN `report_template` varchar(32) NOT NULL DEFAULT 'V2'
-- COMMENT '报告导出模板编码(V2完整版/DCPReport三节版式)'
-- AFTER `show_child`;
--
-- query_gateway_child:开启后 /iot/device/shortList **按设备编号查询时**(serialNumber 非空):
-- ① 命中的若是网关子设备编号,返回其所属的网关设备(子设备 gw_dev_code 存的就是网关 serial_number,
@ -60,6 +64,13 @@
-- 设备管理页进入时读一次该设置并作为查询参数下发,故本列是「前端读取、后端不感知」的偏好,
-- DeviceController 在未收到 showChild 时依然按 false 处理。
-- 默认 0 = 与引入本开关前完全一致。
--
-- report_template:报告导出模板版式编码,取值即 ReportTemplate.code(V2 完整版 /
-- DCPReport 三节版式,可扩展其它版式)。
-- 版式是 per-user 偏好:报告导出由登录用户触发(异步线程已显式透传 SecurityContext,
-- 能拿到 userId),故落本表而非全局 sys_config;无登录态不允许导出,不做全局兜底。
-- 由「系统设置」页维护(下拉选择),新增版式 = 枚举新增项 + 放 templates/ 下模板文件。
-- 默认 'V2' = 与引入本开关前完全一致(完整版,按监测分类取 DEVICE_{CATEGORY}_V2)。
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `sys_user_setting` (
`setting_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '设置主键',
@ -68,6 +79,7 @@ CREATE TABLE IF NOT EXISTS `sys_user_setting` (
`pinned_tags` text NOT NULL DEFAULT ('[]') COMMENT '任务栏固定标签JSON数组,按序存 path;空数组表示未固定',
`query_gateway_child` tinyint(1) NOT NULL DEFAULT 0 COMMENT '按编号查询时是否查网关子设备(0否1是)',
`show_child` tinyint(1) NOT NULL DEFAULT 0 COMMENT '设备管理列表是否包含下级机构数据(0否1是)',
`report_template` varchar(32) NOT NULL DEFAULT 'V2' COMMENT '报告导出模板编码(V2完整版/DCPReport三节版式)',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建者',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新者',
@ -87,7 +99,7 @@ CREATE TABLE IF NOT EXISTS `sys_user_setting` (
-- ------------------------------------------------------------
INSERT INTO `sys_config`
(`config_name`, `config_key`, `config_value`, `config_type`, `create_by`, `create_time`, `remark`)
SELECT '是否下发读取设备IMEI/ICCID指令', 'iot.power_on.read_imei_iccid', 'true', 'Y', 'system', NOW(),
SELECT '是否自动下发读取设备IMEI/ICCID指令', 'iot.power_on.read_imei_iccid', 'true', 'Y', 'system', NOW(),
'设备上电后是否下发 AT+IMEI/AT+ICCID/AT+LBS 指令读取;读该值的线程无登录上下文,故为全局配置'
WHERE NOT EXISTS (
SELECT 1 FROM `sys_config` WHERE `config_key` = 'iot.power_on.read_imei_iccid'

View File

@ -43,3 +43,11 @@ export function listMonitorCategories() {
method: 'get',
});
}
// 报告导出模板版式列表(枚举驱动,供系统设置页下拉选择 per-user 模板)
export function listReportTemplates() {
return request({
url: '/iot/report/templates',
method: 'get',
});
}

View File

@ -25,6 +25,22 @@
<el-switch v-model="form.showChild" />
</setting-row>
<setting-row label="报告导出模板" desc="导出设备监测报告时使用的模板版式,仅对该账号生效。完整版按监测分类输出 10 节报告;DCP 三节版式只出项目概况、主监测指标测点表与时程曲线。">
<el-select
v-model="form.reportTemplate"
:loading="templatesLoading"
placeholder="请选择报告模板版式"
class="setting-control"
>
<el-option
v-for="item in reportTemplates"
:key="item.code"
:label="item.desc"
:value="item.code"
/>
</el-select>
</setting-row>
<div class="setting-actions">
<el-button type="primary" icon="el-icon-check" :loading="saving" @click="submitForm">保存</el-button>
<span class="setting-actions__hint">仅提交改动过的配置项</span>
@ -38,14 +54,17 @@ import {
updateUserSetting,
listLoginRedirectOptions
} from '@/api/system/userSetting';
import { listReportTemplates } from '@/api/iot/report';
import SettingRow from './SettingRow';
// 默认值与后端 UserSettingConstants 保持一致:
// 未指定的账号保持原有的数据大屏落地行为;子设备开关与「查下级机构数据」默认关闭
// 未指定的账号保持原有的数据大屏落地行为;子设备开关与「查下级机构数据」默认关闭;
// 报告模板默认完整版(V2)
const DEFAULT_FORM = {
loginRedirect: '/bigscreen',
queryGatewayChild: false,
showChild: false
showChild: false,
reportTemplate: 'V2'
};
export default {
@ -55,7 +74,9 @@ export default {
return {
saving: false,
optionsLoading: false,
templatesLoading: false,
options: [],
reportTemplates: [],
form: { ...DEFAULT_FORM },
// 服务端当前值的快照:保存时只提交与它不同的字段,未改动的不进 payload
original: { ...DEFAULT_FORM }
@ -63,6 +84,7 @@ export default {
},
created() {
this.getOptions();
this.getTemplates();
this.getSetting();
},
methods: {
@ -76,6 +98,16 @@ export default {
this.optionsLoading = false;
});
},
getTemplates() {
this.templatesLoading = true;
listReportTemplates()
.then((res) => {
this.reportTemplates = res.data || [];
})
.finally(() => {
this.templatesLoading = false;
});
},
getSetting() {
getUserSetting().then((res) => {
const data = res.data || {};
@ -88,7 +120,8 @@ export default {
: data.queryGatewayChild,
showChild: data.showChild === null || data.showChild === undefined
? DEFAULT_FORM.showChild
: data.showChild
: data.showChild,
reportTemplate: data.reportTemplate || DEFAULT_FORM.reportTemplate
};
this.form = { ...form };
// 快照必须与回显值同源(含兜底后的值),否则未改动也会被当成脏数据提交