feat: add database checkpoint management system

Features:
- Create manual database checkpoints with descriptions
- List all available checkpoints with statistics
- Restore database from checkpoints (with dangerous operation warning)
- Delete unwanted checkpoints
- Frontend UI integrated into database admin panel
- JSON-based checkpoint storage in data/checkpoints/

Backend Changes:
- Added checkpoint management functions to licensing_repo.py:
  * create_checkpoint() - backup all tables to JSON
  * list_checkpoints() - enumerate checkpoint files
  * restore_checkpoint() - restore from checkpoint
  * delete_checkpoint() - remove checkpoint file
- Added 4 new API endpoints to v2.py:
  * GET /admin/checkpoints - list checkpoints
  * POST /admin/checkpoints - create checkpoint
  * POST /admin/checkpoints/{id}/restore - restore checkpoint
  * DELETE /admin/checkpoints/{id} - delete checkpoint

Frontend Changes (db_admin.html):
- Added step 5 "检查点管理" to navigation
- Created checkpoint management UI with forms and lists
- Added dangerous operation confirmation modal
- Integrated into existing breadcrumb navigation system

Safety Features:
- All dangerous operations require explicit confirmation
- Restore operations show warning about data loss
- Checkpoints include row counts and table statistics
- Timestamped checkpoint IDs for easy identification

Note: Checkpoint files are stored in data/checkpoints/ directory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Codex Agent 2025-10-30 10:33:35 +08:00
parent 506ea5ce98
commit 9530eabac8
3 changed files with 755 additions and 4 deletions

View File

