feat(ai-report): 新增委托单位管理和报告审批功能

- 新增委托单位管理模块,支持增删改查、导入导出功能
- 实现委托单位 Excel 模板下载和数据导入功能
- 添加报告审批抽象处理器,支持审批流程日志记录
- 统一文件存储路径配置,将各模块独立路径改为统一根目录
- 新增 PQDIF 文件名存储字段,完善文件管理功能
- 修复代码中的乱码问题和错误提示信息
- 优化系统常量定义和字典配置管理
This commit is contained in:
2026-07-01 13:15:57 +08:00
parent 0ea686d3cb
commit c1ad7feec2
99 changed files with 6511 additions and 24 deletions

View File

@@ -11,8 +11,15 @@
</parent>
<artifactId>report-model</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>com.njcn.gather</groupId>
<artifactId>comservice</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>njcn-common</artifactId>
@@ -30,5 +37,21 @@
<artifactId>spingboot2.3.12</artifactId>
<version>2.3.12</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,132 @@
package com.njcn.gather.aireport.reportmodel.component;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
* Local storage for uploaded report model template files.
*/
@Component
public class ReportModelFileStorageService {
private static final String DEFAULT_STORAGE_DIR = "data/report-model";
private static final String REPORT_MODEL_STORAGE_DIR = "report-model";
private static final DateTimeFormatter DATE_DIR_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
@Value("${files.path:}")
private String filesPath;
public StoredFile save(MultipartFile templateFile) {
if (templateFile == null || templateFile.isEmpty()) {
throw new IllegalArgumentException("模板文件不能为空");
}
try {
Path storageRoot = resolveStorageRoot();
String dateDir = LocalDate.now().format(DATE_DIR_FORMATTER);
Path storageDir = storageRoot.resolve(dateDir).normalize();
if (!storageDir.startsWith(storageRoot)) {
throw new IllegalArgumentException("模板文件存储路径不合法");
}
Files.createDirectories(storageDir);
String sanitizedFileName = sanitizeFileName(templateFile.getOriginalFilename());
String storedFileName = UUID.randomUUID().toString().replace("-", "") + "-" + sanitizedFileName;
Path target = storageDir.resolve(storedFileName).normalize();
ensurePathInRoot(storageRoot, target);
try (InputStream inputStream = templateFile.getInputStream()) {
Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING);
}
String storedPath = storageRoot.relativize(target).toString().replace('\\', '/');
return new StoredFile(storedPath, sanitizedFileName);
} catch (Exception exception) {
throw new IllegalArgumentException("保存模板文件失败: " + exception.getMessage(), exception);
}
}
public Path resolveDownloadPath(String storedPath) {
if (trimToNull(storedPath) == null) {
throw new IllegalArgumentException("模板文件路径不能为空");
}
Path storageRoot = resolveStorageRoot();
Path target = storageRoot.resolve(storedPath).normalize();
ensurePathInRoot(storageRoot, target);
return target;
}
public void deleteQuietly(String storedPath) {
try {
if (trimToNull(storedPath) != null) {
Files.deleteIfExists(resolveDownloadPath(storedPath));
}
} catch (Exception ignored) {
// Cleanup failure must not hide the original database error.
}
}
public String resolveDownloadFileName(String storedPath) {
Path fileName = Paths.get(storedPath).getFileName();
if (fileName == null) {
return "report-model";
}
String name = fileName.toString();
int separatorIndex = name.indexOf('-');
return separatorIndex >= 0 && separatorIndex + 1 < name.length() ? name.substring(separatorIndex + 1) : name;
}
private Path resolveStorageRoot() {
String configuredPath = trimToNull(filesPath);
Path path = configuredPath == null ? Paths.get(DEFAULT_STORAGE_DIR) : Paths.get(configuredPath, REPORT_MODEL_STORAGE_DIR);
return path.toAbsolutePath().normalize();
}
private void ensurePathInRoot(Path storageRoot, Path target) {
if (!target.startsWith(storageRoot)) {
throw new IllegalArgumentException("模板文件路径不合法");
}
}
private String sanitizeFileName(String originalFilename) {
String fileName = trimToNull(originalFilename);
if (fileName == null) {
return "unnamed";
}
String normalizedName = Paths.get(fileName).getFileName().toString();
String sanitized = normalizedName.replaceAll("[\\\\/:*?\"<>|]", "_");
return trimToNull(sanitized) == null ? "unnamed" : sanitized;
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isEmpty() ? null : text;
}
public static class StoredFile {
private final String path;
private final String fileName;
public StoredFile(String path, String fileName) {
this.path = path;
this.fileName = fileName;
}
public String getPath() {
return path;
}
public String getFileName() {
return fileName;
}
}
}

