DocumentApplicationService.java

package com.yumu.noveltranslator.application.service;

import com.yumu.noveltranslator.port.dto.entity.DocumentInfoResponse;
import com.yumu.noveltranslator.port.dto.translation.DocumentTranslationRequest;
import com.yumu.noveltranslator.port.dto.translation.DocumentTranslationResponse;
import com.yumu.noveltranslator.domain.model.Document;
import com.yumu.noveltranslator.domain.model.TranslationTask;
import com.yumu.noveltranslator.enums.TranslationStatus;
import com.yumu.noveltranslator.port.in.CollabPort;
import com.yumu.noveltranslator.port.out.DocumentRepositoryPort;
import com.yumu.noveltranslator.port.out.TranslationRepositoryPort;
import com.yumu.noveltranslator.domain.service.TranslationStateMachine;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

/**
 * 文档管理应用服务
 */
@Service
@RequiredArgsConstructor
public class DocumentApplicationService implements com.yumu.noveltranslator.port.in.DocumentPort {

    @Value("${translation.upload-dir:#{systemProperties['user.home']}/novel-translator/uploads}")
    private String uploadDir;

    private final DocumentRepositoryPort documentPort;
    private final TranslationRepositoryPort translationPort;
    private final TranslationStateMachine stateMachine;
    private final CollabPort collabPort;
    private final com.yumu.noveltranslator.port.in.TranslationTaskPort translationTaskPort;

    /**
     * 上传文档
     */
    public Document uploadDocument(Long userId, MultipartFile file, DocumentTranslationRequest request) throws IOException {
        // 创建上传目录
        Path uploadPath = Paths.get(uploadDir);
        if (!Files.exists(uploadPath)) {
            Files.createDirectories(uploadPath);
        }

        // 生成唯一文件名
        String originalFilename = file.getOriginalFilename();
        String extension = originalFilename != null && originalFilename.contains(".")
                ? originalFilename.substring(originalFilename.lastIndexOf("."))
                : "";
        String filename = UUID.randomUUID().toString() + extension;
        Path filePath = uploadPath.resolve(filename);

        // 保存文件
        Files.copy(file.getInputStream(), filePath);

        // 创建文档记录
        Document doc = new Document();
        doc.setUserId(userId);
        doc.setName(originalFilename);
        doc.setPath(filePath.toString());
        doc.setSourceLang(request.getSourceLang());
        doc.setTargetLang(request.getTargetLang());
        doc.setFileType(extension.replace(".", "").toLowerCase());
        doc.setFileSize(file.getSize());
        doc.setStatus(TranslationStatus.PENDING.getValue());
        doc.setMode(request.getMode());
        doc.setCreateTime(LocalDateTime.now());

        documentPort.save(doc);

        return doc;
    }

    /**
     * 获取用户文档列表
     */
    public List<Document> getUserDocuments(Long userId, String status) {
        List<Document> documents = documentPort.findByUserId(userId);
        if (status != null && !"all".equals(status)) {
            return documents.stream()
                    .filter(doc -> status.equals(doc.getStatus()))
                    .collect(Collectors.toList());
        }
        return documents;
    }

    /**
     * 获取文档详情
     */
    public Document getDocumentById(Long docId, Long userId) {
        return documentPort.findByIdAndUserId(docId, userId).orElse(null);
    }

    /**
     * 删除文档
     */
    public boolean deleteDocument(Long docId, Long userId) {
        return documentPort.findByIdAndUserId(docId, userId).map(doc -> {
            try {
                Files.deleteIfExists(Paths.get(doc.getPath()));
            } catch (IOException e) {
                // 忽略文件删除失败
            }
            documentPort.markDeleted(docId);
            return true;
        }).orElse(false);
    }

    /**
     * 重新翻译文档(仅重置状态,实际翻译由前端 SSE 触发)
     */
    public boolean retryTranslation(Long docId, Long userId) {
        return documentPort.findByIdAndUserId(docId, userId).map(doc -> {
            doc.setStatus(TranslationStatus.PENDING.getValue());
            doc.setErrorMessage(null);
            doc.setUpdateTime(LocalDateTime.now());
            documentPort.update(doc);
            // 同时重置关联的所有翻译任务状态
            List<TranslationTask> tasks = translationPort.findTasksByDocumentId(docId);
            for (TranslationTask task : tasks) {
                task.setStatus("pending");
                task.setErrorMessage(null);
                task.setProgress(0);
                task.setUpdateTime(LocalDateTime.now());
                translationPort.updateTask(task);
            }
            return true;
        }).orElse(false);
    }