@ -11,6 +11,10 @@ from lawrisk.services.licensing_repo import (
load_permits_and_risks,
list_region_theme_options,
load_theme_payload,
create_checkpoint,
list_checkpoints,
restore_checkpoint,
delete_checkpoint,
)
from lawrisk.services.lawrisk_service import suggest_questions_embed
@ -250,3 +254,57 @@ def admin_permit_details():
except Exception as exc:
print(f"admin_permit_details error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/checkpoints', methods=['GET'])
def admin_list_checkpoints():
"""List all available checkpoints."""
try:
checkpoints = list_checkpoints()
return jsonify({"success": True, "data": {"checkpoints": checkpoints}})
except Exception as exc:
print(f"admin_list_checkpoints error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/checkpoints', methods=['POST'])
def admin_create_checkpoint():
"""Create a new checkpoint."""
if request.is_json:
payload = request.get_json(silent=True) or {}
else:
payload = request.form.to_dict(flat=True) if request.form else {}
description = payload.get("description", "")
try:
checkpoint = create_checkpoint(description)
return jsonify({"success": True, "data": checkpoint})
except Exception as exc:
print(f"admin_create_checkpoint error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/checkpoints/<checkpoint_id>/restore', methods=['POST'])
def admin_restore_checkpoint(checkpoint_id):
"""Restore database from a checkpoint. DANGEROUS OPERATION!"""
try:
restore_summary = restore_checkpoint(checkpoint_id)
return jsonify({"success": True, "data": restore_summary})
except Exception as exc:
print(f"admin_restore_checkpoint error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/checkpoints/<checkpoint_id>', methods=['DELETE'])
def admin_delete_checkpoint(checkpoint_id):
"""Delete a checkpoint."""
try:
success = delete_checkpoint(checkpoint_id)
if success:
return jsonify({"success": True, "message": "Checkpoint deleted"})
else:
return jsonify({"success": False, "message": "Checkpoint not found"}), 404
except Exception as exc:
print(f"admin_delete_checkpoint error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500

View File

@ -1,8 +1,10 @@
from __future__ import annotations
import json
import os
import re
from collections import OrderedDict
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
import pg8000.dbapi as pg
@ -325,3 +327,202 @@ def load_theme_payload(region_id: str, theme_id: str) -> Dict[str, object]:
"theme": {"id": str(theme_uuid), "name": str(theme_name)},
"permits": permits,
}
def _get_checkpoints_dir() -> str:
"""Get the directory for storing checkpoint files."""
base_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data")
checkpoints_dir = os.path.join(base_dir, "checkpoints")
os.makedirs(checkpoints_dir, exist_ok=True)
return checkpoints_dir
def _get_all_tables() -> List[str]:
"""Get list of all tables in the licensing_risks database."""
sql = """
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
"""
with _lic_pg_conn() as conn:
cur = conn.cursor()
cur.execute(sql)
return [row[0] for row in cur.fetchall()]
def _backup_table(conn: pg.Connection, table_name: str) -> Tuple[List[Dict[str, Any]], int]:
"""Backup a single table and return its data and row count."""
sql = f"SELECT * FROM {table_name}"
cur = conn.cursor()
cur.execute(sql)
rows = cur.fetchall()
colnames = [desc[0] for desc in cur.description]
data = []
for row in rows:
row_dict = {}
for i, col in enumerate(colnames):
value = row[i]
if value is not None:
# Convert UUID and other non-serializable types to strings
if hasattr(value, 'isoformat'): # UUID, datetime, etc.
row_dict[col] = str(value)
else:
row_dict[col] = value
data.append(row_dict)
return data, len(data)
def _restore_table(conn: pg.Connection, table_name: str, data: List[Dict[str, Any]]) -> int:
"""Restore a table from backup data. Returns number of rows restored."""
if not data:
return 0
conn.autocommit = False
try:
cur = conn.cursor()
delete_sql = f"DELETE FROM {table_name}"
cur.execute(delete_sql)
if data:
columns = list(data[0].keys())
placeholders = ", ".join(["%s"] * len(columns))
insert_sql = f"INSERT INTO {table_name} ({', '.join(columns)}) VALUES ({placeholders})"
for row in data:
values = [row.get(col) for col in columns]
cur.execute(insert_sql, values)
conn.commit()
return len(data)
except Exception as e:
conn.rollback()
raise e
finally:
conn.autocommit = False
def create_checkpoint(description: str = "") -> Dict[str, Any]:
"""Create a checkpoint by backing up all tables."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
checkpoint_id = f"checkpoint_{timestamp}"
tables = _get_all_tables()
checkpoint_data = {
"checkpoint_id": checkpoint_id,
"timestamp": timestamp,
"description": description,
"tables": {}
}
total_rows = 0
table_counts = {}
with _lic_pg_conn() as conn:
for table in tables:
data, row_count = _backup_table(conn, table)
checkpoint_data["tables"][table] = data
table_counts[table] = row_count
total_rows += row_count
checkpoint_data["table_counts"] = table_counts
checkpoint_data["total_rows"] = total_rows
checkpoints_dir = _get_checkpoints_dir()
checkpoint_file = os.path.join(checkpoints_dir, f"{checkpoint_id}.json")
def json_serializer(obj):
"""Convert non-JSON serializable objects to strings."""
if hasattr(obj, 'isoformat'): # UUID, datetime, etc.
return str(obj)
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
with open(checkpoint_file, "w", encoding="utf-8") as f:
json.dump(checkpoint_data, f, ensure_ascii=False, indent=2, default=json_serializer)
return {
"checkpoint_id": checkpoint_id,
"timestamp": timestamp,
"description": description,
"total_rows": total_rows,
"table_counts": table_counts
}
def list_checkpoints() -> List[Dict[str, Any]]:
"""List all available checkpoints."""
checkpoints_dir = _get_checkpoints_dir()
checkpoints = []
if not os.path.exists(checkpoints_dir):
return checkpoints
for filename in os.listdir(checkpoints_dir):
if filename.endswith(".json"):
filepath = os.path.join(checkpoints_dir, filename)
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
checkpoints.append({
"checkpoint_id": data["checkpoint_id"],
"timestamp": data["timestamp"],
"description": data.get("description", ""),
"total_rows": data.get("total_rows", 0),
"table_counts": data.get("table_counts", {}),
"filename": filename
})
except Exception as e:
print(f"Error reading checkpoint {filename}: {e}")
return sorted(checkpoints, key=lambda x: x["timestamp"], reverse=True)
def restore_checkpoint(checkpoint_id: str) -> Dict[str, Any]:
"""Restore database from a checkpoint. This is a destructive operation!"""
checkpoints_dir = _get_checkpoints_dir()
checkpoint_file = os.path.join(checkpoints_dir, f"{checkpoint_id}.json")
if not os.path.exists(checkpoint_file):
raise ValueError(f"Checkpoint {checkpoint_id} not found")
with open(checkpoint_file, "r", encoding="utf-8") as f:
checkpoint_data = json.load(f)
tables = checkpoint_data.get("tables", {})
restore_summary = {
"checkpoint_id": checkpoint_id,
"tables_restored": 0,
"total_rows_restored": 0,
"table_details": {}
}
with _lic_pg_conn(autocommit=False) as conn:
try:
for table_name, data in tables.items():
rows_restored = _restore_table(conn, table_name, data)
restore_summary["tables_restored"] += 1
restore_summary["total_rows_restored"] += rows_restored
restore_summary["table_details"][table_name] = rows_restored
conn.commit()
except Exception as e:
conn.rollback()
raise e
return restore_summary
def delete_checkpoint(checkpoint_id: str) -> bool:
"""Delete a checkpoint file."""
checkpoints_dir = _get_checkpoints_dir()
checkpoint_file = os.path.join(checkpoints_dir, f"{checkpoint_id}.json")
if os.path.exists(checkpoint_file):
os.remove(checkpoint_file)
return True
return False

View File

@ -264,6 +264,259 @@
color: white;
}
.checkpoint-nav-item {
background: #fff3cd;
border: 2px solid #ffc107;
}
.checkpoint-nav-item:hover {
background: #ffe69c;
border-color: #ff9800;
}
.checkpoint-section {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid #e0e0e0;
}
.checkpoint-section h3 {
color: #667eea;
font-size: 18px;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.checkpoint-section h3::before {
content: '🔒';
font-size: 20px;
}
.checkpoint-form {
margin-bottom: 20px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 500;
}
.form-group input[type="text"] {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s;
}
.form-group input[type="text"]:focus {
outline: none;
border-color: #667eea;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #5568d3;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c82333;
}
.btn-warning {
background: #ffc107;
color: #333;
}
.btn-warning:hover:not(:disabled) {
background: #e0a800;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.checkpoint-list {
margin-top: 20px;
}
.checkpoint-item {
background: #f8f9fa;
padding: 15px;
margin-bottom: 12px;
border-radius: 6px;
border: 1px solid #e0e0e0;
transition: all 0.3s;
}
.checkpoint-item:hover {
background: #e9ecef;
border-color: #667eea;
}
.checkpoint-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.checkpoint-id {
font-weight: bold;
color: #333;
font-size: 15px;
}
.checkpoint-timestamp {
color: #666;
font-size: 13px;
}
.checkpoint-description {
color: #555;
margin-bottom: 10px;
line-height: 1.6;
}
.checkpoint-stats {
display: flex;
gap: 15px;
margin-bottom: 10px;
font-size: 13px;
color: #666;
}
.checkpoint-stats span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.checkpoint-actions {
display: flex;
gap: 10px;
margin-top: 10px;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.show {
display: flex;
}
.modal-content {
background: white;
border-radius: 12px;
padding: 30px;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
animation: modalFadeIn 0.3s;
}
@keyframes modalFadeIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
text-align: center;
margin-bottom: 20px;
}
.modal-header h3 {
color: #dc3545;
font-size: 20px;
margin-bottom: 10px;
}
.modal-header .warning-icon {
font-size: 48px;
margin-bottom: 15px;
}
.modal-body {
margin-bottom: 20px;
}
.modal-body p {
color: #555;
line-height: 1.6;
margin-bottom: 10px;
}
.modal-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.danger-text {
color: #dc3545;
font-weight: bold;
}
.success-text {
color: #28a745;
font-weight: bold;
}
.details-area {
background: white;
border-radius: 8px;
@ -457,6 +710,11 @@
<div class="step-number">4</div>
<div class="step-label">查看详情</div>
</div>
<div class="arrow"></div>
<div class="step" id="step5">
<div class="step-number">5</div>
<div class="step-label">检查点</div>
</div>
</div>
<div class="content-area">
@ -469,7 +727,12 @@
</h2>
<div class="breadcrumb" id="breadcrumb"></div>
<div class="selection-area">
<div id="navList" class="item-list"></div>
<div id="navList" class="item-list">
<li onclick="quickJump(5)" class="checkpoint-nav-item">
<span class="item-name">🔒 检查点管理</span>
<span class="item-count">备份/恢复</span>
</li>
</div>
</div>
</div>
@ -487,20 +750,40 @@
</div>
</div>
<!-- 危险操作确认模态框 -->
<div class="modal" id="dangerModal">
<div class="modal-content">
<div class="modal-header">
<div class="warning-icon">⚠️</div>
<h3 id="dangerModalTitle">危险操作确认</h3>
</div>
<div class="modal-body">
<p id="dangerModalMessage"></p>
<p class="danger-text" id="dangerModalWarning"></p>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeDangerModal()">取消</button>
<button class="btn btn-danger" id="dangerModalConfirmBtn" onclick="confirmDangerOperation()">确认执行</button>
</div>
</div>
</div>
<script>
// 导航状态管理
let currentStep = 1; // 1=区域, 2=主题, 3=许可, 4=详情
let currentStep = 1; // 1=区域, 2=主题, 3=许可, 4=详情, 5=检查点
let historyStack = []; // 历史记录栈
let currentRegion = null;
let currentTheme = null;
let currentPermit = null;
let pendingDangerOperation = null; // 待执行的危险操作
// 步骤配置
const steps = {
1: { title: '选择区域', loadData: loadRegions },
2: { title: '选择主题', loadData: (region) => loadThemes(region.id, region.name) },
3: { title: '选择许可', loadData: (theme) => loadPermits(theme.id, theme.name) },
4: { title: '许可详情', loadData: null }
4: { title: '许可详情', loadData: null },
5: { title: '检查点管理', loadData: loadCheckpoints }
};
// 加载地区列表
@ -685,6 +968,8 @@
await loadPermits(currentTheme.id, currentTheme.name);
} else if (step === 4) {
await showPermitDetails();
} else if (step === 5) {
await loadCheckpoints();
}
}
@ -877,7 +1162,7 @@
// 更新步骤指示器
function updateStepIndicator(step) {
for (let i = 1; i <= 4; i++) {
for (let i = 1; i <= 5; i++) {
const stepElement = document.getElementById(`step${i}`);
if (i <= step) {
stepElement.classList.add('active');
@ -887,6 +1172,213 @@
}
}
// ================ 检查点管理功能 ================
// 加载检查点列表
async function loadCheckpoints() {
const detailsArea = document.getElementById('detailsArea');
detailsArea.innerHTML = '<div class="loading"></div>加载检查点列表...';
try {
const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/checkpoints');
const data = await response.json();
if (data.success) {
renderCheckpointManager(data.data.checkpoints);
} else {
detailsArea.innerHTML = `<div class="error">加载检查点失败:${data.message}</div>`;
}
} catch (error) {
detailsArea.innerHTML = `<div class="error">网络错误:${error.message}</div>`;
}
}
// 渲染检查点管理界面
function renderCheckpointManager(checkpoints) {
const detailsArea = document.getElementById('detailsArea');
let html = '<div class="details-content">';
// 创建检查点表单
html += `
<div class="checkpoint-section">
<h3>创建新检查点</h3>
<div class="checkpoint-form">
<div class="form-group">
<label for="checkpointDescription">检查点描述(可选):</label>
<input type="text" id="checkpointDescription" placeholder="例如:修改风险提示前的备份">
</div>
<button class="btn btn-primary" onclick="createCheckpoint()">
<span>📸</span> 创建检查点
</button>
</div>
</div>
`;
// 检查点列表
html += '<div class="checkpoint-section">';
html += '<h3>已有检查点</h3>';
if (checkpoints.length === 0) {
html += '<p style="color: #999; text-align: center; padding: 20px;">暂无检查点</p>';
} else {
html += '<div class="checkpoint-list">';
checkpoints.forEach(cp => {
const formattedTime = formatTimestamp(cp.timestamp);
html += `
<div class="checkpoint-item">
<div class="checkpoint-header">
<span class="checkpoint-id">${cp.checkpoint_id}</span>
<span class="checkpoint-timestamp">${formattedTime}</span>
</div>
${cp.description ? `<div class="checkpoint-description">${cp.description}</div>` : ''}
<div class="checkpoint-stats">
<span>📊 总行数:<strong>${cp.total_rows}</strong></span>
<span>📋 表数:<strong>${Object.keys(cp.table_counts).length}</strong></span>
</div>
<div class="checkpoint-actions">
<button class="btn btn-danger btn-sm" onclick="confirmRestoreCheckpoint('${cp.checkpoint_id}')">
<span>🔄</span> 恢复
</button>
<button class="btn btn-warning btn-sm" onclick="deleteCheckpoint('${cp.checkpoint_id}')">
<span>🗑️</span> 删除
</button>
</div>
</div>
`;
});
html += '</div>';
}
html += '</div>';
html += '</div>';
detailsArea.innerHTML = html;
}
// 创建检查点
async function createCheckpoint() {
const description = document.getElementById('checkpointDescription').value;
const btn = event.target;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<div class="loading"></div>创建中...';
try {
const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/checkpoints', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ description })
});
const data = await response.json();
if (data.success) {
alert('检查点创建成功!');
document.getElementById('checkpointDescription').value = '';
await loadCheckpoints();
} else {
alert(`创建失败:${data.message}`);
}
} catch (error) {
alert(`网络错误:${error.message}`);
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
// 确认恢复检查点
function confirmRestoreCheckpoint(checkpointId) {
showDangerModal(
'恢复检查点',
`您确定要恢复检查点 "${checkpointId}" 吗?`,
'此操作将覆盖当前数据库中的所有数据,且无法撤销!请确保您已经创建了新的检查点作为备份。',
() => restoreCheckpoint(checkpointId)
);
}
// 恢复检查点
async function restoreCheckpoint(checkpointId) {
closeDangerModal();
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/checkpoints/${checkpointId}/restore`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
alert(`检查点恢复成功!\n恢复了 ${data.data.total_rows_restored} 行数据,覆盖了 ${data.data.tables_restored} 个表。`);
await loadCheckpoints();
} else {
alert(`恢复失败:${data.message}`);
}
} catch (error) {
alert(`网络错误:${error.message}`);
}
}
// 删除检查点
async function deleteCheckpoint(checkpointId) {
if (!confirm(`确定要删除检查点 "${checkpointId}" 吗?此操作无法撤销。`)) {
return;
}
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/checkpoints/${checkpointId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
alert('检查点已删除');
await loadCheckpoints();
} else {
alert(`删除失败:${data.message}`);
}
} catch (error) {
alert(`网络错误:${error.message}`);
}
}
// 格式化时间戳
function formatTimestamp(timestamp) {
if (!timestamp) return '';
const year = timestamp.substring(0, 4);
const month = timestamp.substring(4, 6);
const day = timestamp.substring(6, 8);
const hour = timestamp.substring(9, 11);
const minute = timestamp.substring(11, 13);
const second = timestamp.substring(13, 15);
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
// ================ 危险操作确认模态框 ================
// 显示危险操作确认模态框
function showDangerModal(title, message, warning, callback) {
pendingDangerOperation = callback;
document.getElementById('dangerModalTitle').textContent = title;
document.getElementById('dangerModalMessage').textContent = message;
document.getElementById('dangerModalWarning').textContent = warning;
document.getElementById('dangerModal').classList.add('show');
}
// 关闭危险操作确认模态框
function closeDangerModal() {
document.getElementById('dangerModal').classList.remove('show');
pendingDangerOperation = null;
}
// 确认执行危险操作
function confirmDangerOperation() {
if (pendingDangerOperation && typeof pendingDangerOperation === 'function') {
pendingDangerOperation();
}
closeDangerModal();
}
// 页面加载时初始化
window.addEventListener('DOMContentLoaded', () => {
goToStep(1);