View File

@@ -0,0 +1,41 @@
package com.njcn.gather.aireport.reportmodel.component;
import cn.hutool.core.util.StrUtil;
import com.njcn.gather.aireport.reportmodel.mapper.ReportModelMapper;
import com.njcn.gather.aireport.reportmodel.pojo.vo.ReportModelUserNameVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
public class ReportModelUserNameResolver {
private final ReportModelMapper reportModelMapper;
public Map<String, String> resolveUserNames(Collection<String> userIds) {
if (userIds == null || userIds.isEmpty()) {
return Collections.emptyMap();
}
Set<String> validIds = userIds.stream()
.filter(StrUtil::isNotBlank)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (validIds.isEmpty()) {
return Collections.emptyMap();
}
List<ReportModelUserNameVO> users = reportModelMapper.selectUserNamesByIds(validIds);
if (users == null || users.isEmpty()) {
return Collections.emptyMap();
}
return users.stream()
.filter(user -> user != null && StrUtil.isNotBlank(user.getId()) && StrUtil.isNotBlank(user.getName()))
.collect(Collectors.toMap(ReportModelUserNameVO::getId, ReportModelUserNameVO::getName, (first, second) -> first));
}
}

View File

@@ -0,0 +1,173 @@
package com.njcn.gather.aireport.reportmodel.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.constant.OperateType;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.gather.aireport.reportmodel.component.ReportModelFileStorageService;
import com.njcn.gather.aireport.reportmodel.pojo.param.ReportModelParam;
import com.njcn.gather.aireport.reportmodel.pojo.po.ReportModelPO;
import com.njcn.gather.aireport.reportmodel.pojo.vo.ReportModelVO;
import com.njcn.gather.aireport.reportmodel.service.ReportModelService;
import com.njcn.gather.comservice.filepreview.FilePreviewService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* Report model management endpoints.
*/
@Api(tags = "报告模板管理")
@RestController
@RequestMapping("/api/report-model")
@RequiredArgsConstructor
public class ReportModelController extends BaseController {
private final ReportModelService reportModelService;
private final ReportModelFileStorageService reportModelFileStorageService;
private final FilePreviewService filePreviewService;
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询报告模板列表")
@PostMapping("/list")
public HttpResult<Page<ReportModelVO>> list(@RequestBody(required = false) ReportModelParam.QueryParam param) {
String methodDescribe = getMethodDescribe("list");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
reportModelService.listReportModels(param), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
@ApiOperation("新增报告模板")
@PostMapping(value = "/add", consumes = {"multipart/form-data"})
public HttpResult<Boolean> add(@RequestPart("request") @Validated ReportModelParam.AddParam param,
@RequestPart("templateFile") MultipartFile templateFile) {
String methodDescribe = getMethodDescribe("add");
boolean result = reportModelService.addReportModel(param, templateFile);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.UPDATE)
@ApiOperation("编辑报告模板")
@PostMapping(value = "/update", consumes = {"multipart/form-data"})
public HttpResult<Boolean> update(@RequestPart("request") @Validated ReportModelParam.UpdateParam param,
@RequestPart(value = "templateFile", required = false) MultipartFile templateFile) {
String methodDescribe = getMethodDescribe("update");
boolean result = reportModelService.updateReportModel(param, templateFile);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DELETE)
@ApiOperation("删除报告模板")
@PostMapping("/delete")
public HttpResult<Boolean> delete(@RequestBody List<String> ids) {
String methodDescribe = getMethodDescribe("delete");
boolean result = reportModelService.deleteReportModels(ids);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("查询报告模板详情")
@ApiImplicitParam(name = "id", value = "模板ID", required = true)
@GetMapping("/{id}")
public HttpResult<ReportModelVO> detail(@PathVariable("id") String id) {
String methodDescribe = getMethodDescribe("detail");
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
reportModelService.getReportModel(id), methodDescribe);
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("下载报告模板文件")
@ApiImplicitParam(name = "id", value = "模板ID", required = true)
@GetMapping("/{id}/download")
public ResponseEntity<InputStreamResource> download(@PathVariable("id") String id) {
ReportModelPO model = reportModelService.requireNormal(id);
Path filePath = reportModelFileStorageService.resolveDownloadPath(model.getPath());
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板文件不存在");
}
try {
String fileName = model.getFileName();
if (fileName == null || fileName.trim().isEmpty()) {
fileName = reportModelFileStorageService.resolveDownloadFileName(model.getPath());
}
String encodedFileName = encodeFileName(fileName);
InputStream inputStream = Files.newInputStream(filePath);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFileName)
.body(new InputStreamResource(inputStream));
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "读取模板文件失败");
}
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
@ApiOperation("预览报告模板文件")
@ApiImplicitParam(name = "id", value = "模板ID", required = true)
@GetMapping("/{id}/preview")
public ResponseEntity<byte[]> preview(@PathVariable("id") String id) {
ReportModelPO model = reportModelService.requireNormal(id);
Path filePath = reportModelFileStorageService.resolveDownloadPath(model.getPath());
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板文件不存在");
}
String fileName = model.getFileName();
if (fileName == null || fileName.trim().isEmpty()) {
fileName = reportModelFileStorageService.resolveDownloadFileName(model.getPath());
}
FilePreviewService.PreviewContent previewContent = filePreviewService.preview(filePath, fileName);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"inline; filename*=UTF-8''" + encodeFileName(fileName))
.header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate")
.contentType(MediaType.parseMediaType(previewContent.getContentType()))
.body(previewContent.getContent());
}
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.UPDATE)
@ApiOperation("提交报告模板审核")
@PostMapping("/submit-audit")
public HttpResult<Boolean> submitAudit(@RequestBody @Validated ReportModelParam.SubmitAuditParam param) {
String methodDescribe = getMethodDescribe("submitAudit");
boolean result = reportModelService.submitAudit(param);
return HttpResultUtil.assembleCommonResponseResult(result ? CommonResponseEnum.SUCCESS : CommonResponseEnum.FAIL,
result, methodDescribe);
}
private String encodeFileName(String fileName) {
try {
return URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()).replace("+", "%20");
} catch (Exception exception) {
throw new BusinessException(CommonResponseEnum.FAIL, "编码模板文件名失败");
}
}
}

