diff --git a/backend/app/main.py b/backend/app/main.py index a766589..b612dc7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1280,6 +1280,7 @@ def _ask_assignment_data( "used_count": len(used_ids), "remaining_count": max(len(items) - len(used_ids), 0), "task_id": assignment.task.task_no, + "public_token": assignment.task.public_token or assignment.task.task_no, "shop_name": assignment.task.shop_name, "product_name": assignment.task.product_name, "product_link": assignment.task.product_link, @@ -1517,7 +1518,15 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True, imag "delete_endpoint": f"/api/admin/task-board/{record.id}", "refund_endpoint": f"/api/admin/task-board/{record.id}/confirm-refund" if record.status == "已通过" else None, "assigned_users": [{"user_id": _public_user_id(record.user), "phone": _public_user_account(record.user), "taobao_id": record.user.taobao_id, "status": status_labels.get(record.status, record.status)}], - "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": q.answer_text, "product_title": q.product_title} for q in selected] if include_images else []), + "selected_questions": ([{ + "id": q.id, + "sequence_no": q.sequence_no, + "question": q.question_text, + "answer": q.answer_text, + "product_title": q.product_title, + "user_id": _public_user_id(record.user), + "phone": _public_user_account(record.user), + } for q in selected] if include_images else []), } @@ -1762,7 +1771,7 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: return { "id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task", "task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务", - "public_token": task.public_token, + "public_token": task.public_token or task.task_no, "shop_name": task.shop_name, "product_name": task.product_name, "product_link": task.product_link, # Keep the persisted task state alongside the display label. The @@ -1855,6 +1864,7 @@ def _ask_review_board_data( "assignment_id": record.id, "status": "已通过", "task_id": assignment.task.task_no, + "public_token": assignment.task.public_token or assignment.task.task_no, "shop_name": assignment.task.shop_name, "product_name": assignment.task.product_name, "product_link": assignment.task.product_link, @@ -1883,6 +1893,7 @@ def _ask_review_board_data( "record_kind": "ask_batch" if is_batch else "ask_assignment", "task_type": "ask", "task_type_label": "提问任务", + "public_token": assignment.task.public_token or assignment.task.task_no, "status_label": "已完成" if refund_completed else "待返款", "shop_name": assignment.task.shop_name, "product_name": assignment.task.product_name, @@ -1915,6 +1926,8 @@ def _ask_review_board_data( "question": question["question"], "answer": "", "product_title": assignment.task.product_name, + "user_id": _public_user_id(assignment.user), + "phone": _public_user_account(assignment.user), } for question in selected] if include_images else []), "detail_endpoint": ( f"/api/admin/task-board/ask-batches/{record.id}" @@ -3400,16 +3413,29 @@ def _save_refund_recipient(db, kind, target_id, account, name): def _snapshot_refund(db, record, kind, user, payer_account="", alipay_trade_no=""): + existing = db.scalar(select(RefundRecord).where( + RefundRecord.target_kind == kind, + RefundRecord.target_id == record.id, + )) + if existing: + # A verified Alipay callback may finish the same payment before the + # originating HTTP request resumes. Keep the immutable snapshot and + # only fill the provider trade number when the older row lacks it. + if alipay_trade_no and not existing.alipay_trade_no: + existing.alipay_trade_no = alipay_trade_no.strip() + return existing identity = _refund_recipient_data(record, kind) task = record.assignment.task if kind == "ask_batch" else record.task if kind == "ask_assignment" else record - db.add(RefundRecord( + snapshot = RefundRecord( target_kind=kind, target_id=record.id, owner_id=task.owner_id, shop_name=task.shop_name or "", order_no=record.submission_no if kind == "answer_submission" else task.task_no, payee_name=identity["payee_name"], payee_account=identity["alipay_account"], amount_fen=identity["refund_amount_fen"], operator_name=user.display_name, payer_account=payer_account.strip(), alipay_trade_no=alipay_trade_no.strip(), - )) + ) + db.add(snapshot) + return snapshot def _complete_alipay_refund_order(db: Session, order: AlipayRefundOrder, trade_no: str) -> bool: @@ -3462,6 +3488,47 @@ def _complete_alipay_refund_order(db: Session, order: AlipayRefundOrder, trade_n return True +def _finalize_refund_confirmation( + db: Session, record, kind: str, user: AdminUser, payer: RefundPaymentAccount, trade_no: str +) -> None: + """Finish one refund exactly once after the provider call returns. + + Alipay can deliver its success callback before the original confirmation + request resumes. Locking the provider order makes the callback, automatic + reconciliation, and the browser request share one idempotent completion + path instead of racing to insert the same refund record. + """ + if settings.alipay_enabled: + order = db.scalar( + select(AlipayRefundOrder) + .where( + AlipayRefundOrder.target_kind == kind, + AlipayRefundOrder.target_id == record.id, + ) + .order_by(AlipayRefundOrder.id.desc()) + .with_for_update() + .execution_options(populate_existing=True) + ) + if not order or not _complete_alipay_refund_order(db, order, trade_no): + raise HTTPException( + status_code=503, + detail="支付宝已返回成功,但本地返款状态暂未完成同步,请稍后刷新,勿重复付款", + ) + return + + _snapshot_refund(db, record, kind, user, payer.name, trade_no) + if kind == "ask_batch": + record.status = "paid" + record.updated_at = datetime.utcnow() + _sync_ask_task_status(record.assignment.task) + elif kind == "ask_assignment": + record.status = "已完成" + record.updated_at = datetime.utcnow() + _sync_ask_task_status(record.task) + else: + record.status = "已完成" + + def _reconcile_pending_alipay_orders_once() -> int: """Query pending/legacy uncertain transfers and finalize verified results.""" if not settings.alipay_enabled: @@ -3812,10 +3879,7 @@ async def confirm_ask_batch_refund(batch_id: int, refund_amount: str | None = Fo payer = _authorized_refund_payer(db, user, payer_account_id) _save_refund_amount(db, batch, "ask_batch", refund_amount, snapshot=True) alipay_trade_no = _perform_alipay_refund(db, batch, "ask_batch", user, payer) - _snapshot_refund(db, batch, "ask_batch", user, payer.name, alipay_trade_no) - batch.status = "paid" - batch.updated_at = datetime.utcnow() - _sync_ask_task_status(batch.assignment.task) + _finalize_refund_confirmation(db, batch, "ask_batch", user, payer, alipay_trade_no) db.commit(); db.refresh(batch) return {"success": True, "data": _ask_review_board_data(batch, include_images=True, image_endpoints=True)} @@ -3832,10 +3896,7 @@ async def confirm_ask_assignment_refund(assignment_id: int, refund_amount: str | payer = _authorized_refund_payer(db, user, payer_account_id) _save_refund_amount(db, assignment, "ask_assignment", refund_amount, snapshot=True) alipay_trade_no = _perform_alipay_refund(db, assignment, "ask_assignment", user, payer) - _snapshot_refund(db, assignment, "ask_assignment", user, payer.name, alipay_trade_no) - assignment.status = "已完成" - assignment.updated_at = datetime.utcnow() - _sync_ask_task_status(assignment.task) + _finalize_refund_confirmation(db, assignment, "ask_assignment", user, payer, alipay_trade_no) db.commit(); db.refresh(assignment) return {"success": True, "data": _ask_review_board_data(assignment, include_images=True, image_endpoints=True)} @@ -3880,8 +3941,7 @@ async def confirm_answer_refund(submission_id: int, refund_amount: str | None = payer = _authorized_refund_payer(db, user, payer_account_id) _save_refund_amount(db, record, "answer_submission", refund_amount, snapshot=True) alipay_trade_no = _perform_alipay_refund(db, record, "answer_submission", user, payer) - _snapshot_refund(db, record, "answer_submission", user, payer.name, alipay_trade_no) - record.status = "已完成" + _finalize_refund_confirmation(db, record, "answer_submission", user, payer, alipay_trade_no) db.commit(); db.refresh(record) return {"success": True, "data": _task_board_data(record, include_images=True, image_endpoints=True)} @@ -4929,14 +4989,21 @@ def admin_usage_stats(_: AdminUser = Depends(require_super_admin), db: Session = if day in trend: trend[day]["count"] += 1 key = (entry.user_id, entry.username, entry.display_name, entry.department_name) - user_totals[key] = user_totals.get(key, 0) + 1 + user_totals.setdefault(key, {"count": 0, "latest": entry.entered_at}) + user_totals[key]["count"] += 1 + if entry.entered_at > user_totals[key]["latest"]: + user_totals[key]["latest"] = entry.entered_at department = entry.department_name or "未分配部门" department_totals.setdefault(department, {"count": 0, "latest": entry.entered_at, "users": {}}) department_totals[department]["count"] += 1 if entry.entered_at > department_totals[department]["latest"]: department_totals[department]["latest"] = entry.entered_at member_key = (entry.user_id, entry.username, entry.display_name) - department_totals[department]["users"][member_key] = department_totals[department]["users"].get(member_key, 0) + 1 + members = department_totals[department]["users"] + members.setdefault(member_key, {"count": 0, "latest": entry.entered_at}) + members[member_key]["count"] += 1 + if entry.entered_at > members[member_key]["latest"]: + members[member_key]["latest"] = entry.entered_at for day in trend: trend[day]["users"] = len({entry.user_id for entry in entries if entry.entered_at.date().isoformat() == day}) users = [ @@ -4945,10 +5012,11 @@ def admin_usage_stats(_: AdminUser = Depends(require_super_admin), db: Session = "username": username, "display_name": display_name, "department_name": department_name, - "count": count, + "count": values["count"], + "latest_at": values["latest"].isoformat(), } - for (user_id, username, display_name, department_name), count in sorted( - user_totals.items(), key=lambda item: (-item[1], item[0][2]) + for (user_id, username, display_name, department_name), values in sorted( + user_totals.items(), key=lambda item: (-item[1]["count"], item[0][2]) ) ] departments = [ @@ -4962,10 +5030,11 @@ def admin_usage_stats(_: AdminUser = Depends(require_super_admin), db: Session = "user_id": user_id, "username": username, "display_name": display_name, - "count": count, + "count": member_values["count"], + "latest_at": member_values["latest"].isoformat(), } - for (user_id, username, display_name), count in sorted( - values["users"].items(), key=lambda item: (-item[1], item[0][2]) + for (user_id, username, display_name), member_values in sorted( + values["users"].items(), key=lambda item: (-item[1]["count"], item[0][2]) ) ], } diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 73ce355..7701db6 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -82,9 +82,11 @@ def test_admin_usage_entry_is_deduplicated_and_visible_in_stats(): assert stats.status_code == 200 operator = next(item for item in stats.json()["data"]["users"] if item["username"] == "operator") assert operator["count"] >= 1 + assert operator["latest_at"] department = next(item for item in stats.json()["data"]["departments"] if operator["department_name"] == item["name"]) assert department["user_count"] >= 1 - assert any(member["username"] == "operator" for member in department["members"]) + member = next(member for member in department["members"] if member["username"] == "operator") + assert member["latest_at"] == operator["latest_at"] def test_operator_cannot_access_super_admin_apis(): @@ -1289,7 +1291,8 @@ def test_approved_answer_appears_as_pending_refund_with_detail(monkeypatch): with TestClient(app) as client: owner_token = answer_owner_token(client) login = client.post("/api/answer/auth/taobao-login", json={"taobao_id": taobao_id, "owner_token": owner_token}) - token = login.json()["data"]["token"] + answer_session = login.json()["data"] + token = answer_session["token"] created = client.post( "/api/answer/me/submissions", data={"token": token, "owner_token": owner_token}, @@ -1334,6 +1337,10 @@ def test_approved_answer_appears_as_pending_refund_with_detail(monkeypatch): assert detail.status_code == 200 assert len(detail.json()["data"]["completion_screenshots"]) == 1 assert len(detail.json()["data"]["payment_screenshots"]) == 1 + assert { + question["user_id"] + for question in detail.json()["data"]["selected_questions"] + } == {answer_session["user_id"]} refunded = client.post( payout["refund_endpoint"], data={"payer_account_id": payer_id}, @@ -1590,6 +1597,13 @@ def test_ask_batches_survive_reservation_timeout_and_parent_completes_only_after assert completed_parent["status_label"] == "已完成" assert completed_parent["completed_count"] == 2 assert len(completed_parent["claimed_tasks"]) == 2 + child_detail = client.get(completed_parent["claimed_tasks"][0]["detail_endpoint"]) + assert child_detail.status_code == 200 + assert child_detail.json()["data"]["public_token"] == completed_parent["public_token"] + assert { + question["user_id"] + for question in child_detail.json()["data"]["selected_questions"] + } == {session["user_id"]} parent_detail = client.get(completed_parent["detail_endpoint"]) assert parent_detail.status_code == 200 assert { diff --git a/backend/tests/test_refund_records.py b/backend/tests/test_refund_records.py index 261f193..d3acc40 100644 --- a/backend/tests/test_refund_records.py +++ b/backend/tests/test_refund_records.py @@ -32,6 +32,23 @@ def test_answer_confirmation_snapshot_survives_changes_and_deletion(setup): assert client.get("/api/admin/refund-records").json()["data"]["items"][0] == row +def test_refund_snapshot_is_idempotent_when_callback_wins_race(setup): + db, _, owner, _, task = setup + payer = db.scalar(select(RefundPaymentAccount)) + + first = main._snapshot_refund( + db, task, "answer_submission", owner, payer.name, "ALI-RACE-001" + ) + db.commit() + second = main._snapshot_refund( + db, task, "answer_submission", owner, payer.name, "ALI-RACE-001" + ) + db.commit() + + assert second.id == first.id + assert db.scalars(select(RefundRecord)).all() == [first] + + @pytest.mark.parametrize("batched", [True, False]) def test_ask_confirmation_creates_record(setup, batched): db, client, owner, person, _ = setup diff --git a/frontend/src/TaskBoardPage.jsx b/frontend/src/TaskBoardPage.jsx index f9e5f08..1694577 100644 --- a/frontend/src/TaskBoardPage.jsx +++ b/frontend/src/TaskBoardPage.jsx @@ -79,8 +79,9 @@ export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, a const [linkCopied, setLinkCopied] = useState(false); const isAskTask = task.task_type === "ask" || task.task_type_label === "提问任务"; - const taskLink = isAskTask - ? `${window.location.origin}/ask/${task.public_token}` + const askTaskToken = task.public_token || task.task_id; + const taskLink = isAskTask && askTaskToken + ? `${window.location.origin}/ask/${encodeURIComponent(askTaskToken)}` : ""; const proofScreenshots = [ ...(task.question_screenshots || []), diff --git a/frontend/src/admin.css b/frontend/src/admin.css index 456fec5..84654f9 100644 --- a/frontend/src/admin.css +++ b/frontend/src/admin.css @@ -790,16 +790,21 @@ background: #fff; } .usage-member-heading { - display: flex; - justify-content: space-between; + display: grid; + grid-template-columns: minmax(0, 1fr) 180px 80px; + gap: 16px; padding: 10px 0 4px; color: #8b9db3; font-size: 12px; } +.usage-member-heading span:nth-child(2), +.usage-member-heading span:nth-child(3) { + text-align: right; +} .usage-user-row { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) 180px 80px; align-items: center; - justify-content: space-between; gap: 16px; padding: 11px 0; border-top: 1px solid #edf2f7; @@ -815,6 +820,13 @@ .usage-user-row b { color: #1764e8; font-size: 13px; + text-align: right; +} +.usage-user-row time { + color: #7187a3; + font-size: 12px; + text-align: right; + white-space: nowrap; } @media (max-width: 700px) { .usage-summary-grid { @@ -833,6 +845,18 @@ flex-direction: column; gap: 6px; } + .usage-member-heading { + display: none; + } + .usage-user-row { + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 12px; + } + .usage-user-row time { + grid-column: 1 / -1; + grid-row: 2; + text-align: left; + } .usage-chart { gap: 2px; } diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 217ce1c..fe63542 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2928,10 +2928,11 @@ function UsagePanel({ data }) { {isExpanded && (