Commit 6cce9eaa 6cce9eaa10ceeca9c2d7280f60f884e189f639f8 by 沈秋雨

feat(review): 实现多人协作审核及状态同步功能

- 新增审核人身份录入和本地缓存功能,支持切换审核人并生成客户端令牌
- 引入审核领取机制,支持防止多人同时修改同一条记录
- 实现审核状态的乐观锁版本控制,防止并发更新冲突
- 后端新增审核相关字段和索引,支持多审核人操作和审核状态管理
- 在审核列表和详情页中展示审核状态、领取状态和审核人信息
- 支持撤销审核、删除标注和入库操作,增加详细错误提示和冲突提示
- 定时同步审核状态,自动刷新审核列表和详情,保持界面数据一致
- 优化审核筛选条件,新增待审核、已审核、已提交过滤项
- 前端增加同步通知提示,展示审核冲突和同步错误信息
- 修改导出和导入逻辑,确保入库操作仅在 staging-db 环境进行
1 parent 7d33be0f
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
import serve_l2_dashboard as dashboard
class FakeCursor:
def __init__(self, update_rowcount: int, snapshot: dict[str, object]) -> None:
self.update_rowcount = update_rowcount
self.snapshot = snapshot
self.rowcount = 0
self.executions: list[tuple[str, object]] = []
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def execute(self, sql, params=None):
self.executions.append((sql, params))
self.rowcount = self.update_rowcount if sql.lstrip().startswith("UPDATE") else 0
def fetchone(self):
return self.snapshot
class FakeConnection:
def __init__(self, cursor: FakeCursor) -> None:
self._cursor = cursor
self.committed = False
self.rolled_back = False
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def cursor(self):
return self._cursor
def commit(self):
self.committed = True
def rollback(self):
self.rolled_back = True
def test_review_update_uses_staging_version_and_claim_token():
snapshot = {
"staging_id": 42,
"source_song_id": "song-1",
"biz_review_status": "approved_import",
"review_version": 8,
}
cursor = FakeCursor(update_rowcount=1, snapshot=snapshot)
conn = FakeConnection(cursor)
with patch.object(dashboard, "_target_conn", return_value=conn):
result = dashboard._update_staging_review(
42, "approved_import", "ok", "alice", 7, "browser-token"
)
update_sql, params = cursor.executions[0]
assert "WHERE staging_id = %s" in update_sql
assert "review_version = %s" in update_sql
assert "review_claim_token = %s" in update_sql
assert params[-4:] == [42, 7, "browser-token", "alice"]
assert result == snapshot
assert conn.committed
def test_stale_review_returns_conflict_without_overwrite():
snapshot = {
"staging_id": 42,
"biz_review_status": "rejected_duplicate",
"reviewed_by": "bob",
"review_version": 9,
}
cursor = FakeCursor(update_rowcount=0, snapshot=snapshot)
conn = FakeConnection(cursor)
with patch.object(dashboard, "_target_conn", return_value=conn):
with pytest.raises(dashboard.ReviewConflictError) as raised:
dashboard._update_staging_review(
42, "approved_import", "stale", "alice", 7, "browser-token"
)
assert raised.value.current_record == snapshot
assert conn.rolled_back
assert not conn.committed
def test_claim_conflict_exposes_current_owner():
snapshot = {
"staging_id": 42,
"review_claimed_by": "bob",
"review_version": 3,
}
cursor = FakeCursor(update_rowcount=0, snapshot=snapshot)
conn = FakeConnection(cursor)
with patch.object(dashboard, "_target_conn", return_value=conn):
with pytest.raises(dashboard.ReviewConflictError) as raised:
dashboard._claim_staging_review(42, "alice", "alice-token")
assert raised.value.current_record["review_claimed_by"] == "bob"
assert conn.rolled_back
def test_heartbeat_does_not_increment_review_version():
snapshot = {"staging_id": 42, "review_version": 5}
cursor = FakeCursor(update_rowcount=1, snapshot=snapshot)
conn = FakeConnection(cursor)
with patch.object(dashboard, "_target_conn", return_value=conn):
dashboard._heartbeat_staging_review(42, "browser-token")
update_sql, _params = cursor.executions[0]
assert "review_version" not in update_sql
assert conn.committed
class ImportCursor(FakeCursor):
def __init__(self) -> None:
super().__init__(update_rowcount=1, snapshot={})
self.result_sets = [
[{"staging_id": 42, "source_song_id": "song-1"}],
[{"source_song_id": "song-1", "target_id": 9001}],
]
def execute(self, sql, params=None):
self.executions.append((sql, params))
self.rowcount = 1
def fetchall(self):
return self.result_sets.pop(0)
def test_import_locks_only_unclaimed_staged_rows():
cursor = ImportCursor()
conn = FakeConnection(cursor)
with patch.object(dashboard, "_target_conn", return_value=conn):
result = dashboard._import_approved_staging()
lock_sql, _params = cursor.executions[0]
assert "FOR UPDATE" in lock_sql
assert "staging_status = 'staged'" in lock_sql
assert "review_claim_expires_at <= NOW()" in lock_sql
assert result["inserted_count"] == 1
assert result["source_ids"] == ["song-1"]
assert conn.committed
def test_browsing_a_list_item_can_auto_claim_without_reloading_groups():
html = Path("l2_review_dashboard.html").read_text(encoding="utf-8")
start = html.index("for (const btn of els.queryList.querySelectorAll('.query-item'))")
end = html.index("function selectedQueryRows()", start)
click_handler = html[start:end]
assert "loadQueryRows()" in click_handler
assert "claimCurrentReview" in click_handler
assert "loadGroups()" not in click_handler
def test_staging_bigint_is_never_converted_to_javascript_number():
html = Path("l2_review_dashboard.html").read_text(encoding="utf-8")
assert "Number(query.staging_id)" not in html
assert "staging_id: String(query.staging_id)" in html
def test_dashboard_distinguishes_reviewed_from_submitted():
html = Path("l2_review_dashboard.html").read_text(encoding="utf-8")
row = dashboard._staging_dashboard_row(
{
"staging_id": 42,
"source_song_id": "song-1",
"dedup_action": "review",
"biz_review_status": "approved_import",
"staging_status": "imported",
}
)
assert row["staging_status"] == "imported"
assert '<option value="reviewed">已审核</option>' in html
assert '<option value="submitted">已提交</option>' in html