本教程給出可直接落地的 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: 8080plt:mode: asyncghostpcl-bin: /usr/local/bin/gpcl6temp-dir: /data/plt/tmpstorage:type: local # local | s3 | oss | miniolocal-dir: /data/plt/outputs3:endpoint: https://s3.amazonaws.combucket: my-bucketaccess-key: ${S3_ACCESS}secret-key: ${S3_SECRET}async:executor-pool-size: 8queue-capacity: 200status-ttl-seconds: 86400security:enabled: truejwt-public-key-location: classpath:jwt.pubwatermark:enabled: truetext: CONFIDENTIALopacity: 0.15font-size: 36governance:audit-log-enabled: truerate-limit-qps: 50max-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/FAILEDprivate Integer progress; // 0-100private 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 {@Beanpublic 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;@Asyncpublic 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 {@Overridepublic boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {// 1) 解析 JWT -> userId/roles/projects// 2) 校驗項目域與動作權限(convert/download)// 3) 不通過 -> 403return 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.messageif (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、安全掃描、指標與告警閉環。
參考與來源:本文的接口約定、配置模型、關鍵代碼骨架、部署與治理要點與原始方案保持一致,并在結構與可執行性上做了教學化重組,以便一氣呵成落地。