Compare commits

..

3 Commits

Author SHA1 Message Date
guke 07556d3d2b Merge branch 'main' into codex/withdraw-review-management 2026-07-27 16:03:23 +08:00
unknown f2a7f5c064 合并:解决提现审核管理测试冲突 2026-07-27 16:00:20 +08:00
unknown c0e6801c69 功能:完善提现审核与用户风险管理 2026-07-26 11:36:35 +08:00
6 changed files with 8 additions and 281 deletions
+7 -53
View File
@@ -63,40 +63,6 @@ def _device_marketing_name(model: str | None) -> str | None:
return _DEVICE_MARKETING_NAMES.get(model.strip().upper())
def _attach_comparison_order_status(db: Session, items: list[ComparisonRecord]) -> None:
"""按 C 端既有口径给比价记录批量补充是否真实下单。"""
user_ids = {item.user_id for item in items if item.user_id is not None}
shop_names = {item.store_name for item in items if item.store_name}
ordered_pairs: set[tuple[int, str]] = set()
if user_ids and shop_names:
rows = db.execute(
select(SavingsRecord.user_id, SavingsRecord.shop_name)
.where(
SavingsRecord.user_id.in_(user_ids),
SavingsRecord.source == "compare",
SavingsRecord.shop_name.in_(shop_names),
)
.distinct()
).all()
ordered_pairs = {
(row.user_id, row.shop_name)
for row in rows
if row.shop_name is not None
}
for item in items:
item.ordered = bool(
item.user_id is not None
and item.store_name
and (item.user_id, item.store_name) in ordered_pairs
)
def _attach_comparison_device_details(items: list[ComparisonRecord]) -> None:
"""给比价记录补充可读机型名,同时保留原始设备编码。"""
for item in items:
item.device_model_name = _device_marketing_name(item.device_model)
def _attach_feedback_device_details(db: Session, feedbacks: list[Feedback]) -> None:
"""按同一用户、同一设备编码及提交时间补齐厂商和 ROM 大版本。"""
candidates = [
@@ -378,8 +344,6 @@ def list_comparison_records(
limit=limit, cursor=cursor,
)
_attach_user_info(db, items)
_attach_comparison_order_status(db, items)
_attach_comparison_device_details(items)
# 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。
# ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。
rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items])
@@ -526,8 +490,6 @@ def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | Non
rec = db.get(ComparisonRecord, record_id)
if rec is not None:
_attach_user_info(db, [rec])
_attach_comparison_order_status(db, [rec])
_attach_comparison_device_details([rec])
return rec
@@ -986,7 +948,7 @@ def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder
def withdraw_summary(db: Session, *, source: str | None = None) -> dict:
"""提现审核台顶部统计。金额单位:分;口径与列表的 source 筛选一致"""
"""提现审核台顶部统计。金额单位:分。"""
summary_stmt = select(
WithdrawOrder.status,
func.count(WithdrawOrder.id),
@@ -1015,29 +977,21 @@ def withdraw_summary(db: Session, *, source: str | None = None) -> dict:
stmt = stmt.where(WithdrawOrder.source == source)
return db.execute(stmt).scalar_one()
today_success_amount_stmt = select(
today_amount_stmt = select(
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)
).where(
WithdrawOrder.status == "success",
WithdrawOrder.updated_at >= today_start,
)
if source:
today_success_amount_stmt = today_success_amount_stmt.where(
WithdrawOrder.source == source
)
today_success_amount = db.execute(today_success_amount_stmt).scalar_one()
def _status_count(status: str) -> int:
return by_status.get(status, {}).get("count", 0)
today_amount_stmt = today_amount_stmt.where(WithdrawOrder.source == source)
today_success_amount = db.execute(today_amount_stmt).scalar_one()
return {
"reviewing_count": _status_count("reviewing"),
"reviewing_count": by_status.get("reviewing", {}).get("count", 0),
"reviewing_amount_cents": by_status.get("reviewing", {}).get("amount_cents", 0),
"pending_count": _status_count("pending"),
"success_count": _status_count("success"),
"rejected_count": _status_count("rejected"),
"failed_count": _status_count("failed"),
"total_count": sum(item["count"] for item in by_status.values()),
"pending_count": by_status.get("pending", {}).get("count", 0),
"failed_count": by_status.get("failed", {}).get("count", 0),
"today_success_count": _today_count("success"),
"today_success_amount_cents": int(today_success_amount),
"today_rejected_count": _today_count("rejected"),
+1 -3
View File
@@ -29,7 +29,6 @@ class AdminComparisonListItem(BaseModel):
source_price_cents: int | None = None
best_price_cents: int | None = None
saved_amount_cents: int | None = None
ordered: bool = False
# debug 概览
total_ms: int | None = None
step_count: int | None = None
@@ -40,10 +39,8 @@ class AdminComparisonListItem(BaseModel):
# 本次比价 LLM 总成本(元,按当时价冻结);旧记录/未回填为 None → 前端「成本」列回退估算。见 services/llm_cost.py。
llm_cost_yuan: float | None = None
device_model: str | None = None
device_model_name: str | None = None
rom_vendor: str | None = None
rom_name: str | None = None
rom_version: int | None = None
android_version: str | None = None
app_version: str | None = None
ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上
@@ -87,6 +84,7 @@ class AdminComparisonDetail(AdminComparisonListItem):
skipped_dish_names: list = []
# 全量环境
device_manufacturer: str | None = None
rom_version: int | None = None
android_sdk: int | None = None
app_version_code: int | None = None
source_app_version: str | None = None
-3
View File
@@ -69,10 +69,7 @@ class WithdrawSummaryOut(BaseModel):
reviewing_count: int
reviewing_amount_cents: int
pending_count: int
success_count: int
rejected_count: int
failed_count: int
total_count: int
today_success_count: int
today_success_amount_cents: int
today_rejected_count: int
-4
View File
@@ -85,10 +85,6 @@ class ComparisonResultIn(BaseModel):
status: str | None = None
# 门店打烊原因(price 为 None 时带): 与 status="store_closed" 等价的更早信号, 一并落库供前端兜底判打烊。
store_closed: str | None = None
# 该平台缺菜(漏菜)数量(pricebot 冗余进 comparison_results 行): 有价但少买了菜时 >0。
# 记录页三平台网格据此逐格标"缺少 X 个菜品"(网格只拿 comparison_results, 拿不到 platform_results)。
# 必须显式声明: 落库走 model_dump(), 不声明会被 pydantic 静默丢弃 → 记录页拿不到数量。
skipped_dish_count: int | None = None
class ComparisonRecordIn(BaseModel):
-201
View File
@@ -504,207 +504,6 @@ def test_invite_withdraw_detail_contains_invitee_first_actions(
assert filtered.json()["invite_overview"]["invite_total"] == 0
def test_withdraw_summary_counts_match_status_lists_by_source(
admin_client: TestClient, admin_token: str
) -> None:
"""每个审核页签的汇总数必须等于相同 source、status 的列表总数。"""
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(
db, phone="13800009003", register_channel="sms"
).id
for index, status in enumerate(
("reviewing", "pending", "success", "rejected", "failed"), start=1
):
db.add(
WithdrawOrder(
user_id=uid,
out_bill_no=f"summary{uid}invite{index}",
amount_cents=index * 10,
status=status,
source="invite_cash",
)
)
db.add(
WithdrawOrder(
user_id=uid,
out_bill_no=f"summary{uid}coinfailed",
amount_cents=99,
status="failed",
source="coin_cash",
)
)
db.commit()
finally:
db.close()
status_fields = {
"reviewing": "reviewing_count",
"pending": "pending_count",
"success": "success_count",
"rejected": "rejected_count",
"failed": "failed_count",
}
for source in ("coin_cash", "invite_cash"):
summary_response = admin_client.get(
"/admin/api/withdraws/summary",
params={"source": source},
headers=_auth(admin_token),
)
assert summary_response.status_code == 200, summary_response.text
summary = summary_response.json()
for status, field in status_fields.items():
list_response = admin_client.get(
"/admin/api/withdraws",
params={"source": source, "status": status, "limit": 1},
headers=_auth(admin_token),
)
assert list_response.status_code == 200, list_response.text
assert summary[field] == list_response.json()["total"]
reviewing_response = admin_client.get(
"/admin/api/withdraws",
params={"source": source, "status": "reviewing", "limit": 100},
headers=_auth(admin_token),
)
assert reviewing_response.status_code == 200, reviewing_response.text
reviewing_body = reviewing_response.json()
assert reviewing_body["total"] == len(reviewing_body["items"])
assert summary["reviewing_amount_cents"] == sum(
item["amount_cents"] for item in reviewing_body["items"]
)
all_response = admin_client.get(
"/admin/api/withdraws",
params={"source": source, "limit": 1},
headers=_auth(admin_token),
)
assert all_response.status_code == 200, all_response.text
assert summary["total_count"] == all_response.json()["total"]
assert summary["total_count"] == sum(
summary[field] for field in status_fields.values()
)
assert admin_client.get(
"/admin/api/withdraws/summary",
params={"source": "unknown"},
headers=_auth(admin_token),
).status_code == 422
def test_comparison_records_show_readable_device_and_rom_version(
admin_client: TestClient, admin_token: str
) -> None:
db = SessionLocal()
try:
user = user_repo.upsert_user_for_login(
db, phone="13800009041", register_channel="sms"
)
record = ComparisonRecord(
user_id=user.id,
trace_id="comparison-readable-device",
status="success",
device_model="V2166BA",
device_manufacturer="vivo",
rom_vendor="vivo",
rom_name="OriginOS",
rom_version=4,
android_version="14",
)
db.add(record)
db.commit()
record_id = record.id
user_id = user.id
finally:
db.close()
response = admin_client.get(
"/admin/api/comparison-records",
params={"user_id": user_id},
headers=_auth(admin_token),
)
assert response.status_code == 200, response.text
item = response.json()["items"][0]
assert item["device_model"] == "V2166BA"
assert item["device_model_name"] == "vivo Y77e"
assert item["rom_name"] == "OriginOS"
assert item["rom_version"] == 4
detail = admin_client.get(
f"/admin/api/comparison-records/{record_id}",
headers=_auth(admin_token),
)
assert detail.status_code == 200, detail.text
assert detail.json()["device_model_name"] == "vivo Y77e"
assert detail.json()["rom_version"] == 4
def test_comparison_records_show_real_order_status(
admin_client: TestClient, admin_token: str
) -> None:
db = SessionLocal()
try:
user = user_repo.upsert_user_for_login(
db, phone="13800009042", register_channel="sms"
)
ordered_record = ComparisonRecord(
user_id=user.id,
trace_id="comparison-ordered-shop",
status="success",
store_name="真实下单店",
)
unordered_record = ComparisonRecord(
user_id=user.id,
trace_id="comparison-demo-order-shop",
status="success",
store_name="演示下单店",
)
db.add_all([ordered_record, unordered_record])
db.flush()
db.add_all(
[
SavingsRecord(
user_id=user.id,
order_amount_cents=1800,
saved_amount_cents=300,
shop_name="真实下单店",
source="compare",
client_event_id="admin-comparison-real-order",
),
SavingsRecord(
user_id=user.id,
order_amount_cents=1500,
saved_amount_cents=200,
shop_name="演示下单店",
source="demo",
),
]
)
db.commit()
ordered_record_id = ordered_record.id
user_id = user.id
finally:
db.close()
response = admin_client.get(
"/admin/api/comparison-records",
params={"user_id": user_id},
headers=_auth(admin_token),
)
assert response.status_code == 200, response.text
by_trace = {item["trace_id"]: item for item in response.json()["items"]}
assert by_trace["comparison-ordered-shop"]["ordered"] is True
assert by_trace["comparison-demo-order-shop"]["ordered"] is False
detail = admin_client.get(
f"/admin/api/comparison-records/{ordered_record_id}",
headers=_auth(admin_token),
)
assert detail.status_code == 200, detail.text
assert detail.json()["ordered"] is True
def test_ad_coin_audit_full_count_truncate_and_only_mismatch(
admin_client: TestClient, admin_token: str
) -> None:
@@ -1,17 +0,0 @@
"""ComparisonResultIn 保留逐平台 skipped_dish_count(落库/回读用)。
记录页三平台网格要按逐平台缺菜数标"缺少 X 个菜品"; pricebot 现把 skipped_dish_count 冗余进
comparison_results 行。上报边界必须显式声明该字段, 否则 model_dump() 会静默丢弃(POST 路径),
记录页拿不到。纯 schema, 不碰 DB。
"""
from app.schemas.compare_record import ComparisonResultIn
def test_comparison_result_in_preserves_skipped_dish_count():
r = ComparisonResultIn(platform_id="jd_waimai", price=25.0, skipped_dish_count=2)
assert r.model_dump().get("skipped_dish_count") == 2
def test_comparison_result_in_skipped_defaults_none():
r = ComparisonResultIn(platform_id="meituan", price=42.0, is_source=True)
assert r.model_dump().get("skipped_dish_count") is None