领码方案|Linux 下 PLT → PDF 转换服务超级完整版:异步、权限、进度(一气呵成)

发布于:2025-09-11 ⋅ 阅读:(23) ⋅ 点赞:(0)

本教程给出可直接落地的 Linux 环境下 PLT→PDF 转换微服务,全链路涵盖:同步/异步模式、JWT+RBAC+项目域权限、任务状态与进度、PDF 水印与审计、可观测性与弹性伸缩;技术栈为 Spring Boot + gpcl6(GhostPCL)+ Redis + S3/OSS,接口名、命令参数、日志字段保持原样,便于与现有前后端快速对接。


架构与数据流

  • 主链路: 上传 → 鉴权 → 同步/异步执行 → GhostPCL 转换 → 水印/脱敏 → 存储后端 → 进度查询/下载
  • 异步形态: 线程池承载(可演进 MQ),任务状态落地 Redis/DB,前端轮询或后续 WebSocket/SSE 推送
  • 安全治理: JWT/OAuth2 鉴权、RBAC + 项目域校验;下载签名 URL/口令;-dSAFER 沙箱化调用
  • 可观测: 指标、结构化日志、链路追踪、全量审计事件,支撑生产级运行与问题闭环。

接口契约与状态语义

  • /plt/upload [POST]: form-data: file, projectId, mode=sync/async → sync:{downloadUrl} / async:{taskId}
  • /plt/status/{taskId} [GET]: {status, progress, outputName, message}
  • /plt/list [GET]: page,size,projectId → {items[], total}
  • /plt/download/{fileName} [GET]: 下载 PDF
  • /plt/uploadConverted [POST]: form-data: file, meta → {url}
  • /auth/check [GET]: Authorization → {allowed, scopes}
  • 状态枚举: PENDING / PROCESSING / DONE / FAILED
  • 权限维度: 角色(ROLE_ENGINEER/ROLE_PM/ROLE_ADMIN)× 项目域(projectId)× 动作(convert/download)。

配置模型与前端对接

后端 application.yml(关键片段)
server:
  port: 8080

plt:
  mode: async
  ghostpcl-bin: /usr/local/bin/gpcl6
  temp-dir: /data/plt/tmp
  storage:
    type: local # local | s3 | oss | minio
    local-dir: /data/plt/output
    s3:
      endpoint: https://s3.amazonaws.com
      bucket: my-bucket
      access-key: ${S3_ACCESS}
      secret-key: ${S3_SECRET}
  async:
    executor-pool-size: 8
    queue-capacity: 200
  status-ttl-seconds: 86400

security:
  enabled: true
  jwt-public-key-location: classpath:jwt.pub

watermark:
  enabled: true
  text: CONFIDENTIAL
  opacity: 0.15
  font-size: 36

governance:
  audit-log-enabled: true
  rate-limit-qps: 50
  max-upload-mb: 50

Sources:

前端 config.js(统一 API)
const API_BASE = process.env.VUE_APP_API_BASE || 'http://localhost:8080';
export default {
  api: {
    listFiles: `${API_BASE}/plt/list`,
    uploadPlt: `${API_BASE}/plt/upload`,
    taskStatus: (taskId) => `${API_BASE}/plt/status/${taskId}`,
    downloadPdf: (fn) => `${API_BASE}/plt/download/${fn}`,
    uploadConvertedPdf: `${API_BASE}/plt/uploadConverted`,
    checkPermission: `${API_BASE}/auth/check`
  },
  upload: { maxSizeMB: 50, allowedTypes: ['plt'], asyncMode: true, defaultProjectId: '' },
  progress: { pollingInterval: 2000, useWebSocket: false }
};

Sources:


核心组件与职责

组件 职责 关键技术/要点
PltConverter 调用 gpcl6 将 PLT→PDF;解析标准输出估算进度 ProcessBuilder;-sDEVICE=pdfwrite -dNOPAUSE -dBATCH -dSAFER
AsyncConfig 配置异步线程池承载并发转换 @EnableAsync;ThreadPoolTaskExecutor(core=max=8,queue=200)
AsyncPltService / SyncPltService 异步/同步编排转换与状态更新 @Async;TaskStatusStore;OutputStorage
TaskStatusStore 任务状态持久化与过期清理 Redis/DB;put/update/get/expire
OutputStorage 输出 PDF 的可插拔存储 local / S3 / OSS / MinIO
PermissionInterceptor JWT + RBAC + 项目域鉴权 HandlerInterceptor;未授权 403
PdfWatermarkService PDF 每页水印 Apache PDFBox

Sources:


参考代码(关键骨架)

任务状态与存储接口
@Data
@Builder
public class TaskStatus {
  private String taskId;
  private String status;      // PENDING/PROCESSING/DONE/FAILED
  private Integer progress;   // 0-100
  private String fileName;
  private String outputName;
  private String userId;
  private String projectId;
  private String message;
  private Long createdAt;
  private Long updatedAt;
}

