修复支付是出现500报错的BUG,添加认领人ID,添加使用统计最近上线时间

This commit is contained in:
db_huang 2026-11-04 11:09:59 +08:00
parent 858fc132ef
commit 00239b0431
6 changed files with 157 additions and 31 deletions

View File

@ -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])
)
],
}

View File

@ -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 {

View File

@ -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

View File

@ -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 || []),

View File

@ -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;
}

View File

@ -2928,10 +2928,11 @@ function UsagePanel({ data }) {
</button>
{isExpanded && (
<div className="usage-department-members">
<div className="usage-member-heading"><span>成员</span><span>使用次数</span></div>
<div className="usage-member-heading"><span>成员</span><span>最近使用</span><span>使用次数</span></div>
{department.members.map((member) => (
<div className="usage-user-row" key={`${department.name}-${member.user_id}-${member.username}`}>
<div><strong>{member.display_name}</strong><span>{member.username}</span></div>
<time>{formatDateTime(member.latest_at)}</time>
<b>{member.count} 次</b>
</div>
))}