feat(review): 实现多人协作审核及状态同步功能
- 新增审核人身份录入和本地缓存功能,支持切换审核人并生成客户端令牌 - 引入审核领取机制,支持防止多人同时修改同一条记录 - 实现审核状态的乐观锁版本控制,防止并发更新冲突 - 后端新增审核相关字段和索引,支持多审核人操作和审核状态管理 - 在审核列表和详情页中展示审核状态、领取状态和审核人信息 - 支持撤销审核、删除标注和入库操作,增加详细错误提示和冲突提示 - 定时同步审核状态,自动刷新审核列表和详情,保持界面数据一致 - 优化审核筛选条件,新增待审核、已审核、已提交过滤项 - 前端增加同步通知提示,展示审核冲突和同步错误信息 - 修改导出和导入逻辑,确保入库操作仅在 staging-db 环境进行
Showing
3 changed files
with
193 additions
and
0 deletions
This diff is collapsed.
Click to expand it.
This diff is collapsed.
Click to expand it.
test_serve_l2_dashboard.py
0 → 100644
| 1 | from __future__ import annotations | ||
| 2 | |||
| 3 | from pathlib import Path | ||
| 4 | from unittest.mock import patch | ||
| 5 | |||
| 6 | import pytest | ||
| 7 | |||
| 8 | import serve_l2_dashboard as dashboard | ||
| 9 | |||
| 10 | |||
| 11 | class FakeCursor: | ||
| 12 | def __init__(self, update_rowcount: int, snapshot: dict[str, object]) -> None: | ||
| 13 | self.update_rowcount = update_rowcount | ||
| 14 | self.snapshot = snapshot | ||
| 15 | self.rowcount = 0 | ||
| 16 | self.executions: list[tuple[str, object]] = [] | ||
| 17 | |||
| 18 | def __enter__(self): | ||
| 19 | return self | ||
| 20 | |||
| 21 | def __exit__(self, *_args): | ||
| 22 | return False | ||
| 23 | |||
| 24 | def execute(self, sql, params=None): | ||
| 25 | self.executions.append((sql, params)) | ||
| 26 | self.rowcount = self.update_rowcount if sql.lstrip().startswith("UPDATE") else 0 | ||
| 27 | |||
| 28 | def fetchone(self): | ||
| 29 | return self.snapshot | ||
| 30 | |||
| 31 | |||
| 32 | class FakeConnection: | ||
| 33 | def __init__(self, cursor: FakeCursor) -> None: | ||
| 34 | self._cursor = cursor | ||
| 35 | self.committed = False | ||
| 36 | self.rolled_back = False | ||
| 37 | |||
| 38 | def __enter__(self): | ||
| 39 | return self | ||
| 40 | |||
| 41 | def __exit__(self, *_args): | ||
| 42 | return False | ||
| 43 | |||
| 44 | def cursor(self): | ||
| 45 | return self._cursor | ||
| 46 | |||
| 47 | def commit(self): | ||
| 48 | self.committed = True | ||
| 49 | |||
| 50 | def rollback(self): | ||
| 51 | self.rolled_back = True | ||
| 52 | |||
| 53 | |||
| 54 | def test_review_update_uses_staging_version_and_claim_token(): | ||
| 55 | snapshot = { | ||
| 56 | "staging_id": 42, | ||
| 57 | "source_song_id": "song-1", | ||
| 58 | "biz_review_status": "approved_import", | ||
| 59 | "review_version": 8, | ||
| 60 | } | ||
| 61 | cursor = FakeCursor(update_rowcount=1, snapshot=snapshot) | ||
| 62 | conn = FakeConnection(cursor) | ||
| 63 | |||
| 64 | with patch.object(dashboard, "_target_conn", return_value=conn): | ||
| 65 | result = dashboard._update_staging_review( | ||
| 66 | 42, "approved_import", "ok", "alice", 7, "browser-token" | ||
| 67 | ) | ||
| 68 | |||
| 69 | update_sql, params = cursor.executions[0] | ||
| 70 | assert "WHERE staging_id = %s" in update_sql | ||
| 71 | assert "review_version = %s" in update_sql | ||
| 72 | assert "review_claim_token = %s" in update_sql | ||
| 73 | assert params[-4:] == [42, 7, "browser-token", "alice"] | ||
| 74 | assert result == snapshot | ||
| 75 | assert conn.committed | ||
| 76 | |||
| 77 | |||
| 78 | def test_stale_review_returns_conflict_without_overwrite(): | ||
| 79 | snapshot = { | ||
| 80 | "staging_id": 42, | ||
| 81 | "biz_review_status": "rejected_duplicate", | ||
| 82 | "reviewed_by": "bob", | ||
| 83 | "review_version": 9, | ||
| 84 | } | ||
| 85 | cursor = FakeCursor(update_rowcount=0, snapshot=snapshot) | ||
| 86 | conn = FakeConnection(cursor) | ||
| 87 | |||
| 88 | with patch.object(dashboard, "_target_conn", return_value=conn): | ||
| 89 | with pytest.raises(dashboard.ReviewConflictError) as raised: | ||
| 90 | dashboard._update_staging_review( | ||
| 91 | 42, "approved_import", "stale", "alice", 7, "browser-token" | ||
| 92 | ) | ||
| 93 | |||
| 94 | assert raised.value.current_record == snapshot | ||
| 95 | assert conn.rolled_back | ||
| 96 | assert not conn.committed | ||
| 97 | |||
| 98 | |||
| 99 | def test_claim_conflict_exposes_current_owner(): | ||
| 100 | snapshot = { | ||
| 101 | "staging_id": 42, | ||
| 102 | "review_claimed_by": "bob", | ||
| 103 | "review_version": 3, | ||
| 104 | } | ||
| 105 | cursor = FakeCursor(update_rowcount=0, snapshot=snapshot) | ||
| 106 | conn = FakeConnection(cursor) | ||
| 107 | |||
| 108 | with patch.object(dashboard, "_target_conn", return_value=conn): | ||
| 109 | with pytest.raises(dashboard.ReviewConflictError) as raised: | ||
| 110 | dashboard._claim_staging_review(42, "alice", "alice-token") | ||
| 111 | |||
| 112 | assert raised.value.current_record["review_claimed_by"] == "bob" | ||
| 113 | assert conn.rolled_back | ||
| 114 | |||
| 115 | |||
| 116 | def test_heartbeat_does_not_increment_review_version(): | ||
| 117 | snapshot = {"staging_id": 42, "review_version": 5} | ||
| 118 | cursor = FakeCursor(update_rowcount=1, snapshot=snapshot) | ||
| 119 | conn = FakeConnection(cursor) | ||
| 120 | |||
| 121 | with patch.object(dashboard, "_target_conn", return_value=conn): | ||
| 122 | dashboard._heartbeat_staging_review(42, "browser-token") | ||
| 123 | |||
| 124 | update_sql, _params = cursor.executions[0] | ||
| 125 | assert "review_version" not in update_sql | ||
| 126 | assert conn.committed | ||
| 127 | |||
| 128 | |||
| 129 | class ImportCursor(FakeCursor): | ||
| 130 | def __init__(self) -> None: | ||
| 131 | super().__init__(update_rowcount=1, snapshot={}) | ||
| 132 | self.result_sets = [ | ||
| 133 | [{"staging_id": 42, "source_song_id": "song-1"}], | ||
| 134 | [{"source_song_id": "song-1", "target_id": 9001}], | ||
| 135 | ] | ||
| 136 | |||
| 137 | def execute(self, sql, params=None): | ||
| 138 | self.executions.append((sql, params)) | ||
| 139 | self.rowcount = 1 | ||
| 140 | |||
| 141 | def fetchall(self): | ||
| 142 | return self.result_sets.pop(0) | ||
| 143 | |||
| 144 | |||
| 145 | def test_import_locks_only_unclaimed_staged_rows(): | ||
| 146 | cursor = ImportCursor() | ||
| 147 | conn = FakeConnection(cursor) | ||
| 148 | |||
| 149 | with patch.object(dashboard, "_target_conn", return_value=conn): | ||
| 150 | result = dashboard._import_approved_staging() | ||
| 151 | |||
| 152 | lock_sql, _params = cursor.executions[0] | ||
| 153 | assert "FOR UPDATE" in lock_sql | ||
| 154 | assert "staging_status = 'staged'" in lock_sql | ||
| 155 | assert "review_claim_expires_at <= NOW()" in lock_sql | ||
| 156 | assert result["inserted_count"] == 1 | ||
| 157 | assert result["source_ids"] == ["song-1"] | ||
| 158 | assert conn.committed | ||
| 159 | |||
| 160 | |||
| 161 | def test_browsing_a_list_item_can_auto_claim_without_reloading_groups(): | ||
| 162 | html = Path("l2_review_dashboard.html").read_text(encoding="utf-8") | ||
| 163 | start = html.index("for (const btn of els.queryList.querySelectorAll('.query-item'))") | ||
| 164 | end = html.index("function selectedQueryRows()", start) | ||
| 165 | click_handler = html[start:end] | ||
| 166 | |||
| 167 | assert "loadQueryRows()" in click_handler | ||
| 168 | assert "claimCurrentReview" in click_handler | ||
| 169 | assert "loadGroups()" not in click_handler | ||
| 170 | |||
| 171 | |||
| 172 | def test_staging_bigint_is_never_converted_to_javascript_number(): | ||
| 173 | html = Path("l2_review_dashboard.html").read_text(encoding="utf-8") | ||
| 174 | |||
| 175 | assert "Number(query.staging_id)" not in html | ||
| 176 | assert "staging_id: String(query.staging_id)" in html | ||
| 177 | |||
| 178 | |||
| 179 | def test_dashboard_distinguishes_reviewed_from_submitted(): | ||
| 180 | html = Path("l2_review_dashboard.html").read_text(encoding="utf-8") | ||
| 181 | row = dashboard._staging_dashboard_row( | ||
| 182 | { | ||
| 183 | "staging_id": 42, | ||
| 184 | "source_song_id": "song-1", | ||
| 185 | "dedup_action": "review", | ||
| 186 | "biz_review_status": "approved_import", | ||
| 187 | "staging_status": "imported", | ||
| 188 | } | ||
| 189 | ) | ||
| 190 | |||
| 191 | assert row["staging_status"] == "imported" | ||
| 192 | assert '<option value="reviewed">已审核</option>' in html | ||
| 193 | assert '<option value="submitted">已提交</option>' in html |
-
Please register or sign in to post a comment