public interface TaskStatusStore {
  void put(TaskStatus status);
  void update(String taskId, Consumer<TaskStatus> updater);
  Optional<TaskStatus> get(String taskId);
  void expire(String taskId, Duration ttl);
}
异步执行器与服务
@EnableAsync
@Configuration
public class AsyncConfig {
  @Bean
  public Executor taskExecutor(PltProperties props) {
    ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
    exec.setCorePoolSize(props.getAsync().getExecutorPoolSize());
    exec.setMaxPoolSize(props.getAsync().getExecutorPoolSize());
    exec.setQueueCapacity(props.getAsync().getQueueCapacity());
    exec.setThreadNamePrefix("plt-worker-");
    exec.initialize();
    return exec;
  }
}

@Service
@RequiredArgsConstructor
public class AsyncPltService {
  private final TaskStatusStore store;
  private final PltConverter converter;
  private final OutputStorage storage;

  @Async
  public void process(String taskId, File input, String outputName, PltProperties props) {
    store.update(taskId, s -> { s.setStatus("PROCESSING"); s.setProgress(10); s.setMessage("任务开始"); });
    File output = new File(props.getStorage().getLocalDir(), outputName);
    try {
      store.update(taskId, s -> { s.setProgress(30); s.setMessage("准备调用 GhostPCL"); });
      converter.convertWithProgress(input, output, props.getGhostpclBin(),
        (p, msg) -> store.update(taskId, s -> { s.setProgress(p); s.setMessage(msg); })
      );
      store.update(taskId, s -> { s.setProgress(85); s.setMessage("应用水印/脱敏"); });
      String finalName = storage.save(output);
      store.update(taskId, s -> {
        s.setStatus("DONE"); s.setProgress(100); s.setOutputName(finalName); s.setMessage("转换完成");
      });
    } catch (Exception e) {
      store.update(taskId, s -> { s.setStatus("FAILED"); s.setProgress(0); s.setMessage("失败: " + e.getMessage()); });
    } finally {
      input.delete();
    }
  }
}
转换器(gpcl6 调用)与进度回调
@Component
public class PltConverter {
  public interface ProgressListener { void onProgress(int percent, String message); }

  public void convertWithProgress(File input, File output, String gpcl, ProgressListener cb) throws Exception {
    cb.onProgress(40, "GhostPCL 参数初始化");
    String[] args = {
      gpcl, "-sDEVICE=pdfwrite", "-dNOPAUSE", "-dBATCH", "-dSAFER",
      "-sOutputFile=" + output.getAbsolutePath(),
      input.getAbsolutePath()
    };
    cb.onProgress(50, "开始转换");
    Process proc = new ProcessBuilder(args).redirectErrorStream(true).start();
    try (BufferedReader br = new BufferedReader(new InputStreamReader(proc.getInputStream()))) {
      String line; int tick = 50;
      while ((line = br.readLine()) != null) {
        tick = Math.min(80, tick + 1);
        cb.onProgress(tick, "转换中");
      }
    }
    int code = proc.waitFor();
    if (code != 0) throw new IllegalStateException("GhostPCL 退出码: " + code);
    cb.onProgress(90, "转换完成,收尾处理");
  }
}
权限拦截与水印服务
@Component
public class PermissionInterceptor implements HandlerInterceptor {
  @Override
  public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
    // 1) 解析 JWT -> userId/roles/projects
    // 2) 校验项目域与动作权限(convert/download)
    // 3) 不通过 -> 403
    return true;
  }
}

@Component
public class PdfWatermarkService {
  public void addWatermark(File pdf, String text, float opacity, int fontSize) {
    // 使用 PDFBox 遍历每页绘制透明文本水印(示意)
  }
}
控制器:同步/异步统一入口
@RestController
@RequestMapping("/plt")
@RequiredArgsConstructor
public class PltController {
  private final PltProperties props;
  private final AsyncPltService asyncService;
  private final SyncPltService syncService;
  private final TaskStatusStore store;

  @PostMapping("/upload")
  public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file,
                                  @RequestParam(required = false) String projectId,
                                  @RequestParam(required = false, defaultValue = "async") String mode,
                                  Principal principal) throws Exception {
    String userId = principal.getName();
    String orig = Objects.requireNonNull(file.getOriginalFilename());
    String taskId = UUID.randomUUID().toString();
    String outputName = orig.replaceAll("\\.plt$", "") + "-" + taskId.substring(0, 8) + ".pdf";
    File input = new File(props.getTempDir(), taskId + "-" + orig);
    file.transferTo(input);

    store.put(TaskStatus.builder()
      .taskId(taskId).status("PENDING").progress(0)
      .fileName(orig).outputName(outputName)
      .userId(userId).projectId(projectId)
      .createdAt(System.currentTimeMillis()).updatedAt(System.currentTimeMillis())
      .message("已接收").build());

    if ("sync".equalsIgnoreCase(mode)) {
      String url = syncService.processImmediate(input, outputName, userId, projectId);
      return ResponseEntity.ok(Map.of("downloadUrl", url, "mode", "sync"));
    } else {
      asyncService.process(taskId, input, outputName, props);
      return ResponseEntity.ok(Map.of("taskId", taskId, "mode", "async"));
    }
  }

  @GetMapping("/status/{taskId}")
  public ResponseEntity<?> status(@PathVariable String taskId, Principal p) {
    return store.get(taskId)
      .map(s -> s.getUserId().equals(p.getName()) ? ResponseEntity.ok(s) : ResponseEntity.status(403).build())
      .orElse(ResponseEntity.notFound().build());
  }
}