View File

@@ -0,0 +1,25 @@
package com.njcn.gather.aireport.reportmodel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.njcn.gather.aireport.reportmodel.pojo.po.ReportModelPO;
import com.njcn.gather.aireport.reportmodel.pojo.vo.ReportModelUserNameVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collection;
import java.util.List;
@Mapper
public interface ReportModelMapper extends BaseMapper<ReportModelPO> {
@Select({
"<script>",
"select id, name from sys_user where id in",
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>",
"#{id}",
"</foreach>",
"</script>"
})
List<ReportModelUserNameVO> selectUserNamesByIds(@Param("ids") Collection<String> ids);
}

View File

@@ -0,0 +1,19 @@
package com.njcn.gather.aireport.reportmodel.pojo.constant;
/**
* Report model state constants.
*/
public final class ReportModelConst {
private ReportModelConst() {
}
public static final String STATE_EDITING = "01";
public static final String STATE_AUDITING = "02";
public static final String STATE_APPROVED = "03";
public static final String STATE_REJECTED = "04";
public static final String STATE_DELETED = "05";
public static final Integer STATUS_DELETED = 0;
public static final Integer STATUS_NORMAL = 1;
}

View File

@@ -0,0 +1,58 @@
package com.njcn.gather.aireport.reportmodel.pojo.param;
import com.njcn.web.pojo.param.BaseParam;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* Report model request parameters.
*/
public class ReportModelParam {
@Data
@EqualsAndHashCode(callSuper = true)
public static class QueryParam extends BaseParam {
@ApiModelProperty("Template name keyword")
private String name;
@ApiModelProperty("Template state")
private String state;
@ApiModelProperty("Create time begin, yyyy-MM-dd HH:mm:ss")
private String searchBeginTime;
@ApiModelProperty("Create time end, yyyy-MM-dd HH:mm:ss")
private String searchEndTime;
}
@Data
public static class AddParam {
@ApiModelProperty("Template name")
@NotBlank(message = "模板名称不能为空")
@Size(max = 32, message = "模板名称不能超过32个字符")
private String name;
}
@Data
public static class UpdateParam {
@ApiModelProperty("Template ID")
@NotBlank(message = "模板ID不能为空")
private String id;
@ApiModelProperty("Template name")
@NotBlank(message = "模板名称不能为空")
@Size(max = 32, message = "模板名称不能超过32个字符")
private String name;
}
@Data
public static class SubmitAuditParam {
@ApiModelProperty("Template ID")
@NotBlank(message = "模板ID不能为空")
private String id;
}
}

