2026-04-30 10:52:55 +00:00
|
|
|
|
# --- routers/lock.py ---
|
|
|
|
|
|
import time
|
2026-05-19 10:25:10 +00:00
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
2026-04-30 10:52:55 +00:00
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
from typing import Dict, Optional
|
|
|
|
|
|
|
2026-05-13 10:31:34 +00:00
|
|
|
|
router = APIRouter(prefix="/locks", tags=["Lock Management"])
|
2026-04-30 10:52:55 +00:00
|
|
|
|
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
# 💡 全域鎖定表 (In-Memory Lock Table)
|
2026-05-19 10:25:10 +00:00
|
|
|
|
# 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
|
2026-06-01 08:20:34 +00:00
|
|
|
|
# ⚠️ 嚴格規則:Lock Key 絕對不包含 config_type,確保 running 與 full 視圖共用同一把鎖!
|
2026-04-30 10:52:55 +00:00
|
|
|
|
# ==========================================
|
|
|
|
|
|
ACTIVE_LOCKS: Dict[str, dict] = {}
|
2026-05-19 10:25:10 +00:00
|
|
|
|
LOCK_TIMEOUT = 120
|
2026-04-30 10:52:55 +00:00
|
|
|
|
|
2026-06-01 08:20:34 +00:00
|
|
|
|
# 🌟 請求模型嚴格排除 config_type
|
2026-04-30 10:52:55 +00:00
|
|
|
|
class LockAcquireReq(BaseModel):
|
2026-05-19 10:25:10 +00:00
|
|
|
|
host: str
|
2026-04-30 10:52:55 +00:00
|
|
|
|
path: str
|
2026-05-19 10:25:10 +00:00
|
|
|
|
user_id: str
|
|
|
|
|
|
username: str
|
2026-04-30 10:52:55 +00:00
|
|
|
|
|
|
|
|
|
|
class LockActionReq(BaseModel):
|
2026-05-19 10:25:10 +00:00
|
|
|
|
host: str
|
2026-04-30 10:52:55 +00:00
|
|
|
|
path: str
|
|
|
|
|
|
user_id: str
|
|
|
|
|
|
|
|
|
|
|
|
def clean_expired_locks():
|
|
|
|
|
|
current_time = time.time()
|
2026-05-19 10:25:10 +00:00
|
|
|
|
expired_keys = [k for k, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time]
|
|
|
|
|
|
for k in expired_keys:
|
|
|
|
|
|
del ACTIVE_LOCKS[k]
|
2026-04-30 10:52:55 +00:00
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
def is_path_locked_by_others(target_host: str, target_path: str, user_id: str) -> tuple[Optional[dict], str]:
|
2026-04-30 10:52:55 +00:00
|
|
|
|
clean_expired_locks()
|
2026-05-19 10:25:10 +00:00
|
|
|
|
prefix = f"{target_host}@@"
|
2026-04-30 10:52:55 +00:00
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
for locked_key, lock_info in ACTIVE_LOCKS.items():
|
|
|
|
|
|
# 🌟 只檢查同一台設備 (IP) 的鎖定
|
|
|
|
|
|
if not locked_key.startswith(prefix):
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
locked_path = locked_key.split("@@", 1)[1]
|
2026-04-30 10:52:55 +00:00
|
|
|
|
if lock_info["user_id"] == user_id:
|
2026-05-19 10:25:10 +00:00
|
|
|
|
continue
|
2026-04-30 10:52:55 +00:00
|
|
|
|
|
|
|
|
|
|
if target_path == locked_path:
|
|
|
|
|
|
return lock_info, f"此區塊正由 [{lock_info['username']}] 編輯中"
|
|
|
|
|
|
|
2026-05-04 10:26:12 +00:00
|
|
|
|
if target_path.startswith(locked_path + "::"):
|
2026-04-30 10:52:55 +00:00
|
|
|
|
return lock_info, f"父層級 [{locked_path}] 已被 [{lock_info['username']}] 鎖定,無法編輯子區塊"
|
|
|
|
|
|
|
2026-05-04 10:26:12 +00:00
|
|
|
|
if locked_path.startswith(target_path + "::"):
|
2026-04-30 10:52:55 +00:00
|
|
|
|
return lock_info, f"子層級 [{locked_path}] 已被 [{lock_info['username']}] 鎖定,無法鎖定整個父區塊"
|
|
|
|
|
|
|
|
|
|
|
|
return None, ""
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/acquire")
|
|
|
|
|
|
async def acquire_lock(req: LockAcquireReq):
|
2026-05-19 10:25:10 +00:00
|
|
|
|
conflict_lock, error_msg = is_path_locked_by_others(req.host, req.path, req.user_id)
|
2026-04-30 10:52:55 +00:00
|
|
|
|
|
|
|
|
|
|
if conflict_lock:
|
2026-05-19 10:25:10 +00:00
|
|
|
|
raise HTTPException(status_code=409, detail=error_msg)
|
2026-04-30 10:52:55 +00:00
|
|
|
|
|
2026-06-01 08:20:34 +00:00
|
|
|
|
# ⚠️ 嚴格綁定 host 與 path,跨視圖共用
|
2026-05-19 10:25:10 +00:00
|
|
|
|
lock_key = f"{req.host}@@{req.path}"
|
|
|
|
|
|
ACTIVE_LOCKS[lock_key] = {
|
2026-04-30 10:52:55 +00:00
|
|
|
|
"user_id": req.user_id,
|
|
|
|
|
|
"username": req.username,
|
|
|
|
|
|
"expires_at": time.time() + LOCK_TIMEOUT
|
|
|
|
|
|
}
|
|
|
|
|
|
return {"status": "success", "message": f"成功鎖定路徑: {req.path}"}
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/heartbeat")
|
|
|
|
|
|
async def heartbeat_lock(req: LockActionReq):
|
|
|
|
|
|
clean_expired_locks()
|
2026-05-19 10:25:10 +00:00
|
|
|
|
lock_key = f"{req.host}@@{req.path}"
|
2026-04-30 10:52:55 +00:00
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
if lock_key not in ACTIVE_LOCKS:
|
2026-04-30 10:52:55 +00:00
|
|
|
|
raise HTTPException(status_code=404, detail="鎖定已失效,請重新獲取")
|
|
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
if ACTIVE_LOCKS[lock_key]["user_id"] != req.user_id:
|
2026-04-30 10:52:55 +00:00
|
|
|
|
raise HTTPException(status_code=403, detail="無權更新他人的鎖定")
|
|
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
ACTIVE_LOCKS[lock_key]["expires_at"] = time.time() + LOCK_TIMEOUT
|
2026-04-30 10:52:55 +00:00
|
|
|
|
return {"status": "success", "message": "心跳更新成功"}
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/release")
|
|
|
|
|
|
async def release_lock(req: LockActionReq):
|
2026-05-19 10:25:10 +00:00
|
|
|
|
lock_key = f"{req.host}@@{req.path}"
|
|
|
|
|
|
if lock_key in ACTIVE_LOCKS and ACTIVE_LOCKS[lock_key]["user_id"] == req.user_id:
|
|
|
|
|
|
del ACTIVE_LOCKS[lock_key]
|
2026-04-30 10:52:55 +00:00
|
|
|
|
return {"status": "success", "message": "鎖定已釋放"}
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/status")
|
2026-05-19 10:25:10 +00:00
|
|
|
|
async def get_lock_status(host: str = Query(None)):
|
|
|
|
|
|
"""獲取特定設備的鎖定狀態"""
|
2026-04-30 10:52:55 +00:00
|
|
|
|
clean_expired_locks()
|
2026-05-19 10:25:10 +00:00
|
|
|
|
if host:
|
|
|
|
|
|
prefix = f"{host}@@"
|
|
|
|
|
|
# 🌟 貼心設計:拔除 host@@ 前綴,讓前端收到的依然是乾淨的 path,UI 不用改!
|
|
|
|
|
|
filtered_locks = {k.split("@@", 1)[1]: v for k, v in ACTIVE_LOCKS.items() if k.startswith(prefix)}
|
|
|
|
|
|
return {"status": "success", "data": filtered_locks}
|
|
|
|
|
|
|
|
|
|
|
|
return {"status": "success", "data": {}}
|