    /**
     * 转换 Document 为 DocumentInfoResponse
     */
    public DocumentInfoResponse toDocumentInfoResponse(Document doc) {
        if (doc == null) {
            return null;
        }
        DocumentInfoResponse response = new DocumentInfoResponse();
        response.setId(doc.getId());
        response.setName(doc.getName());
        response.setFileType(doc.getFileType());
        response.setFileSize(doc.getFileSize());
        response.setSourceLang(doc.getSourceLang());
        response.setTargetLang(doc.getTargetLang());
        response.setTaskId(doc.getTaskId());
        response.setStatus(doc.getStatus());
        response.setProgress(resolveProgress(doc));
        response.setCreateTime(doc.getCreateTime() != null
                ? doc.getCreateTime().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
                : null);
        response.setCompletedTime(doc.getCompletedTime() != null
                ? doc.getCompletedTime().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
                : null);
        response.setErrorMessage(doc.getErrorMessage());
        return response;
    }

    /**
     * 上传文档并根据翻译模式自动启动翻译
     * 团队模式:添加章节到协作项目或创建新项目;fast/expert 模式:创建翻译任务
     */
    public DocumentTranslationResponse uploadAndStartTranslation(
            Long userId, MultipartFile file, DocumentTranslationRequest request) throws IOException {

        Document doc = uploadDocument(userId, file, request);

        if ("team".equals(request.getMode())) {
            // 团队模式:文档已通过 documentId 关联(uploadDocument 创建了 doc,但没传 projectId)
            // 实际上团队模式需要 projectId,所以这个方法由调用方决定走哪条路
            // 为保持接口简洁,团队模式不使用此方法,调用方直接用 uploadDocument + collabPort
            throw new UnsupportedOperationException("团队模式请使用 uploadDocument 后手动调用 CollabPort");
        }

        // fast/expert 模式:创建任务后直接异步启动翻译
        TranslationTask task = translationTaskPort.createDocumentTask(userId, doc);
        translationTaskPort.startDocumentTranslation(task, doc);

        DocumentTranslationResponse response = new DocumentTranslationResponse();
        response.setTaskId(task.getTaskId());
        response.setDocumentId(doc.getId());
        response.setDocumentName(doc.getName());
        response.setStatus(task.getStatus());
        response.setProjectId(null);
        response.setMessage("文档上传成功");

        return response;
    }

    /**
     * 取消翻译:先尝试取消翻译任务,无任务时直接更新文档状态
     */
    public boolean cancelTranslation(Long docId, Long userId) {
        TranslationTask task = translationTaskPort.getTaskByDocumentId(docId);
        if (task != null) {
            if (!task.getUserId().equals(userId)) {
                return false;
            }
            return translationTaskPort.cancelTask(task.getTaskId(), userId);
        }

        // 翻译任务不存在,直接更新文档状态
        Document doc = documentPort.findByIdAndUserId(docId, userId).orElse(null);
        if (doc == null) {
            return false;
        }
        if (!TranslationStatus.PENDING.getValue().equals(doc.getStatus())
                && !TranslationStatus.PROCESSING.getValue().equals(doc.getStatus())) {
            return false;
        }
        doc.setStatus(TranslationStatus.FAILED.getValue());
        doc.setErrorMessage("用户取消翻译");
        doc.setUpdateTime(java.time.LocalDateTime.now());
        documentPort.update(doc);
        return true;
    }

    /**
     * 从 Document 或关联的 TranslationTask 获取真实进度
     */
    private int resolveProgress(Document doc) {
        if (TranslationStatus.COMPLETED.getValue().equals(doc.getStatus())) {
            return 100;
        }
        if (TranslationStatus.PROCESSING.getValue().equals(doc.getStatus()) || TranslationStatus.PENDING.getValue().equals(doc.getStatus())) {
            int realProgress = getRealProgress(doc);
            if (realProgress > 0) {
                return realProgress;
            }
            // 返回基于时间的平滑进度,提升用户体验
            if (doc.getCreateTime() != null) {
                long elapsedSeconds = java.time.Duration.between(doc.getCreateTime(), LocalDateTime.now()).getSeconds();
                // 预估 3 分钟完成,每分钟约 33%,最多到 95%
                int estimated = Math.min(95, (int) (elapsedSeconds * 100.0 / 180.0));
                return Math.max(5, estimated); // 至少 5%
            }
        }
        return 0;
    }

    private int getRealProgress(Document doc) {
        if (doc.getTaskId() != null) {
            return translationPort.findTaskByTaskId(doc.getTaskId())
                    .map(task -> task.getProgress() != null ? task.getProgress() : 0)
                    .orElse(0);
        }
        return 0;
    }
}