新增 PPT 預覽快取預熱操作
All checks were successful
CD Pipeline / deploy (push) Successful in 1m7s

This commit is contained in:
OoO
2026-05-18 20:03:19 +08:00
parent bc900321f8
commit 48d71c711b
6 changed files with 187 additions and 24 deletions

View File

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.203 補 `/observability/ppt_audit_history` 單檔 PDF 預熱操作:未快取的可預覽 PPTX 會顯示「預熱 PDF」透過 JSON 端點建立 PDF 快取並即時更新頁面狀態。
- V10.201 強化 `/observability/ppt_audit_history` 線上預覽可診斷性:產線清單不觸發轉檔即可顯示 PDF 預覽快取狀態Pipeline Health、Preview Workbench 與已產檔案表同步標記「PDF 快取 / 首次轉檔」。
- V10.199 讓 `/observability/ppt_audit_history` Action Queue 可直接處理異常:待補齊與異常優先項目新增單一報表「重跑」按鈕,透過既有非阻塞背景產線排入指定 report_type。
- V10.197 強化 `/observability/ppt_audit_history` Action Queue新增「異常優先」lane將產出失敗、PPTX 檔案異常、視覺 QA 失敗拉到最前面,並顯示錯誤訊息與可預覽入口。

View File

@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.202"
SYSTEM_VERSION = "V10.203"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -1860,6 +1860,68 @@ def ppt_audit_generate_missing():
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500
def _resolve_ppt_report_path(filename: str):
"""在 REPORTS_DIR 內解析簡報檔名,並阻擋路徑逃逸。"""
import os
from utils.security import safe_join
reports_dir = os.environ.get('REPORTS_DIR', '/app/data/reports')
safe_path = safe_join(reports_dir, filename)
if not safe_path.exists() or not safe_path.is_file():
return None, ('檔案不存在', 404)
if safe_path.suffix.lower() != '.pptx':
return None, ('不支援的檔案格式', 400)
return safe_path, None
def _validate_pptx_for_preview(safe_path):
import zipfile
try:
with zipfile.ZipFile(safe_path, 'r') as zf:
bad = zf.testzip()
if bad is not None:
return f'PPT 檔案損毀,無法預覽(損毀區段:{bad}'
except zipfile.BadZipFile:
return 'PPT 檔案損毀,無法預覽(非有效 zip'
except Exception as e:
return f'預覽檢查失敗:{type(e).__name__}'
return None
@admin_observability_bp.route('/ppt_audit_file/<path:filename>/prewarm', methods=['POST'])
@login_required
def ppt_audit_file_prewarm(filename: str):
"""建立單一 PPT 的 PDF 預覽快取,並回傳 JSON 狀態。"""
try:
safe_path, error_response = _resolve_ppt_report_path(filename)
if error_response:
message, status_code = error_response
return jsonify({'ok': False, 'error': message}), status_code
validation_error = _validate_pptx_for_preview(safe_path)
if validation_error:
return jsonify({'ok': False, 'error': validation_error}), 409
from services.ppt_preview_service import build_ppt_preview
preview = build_ppt_preview(safe_path)
if not preview.ok or not preview.pdf_path:
return jsonify({'ok': False, 'error': preview.error or '無法產生預覽'}), 409
return jsonify({
'ok': True,
'filename': safe_path.name,
'cache_hit': bool(preview.cache_hit),
'converter': preview.converter,
'message': 'PDF 預覽快取已建立' if not preview.cache_hit else 'PDF 預覽快取已存在',
})
except ValueError:
return jsonify({'ok': False, 'error': '非法路徑'}), 400
except Exception as e:
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500
@admin_observability_bp.route('/ppt_audit_file/<path:filename>')
@login_required
def ppt_audit_file(filename: str):
@@ -1871,29 +1933,15 @@ def ppt_audit_file(filename: str):
"""
action = (request.args.get('action', 'view') or 'view').strip().lower()
try:
import os
import zipfile
from utils.security import safe_join
reports_dir = os.environ.get('REPORTS_DIR', '/app/data/reports')
safe_path = safe_join(reports_dir, filename)
if not safe_path.exists() or not safe_path.is_file():
return '檔案不存在', 404
if safe_path.suffix.lower() != '.pptx':
return '不支援的檔案格式', 400
safe_path, error_response = _resolve_ppt_report_path(filename)
if error_response:
message, status_code = error_response
return message, status_code
if action in ('view', 'pdf'):
try:
with zipfile.ZipFile(safe_path, 'r') as zf:
bad = zf.testzip()
if bad is not None:
return f'PPT 檔案損毀,無法預覽(損毀區段:{bad}', 409
except zipfile.BadZipFile:
return 'PPT 檔案損毀,無法預覽(非有效 zip', 409
except Exception as e:
return f'預覽檢查失敗:{type(e).__name__}', 409
validation_error = _validate_pptx_for_preview(safe_path)
if validation_error:
return validation_error, 409
if action in ('view', 'pdf'):
from services.ppt_preview_service import build_ppt_preview

View File

@@ -111,7 +111,7 @@
<span>{{ f.source }}</span>
{% if f.file_exists and f.is_valid_ppt %}<span class="status-good">可預覽</span>{% else %}<span class="status-bad">需回補</span>{% endif %}
{% if f.file_exists and f.is_valid_ppt %}
{% if f.preview_cache_ready %}<span class="status-blue">PDF 快取</span>{% else %}<span class="status-warn">首次轉檔</span>{% endif %}
{% if f.preview_cache_ready %}<span class="status-blue" data-ppt-preview-state="{{ f.name }}">PDF 快取</span>{% else %}<span class="status-warn" data-ppt-preview-state="{{ f.name }}">首次轉檔</span>{% endif %}
{% endif %}
</div>
<div class="ppt-file-actions">
@@ -119,6 +119,11 @@
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_observability.ppt_audit_file', filename=f.name) }}" target="_blank" rel="noopener">
<i class="fas fa-eye me-1"></i>線上預覽
</a>
{% if not f.preview_cache_ready %}
<button class="btn btn-outline-primary btn-sm" type="button" data-ppt-prewarm-preview data-ppt-filename="{{ f.name }}">
<i class="fas fa-fire me-1"></i>預熱 PDF
</button>
{% endif %}
{% endif %}
{% if f.file_exists %}
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('admin_observability.ppt_audit_file', filename=f.name, action='download') }}">
@@ -423,7 +428,7 @@
{% endif %}
{% if f.file_exists and f.is_valid_ppt %}
<div>
<small class="{{ 'status-blue' if f.preview_cache_ready else 'status-warn' }}">
<small class="{{ 'status-blue' if f.preview_cache_ready else 'status-warn' }}" data-ppt-preview-state="{{ f.name }}">
{% if f.preview_cache_ready %}
PDF 預覽快取已建立{% if f.preview_cache_mtime %} · {{ f.preview_cache_mtime }}{% endif %}
{% else %}
@@ -440,6 +445,11 @@
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_observability.ppt_audit_file', filename=f.name) }}" target="_blank" rel="noopener">
<i class="fas fa-eye me-1"></i>線上預覽
</a>
{% if not f.preview_cache_ready %}
<button class="btn btn-outline-primary btn-sm" type="button" data-ppt-prewarm-preview data-ppt-filename="{{ f.name }}">
<i class="fas fa-fire me-1"></i>預熱 PDF
</button>
{% endif %}
{% else %}
<span class="small status-bad">檔案不可預覽</span>
{% endif %}

View File

@@ -315,6 +315,62 @@ def test_ppt_audit_file_view_renders_online_preview(client, monkeypatch, tmp_pat
assert '下載 PPTX' in html
def test_ppt_audit_history_shows_preview_prewarm_action(client, monkeypatch, tmp_path):
"""未快取 PDF 的 PPTX 要能在產線清單直接預熱預覽。"""
import zipfile
reports_dir = tmp_path / 'reports'
reports_dir.mkdir()
pptx = reports_dir / 'ocbot_daily_20260518.pptx'
with zipfile.ZipFile(pptx, 'w') as zf:
zf.writestr('[Content_Types].xml', '<Types></Types>')
monkeypatch.setenv('REPORTS_DIR', str(reports_dir))
monkeypatch.setenv('PPT_PREVIEW_CACHE_DIR', str(tmp_path / 'preview-cache'))
r = client.get('/observability/ppt_audit_history?month=2026-05')
html = r.data.decode('utf-8')
assert r.status_code == 200
assert '首次轉檔' in html
assert 'data-ppt-prewarm-preview' in html
assert 'data-ppt-filename="ocbot_daily_20260518.pptx"' in html
assert '預熱 PDF' in html
def test_ppt_audit_file_prewarm_builds_preview_cache(client, monkeypatch, tmp_path):
"""預熱端點應回 JSON不直接把 PDF body 丟給前端。"""
import zipfile
from services import ppt_preview_service as preview_svc
reports_dir = tmp_path / 'reports'
reports_dir.mkdir()
pptx = reports_dir / 'ocbot_daily_20260518.pptx'
pdf = reports_dir / 'preview.pdf'
with zipfile.ZipFile(pptx, 'w') as zf:
zf.writestr('[Content_Types].xml', '<Types></Types>')
monkeypatch.setenv('REPORTS_DIR', str(reports_dir))
monkeypatch.setattr(
preview_svc,
'build_ppt_preview',
lambda *_args, **_kwargs: preview_svc.PPTPreviewResult(
ok=True,
pdf_path=str(pdf),
cache_hit=False,
converter='/usr/bin/libreoffice',
),
)
r = client.post('/observability/ppt_audit_file/ocbot_daily_20260518.pptx/prewarm')
data = r.get_json()
assert r.status_code == 200
assert data['ok'] is True
assert data['filename'] == 'ocbot_daily_20260518.pptx'
assert data['message'] == 'PDF 預覽快取已建立'
# ──────────────────────────────────────────────────────────────────────────
# /observability/host_health
# ──────────────────────────────────────────────────────────────────────────

View File

@@ -595,6 +595,7 @@
function initPptAutoGeneration() {
const panel = document.querySelector('[data-ppt-auto-generation]');
const pageStatus = document.querySelector('[data-ppt-auto-status]');
document.querySelectorAll('[data-ppt-aider-heal]').forEach(button => {
if (button.dataset.bound === '1') return;
button.dataset.bound = '1';
@@ -602,6 +603,53 @@
window.triggerAiderHeal(button.dataset.pptFilename || '', button.dataset.pptError || '');
});
});
document.querySelectorAll('[data-ppt-prewarm-preview]').forEach(button => {
if (button.dataset.bound === '1') return;
button.dataset.bound = '1';
button.addEventListener('click', async () => {
const filename = button.dataset.pptFilename || '';
if (!filename) return;
const originalHtml = button.innerHTML;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>預熱中';
if (pageStatus) {
pageStatus.classList.add('is-working');
pageStatus.textContent = `${filename} 正在建立 PDF 預覽快取。`;
}
try {
const response = await postJson(`/observability/ppt_audit_file/${encodeURIComponent(filename)}/prewarm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!response.ok || !data.ok) {
throw new Error(data.error || '預熱失敗');
}
document.querySelectorAll('[data-ppt-preview-state]').forEach(node => {
if (node.dataset.pptPreviewState !== filename) return;
node.classList.remove('status-warn');
node.classList.add('status-blue');
node.textContent = 'PDF 預覽快取已建立';
});
document.querySelectorAll('[data-ppt-prewarm-preview]').forEach(node => {
if (node.dataset.pptFilename !== filename) return;
node.innerHTML = '<i class="fas fa-check me-1"></i>已快取';
node.disabled = true;
});
if (pageStatus) {
pageStatus.textContent = data.message || `${filename} 的 PDF 預覽快取已建立。`;
}
} catch (error) {
console.warn('ppt_preview_prewarm_failed', error);
button.disabled = false;
button.innerHTML = originalHtml;
if (pageStatus) {
pageStatus.classList.remove('is-working');
pageStatus.textContent = 'PDF 預覽預熱失敗,請稍後再試或直接開啟線上預覽。';
}
}
});
});
if (!panel) return;