feat: 乐观锁版本号机制解决并发编辑冲突
- 每条记录增加version字段,每次保存版本号递增 - 客户端保存时携带版本号,服务端检查是否一致 - 版本冲突时返回HTTP 409,包含服务端最新内容 - 前端显示冲突对话框,用户可选择放弃或强制覆盖 - 旧数据自动添加version=1 测试场景: - A和B同时加载版本1 - A保存成功 → 版本2 - B保存失败 → 409冲突 - B选择覆盖 → 版本3
This commit is contained in:
37
app.py
37
app.py
@@ -23,17 +23,23 @@ DATA_DIR = Path(__file__).parent / 'data'
|
|||||||
DATA_DIR.mkdir(exist_ok=True)
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
NOTES_FILE = DATA_DIR / 'notes.json'
|
NOTES_FILE = DATA_DIR / 'notes.json'
|
||||||
|
|
||||||
# 大模型配置
|
# 大模型配置 (LLM Proxy)
|
||||||
LLM_CONFIG = {
|
LLM_CONFIG = {
|
||||||
'base_url': 'http://192.168.2.5:1234/v1',
|
'base_url': 'http://192.168.2.17:19007/v1',
|
||||||
'api_key': 'sk-lm-fuP5tGU8:Hi7YU87jHyDP6Ay8Tl2j',
|
'api_key': 'xxxx',
|
||||||
'model': 'qwen3.5-4b',
|
'model': 'auto',
|
||||||
}
|
}
|
||||||
|
|
||||||
def load_notes():
|
def load_notes():
|
||||||
"""加载所有笔记"""
|
"""加载所有笔记"""
|
||||||
if NOTES_FILE.exists():
|
if NOTES_FILE.exists():
|
||||||
notes = json.loads(NOTES_FILE.read_text(encoding='utf-8'))
|
notes = json.loads(NOTES_FILE.read_text(encoding='utf-8'))
|
||||||
|
|
||||||
|
# 自动为旧数据添加版本号
|
||||||
|
for note in notes:
|
||||||
|
if 'version' not in note:
|
||||||
|
note['version'] = 1
|
||||||
|
|
||||||
# 按置顶和更新时间排序(置顶在前,更新时间降序)
|
# 按置顶和更新时间排序(置顶在前,更新时间降序)
|
||||||
return sorted(notes, key=lambda x: (-int(x.get('pinned', False)), x.get('updated_at', '')), reverse=True)
|
return sorted(notes, key=lambda x: (-int(x.get('pinned', False)), x.get('updated_at', '')), reverse=True)
|
||||||
return []
|
return []
|
||||||
@@ -132,6 +138,10 @@ def api_note_detail(note_id):
|
|||||||
if not note:
|
if not note:
|
||||||
return jsonify({'error': 'Note not found'}), 404
|
return jsonify({'error': 'Note not found'}), 404
|
||||||
|
|
||||||
|
# 确保有版本号
|
||||||
|
if 'version' not in note:
|
||||||
|
note['version'] = 1
|
||||||
|
|
||||||
return jsonify(note)
|
return jsonify(note)
|
||||||
|
|
||||||
@app.route('/api/notes', methods=['POST'])
|
@app.route('/api/notes', methods=['POST'])
|
||||||
@@ -155,9 +165,10 @@ def api_create_note():
|
|||||||
|
|
||||||
@app.route('/api/notes/<note_id>', methods=['PUT'])
|
@app.route('/api/notes/<note_id>', methods=['PUT'])
|
||||||
def api_update_note(note_id):
|
def api_update_note(note_id):
|
||||||
"""更新笔记内容"""
|
"""更新笔记内容(带版本检查)"""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
content = data.get('content', '')
|
content = data.get('content', '')
|
||||||
|
client_version = data.get('version', 0) # 客户端提交的版本号
|
||||||
|
|
||||||
notes = load_notes()
|
notes = load_notes()
|
||||||
note = next((n for n in notes if n['id'] == note_id), None)
|
note = next((n for n in notes if n['id'] == note_id), None)
|
||||||
@@ -165,6 +176,21 @@ def api_update_note(note_id):
|
|||||||
if not note:
|
if not note:
|
||||||
return jsonify({'error': 'Note not found'}), 404
|
return jsonify({'error': 'Note not found'}), 404
|
||||||
|
|
||||||
|
# 确保服务端版本号存在
|
||||||
|
server_version = note.get('version', 1)
|
||||||
|
|
||||||
|
# 版本冲突检查:如果客户端版本 != 服务端版本,说明有并发编辑
|
||||||
|
if client_version > 0 and client_version != server_version:
|
||||||
|
# 返回冲突响应,包含服务端最新内容
|
||||||
|
return jsonify({
|
||||||
|
'error': 'version_conflict',
|
||||||
|
'message': '该记录已被其他设备修改,请先查看最新内容',
|
||||||
|
'server_version': server_version,
|
||||||
|
'server_content': note.get('content', ''),
|
||||||
|
'server_title': note.get('title', ''),
|
||||||
|
'server_updated_at': note.get('updated_at', '')
|
||||||
|
}), 409 # HTTP 409 Conflict
|
||||||
|
|
||||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
# 新记录第一次输入内容时,自动生成标题
|
# 新记录第一次输入内容时,自动生成标题
|
||||||
@@ -173,6 +199,7 @@ def api_update_note(note_id):
|
|||||||
|
|
||||||
note['content'] = content
|
note['content'] = content
|
||||||
note['updated_at'] = now
|
note['updated_at'] = now
|
||||||
|
note['version'] = server_version + 1 # 版本号递增
|
||||||
|
|
||||||
# 如果是新记录第一次输入内容,异步生成标题
|
# 如果是新记录第一次输入内容,异步生成标题
|
||||||
if need_generate_title:
|
if need_generate_title:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>碎片记录</title>
|
<title>碎片记录</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
@@ -117,6 +118,7 @@
|
|||||||
<script>
|
<script>
|
||||||
let currentNoteId = null;
|
let currentNoteId = null;
|
||||||
let currentNotePinned = false;
|
let currentNotePinned = false;
|
||||||
|
let currentNoteVersion = 0; // 当前笔记版本号
|
||||||
let saveTimer = null;
|
let saveTimer = null;
|
||||||
let notes = [];
|
let notes = [];
|
||||||
let titleUpdateTimer = null;
|
let titleUpdateTimer = null;
|
||||||
@@ -200,6 +202,7 @@
|
|||||||
const note = await res.json();
|
const note = await res.json();
|
||||||
|
|
||||||
currentNotePinned = note.pinned || false;
|
currentNotePinned = note.pinned || false;
|
||||||
|
currentNoteVersion = note.version || 1; // 保存版本号
|
||||||
showEditor(note);
|
showEditor(note);
|
||||||
loadNotes();
|
loadNotes();
|
||||||
}
|
}
|
||||||
@@ -246,14 +249,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发送版本号进行保存
|
||||||
const res = await fetch(`/api/notes/${currentNoteId}`, {
|
const res = await fetch(`/api/notes/${currentNoteId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content })
|
body: JSON.stringify({ content, version: currentNoteVersion })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res.status === 409) {
|
||||||
|
// 版本冲突!
|
||||||
|
const conflict = await res.json();
|
||||||
|
showConflictDialog(conflict, content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const note = await res.json();
|
const note = await res.json();
|
||||||
|
|
||||||
|
// 更新本地版本号
|
||||||
|
currentNoteVersion = note.version;
|
||||||
|
|
||||||
// 更新标题显示
|
// 更新标题显示
|
||||||
document.getElementById('titleText').textContent = note.title;
|
document.getElementById('titleText').textContent = note.title;
|
||||||
document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`;
|
document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`;
|
||||||
@@ -263,6 +277,89 @@
|
|||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显示冲突对话框
|
||||||
|
function showConflictDialog(conflict, myContent) {
|
||||||
|
// 移除旧的对话框
|
||||||
|
const oldDialog = document.getElementById('conflictDialog');
|
||||||
|
if (oldDialog) oldDialog.remove();
|
||||||
|
|
||||||
|
const dialog = document.createElement('div');
|
||||||
|
dialog.id = 'conflictDialog';
|
||||||
|
dialog.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
|
||||||
|
dialog.innerHTML = `
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full mx-4 p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-red-600 mb-4 flex items-center gap-2">
|
||||||
|
<i class="ri-error-warning-line"></i> 检测到同步冲突
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 mb-4">该记录在其他设备上已被修改。您可以选择:</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||||
|
<div class="border rounded-lg p-4 bg-gray-50">
|
||||||
|
<h4 class="font-medium text-gray-700 mb-2">其他设备的版本 (最新)</h4>
|
||||||
|
<p class="text-xs text-gray-500 mb-2">更新时间: ${conflict.server_updated_at}</p>
|
||||||
|
<div class="bg-white border rounded p-3 max-h-40 overflow-auto text-sm">
|
||||||
|
${conflict.server_content || '<span class="text-gray-400">空内容</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border rounded-lg p-4 bg-purple-50">
|
||||||
|
<h4 class="font-medium text-purple-700 mb-2">您的版本</h4>
|
||||||
|
<p class="text-xs text-gray-500 mb-2">刚才编辑的内容</p>
|
||||||
|
<div class="bg-white border rounded p-3 max-h-40 overflow-auto text-sm">
|
||||||
|
${myContent || '<span class="text-gray-400">空内容</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<button onclick="handleConflictChoice('discard')"
|
||||||
|
class="px-4 py-2 border rounded-lg hover:bg-gray-50 text-gray-600">
|
||||||
|
放弃我的修改,保留最新版本
|
||||||
|
</button>
|
||||||
|
<button onclick="handleConflictChoice('overwrite')"
|
||||||
|
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||||
|
强制保存我的版本
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
|
// 存储冲突数据供后续处理
|
||||||
|
window.conflictData = { conflict, myContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理冲突选择
|
||||||
|
async function handleConflictChoice(choice) {
|
||||||
|
const { conflict, myContent } = window.conflictData;
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const dialog = document.getElementById('conflictDialog');
|
||||||
|
if (dialog) dialog.remove();
|
||||||
|
|
||||||
|
if (choice === 'discard') {
|
||||||
|
// 放弃我的修改,加载服务端最新版本
|
||||||
|
document.getElementById('editor').value = conflict.server_content;
|
||||||
|
document.getElementById('titleText').textContent = conflict.server_title;
|
||||||
|
document.getElementById('currentTime').textContent = `创建于 ${conflict.server_updated_at}`;
|
||||||
|
currentNoteVersion = conflict.server_version;
|
||||||
|
showSaveToast('已加载最新版本');
|
||||||
|
} else if (choice === 'overwrite') {
|
||||||
|
// 强制保存我的版本(不带版本号,直接覆盖)
|
||||||
|
const res = await fetch(`/api/notes/${currentNoteId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: myContent, version: 0 }) // version=0 表示强制覆盖
|
||||||
|
});
|
||||||
|
|
||||||
|
const note = await res.json();
|
||||||
|
currentNoteVersion = note.version;
|
||||||
|
document.getElementById('titleText').textContent = note.title;
|
||||||
|
document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`;
|
||||||
|
showSaveToast('已强制保存');
|
||||||
|
loadNotes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 搜索笔记
|
// 搜索笔记
|
||||||
function searchNotes() {
|
function searchNotes() {
|
||||||
loadNotes();
|
loadNotes();
|
||||||
@@ -497,6 +594,70 @@
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+S 快捷键保存
|
||||||
|
document.addEventListener('keydown', async (e) => {
|
||||||
|
// 检测 Ctrl+S (Windows/Linux) 或 Cmd+S (Mac)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault(); // 阻止浏览器默认保存页面
|
||||||
|
|
||||||
|
if (currentNoteId) {
|
||||||
|
// 立即保存(清除延迟计时器)
|
||||||
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
|
|
||||||
|
const content = document.getElementById('editor').value;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/notes/${currentNoteId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content, version: currentNoteVersion })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 409) {
|
||||||
|
// 版本冲突!
|
||||||
|
const conflict = await res.json();
|
||||||
|
showConflictDialog(conflict, content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = await res.json();
|
||||||
|
|
||||||
|
// 更新本地版本号
|
||||||
|
currentNoteVersion = note.version;
|
||||||
|
|
||||||
|
// 更新标题显示
|
||||||
|
document.getElementById('titleText').textContent = note.title;
|
||||||
|
document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`;
|
||||||
|
|
||||||
|
// 显示保存提示
|
||||||
|
showSaveToast();
|
||||||
|
|
||||||
|
// 刷新列表
|
||||||
|
loadNotes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 显示保存成功提示
|
||||||
|
function showSaveToast(message = '已保存') {
|
||||||
|
// 移除旧的提示
|
||||||
|
const oldToast = document.getElementById('saveToast');
|
||||||
|
if (oldToast) oldToast.remove();
|
||||||
|
|
||||||
|
// 创建新提示
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.id = 'saveToast';
|
||||||
|
toast.className = 'fixed bottom-4 right-4 px-4 py-2 bg-green-500 text-white rounded-lg shadow-lg fade-in flex items-center gap-2';
|
||||||
|
toast.innerHTML = `<i class="ri-check-line"></i> ${message}`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
// 2秒后消失
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
toast.style.transition = 'opacity 0.3s';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
loadNotes();
|
loadNotes();
|
||||||
startTitlePolling();
|
startTitlePolling();
|
||||||
|
|||||||
Reference in New Issue
Block a user