调整项目结构

This commit is contained in:
2026-08-29 14:02:28 +08:00
parent c56aa6e752
commit c13302c0cb
52 changed files with 55270 additions and 124 deletions

View File

@@ -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());
}
}

View File

@@ -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) {
}
}

View File

@@ -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) {
}
}