前端上传与进度(轮询示例)

import cfg from './config';
import axios from 'axios';

export async function uploadAndTrack(file, projectId) {
  const fd = new FormData();
  fd.append('file', file);
  fd.append('projectId', projectId);
  fd.append('mode', cfg.upload.asyncMode ? 'async' : 'sync');

  const { data } = await axios.post(cfg.api.uploadPlt, fd);
  if (data.mode === 'sync') {
    window.location.href = data.downloadUrl;
    return;
  }

  const taskId = data.taskId;
  const timer = setInterval(async () => {
    const { data: st } = await axios.get(cfg.api.taskStatus(taskId));
    // 渲染 st.progress / st.message
    if (st.status === 'DONE') {
      clearInterval(timer);
      window.location.href = cfg.api.downloadPdf(st.outputName);
    } else if (st.status === 'FAILED') {
      clearInterval(timer);
      alert('转换失败:' + st.message);
    }
  }, cfg.progress.pollingInterval);
}

部署与运维

  • Dockerfile: Temurin JRE 基础镜像;创建 /data/plt/tmp 与 /data/plt/output;JAVA_OPTS 可按内存调优
  • Kubernetes 要点:
    • ConfigMap/Secret 外置 application.yml 与凭据
    • PVC 挂载或对象存储直传直取(生产推荐对象存储)
    • HPA 基于 CPU/自定义指标(队列长度、处理耗时)弹性扩缩
    • Pod 安全:非 root、只读根文件系统、能力最小化。

可观测性与治理

  • 指标: QPS、成功率、P95 时延、状态迁移计数(PENDING→DONE/FAILED)、平均耗时、文件大小分布、失败原因 TopN
  • 日志: 结构化 JSON,统一字段 traceId、userId、taskId、projectId
  • 审计: 上传/鉴权/转换/水印/下载全链路事件留痕
  • 限流熔断: 网关按 IP/User/Project 限流;任务排队超时的用户级提示。

性能与稳定性

  • I/O 路径: 临时文件优先 tmpfs;对象存储直传直取,服务只签名与登记元数据
  • 并发控制: 动态调线程池与队列;大文件分级限流(如 >100MB 强制异步+限速)
  • 容错补偿: 输出文件名包含 taskId 保幂等;失败指数退避重试;失败原因分级处理
  • 安全加固: gpcl6 启动加 -dSAFER;容器最小权限运行;按需接入上传安全扫描。

常见问题(速查)

  • 转换慢/偶发失败: 核查 I/O 瓶颈与资源配额;调优线程池与 GhostPCL 参数;失败重试与日志定位
  • 进度不准: 采用“阶段+估算曲线”,或解析 gpcl6 输出提升拟合度
  • 权限绕过: 严格后端鉴权与项目域校验;下载接口核验 userId/projectId;签名 URL 短时效
  • 磁盘占满: 临时目录定时清理+对象存储归档;状态 TTL 配合清理任务。

目录结构建议

plt-service/
├─ src/main/java/com/acme/plt/
│  ├─ api/PltController.java
│  ├─ config/AsyncConfig.java
│  ├─ config/SecurityConfig.java
│  ├─ core/PltConverter.java
│  ├─ core/PdfWatermarkService.java
│  ├─ domain/TaskStatus.java
│  ├─ repo/TaskStatusStore.java
│  ├─ service/AsyncPltService.java
│  ├─ service/SyncPltService.java
│  ├─ storage/OutputStorage.java
│  └─ web/PermissionInterceptor.java
├─ src/main/resources/
│  ├─ application.yml
│  └─ jwt.pub
├─ Dockerfile
└─ README.md

实施清单(拿去用)

  • 接口: /plt/upload, /plt/status/{taskId}, /plt/download/{fileName}, /plt/list, /plt/uploadConverted, /auth/check
  • 模式: sync/async 配置切换;异步配合轮询进度
  • 权限: JWT + RBAC + 项目域强校验;未授权 403;签名下载
  • 存储: Redis 记录任务;输出 local/S3/OSS/MinIO 可插拔
  • 部署: Docker/K8s 友好;HPA + 限流;对象存储直传直取
  • 水印/审计: PDFBox 加水印;全链路审计可追溯
  • 优化: tmpfs 临时盘、指数重试、-dSAFER、安全扫描、指标与告警闭环。

参考与来源:本文的接口约定、配置模型、关键代码骨架、部署与治理要点与原始方案保持一致,并在结构与可执行性上做了教学化重组,以便一气呵成落地。


网站公告

今日签到

点亮在社区的每一天
去签到