View File

@@ -0,0 +1,45 @@
package com.njcn.gather.aireport.reportmodel.pojo.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* Report model template record.
*/
@Data
@TableName("ai_reportmodel")
public class ReportModelPO implements Serializable {
private static final long serialVersionUID = -1413572272542367267L;
@TableId("id")
private String id;
@TableField("name")
private String name;
@TableField("state")
private String state;
@TableField("check_id")
private String checkId;
@TableField("check_time")
private LocalDateTime checkTime;
@TableField("check_result")
private String checkResult;
@TableField("path")
private String path;
@TableField("file_name")
private String fileName;
@TableField("status")
private Integer status;
@TableField("create_by")
private String createBy;
@TableField("create_time")
private LocalDateTime createTime;
@TableField("update_by")
private String updateBy;
@TableField("update_time")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,9 @@
package com.njcn.gather.aireport.reportmodel.pojo.vo;
import lombok.Data;
@Data
public class ReportModelUserNameVO {
private String id;
private String name;
}

View File

@@ -0,0 +1,108 @@
package com.njcn.gather.aireport.reportmodel.pojo.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
/**
* Report model view object.
*/
@Data
public class ReportModelVO {
private static final String DEFAULT_EMPTY_DISPLAY = "-";
@ApiModelProperty("Template ID")
private String id;
@ApiModelProperty("Template name")
private String name;
@ApiModelProperty("Template state")
private String state;
@ApiModelProperty("Auditor ID")
private String checkId;
@ApiModelProperty("Auditor display name")
private String checkerName;
@ApiModelProperty("Audit time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime checkTime;
@ApiModelProperty("Audit comment")
private String checkResult;
@ApiModelProperty("Template file path")
private String path;
@ApiModelProperty("Template document file name")
private String fileName;
@ApiModelProperty("Logical status")
private Integer status;
@ApiModelProperty("Creator display name")
private String createBy;
@ApiModelProperty("Create time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime createTime;
@ApiModelProperty("Editor display name")
private String updateBy;
@ApiModelProperty("Editor display name")
private String editorName;
@ApiModelProperty("Update time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime updateTime;
public String getId() {
return defaultDisplay(id);
}
public String getName() {
return defaultDisplay(name);
}
public String getState() {
return defaultDisplay(state);
}
public String getCheckId() {
return defaultDisplay(checkId);
}
public String getCheckerName() {
return defaultDisplay(checkerName);
}
public String getCheckResult() {
return defaultDisplay(checkResult);
}
public String getPath() {
return defaultDisplay(path);
}
public String getFileName() {
return defaultDisplay(fileName);
}
public String getCreateBy() {
return defaultDisplay(createBy);
}
public String getUpdateBy() {
return defaultDisplay(updateBy);
}
public String getEditorName() {
return defaultDisplay(editorName);
}
private String defaultDisplay(String value) {
return StringUtils.hasText(value) ? value : DEFAULT_EMPTY_DISPLAY;
}
}

View File

@@ -0,0 +1,27 @@
package com.njcn.gather.aireport.reportmodel.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.njcn.gather.aireport.reportmodel.pojo.param.ReportModelParam;
import com.njcn.gather.aireport.reportmodel.pojo.po.ReportModelPO;
import com.njcn.gather.aireport.reportmodel.pojo.vo.ReportModelVO;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
public interface ReportModelService extends IService<ReportModelPO> {
Page<ReportModelVO> listReportModels(ReportModelParam.QueryParam param);
boolean addReportModel(ReportModelParam.AddParam param, MultipartFile templateFile);
boolean updateReportModel(ReportModelParam.UpdateParam param, MultipartFile templateFile);
boolean deleteReportModels(List<String> ids);
ReportModelVO getReportModel(String id);
ReportModelPO requireNormal(String id);
boolean submitAudit(ReportModelParam.SubmitAuditParam param);
}

View File

@@ -0,0 +1,279 @@
package com.njcn.gather.aireport.reportmodel.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.aireport.reportmodel.component.ReportModelFileStorageService;
import com.njcn.gather.aireport.reportmodel.component.ReportModelUserNameResolver;
import com.njcn.gather.aireport.reportmodel.mapper.ReportModelMapper;
import com.njcn.gather.aireport.reportmodel.pojo.constant.ReportModelConst;
import com.njcn.gather.aireport.reportmodel.pojo.param.ReportModelParam;
import com.njcn.gather.aireport.reportmodel.pojo.po.ReportModelPO;
import com.njcn.gather.aireport.reportmodel.pojo.vo.ReportModelVO;
import com.njcn.gather.aireport.reportmodel.service.ReportModelService;
import com.njcn.web.factory.PageFactory;
import com.njcn.web.utils.RequestUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Report model management service.
*/
@Service
public class ReportModelServiceImpl extends ServiceImpl<ReportModelMapper, ReportModelPO> implements ReportModelService {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final ReportModelFileStorageService reportModelFileStorageService;
private final ReportModelUserNameResolver reportModelUserNameResolver;
public ReportModelServiceImpl(ReportModelFileStorageService reportModelFileStorageService,
ReportModelUserNameResolver reportModelUserNameResolver) {
this.reportModelFileStorageService = reportModelFileStorageService;
this.reportModelUserNameResolver = reportModelUserNameResolver;
}
@Override
public Page<ReportModelVO> listReportModels(ReportModelParam.QueryParam param) {
ReportModelParam.QueryParam query = param == null ? new ReportModelParam.QueryParam() : param;
LambdaQueryWrapper<ReportModelPO> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ReportModelPO::getStatus, ReportModelConst.STATUS_NORMAL)
.like(StrUtil.isNotBlank(query.getName()), ReportModelPO::getName, query.getName())
.eq(StrUtil.isNotBlank(query.getState()), ReportModelPO::getState, query.getState());
if (StrUtil.isNotBlank(query.getSearchBeginTime())) {
wrapper.ge(ReportModelPO::getUpdateTime, parseDateTime(query.getSearchBeginTime(), "开始时间格式不正确"));
}
if (StrUtil.isNotBlank(query.getSearchEndTime())) {
wrapper.le(ReportModelPO::getUpdateTime, parseDateTime(query.getSearchEndTime(), "结束时间格式不正确"));
}
wrapper.orderByDesc(ReportModelPO::getCreateTime);
Page<ReportModelPO> page = this.page(new Page<>(PageFactory.getPageNum(query), PageFactory.getPageSize(query)), wrapper);
Page<ReportModelVO> result = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
Map<String, String> userNameMap = resolveUserNameMap(page.getRecords());
result.setRecords(page.getRecords().stream().map(model -> toVO(model, userNameMap)).collect(Collectors.toList()));
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean addReportModel(ReportModelParam.AddParam param, MultipartFile templateFile) {
String name = resolveName(param == null ? null : param.getName());
checkNameUnique(name, null);
ReportModelFileStorageService.StoredFile storedFile = reportModelFileStorageService.save(templateFile);
try {
LocalDateTime now = LocalDateTime.now();
String userId = resolveCurrentUserId();
ReportModelPO model = new ReportModelPO();
model.setId(UUID.randomUUID().toString());
model.setName(name);
model.setState(ReportModelConst.STATE_EDITING);
model.setPath(storedFile.getPath());
model.setFileName(storedFile.getFileName());
model.setStatus(ReportModelConst.STATUS_NORMAL);
model.setCreateBy(userId);
model.setCreateTime(now);
model.setUpdateBy(userId);
model.setUpdateTime(now);
return this.save(model);
} catch (RuntimeException exception) {
reportModelFileStorageService.deleteQuietly(storedFile.getPath());
throw exception;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateReportModel(ReportModelParam.UpdateParam param, MultipartFile templateFile) {
if (param == null || StrUtil.isBlank(param.getId())) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板ID不能为空");
}
ReportModelPO model = requireNormal(param.getId());
if (!allowEdit(model.getState())) {
throw new BusinessException(CommonResponseEnum.FAIL, "仅编制或退回状态的模板允许编辑");
}
String name = resolveName(param.getName());
checkNameUnique(name, model.getId());
String newStoredPath = null;
if (templateFile != null && !templateFile.isEmpty()) {
ReportModelFileStorageService.StoredFile storedFile = reportModelFileStorageService.save(templateFile);
newStoredPath = storedFile.getPath();
model.setPath(storedFile.getPath());
model.setFileName(storedFile.getFileName());
}
try {
model.setName(name);
model.setUpdateBy(resolveCurrentUserId());
model.setUpdateTime(LocalDateTime.now());
return this.updateById(model);
} catch (RuntimeException exception) {
reportModelFileStorageService.deleteQuietly(newStoredPath);
throw exception;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean deleteReportModels(List<String> ids) {
if (ids == null || ids.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板ID不能为空");
}
List<String> validIds = ids.stream().filter(StrUtil::isNotBlank).collect(Collectors.toList());
if (validIds.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板ID不能为空");
}
return this.lambdaUpdate()
.set(ReportModelPO::getStatus, ReportModelConst.STATUS_DELETED)
.set(ReportModelPO::getState, ReportModelConst.STATE_DELETED)
.set(ReportModelPO::getUpdateBy, resolveCurrentUserId())
.set(ReportModelPO::getUpdateTime, LocalDateTime.now())
.in(ReportModelPO::getId, validIds)
.eq(ReportModelPO::getStatus, ReportModelConst.STATUS_NORMAL)
.update();
}
@Override
public ReportModelVO getReportModel(String id) {
ReportModelPO model = requireNormal(id);
return toVO(model, resolveUserNameMap(Arrays.asList(model)));
}
@Override
public ReportModelPO requireNormal(String id) {
if (StrUtil.isBlank(id)) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板ID不能为空");
}
ReportModelPO model = this.lambdaQuery()
.eq(ReportModelPO::getId, id)
.eq(ReportModelPO::getStatus, ReportModelConst.STATUS_NORMAL)
.one();
if (model == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板不存在或已删除");
}
return model;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean submitAudit(ReportModelParam.SubmitAuditParam param) {
if (param == null || StrUtil.isBlank(param.getId())) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板ID不能为空");
}
ReportModelPO model = requireNormal(param.getId());
if (!ReportModelConst.STATE_EDITING.equals(model.getState())) {
throw new BusinessException(CommonResponseEnum.FAIL, "仅编制状态的模板允许提交审核");
}
model.setState(ReportModelConst.STATE_AUDITING);
model.setCheckId(null);
model.setCheckTime(null);
model.setCheckResult(null);
model.setUpdateBy(resolveCurrentUserId());
model.setUpdateTime(LocalDateTime.now());
return this.updateById(model);
}
private boolean allowEdit(String state) {
return ReportModelConst.STATE_EDITING.equals(state) || ReportModelConst.STATE_REJECTED.equals(state);
}
private void checkNameUnique(String name, String excludeId) {
LambdaQueryWrapper<ReportModelPO> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ReportModelPO::getName, name)
.eq(ReportModelPO::getStatus, ReportModelConst.STATUS_NORMAL)
.ne(StrUtil.isNotBlank(excludeId), ReportModelPO::getId, excludeId);
if (this.count(wrapper) > 0) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板名称已存在");
}
}
private String resolveName(String name) {
String result = trimToNull(name);
if (result == null) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板名称不能为空");
}
if (result.length() > 32) {
throw new BusinessException(CommonResponseEnum.FAIL, "模板名称不能超过32个字符");
}
return result;
}
private LocalDateTime parseDateTime(String value, String errorMessage) {
if (StrUtil.isBlank(value)) {
return null;
}
try {
return LocalDateTime.parse(value.trim(), DATE_TIME_FORMATTER);
} catch (DateTimeParseException exception) {
throw new BusinessException(CommonResponseEnum.FAIL, errorMessage);
}
}
private ReportModelVO toVO(ReportModelPO model) {
return toVO(model, resolveUserNameMap(Arrays.asList(model)));
}
private ReportModelVO toVO(ReportModelPO model, Map<String, String> userNameMap) {
ReportModelVO vo = new ReportModelVO();
BeanUtil.copyProperties(model, vo);
vo.setCreateBy(resolveUserName(model.getCreateBy(), userNameMap));
vo.setUpdateBy(resolveUserName(model.getUpdateBy(), userNameMap));
vo.setEditorName(resolveEditorName(model, userNameMap));
vo.setCheckerName(resolveUserName(model.getCheckId(), userNameMap));
return vo;
}
/**
* 列表“编辑人”优先取更新人,新增后若尚未产生独立更新记录,则回退到创建人。
*/
private Map<String, String> resolveUserNameMap(List<ReportModelPO> models) {
List<String> userIds = models.stream()
.flatMap(model -> Arrays.asList(model.getCreateBy(), model.getUpdateBy(), model.getCheckId()).stream())
.filter(StrUtil::isNotBlank)
.collect(Collectors.toList());
return reportModelUserNameResolver.resolveUserNames(userIds);
}
private String resolveEditorName(ReportModelPO model, Map<String, String> userNameMap) {
if (model == null) {
return null;
}
if (StrUtil.isNotBlank(model.getUpdateBy())) {
return resolveUserName(model.getUpdateBy(), userNameMap);
}
return StrUtil.isNotBlank(model.getCreateBy()) ? resolveUserName(model.getCreateBy(), userNameMap) : null;
}
private String resolveUserName(String userId, Map<String, String> userNameMap) {
if (StrUtil.isBlank(userId)) {
return null;
}
String userName = userNameMap == null ? null : userNameMap.get(userId);
return StrUtil.isNotBlank(userName) ? userName : userId;
}
private String resolveCurrentUserId() {
String userId = RequestUtil.getUserId();
return StrUtil.isBlank(userId) ? null : userId;
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isEmpty() ? null : text;
}
}

View File

@@ -0,0 +1,39 @@
package com.njcn.gather.aireport.reportmodel.component;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Files;
import java.nio.file.Path;
public class ReportModelFileStorageServiceTest {
@Test
public void saveSanitizesFileNameAndReturnsRelativePath() throws Exception {
Path filesRoot = Files.createTempDirectory("files-root");
ReportModelFileStorageService service = new ReportModelFileStorageService();
ReflectionTestUtils.setField(service, "filesPath", filesRoot.toString());
ReportModelFileStorageService.StoredFile storedFile = service.save(new MockMultipartFile("templateFile", "../a:b.docx",
"application/octet-stream", "content".getBytes("UTF-8")));
String storedPath = storedFile.getPath();
Assert.assertFalse(storedPath.contains(".."));
Assert.assertTrue(storedPath.endsWith("-a_b.docx"));
Assert.assertEquals("a_b.docx", storedFile.getFileName());
Assert.assertTrue(service.resolveDownloadPath(storedPath)
.startsWith(filesRoot.resolve("report-model").toAbsolutePath().normalize()));
Assert.assertTrue(Files.exists(service.resolveDownloadPath(storedPath)));
}
@Test(expected = IllegalArgumentException.class)
public void resolveDownloadPathRejectsPathOutsideStorageRoot() throws Exception {
Path filesRoot = Files.createTempDirectory("files-root");
ReportModelFileStorageService service = new ReportModelFileStorageService();
ReflectionTestUtils.setField(service, "filesPath", filesRoot.toString());
service.resolveDownloadPath("../outside.docx");
}
}

View File

@@ -0,0 +1,30 @@
package com.njcn.gather.aireport.reportmodel.component;
import com.njcn.gather.aireport.reportmodel.mapper.ReportModelMapper;
import com.njcn.gather.aireport.reportmodel.pojo.vo.ReportModelUserNameVO;
import org.junit.Assert;
import org.junit.Test;
import java.util.Arrays;
import java.util.Map;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class ReportModelUserNameResolverTest {
@Test
public void resolveUserNamesReturnsSysUserNameById() {
ReportModelMapper mapper = mock(ReportModelMapper.class);
ReportModelUserNameVO user = new ReportModelUserNameVO();
user.setId("user-001");
user.setName("张三");
when(mapper.selectUserNamesByIds(anyCollection())).thenReturn(Arrays.asList(user));
ReportModelUserNameResolver resolver = new ReportModelUserNameResolver(mapper);
Map<String, String> result = resolver.resolveUserNames(Arrays.asList("user-001"));
Assert.assertEquals("张三", result.get("user-001"));
}
}

View File

@@ -0,0 +1,33 @@
package com.njcn.gather.aireport.reportmodel.pojo.vo;
import org.junit.Assert;
import org.junit.Test;
public class ReportModelVODefaultDisplayTest {
@Test
public void stringFieldsUseDashWhenValueMissing() {
ReportModelVO vo = new ReportModelVO();
vo.setCheckerName(" ");
vo.setCheckResult(null);
vo.setEditorName("");
vo.setUpdateBy(" ");
vo.setFileName(null);
Assert.assertEquals("-", vo.getCheckerName());
Assert.assertEquals("-", vo.getCheckResult());
Assert.assertEquals("-", vo.getEditorName());
Assert.assertEquals("-", vo.getUpdateBy());
Assert.assertEquals("-", vo.getFileName());
}
@Test
public void stringFieldsKeepOriginalTextWhenValuePresent() {
ReportModelVO vo = new ReportModelVO();
vo.setCheckerName("审核人A");
vo.setCheckResult("审核通过");
Assert.assertEquals("审核人A", vo.getCheckerName());
Assert.assertEquals("审核通过", vo.getCheckResult());
}
}

View File

@@ -0,0 +1,23 @@
package com.njcn.gather.aireport.reportmodel.pojo.vo;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import java.time.LocalDateTime;
public class ReportModelVOTimeFormatTest {
@Test
public void timeJsonUsesStandardTwentyFourHourFormat() throws Exception {
ReportModelVO vo = new ReportModelVO();
vo.setCreateTime(LocalDateTime.of(2026, 6, 25, 13, 19, 25));
vo.setUpdateTime(LocalDateTime.of(2026, 6, 25, 13, 20, 41));
String json = new ObjectMapper().writeValueAsString(vo);
Assert.assertTrue(json.contains("\"createTime\":\"2026-06-25 13:19:25\""));
Assert.assertTrue(json.contains("\"updateTime\":\"2026-06-25 13:20:41\""));
Assert.assertFalse(json.contains("2026-06-25T13"));
}
}

View File

@@ -0,0 +1,67 @@
package com.njcn.gather.aireport.reportmodel.service.impl;
import com.njcn.gather.aireport.reportmodel.component.ReportModelFileStorageService;
import com.njcn.gather.aireport.reportmodel.component.ReportModelUserNameResolver;
import com.njcn.gather.aireport.reportmodel.pojo.constant.ReportModelConst;
import com.njcn.gather.aireport.reportmodel.pojo.param.ReportModelParam;
import com.njcn.gather.aireport.reportmodel.pojo.po.ReportModelPO;
import org.junit.Assert;
import org.junit.Test;
import java.time.LocalDateTime;
import static org.mockito.Mockito.mock;
public class ReportModelServiceImplTest {
@Test
public void submitAuditShouldResetAuditFieldsWhenMoveToAuditing() {
ReportModelPO model = new ReportModelPO();
model.setId("model-001");
model.setState(ReportModelConst.STATE_EDITING);
model.setStatus(ReportModelConst.STATUS_NORMAL);
model.setCheckId("checker-001");
model.setCheckTime(LocalDateTime.of(2026, 6, 26, 9, 30, 0));
model.setCheckResult("已有审核意见");
TestableReportModelServiceImpl service = new TestableReportModelServiceImpl(model);
ReportModelParam.SubmitAuditParam param = new ReportModelParam.SubmitAuditParam();
param.setId("model-001");
boolean result = service.submitAudit(param);
Assert.assertTrue(result);
Assert.assertEquals(ReportModelConst.STATE_AUDITING, model.getState());
Assert.assertNull(model.getCheckId());
Assert.assertNull(model.getCheckTime());
Assert.assertNull(model.getCheckResult());
Assert.assertSame(model, service.getUpdatedModel());
Assert.assertNotNull(model.getUpdateTime());
}
private static class TestableReportModelServiceImpl extends ReportModelServiceImpl {
private final ReportModelPO model;
private ReportModelPO updatedModel;
private TestableReportModelServiceImpl(ReportModelPO model) {
super(mock(ReportModelFileStorageService.class), mock(ReportModelUserNameResolver.class));
this.model = model;
}
@Override
public ReportModelPO requireNormal(String id) {
return model;
}
@Override
public boolean updateById(ReportModelPO entity) {
this.updatedModel = entity;
return true;
}
private ReportModelPO getUpdatedModel() {
return updatedModel;
}
}
}