调整项目结构
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
package tech.easyflow.manuagent.artifact;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 提供项目产物列表与下载接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class ArtifactController {
|
||||
|
||||
private final ArtifactService artifactService;
|
||||
|
||||
/**
|
||||
* 创建产物控制器。
|
||||
*
|
||||
* @param artifactService 产物服务
|
||||
*/
|
||||
public ArtifactController(ArtifactService artifactService) {
|
||||
this.artifactService = artifactService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出项目产物。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 产物列表
|
||||
*/
|
||||
@GetMapping("/projects/{projectId}/artifacts")
|
||||
public List<ArtifactService.ArtifactView> list(@PathVariable UUID projectId) {
|
||||
return artifactService.list(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载产物。
|
||||
*
|
||||
* @param artifactId 产物 ID
|
||||
* @return 文件响应
|
||||
*/
|
||||
@GetMapping("/artifacts/{artifactId}/download")
|
||||
public ResponseEntity<org.springframework.core.io.Resource> download(@PathVariable UUID artifactId) {
|
||||
ArtifactService.Download download = artifactService.download(artifactId);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(download.mimeType()))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename*=UTF-8''" + URLEncoder.encode(download.name(), StandardCharsets.UTF_8))
|
||||
.body(download.resource());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package tech.easyflow.manuagent.artifact;
|
||||
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.project.ProjectFileService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Instant;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 校验、登记和下载 Agent 最终产物。
|
||||
*/
|
||||
@Service
|
||||
public class ArtifactService {
|
||||
|
||||
private final JdbcClient jdbc;
|
||||
private final ProjectFileService fileService;
|
||||
private final DocxValidator docxValidator;
|
||||
|
||||
/**
|
||||
* 创建产物服务。
|
||||
*
|
||||
* @param jdbc JDBC 客户端
|
||||
* @param fileService 项目文件服务
|
||||
* @param docxValidator DOCX 校验器
|
||||
*/
|
||||
public ArtifactService(JdbcClient jdbc, ProjectFileService fileService, DocxValidator docxValidator) {
|
||||
this.jdbc = jdbc;
|
||||
this.fileService = fileService;
|
||||
this.docxValidator = docxValidator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验本轮沙箱候选 DOCX,原子复制到正式目录并登记产物。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param runId Run ID
|
||||
* @param runStartedAt Run 开始时间
|
||||
* @param metadata 业务元数据
|
||||
* @return 已发布产物
|
||||
*/
|
||||
public ArtifactView publishCandidate(
|
||||
UUID projectId,
|
||||
UUID runId,
|
||||
Instant runStartedAt,
|
||||
JsonNode metadata) {
|
||||
Path candidates = fileService.safeProjectPath(projectId, "work/candidates");
|
||||
Path candidate = newestDocx(candidates, runStartedAt);
|
||||
DocxValidator.ValidationResult validation = docxValidator.validate(candidate);
|
||||
Path target = fileService.safeProjectPath(projectId, "artifacts/" + candidate.getFileName());
|
||||
Path temporary = target.resolveSibling(target.getFileName() + ".publishing");
|
||||
try {
|
||||
Files.copy(candidate, temporary, StandardCopyOption.REPLACE_EXISTING);
|
||||
docxValidator.validate(temporary);
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException exception) {
|
||||
try {
|
||||
Files.deleteIfExists(temporary);
|
||||
} catch (IOException cleanupException) {
|
||||
exception.addSuppressed(cleanupException);
|
||||
}
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_COPY_FAILED", "申报书发布失败");
|
||||
}
|
||||
com.fasterxml.jackson.databind.node.ObjectNode enriched = metadata.isObject()
|
||||
? ((com.fasterxml.jackson.databind.node.ObjectNode) metadata).deepCopy()
|
||||
: com.fasterxml.jackson.databind.node.JsonNodeFactory.instance.objectNode();
|
||||
enriched.put("docxValidated", true);
|
||||
enriched.put("docxEntries", validation.entryCount());
|
||||
enriched.put("commentCount", validation.commentCount());
|
||||
return publish(
|
||||
projectId,
|
||||
runId,
|
||||
"DOCX",
|
||||
target.getFileName().toString(),
|
||||
"artifacts/" + target.getFileName(),
|
||||
enriched);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布工作区中的 DOCX 产物。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param runId Run ID
|
||||
* @param kind 产物类型
|
||||
* @param name 文件名
|
||||
* @param relativePath 项目相对路径
|
||||
* @param metadata 业务摘要
|
||||
* @return 产物元数据
|
||||
*/
|
||||
public ArtifactView publish(
|
||||
UUID projectId,
|
||||
UUID runId,
|
||||
String kind,
|
||||
String name,
|
||||
String relativePath,
|
||||
JsonNode metadata) {
|
||||
Path path = fileService.safeProjectPath(projectId, relativePath);
|
||||
if (!relativePath.startsWith("artifacts/") || !Files.isRegularFile(path)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_INVALID", "产物文件不存在或不在发布目录");
|
||||
}
|
||||
try {
|
||||
long size = Files.size(path);
|
||||
if (size <= 0) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_EMPTY", "产物文件为空");
|
||||
}
|
||||
String hash = sha256(path);
|
||||
UUID id = jdbc.sql("""
|
||||
INSERT INTO app.artifact(
|
||||
id, project_id, run_id, kind, name, relative_path, mime_type,
|
||||
size_bytes, sha256, metadata_json)
|
||||
VALUES (:id, :projectId, :runId, :kind, :name, :path,
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
:size, :sha256, CAST(:metadata AS jsonb))
|
||||
ON CONFLICT (project_id, relative_path) DO UPDATE SET
|
||||
run_id = EXCLUDED.run_id,
|
||||
kind = EXCLUDED.kind,
|
||||
name = EXCLUDED.name,
|
||||
mime_type = EXCLUDED.mime_type,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
sha256 = EXCLUDED.sha256,
|
||||
metadata_json = EXCLUDED.metadata_json,
|
||||
published_at = CURRENT_TIMESTAMP
|
||||
RETURNING id
|
||||
""")
|
||||
.param("id", UUID.randomUUID())
|
||||
.param("projectId", projectId)
|
||||
.param("runId", runId)
|
||||
.param("kind", kind)
|
||||
.param("name", name)
|
||||
.param("path", relativePath)
|
||||
.param("size", size)
|
||||
.param("sha256", hash)
|
||||
.param("metadata", metadata.toString())
|
||||
.query(UUID.class)
|
||||
.single();
|
||||
return require(id);
|
||||
} catch (IOException | NoSuchAlgorithmException exception) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PUBLISH_FAILED", "产物校验失败");
|
||||
}
|
||||
}
|
||||
|
||||
private Path newestDocx(Path directory, Instant runStartedAt) {
|
||||
try (java.util.stream.Stream<Path> paths = Files.list(directory)) {
|
||||
return paths
|
||||
.filter(path -> path.getFileName().toString().toLowerCase(java.util.Locale.ROOT).endsWith(".docx"))
|
||||
.filter(path -> path.toFile().lastModified() >= runStartedAt.toEpochMilli())
|
||||
.max(java.util.Comparator.comparingLong(path -> path.toFile().lastModified()))
|
||||
.orElseThrow(() -> new ApiException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DOCX_NOT_GENERATED",
|
||||
"Agent 未生成本轮 DOCX"));
|
||||
} catch (IOException exception) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_SCAN_FAILED", "申报书候选目录无法读取");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出项目产物。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 按发布时间倒序的产物
|
||||
*/
|
||||
public List<ArtifactView> list(UUID projectId) {
|
||||
return jdbc.sql(ARTIFACT_SELECT + " WHERE project_id = :projectId ORDER BY published_at DESC")
|
||||
.param("projectId", projectId)
|
||||
.query(ArtifactService::mapArtifact)
|
||||
.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取产物下载资源。
|
||||
*
|
||||
* @param artifactId 产物 ID
|
||||
* @return 下载信息
|
||||
*/
|
||||
public Download download(UUID artifactId) {
|
||||
StoredArtifact artifact = jdbc.sql("""
|
||||
SELECT project_id, name, relative_path, mime_type, size_bytes, sha256
|
||||
FROM app.artifact WHERE id = :id
|
||||
""")
|
||||
.param("id", artifactId)
|
||||
.query((rs, rowNum) -> new StoredArtifact(
|
||||
rs.getObject("project_id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getString("relative_path"),
|
||||
rs.getString("mime_type"),
|
||||
rs.getLong("size_bytes"),
|
||||
rs.getString("sha256")))
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在"));
|
||||
try {
|
||||
Path path = fileService.safeProjectPath(artifact.projectId(), artifact.relativePath());
|
||||
Resource resource = new UrlResource(path.toUri());
|
||||
if (!resource.exists()) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_FILE_NOT_FOUND", "产物文件不存在");
|
||||
}
|
||||
if (Files.size(path) != artifact.sizeBytes() || !sha256(path).equals(artifact.sha256())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "ARTIFACT_INTEGRITY_FAILED", "产物完整性校验失败,请重新生成");
|
||||
}
|
||||
return new Download(artifact.name(), artifact.mimeType(), resource);
|
||||
} catch (java.net.MalformedURLException exception) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PATH_INVALID", "产物路径无效");
|
||||
} catch (IOException | NoSuchAlgorithmException exception) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "ARTIFACT_INTEGRITY_FAILED", "产物完整性校验失败,请重新生成");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算文件 SHA-256。
|
||||
*
|
||||
* @param path 文件路径
|
||||
* @return 小写十六进制哈希
|
||||
* @throws IOException 文件读取失败时抛出
|
||||
* @throws NoSuchAlgorithmException 运行环境不支持 SHA-256 时抛出
|
||||
*/
|
||||
private String sha256(Path path) throws IOException, NoSuchAlgorithmException {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
try (InputStream input = Files.newInputStream(path)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
for (int read; (read = input.read(buffer)) >= 0;) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private ArtifactView require(UUID id) {
|
||||
return jdbc.sql(ARTIFACT_SELECT + " WHERE id = :id")
|
||||
.param("id", id)
|
||||
.query(ArtifactService::mapArtifact)
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在"));
|
||||
}
|
||||
|
||||
private static ArtifactView mapArtifact(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
||||
return new ArtifactView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getObject("project_id", UUID.class),
|
||||
rs.getObject("run_id", UUID.class),
|
||||
rs.getString("kind"),
|
||||
rs.getString("name"),
|
||||
rs.getLong("size_bytes"),
|
||||
rs.getString("metadata_json"),
|
||||
rs.getObject("published_at", OffsetDateTime.class));
|
||||
}
|
||||
|
||||
private static final String ARTIFACT_SELECT = """
|
||||
SELECT id, project_id, run_id, kind, name, size_bytes, metadata_json, published_at
|
||||
FROM app.artifact
|
||||
""";
|
||||
|
||||
/**
|
||||
* 产物元数据。
|
||||
*
|
||||
* @param id 产物 ID
|
||||
* @param projectId 项目 ID
|
||||
* @param runId 生成 Run
|
||||
* @param kind 类型
|
||||
* @param name 文件名
|
||||
* @param sizeBytes 文件大小
|
||||
* @param metadataJson 业务摘要 JSON
|
||||
* @param publishedAt 发布时间
|
||||
*/
|
||||
public record ArtifactView(
|
||||
UUID id,
|
||||
UUID projectId,
|
||||
UUID runId,
|
||||
String kind,
|
||||
String name,
|
||||
long sizeBytes,
|
||||
String metadataJson,
|
||||
OffsetDateTime publishedAt) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载结果。
|
||||
*
|
||||
* @param name 下载文件名
|
||||
* @param mimeType MIME 类型
|
||||
* @param resource 文件资源
|
||||
*/
|
||||
public record Download(String name, String mimeType, Resource resource) {
|
||||
}
|
||||
|
||||
private record StoredArtifact(
|
||||
UUID projectId,
|
||||
String name,
|
||||
String relativePath,
|
||||
String mimeType,
|
||||
long sizeBytes,
|
||||
String sha256) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package tech.easyflow.manuagent.artifact;
|
||||
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
/**
|
||||
* 使用 JDK 标准库校验 DOCX 容器、核心 OOXML 和原生批注引用。
|
||||
*/
|
||||
@Component
|
||||
public class DocxValidator {
|
||||
|
||||
private static final Set<String> REQUIRED_ENTRIES = Set.of(
|
||||
"[Content_Types].xml", "_rels/.rels", "word/document.xml");
|
||||
private static final int MAX_ENTRIES = 10_000;
|
||||
private static final long MAX_UNCOMPRESSED_BYTES = 512L * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 校验 DOCX 文件可以被 Word 作为完整 OOXML 文档读取。
|
||||
*
|
||||
* @param path DOCX 文件
|
||||
* @return 校验摘要
|
||||
* @throws ApiException 文件损坏、结构缺失或批注引用异常时抛出
|
||||
*/
|
||||
public ValidationResult validate(Path path) {
|
||||
if (!Files.isRegularFile(path)) {
|
||||
throw invalid("DOCX 文件不存在");
|
||||
}
|
||||
try (ZipFile zip = new ZipFile(path.toFile())) {
|
||||
Set<String> names = new HashSet<>();
|
||||
long uncompressed = 0;
|
||||
var entries = zip.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
ZipEntry entry = entries.nextElement();
|
||||
String name = entry.getName();
|
||||
if (name.startsWith("/") || name.contains("../") || !names.add(name)) {
|
||||
throw invalid("DOCX 包含非法或重复路径");
|
||||
}
|
||||
if (names.size() > MAX_ENTRIES) {
|
||||
throw invalid("DOCX 文件数量超过限制");
|
||||
}
|
||||
if (entry.getSize() > 0) {
|
||||
uncompressed = Math.addExact(uncompressed, entry.getSize());
|
||||
if (uncompressed > MAX_UNCOMPRESSED_BYTES) {
|
||||
throw invalid("DOCX 解压后大小超过限制");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!names.containsAll(REQUIRED_ENTRIES)) {
|
||||
throw invalid("DOCX 缺少必要的 Word 文档结构");
|
||||
}
|
||||
|
||||
Document document = parse(zip, "word/document.xml");
|
||||
if (!"document".equals(document.getDocumentElement().getLocalName())
|
||||
|| document.getElementsByTagNameNS("*", "body").getLength() != 1
|
||||
|| document.getElementsByTagNameNS("*", "t").getLength() == 0) {
|
||||
throw invalid("DOCX 正文结构为空或无效");
|
||||
}
|
||||
int comments = validateComments(zip, names, document);
|
||||
return new ValidationResult(Files.size(path), names.size(), comments);
|
||||
} catch (ApiException exception) {
|
||||
throw exception;
|
||||
} catch (ArithmeticException | IOException exception) {
|
||||
throw invalid("DOCX 容器损坏或无法读取");
|
||||
}
|
||||
}
|
||||
|
||||
private int validateComments(ZipFile zip, Set<String> names, Document document) throws IOException {
|
||||
Map<String, Integer> references = idCounts(document, "commentReference");
|
||||
Map<String, Integer> starts = idCounts(document, "commentRangeStart");
|
||||
Map<String, Integer> ends = idCounts(document, "commentRangeEnd");
|
||||
Set<String> referenced = new HashSet<>(references.keySet());
|
||||
referenced.addAll(starts.keySet());
|
||||
referenced.addAll(ends.keySet());
|
||||
if (!names.contains("word/comments.xml")) {
|
||||
if (!referenced.isEmpty()) {
|
||||
throw invalid("DOCX 正文引用了不存在的批注");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
Document comments = parse(zip, "word/comments.xml");
|
||||
Set<String> declared = new HashSet<>();
|
||||
NodeList nodes = comments.getElementsByTagNameNS("*", "comment");
|
||||
for (int index = 0; index < nodes.getLength(); index++) {
|
||||
String id = attributeByLocalName((Element) nodes.item(index), "id");
|
||||
if (id.isBlank() || !declared.add(id)) {
|
||||
throw invalid("DOCX 包含重复或无编号批注");
|
||||
}
|
||||
}
|
||||
if (declared.isEmpty() || !declared.equals(referenced)) {
|
||||
throw invalid("DOCX 批注与正文锚点不一致");
|
||||
}
|
||||
for (String id : declared) {
|
||||
if (references.getOrDefault(id, 0) != 1
|
||||
|| starts.getOrDefault(id, 0) != 1
|
||||
|| ends.getOrDefault(id, 0) != 1) {
|
||||
throw invalid("DOCX 批注锚点重复或不完整");
|
||||
}
|
||||
}
|
||||
if (!names.contains("word/_rels/document.xml.rels")) {
|
||||
throw invalid("DOCX 批注缺少关系定义");
|
||||
}
|
||||
Document relationships = parse(zip, "word/_rels/document.xml.rels");
|
||||
boolean linked = false;
|
||||
NodeList relations = relationships.getElementsByTagNameNS("*", "Relationship");
|
||||
for (int index = 0; index < relations.getLength(); index++) {
|
||||
Element relation = (Element) relations.item(index);
|
||||
if ("comments.xml".equals(relation.getAttribute("Target"))) {
|
||||
linked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!linked) {
|
||||
throw invalid("DOCX 批注关系未连接到正文");
|
||||
}
|
||||
return declared.size();
|
||||
}
|
||||
|
||||
private Map<String, Integer> idCounts(Document document, String localName) {
|
||||
Map<String, Integer> values = new HashMap<>();
|
||||
NodeList nodes = document.getElementsByTagNameNS("*", localName);
|
||||
for (int index = 0; index < nodes.getLength(); index++) {
|
||||
String id = attributeByLocalName((Element) nodes.item(index), "id");
|
||||
if (!id.isBlank()) {
|
||||
values.merge(id, 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private String attributeByLocalName(Element element, String localName) {
|
||||
for (int index = 0; index < element.getAttributes().getLength(); index++) {
|
||||
Node attribute = element.getAttributes().item(index);
|
||||
if (localName.equals(attribute.getLocalName()) || localName.equals(attribute.getNodeName())) {
|
||||
return attribute.getNodeValue();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private Document parse(ZipFile zip, String name) throws IOException {
|
||||
ZipEntry entry = zip.getEntry(name);
|
||||
if (entry == null) {
|
||||
throw invalid("DOCX 缺少必要 XML:" + name);
|
||||
}
|
||||
try (InputStream input = zip.getInputStream(entry)) {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
|
||||
return factory.newDocumentBuilder().parse(input);
|
||||
} catch (ApiException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw invalid("DOCX XML 无法解析:" + name);
|
||||
}
|
||||
}
|
||||
|
||||
private ApiException invalid(String message) {
|
||||
return new ApiException(HttpStatus.UNPROCESSABLE_ENTITY, "DOCX_INVALID", message);
|
||||
}
|
||||
|
||||
/**
|
||||
* DOCX 校验摘要。
|
||||
*
|
||||
* @param sizeBytes 文件大小
|
||||
* @param entryCount ZIP 条目数
|
||||
* @param commentCount 原生批注数
|
||||
*/
|
||||
public record ValidationResult(long sizeBytes, int entryCount, int commentCount) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user