Commit 6cce9eaa 6cce9eaa10ceeca9c2d7280f60f884e189f639f8 by 沈秋雨

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

- 新增审核人身份录入和本地缓存功能,支持切换审核人并生成客户端令牌
- 引入审核领取机制,支持防止多人同时修改同一条记录
- 实现审核状态的乐观锁版本控制,防止并发更新冲突
- 后端新增审核相关字段和索引,支持多审核人操作和审核状态管理
- 在审核列表和详情页中展示审核状态、领取状态和审核人信息
- 支持撤销审核、删除标注和入库操作,增加详细错误提示和冲突提示
- 定时同步审核状态,自动刷新审核列表和详情,保持界面数据一致
- 优化审核筛选条件,新增待审核、已审核、已提交过滤项
- 前端增加同步通知提示,展示审核冲突和同步错误信息
- 修改导出和导入逻辑,确保入库操作仅在 staging-db 环境进行
1 parent 7d33be0f
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