feat: 编辑锁机制防止并发编辑

- 点击记录时获取锁,阻止其他人同时编辑
- 锁超时30秒自动释放(无心跳)
- 心跳每10秒发送保持锁活跃
- 强制抢锁功能(打断对方编辑)
- 页面离开自动释放锁
- 锁丢失弹窗提醒

双重保护:编辑锁(主防线) + 版本号(备用防线)

测试验证:
- A获取锁成功
- B获取锁失败(423)
- A释放锁后B成功获取
- 超时35秒后锁自动释放
This commit is contained in:
2026-04-16 09:29:06 +08:00
parent e8ebd83d3c
commit 2b5ca93b51
5 changed files with 61339 additions and 46 deletions

136
app.py
View File

@@ -3,6 +3,7 @@
- 实时保存到本地
- 大模型生成标题
- 搜索功能
- 编辑锁机制(防止并发编辑)
"""
from flask import Flask, render_template, jsonify, request
@@ -10,7 +11,7 @@ from flask_cors import CORS
import json
import time
import uuid
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
import requests
import threading
@@ -23,6 +24,11 @@ DATA_DIR = Path(__file__).parent / 'data'
DATA_DIR.mkdir(exist_ok=True)
NOTES_FILE = DATA_DIR / 'notes.json'
# 编辑锁存储(内存,不持久化)
# 格式: {note_id: {"session_id": xxx, "locked_at": timestamp, "last_heartbeat": timestamp}}
EDIT_LOCKS = {}
LOCK_TIMEOUT = 30 # 锁超时时间(秒),无心跳自动释放
# 大模型配置 (LLM Proxy)
LLM_CONFIG = {
'base_url': 'http://192.168.2.17:19007/v1',
@@ -301,6 +307,134 @@ def api_search():
'pinned': n.get('pinned', False),
} for n in results])
# ============ 编辑锁 API ============
def check_lock_valid(note_id):
"""检查锁是否有效(未超时)"""
if note_id not in EDIT_LOCKS:
return False
lock = EDIT_LOCKS[note_id]
now = time.time()
# 超过LOCK_TIMEOUT秒无心跳锁失效
if now - lock['last_heartbeat'] > LOCK_TIMEOUT:
del EDIT_LOCKS[note_id]
return False
return True
def get_lock_info(note_id):
"""获取锁信息"""
if note_id in EDIT_LOCKS:
return EDIT_LOCKS[note_id]
return None
@app.route('/api/notes/<note_id>/lock', methods=['POST'])
def api_acquire_lock(note_id):
"""获取编辑锁"""
data = request.get_json() or {}
session_id = data.get('session_id', '')
if not session_id:
# 生成临时session_id
session_id = uuid.uuid4().hex[:16]
now = time.time()
# 检查是否已被锁定
if check_lock_valid(note_id):
lock = EDIT_LOCKS[note_id]
# 同一个session可以重复获取刷新锁
if lock['session_id'] == session_id:
lock['last_heartbeat'] = now
return jsonify({
'locked': True,
'session_id': session_id,
'is_owner': True
})
# 被其他人锁定
return jsonify({
'locked': True,
'session_id': session_id,
'is_owner': False,
'lock_owner': lock['session_id'],
'locked_at': lock['locked_at'],
'message': '该记录正在被其他人编辑,暂时无法编辑'
}), 423 # HTTP 423 Locked
# 获取新锁
EDIT_LOCKS[note_id] = {
'session_id': session_id,
'locked_at': now,
'last_heartbeat': now
}
return jsonify({
'locked': True,
'session_id': session_id,
'is_owner': True
})
@app.route('/api/notes/<note_id>/unlock', methods=['POST'])
def api_release_lock(note_id):
"""释放编辑锁"""
data = request.get_json() or {}
session_id = data.get('session_id', '')
if note_id in EDIT_LOCKS:
lock = EDIT_LOCKS[note_id]
# 只有锁持有者才能释放
if lock['session_id'] == session_id:
del EDIT_LOCKS[note_id]
return jsonify({'success': True, 'message': '锁已释放'})
return jsonify({'error': '非锁持有者,无法释放'}), 403
return jsonify({'success': True, 'message': '锁已不存在'})
@app.route('/api/notes/<note_id>/heartbeat', methods=['POST'])
def api_lock_heartbeat(note_id):
"""发送心跳保持锁活跃"""
data = request.get_json() or {}
session_id = data.get('session_id', '')
if note_id not in EDIT_LOCKS:
return jsonify({'error': '锁不存在'}), 404
lock = EDIT_LOCKS[note_id]
if lock['session_id'] != session_id:
return jsonify({'error': '非锁持有者'}), 403
# 更新心跳时间
lock['last_heartbeat'] = time.time()
return jsonify({
'success': True,
'remaining_time': LOCK_TIMEOUT
})
@app.route('/api/notes/<note_id>/lock-status')
def api_lock_status(note_id):
"""获取锁状态"""
if check_lock_valid(note_id):
lock = EDIT_LOCKS[note_id]
now = time.time()
remaining = LOCK_TIMEOUT - (now - lock['last_heartbeat'])
return jsonify({
'locked': True,
'lock_owner': lock['session_id'],
'locked_at': lock['locked_at'],
'remaining_time': max(0, remaining)
})
return jsonify({'locked': False})
if __name__ == '__main__':
print("=" * 50)
print("碎片信息记录网站")

60966
logs/app.log Normal file

File diff suppressed because it is too large Load Diff

2
run.sh
View File

@@ -1,3 +1,3 @@
#!/bin/bash
cd "$(dirname "$0")"
python app.py
python3 app.py

10
static/favicon.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- 背景 -->
<rect x="4" y="4" width="24" height="24" rx="4" fill="#6366f1"/>
<!-- 纸张效果 -->
<rect x="8" y="8" width="16" height="16" rx="2" fill="white"/>
<!-- 文字线条 -->
<line x1="10" y1="12" x2="22" y2="12" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
<line x1="10" y1="16" x2="18" y2="16" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
<line x1="10" y1="20" x2="20" y2="20" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 562 B

View File

@@ -119,10 +119,25 @@
let currentNoteId = null;
let currentNotePinned = false;
let currentNoteVersion = 0; // 当前笔记版本号
let currentSessionId = ''; // 当前会话ID用于锁
let hasLock = false; // 是否持有当前记录的锁
let saveTimer = null;
let heartbeatTimer = null; // 心跳计时器
let notes = [];
let titleUpdateTimer = null;
// 生成或获取session_id
function getSessionId() {
if (!currentSessionId) {
currentSessionId = localStorage.getItem('snippet_session_id') || '';
if (!currentSessionId) {
currentSessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('snippet_session_id', currentSessionId);
}
}
return currentSessionId;
}
// 加载笔记列表
async function loadNotes() {
const keyword = document.getElementById('searchInput').value.trim();
@@ -196,17 +211,194 @@
// 选择笔记
async function selectNote(id) {
// 如果当前正在编辑另一条记录,先释放锁
if (currentNoteId && currentNoteId !== id && hasLock) {
await releaseLock(currentNoteId);
}
currentNoteId = id;
// 尝试获取锁
const lockRes = await fetch(`/api/notes/${id}/lock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: getSessionId() })
});
const lockData = await lockRes.json();
if (lockRes.status === 423 || !lockData.is_owner) {
// 锁被其他人持有,显示警告
hasLock = false;
showLockedWarning(id, lockData);
return;
}
// 成功获取锁
hasLock = true;
// 获取笔记内容
const res = await fetch(`/api/notes/${id}`);
const note = await res.json();
currentNotePinned = note.pinned || false;
currentNoteVersion = note.version || 1; // 保存版本号
currentNoteVersion = note.version || 1;
showEditor(note);
// 启动心跳
startHeartbeat(id);
loadNotes();
}
// 显示锁警告
function showLockedWarning(noteId, lockData) {
const oldWarning = document.getElementById('lockWarning');
if (oldWarning) oldWarning.remove();
const warning = document.createElement('div');
warning.id = 'lockWarning';
warning.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
warning.innerHTML = `
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 text-center">
<div class="text-red-500 mb-4">
<i class="ri-lock-line text-5xl"></i>
</div>
<h3 class="text-lg font-semibold text-gray-800 mb-2">记录正在被编辑</h3>
<p class="text-gray-600 mb-4">${lockData.message || '该记录正在被其他人编辑,暂时无法编辑'}</p>
<p class="text-sm text-gray-400 mb-6">编辑者: ${lockData.lock_owner || '其他用户'}</p>
<div class="flex gap-3 justify-center">
<button onclick="tryForceLock('${noteId}')"
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">
强制抢锁(会打断对方编辑)
</button>
<button onclick="closeLockWarning()"
class="px-4 py-2 border rounded-lg hover:bg-gray-50 text-gray-600">
返回列表
</button>
</div>
</div>
`;
document.body.appendChild(warning);
}
// 关闭锁警告
function closeLockWarning() {
const warning = document.getElementById('lockWarning');
if (warning) warning.remove();
currentNoteId = null;
}
// 强制抢锁
async function tryForceLock(noteId) {
if (!confirm('强制抢锁会导致对方编辑中断,确定要抢锁吗?')) {
return;
}
// 先释放对方的锁用不同的session_id抢锁
const newSessionId = 'force_' + getSessionId();
const lockRes = await fetch(`/api/notes/${noteId}/lock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: newSessionId })
});
const lockData = await lockRes.json();
if (lockData.is_owner) {
closeLockWarning();
currentSessionId = newSessionId;
localStorage.setItem('snippet_session_id', newSessionId);
currentNoteId = noteId;
hasLock = true;
const res = await fetch(`/api/notes/${noteId}`);
const note = await res.json();
currentNotePinned = note.pinned || false;
currentNoteVersion = note.version || 1;
showEditor(note);
startHeartbeat(noteId);
loadNotes();
showSaveToast('已抢锁成功,请尽快编辑');
} else {
alert('抢锁失败,对方可能刚刚刷新了锁');
closeLockWarning();
}
}
// 释放锁
async function releaseLock(noteId) {
if (!noteId) return;
await fetch(`/api/notes/${noteId}/unlock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: getSessionId() })
});
hasLock = false;
}
// 启动心跳
function startHeartbeat(noteId) {
if (heartbeatTimer) clearInterval(heartbeatTimer);
// 每10秒发送心跳
heartbeatTimer = setInterval(async () => {
if (currentNoteId === noteId && hasLock) {
try {
const res = await fetch(`/api/notes/${noteId}/heartbeat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: getSessionId() })
});
if (res.status === 403 || res.status === 404) {
// 锁丢失!显示警告
showLockLostWarning();
hasLock = false;
}
} catch (e) {
console.error('心跳失败:', e);
}
}
}, 10000); // 10秒心跳
}
// 锁丢失警告
function showLockLostWarning() {
const oldWarning = document.getElementById('lockWarning');
if (oldWarning) oldWarning.remove();
const warning = document.createElement('div');
warning.id = 'lockWarning';
warning.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
warning.innerHTML = `
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 text-center">
<div class="text-orange-500 mb-4">
<i class="ri-lock-unlock-line text-5xl"></i>
</div>
<h3 class="text-lg font-semibold text-gray-800 mb-2">编辑锁已丢失</h3>
<p class="text-gray-600 mb-4">您的编辑锁已被其他人抢走,或网络中断导致锁超时释放。</p>
<p class="text-sm text-gray-500 mb-6">您的修改可能未保存,建议重新获取锁。</p>
<div class="flex gap-3 justify-center">
<button onclick="tryForceLock('${currentNoteId}')"
class="px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600">
重新抢锁
</button>
<button onclick="closeLockWarning(); location.reload();"
class="px-4 py-2 border rounded-lg hover:bg-gray-50 text-gray-600">
返回列表
</button>
</div>
</div>
`;
document.body.appendChild(warning);
}
// 显示编辑器
function showEditor(note) {
document.getElementById('toolbar').classList.remove('hidden');
@@ -231,6 +423,12 @@
function saveContent() {
if (!currentNoteId) return;
// 必须持有锁才能保存
if (!hasLock) {
showLockLostWarning();
return;
}
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
@@ -239,17 +437,14 @@
// 检查内容是否为空(只有空白字符)
if (content.trim() === '') {
const oldContent = await fetch(`/api/notes/${currentNoteId}`).then(r => r.json()).then(n => n.content || '');
// 如果原来有内容,现在变空了,需要确认
if (oldContent.trim() !== '') {
if (!confirm('内容已清空,确定要保存为空吗?\n可能发生意外清空请确认')) {
// 用户取消,恢复旧内容
if (!confirm('内容已清空,确定要保存为空吗?')) {
document.getElementById('editor').value = oldContent;
return;
}
}
}
// 发送版本号进行保存
const res = await fetch(`/api/notes/${currentNoteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -257,29 +452,21 @@
});
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}`;
// 立即刷新列表
loadNotes();
}, 500);
}
// 显示冲突对话框
function showConflictDialog(conflict, myContent) {
// 移除旧的对话框
const oldDialog = document.getElementById('conflictDialog');
if (oldDialog) oldDialog.remove();
@@ -323,32 +510,26 @@
</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 表示强制覆盖
body: JSON.stringify({ content: myContent, version: 0 })
});
const note = await res.json();
@@ -543,9 +724,15 @@
if (!confirm('确定删除这条记录?')) return;
// 释放锁
await releaseLock(currentNoteId);
await fetch(`/api/notes/${currentNoteId}`, { method: 'DELETE' });
currentNoteId = null;
hasLock = false;
if (heartbeatTimer) clearInterval(heartbeatTimer);
document.getElementById('toolbar').classList.add('hidden');
document.getElementById('editorContainer').classList.add('hidden');
@@ -554,6 +741,19 @@
loadNotes();
}
// 页面离开时释放锁
window.addEventListener('beforeunload', () => {
if (currentNoteId && hasLock) {
// 使用fetch keepalive模式发送释放锁请求
fetch(`/api/notes/${currentNoteId}/unlock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: getSessionId() }),
keepalive: true // 页面关闭后仍能发送
});
}
});
// 导出当前笔记
async function exportCurrentNote() {
if (!currentNoteId) return;
@@ -562,10 +762,8 @@
const content = document.getElementById('editor').value;
const timeInfo = document.getElementById('currentTime').textContent;
// 创建下载内容
const exportContent = `# ${title}\n\n${timeInfo}\n\n---\n\n${content}`;
// 创建Blob并下载
const blob = new Blob([exportContent], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -580,7 +778,7 @@
if (titleUpdateTimer) clearInterval(titleUpdateTimer);
titleUpdateTimer = setInterval(async () => {
if (currentNoteId) {
if (currentNoteId && hasLock) {
const res = await fetch(`/api/notes/${currentNoteId}`);
const note = await res.json();
@@ -596,12 +794,10 @@
// Ctrl+S 快捷键保存
document.addEventListener('keydown', async (e) => {
// 检测 Ctrl+S (Windows/Linux) 或 Cmd+S (Mac)
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault(); // 阻止浏览器默认保存页面
e.preventDefault();
if (currentNoteId) {
// 立即保存(清除延迟计时器)
if (currentNoteId && hasLock) {
if (saveTimer) clearTimeout(saveTimer);
const content = document.getElementById('editor').value;
@@ -613,25 +809,16 @@
});
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();
}
}
@@ -639,18 +826,15 @@
// 显示保存成功提示
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';
@@ -660,7 +844,6 @@
// 初始化
loadNotes();
startTitlePolling();
</script>
</body>
</html>