Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a97faeb179 | |||
| f7f5a36250 | |||
| 8498144703 | |||
| bc2ed5de56 | |||
| b703bec7e4 | |||
| abd6329035 | |||
| 5a66c302cb | |||
| 09b9381d03 | |||
| 7419f35f4b | |||
| 9de73152ec | |||
| 9036bc5a08 | |||
| 1a61cb5a65 | |||
| 67ac2dcbbb | |||
| 08a49504fa | |||
| ab2de6ec79 | |||
| 84251770b4 | |||
| 2e91c9f72f | |||
| a69b7d777d | |||
| 0fc8521c3b |
@@ -0,0 +1,76 @@
|
||||
"""normalize granular comparison outcomes into terminal record statuses
|
||||
|
||||
Revision ID: comparison_below_min_success
|
||||
Revises: limit_policy_global_bundle
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "comparison_below_min_success"
|
||||
down_revision: str | Sequence[str] | None = "limit_policy_global_bundle"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
_FAILED_OUTCOMES = (
|
||||
"store_closed",
|
||||
"store_not_found",
|
||||
"items_not_found",
|
||||
"no_delivery",
|
||||
"unsupported",
|
||||
)
|
||||
|
||||
|
||||
def _comparison_record() -> sa.TableClause:
|
||||
return sa.table(
|
||||
"comparison_record",
|
||||
sa.column("status", sa.String(16)),
|
||||
sa.column("fail_reason", sa.String(256)),
|
||||
sa.column("raw_payload", _JSON),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
comparison_record = _comparison_record()
|
||||
op.execute(
|
||||
comparison_record.update()
|
||||
.where(comparison_record.c.status == "below_minimum")
|
||||
.values(status="success", fail_reason=None)
|
||||
)
|
||||
op.execute(
|
||||
comparison_record.update()
|
||||
.where(comparison_record.c.status.in_(_FAILED_OUTCOMES))
|
||||
.values(status="failed")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
comparison_record = _comparison_record()
|
||||
# The write paths deliberately preserve the granular outcome. Restore only
|
||||
# rows that this change normalized, without touching ordinary successes.
|
||||
raw_outcome = sa.func.coalesce(
|
||||
comparison_record.c.raw_payload["record_status"].as_string(),
|
||||
comparison_record.c.raw_payload["status"].as_string(),
|
||||
)
|
||||
op.execute(
|
||||
comparison_record.update()
|
||||
.where(
|
||||
comparison_record.c.status == "success",
|
||||
raw_outcome == "below_minimum",
|
||||
)
|
||||
.values(status="below_minimum")
|
||||
)
|
||||
op.execute(
|
||||
comparison_record.update()
|
||||
.where(
|
||||
comparison_record.c.status == "failed",
|
||||
raw_outcome.in_(_FAILED_OUTCOMES),
|
||||
)
|
||||
.values(status=raw_outcome)
|
||||
)
|
||||
@@ -0,0 +1,211 @@
|
||||
"""correct DeepSeek V4 Flash token price and frozen historical costs
|
||||
|
||||
Revision ID: deepseek_v4_flash_price
|
||||
Revises: comparison_below_min_success
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "deepseek_v4_flash_price"
|
||||
down_revision: str | Sequence[str] | None = "comparison_below_min_success"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
_CONFIG_KEY = "llm_token_price"
|
||||
_MODEL = "deepseek-v4-flash"
|
||||
_OLD_INPUT_PRICE = 3.0
|
||||
_OLD_OUTPUT_PRICE = 15.0
|
||||
_NEW_INPUT_PRICE = 1.0
|
||||
_NEW_OUTPUT_PRICE = 2.0
|
||||
_CORRECTION_MARKER = "deepseek_v4_flash_price"
|
||||
_CONFIG_MARKER_KEY = "migration_deepseek_v4_flash_price"
|
||||
|
||||
|
||||
def _decode_object(value: Any) -> dict[str, Any] | None:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
return None
|
||||
|
||||
|
||||
def _model_tokens(calls: Any) -> tuple[int, int]:
|
||||
if not isinstance(calls, list):
|
||||
return 0, 0
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
for call in calls:
|
||||
if not isinstance(call, dict) or call.get("error") or call.get("model") != _MODEL:
|
||||
continue
|
||||
usage = _decode_object(call.get("usage"))
|
||||
if usage is None:
|
||||
continue
|
||||
input_tokens += int(usage.get("prompt_tokens") or 0)
|
||||
output_tokens += int(usage.get("completion_tokens") or 0)
|
||||
return input_tokens, output_tokens
|
||||
|
||||
|
||||
def _app_config_table() -> sa.TableClause:
|
||||
return sa.table(
|
||||
"app_config",
|
||||
sa.column("key", sa.String(64)),
|
||||
sa.column("value", _JSON),
|
||||
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
|
||||
def _comparison_table() -> sa.TableClause:
|
||||
return sa.table(
|
||||
"comparison_record",
|
||||
sa.column("id", sa.Integer),
|
||||
sa.column("llm_calls", _JSON),
|
||||
sa.column("llm_cost_yuan", sa.Float),
|
||||
sa.column("llm_price_snapshot", _JSON),
|
||||
)
|
||||
|
||||
|
||||
def _update_config(conn, *, upgrade: bool) -> None:
|
||||
table = _app_config_table()
|
||||
row = conn.execute(
|
||||
sa.select(table.c.value).where(table.c.key == _CONFIG_KEY)
|
||||
).mappings().first()
|
||||
if row is None or not isinstance(row["value"], dict):
|
||||
return
|
||||
|
||||
config = dict(row["value"])
|
||||
per_model = dict(config.get("per_model") or {})
|
||||
current = per_model.get(_MODEL)
|
||||
if upgrade:
|
||||
# Preserve an operator's explicit model price. The production defect is specifically
|
||||
# the missing key falling through to the generic 3/15 price.
|
||||
if current is not None:
|
||||
return
|
||||
per_model[_MODEL] = {
|
||||
"input_per_1m": _NEW_INPUT_PRICE,
|
||||
"output_per_1m": _NEW_OUTPUT_PRICE,
|
||||
}
|
||||
conn.execute(table.insert().values(key=_CONFIG_MARKER_KEY, value=True))
|
||||
else:
|
||||
marker_exists = conn.execute(
|
||||
sa.select(table.c.key).where(table.c.key == _CONFIG_MARKER_KEY)
|
||||
).scalar_one_or_none()
|
||||
if marker_exists is None:
|
||||
return
|
||||
if current != {
|
||||
"input_per_1m": _NEW_INPUT_PRICE,
|
||||
"output_per_1m": _NEW_OUTPUT_PRICE,
|
||||
}:
|
||||
conn.execute(table.delete().where(table.c.key == _CONFIG_MARKER_KEY))
|
||||
return
|
||||
per_model.pop(_MODEL, None)
|
||||
conn.execute(table.delete().where(table.c.key == _CONFIG_MARKER_KEY))
|
||||
config["per_model"] = per_model
|
||||
conn.execute(
|
||||
table.update()
|
||||
.where(table.c.key == _CONFIG_KEY)
|
||||
# 这是对历史误配置的追溯修正,不是从部署时刻开始的新价格。保留原 updated_at,
|
||||
# 否则缺失成本回填会把部署前的记录全部排除。
|
||||
.values(value=config)
|
||||
)
|
||||
|
||||
|
||||
def _correct_frozen_costs(conn, *, upgrade: bool) -> None:
|
||||
table = _comparison_table()
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
table.c.id,
|
||||
table.c.llm_calls,
|
||||
table.c.llm_cost_yuan,
|
||||
table.c.llm_price_snapshot,
|
||||
).where(
|
||||
table.c.llm_cost_yuan.is_not(None),
|
||||
table.c.llm_price_snapshot.is_not(None),
|
||||
)
|
||||
).mappings()
|
||||
|
||||
for row in rows:
|
||||
snapshot = row["llm_price_snapshot"]
|
||||
if not isinstance(snapshot, dict):
|
||||
continue
|
||||
prices = snapshot.get("prices")
|
||||
if not isinstance(prices, dict):
|
||||
continue
|
||||
model_price = prices.get(_MODEL)
|
||||
if not isinstance(model_price, dict):
|
||||
continue
|
||||
|
||||
if upgrade:
|
||||
if not (
|
||||
model_price.get("_source") == "default"
|
||||
and model_price.get("input_per_1m") == _OLD_INPUT_PRICE
|
||||
and model_price.get("output_per_1m") == _OLD_OUTPUT_PRICE
|
||||
):
|
||||
continue
|
||||
elif snapshot.get("pricing_correction") != _CORRECTION_MARKER:
|
||||
continue
|
||||
|
||||
input_tokens, output_tokens = _model_tokens(row["llm_calls"])
|
||||
if input_tokens == 0 and output_tokens == 0:
|
||||
continue
|
||||
if upgrade:
|
||||
delta = (
|
||||
input_tokens / 1_000_000 * (_OLD_INPUT_PRICE - _NEW_INPUT_PRICE)
|
||||
+ output_tokens / 1_000_000 * (_OLD_OUTPUT_PRICE - _NEW_OUTPUT_PRICE)
|
||||
)
|
||||
corrected_price = {
|
||||
"input_per_1m": _NEW_INPUT_PRICE,
|
||||
"output_per_1m": _NEW_OUTPUT_PRICE,
|
||||
"_source": "per_model",
|
||||
}
|
||||
snapshot["pricing_correction"] = _CORRECTION_MARKER
|
||||
new_cost = max(0.0, float(row["llm_cost_yuan"]) - delta)
|
||||
else:
|
||||
delta = (
|
||||
input_tokens / 1_000_000 * (_OLD_INPUT_PRICE - _NEW_INPUT_PRICE)
|
||||
+ output_tokens / 1_000_000 * (_OLD_OUTPUT_PRICE - _NEW_OUTPUT_PRICE)
|
||||
)
|
||||
corrected_price = {
|
||||
"input_per_1m": _OLD_INPUT_PRICE,
|
||||
"output_per_1m": _OLD_OUTPUT_PRICE,
|
||||
"_source": "default",
|
||||
}
|
||||
snapshot.pop("pricing_correction", None)
|
||||
new_cost = float(row["llm_cost_yuan"]) + delta
|
||||
|
||||
updated_prices = dict(prices)
|
||||
updated_prices[_MODEL] = corrected_price
|
||||
updated_snapshot = dict(snapshot)
|
||||
updated_snapshot["prices"] = updated_prices
|
||||
conn.execute(
|
||||
table.update()
|
||||
.where(table.c.id == row["id"])
|
||||
.values(
|
||||
llm_cost_yuan=round(new_cost, 6),
|
||||
llm_price_snapshot=updated_snapshot,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
_update_config(conn, upgrade=True)
|
||||
_correct_frozen_costs(conn, upgrade=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
_correct_frozen_costs(conn, upgrade=False)
|
||||
_update_config(conn, upgrade=False)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""admin 比价记录展示口径:把「流程跑完、外部原因致结果缺失」的记录判为成功。
|
||||
|
||||
#209 / C 端落库把 store_not_found / items_not_found / store_closed / no_delivery /
|
||||
unsupported 归一化成 status='failed';admin 排查视角改按记录级原始业务结局
|
||||
(raw_payload.record_status)重判——这些「外部缺失」算成功(附缺失提示),
|
||||
只有纯技术故障才是失败。仅 admin 用,不碰 C 端 / #209 落库。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.models.comparison import ComparisonRecord
|
||||
|
||||
# 记录级原始业务结局里算「成功(流程跑完)」的集合;其余(failed / 未知)才是技术故障。
|
||||
ADMIN_SUCCESS_OUTCOMES = frozenset({
|
||||
"success", "below_minimum", "store_closed",
|
||||
"store_not_found", "items_not_found", "no_delivery", "unsupported",
|
||||
})
|
||||
# 有缺失的成功 → 感叹号 hover 提示;success 本身无提示。
|
||||
OUTCOME_HINTS = {
|
||||
"below_minimum": "未满起送",
|
||||
"store_closed": "门店打烊",
|
||||
"store_not_found": "未找到店",
|
||||
"items_not_found": "未找到菜",
|
||||
"no_delivery": "单点不配送",
|
||||
"unsupported": "平台·场景不支持",
|
||||
}
|
||||
|
||||
|
||||
def derive_admin_outcome(raw_payload: dict | None, status: str) -> tuple[str, str | None]:
|
||||
"""(admin_status, outcome_hint)。列表 Python 层派生(raw_payload 已随 ORM 加载)。"""
|
||||
if status in ("cancelled", "running"):
|
||||
return status, None
|
||||
raw = raw_payload or {}
|
||||
# 原始结局:优先 raw.record_status,其次 raw.status,兜底 status 列
|
||||
# (兼容迁移未覆盖、细分值残留在 status 列的老记录;与下方 SQL 口径一致)。
|
||||
original = raw.get("record_status") or raw.get("status") or status
|
||||
if original in ADMIN_SUCCESS_OUTCOMES:
|
||||
return "success", OUTCOME_HINTS.get(original)
|
||||
return "failed", None
|
||||
|
||||
|
||||
def _original_expr():
|
||||
"""SQL:原始结局 = coalesce(nullif(raw.record_status,''), nullif(raw.status,''), status 列)。
|
||||
nullif('') 让空串与 Python `or` 口径一致地跳过(as_string 跨方言,#209 迁移已验证)。"""
|
||||
return func.coalesce(
|
||||
func.nullif(ComparisonRecord.raw_payload["record_status"].as_string(), ""),
|
||||
func.nullif(ComparisonRecord.raw_payload["status"].as_string(), ""),
|
||||
ComparisonRecord.status,
|
||||
)
|
||||
|
||||
|
||||
def admin_success_sql():
|
||||
"""SQL 层 admin 成功判定(概览 / 大盘的 case / where 共用)。
|
||||
|
||||
排除 cancelled / running(生命周期态,不看结局);其余按原始结局 ∈ S。
|
||||
coalesce 兜底 status 列 → original 永非 NULL、且兼容 status 列残留的细分值。
|
||||
"""
|
||||
return ComparisonRecord.status.notin_(("cancelled", "running")) & _original_expr().in_(
|
||||
tuple(ADMIN_SUCCESS_OUTCOMES)
|
||||
)
|
||||
@@ -166,6 +166,18 @@ def _session_to_row(
|
||||
point_stats: dict | None = None,
|
||||
) -> dict:
|
||||
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
|
||||
# 中途退出可能发生在第一张券产生终态之前,此时没有逐券事件。
|
||||
# 明确返回 0/0,让前端区分「退出前无单券结果」与其它状态的埋点缺失。
|
||||
if point_stats is not None:
|
||||
point_success_count = point_stats["succeeded"]
|
||||
point_total_count = point_stats["tried"]
|
||||
elif r.status == "abandoned":
|
||||
point_success_count = 0
|
||||
point_total_count = 0
|
||||
else:
|
||||
point_success_count = None
|
||||
point_total_count = None
|
||||
point_event_count = point_stats["events"] if point_stats is not None else 0
|
||||
return {
|
||||
"id": r.id,
|
||||
"trace_id": r.trace_id,
|
||||
@@ -182,8 +194,9 @@ def _session_to_row(
|
||||
"app_env": r.app_env,
|
||||
"started_at": r.started_at,
|
||||
"claimed_count": r.claimed_count,
|
||||
"point_success_count": point_stats["succeeded"] if point_stats else None,
|
||||
"point_total_count": point_stats["tried"] if point_stats else None,
|
||||
"point_success_count": point_success_count,
|
||||
"point_total_count": point_total_count,
|
||||
"point_event_count": point_event_count,
|
||||
"trace_url": r.trace_url,
|
||||
"ad_revenue_yuan": ad_revenue_yuan,
|
||||
}
|
||||
@@ -194,21 +207,24 @@ def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict[
|
||||
if not trace_ids:
|
||||
return {}
|
||||
succeeded = func.sum(case((CouponClaimEvent.status.in_(_SLOT_OK), 1), else_=0))
|
||||
tried = func.sum(case((CouponClaimEvent.status.in_(_SLOT_TRIED), 1), else_=0))
|
||||
rows = db.execute(
|
||||
select(
|
||||
CouponClaimEvent.trace_id,
|
||||
succeeded.label("succeeded"),
|
||||
func.count().label("tried"),
|
||||
)
|
||||
.where(
|
||||
CouponClaimEvent.trace_id.in_(trace_ids),
|
||||
CouponClaimEvent.status.in_(_SLOT_TRIED),
|
||||
tried.label("tried"),
|
||||
func.count().label("events"),
|
||||
)
|
||||
.where(CouponClaimEvent.trace_id.in_(trace_ids))
|
||||
.group_by(CouponClaimEvent.trace_id)
|
||||
).all()
|
||||
return {
|
||||
trace_id: {"succeeded": int(success_count or 0), "tried": int(tried or 0)}
|
||||
for trace_id, success_count, tried in rows
|
||||
trace_id: {
|
||||
"succeeded": int(success_count or 0),
|
||||
"tried": int(tried_count or 0),
|
||||
"events": int(event_count or 0),
|
||||
}
|
||||
for trace_id, success_count, tried_count, event_count in rows
|
||||
if trace_id is not None
|
||||
}
|
||||
|
||||
@@ -400,12 +416,15 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
|
||||
total = db.execute(
|
||||
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
|
||||
).scalar_one()
|
||||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows])
|
||||
trace_ids = [r.trace_id for r in rows]
|
||||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, trace_ids)
|
||||
point_stats_map = _point_scores_by_trace(db, trace_ids)
|
||||
return {
|
||||
"items": [
|
||||
_session_to_row(
|
||||
r,
|
||||
ad_revenue_yuan=rev_map.get(r.trace_id, 0.0),
|
||||
point_stats=point_stats_map.get(r.trace_id),
|
||||
)
|
||||
for r in rows
|
||||
],
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import Select, and_, asc, case, desc, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.comparison_outcome import admin_success_sql, derive_admin_outcome
|
||||
from app.core import rewards
|
||||
from app.core.config import settings
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
@@ -39,6 +40,16 @@ from app.repositories import activity, ad_ecpm
|
||||
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
|
||||
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
|
||||
|
||||
|
||||
def _comparison_status_condition(status: str):
|
||||
"""列表/概览「状态」筛选:success/failed 按 admin 口径(与显示/统计一致);
|
||||
cancelled/running 按 status 列生命周期。"""
|
||||
if status == "success":
|
||||
return admin_success_sql()
|
||||
if status == "failed":
|
||||
return ~admin_success_sql() & ComparisonRecord.status.notin_(("cancelled", "running"))
|
||||
return ComparisonRecord.status == status
|
||||
|
||||
# 信息流点位场景 → 金币记录「赚取途径」展示名;NULL/未知 = 历史未分类。
|
||||
_FEED_SCENE_LABEL = {
|
||||
"comparison": "比价信息流",
|
||||
@@ -334,7 +345,7 @@ def _comparison_conditions(
|
||||
)
|
||||
)
|
||||
if status:
|
||||
conditions.append(ComparisonRecord.status == status)
|
||||
conditions.append(_comparison_status_condition(status))
|
||||
if business_type:
|
||||
conditions.append(ComparisonRecord.business_type == business_type)
|
||||
if store:
|
||||
@@ -343,10 +354,10 @@ def _comparison_conditions(
|
||||
conditions.append(ComparisonRecord.product_names.like(f"%{product}%"))
|
||||
beijing = ZoneInfo("Asia/Shanghai")
|
||||
if date_from is not None:
|
||||
start_utc = datetime.combine(date_from, time.min, tzinfo=beijing).astimezone(timezone.utc)
|
||||
start_utc = datetime.combine(date_from, time.min, tzinfo=beijing).astimezone(UTC)
|
||||
conditions.append(ComparisonRecord.created_at >= start_utc)
|
||||
if date_to is not None:
|
||||
end_utc = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=beijing).astimezone(timezone.utc)
|
||||
end_utc = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=beijing).astimezone(UTC)
|
||||
conditions.append(ComparisonRecord.created_at < end_utc)
|
||||
return conditions
|
||||
|
||||
@@ -386,6 +397,7 @@ def list_comparison_records(
|
||||
rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items])
|
||||
for it in items:
|
||||
it.ad_revenue_yuan = rev.get(it.trace_id, 0.0)
|
||||
it.admin_status, it.outcome_hint = derive_admin_outcome(it.raw_payload, it.status)
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
@@ -409,8 +421,8 @@ def _round_duration_ms(value) -> int | None:
|
||||
return int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles: tuple[float, ...]):
|
||||
"""PostgreSQL 耗时聚合语句;每种状态只返回一行。"""
|
||||
def _comparison_duration_aggregate_stmt(conditions: list, status_filter, quantiles: tuple[float, ...]):
|
||||
"""PostgreSQL 耗时聚合语句;status_filter 为已构造的口径条件表达式,每口径只返回一行。"""
|
||||
return select(
|
||||
func.avg(ComparisonRecord.total_ms),
|
||||
*(
|
||||
@@ -419,7 +431,7 @@ def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles
|
||||
),
|
||||
).where(
|
||||
*conditions,
|
||||
ComparisonRecord.status == status,
|
||||
status_filter,
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
)
|
||||
|
||||
@@ -428,13 +440,13 @@ def _comparison_duration_aggregates(
|
||||
db: Session,
|
||||
*,
|
||||
conditions: list,
|
||||
status: str,
|
||||
status_filter,
|
||||
quantiles: tuple[float, ...],
|
||||
) -> list[int | None]:
|
||||
"""返回平均值和各分位数;生产 PG 在数据库内聚合,SQLite 仅作测试回退。"""
|
||||
if db.bind is not None and db.bind.dialect.name == "postgresql":
|
||||
row = db.execute(
|
||||
_comparison_duration_aggregate_stmt(conditions, status, quantiles)
|
||||
_comparison_duration_aggregate_stmt(conditions, status_filter, quantiles)
|
||||
).one()
|
||||
return [_round_duration_ms(value) for value in row]
|
||||
|
||||
@@ -444,7 +456,7 @@ def _comparison_duration_aggregates(
|
||||
select(ComparisonRecord.total_ms)
|
||||
.where(
|
||||
*conditions,
|
||||
ComparisonRecord.status == status,
|
||||
status_filter,
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
)
|
||||
.order_by(ComparisonRecord.total_ms)
|
||||
@@ -471,16 +483,14 @@ def comparison_records_summary(
|
||||
user_id=user_id, phone=phone, status=status, business_type=business_type,
|
||||
store=store, product=product, date_from=date_from, date_to=date_to,
|
||||
)
|
||||
_success = admin_success_sql()
|
||||
row = db.execute(
|
||||
select(
|
||||
func.count(ComparisonRecord.id),
|
||||
func.sum(case((ComparisonRecord.status.in_(("success", "failed")), 1), else_=0)),
|
||||
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
|
||||
func.sum(case((_success | (ComparisonRecord.status == "failed"), 1), else_=0)),
|
||||
func.sum(case((_success, 1), else_=0)),
|
||||
func.avg(ComparisonRecord.llm_cost_yuan),
|
||||
func.sum(case((
|
||||
(ComparisonRecord.status == "success")
|
||||
& (ComparisonRecord.saved_amount_cents > 0), 1
|
||||
), else_=0)),
|
||||
func.sum(case((_success & (ComparisonRecord.saved_amount_cents > 0), 1), else_=0)),
|
||||
func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)),
|
||||
).where(*conditions)
|
||||
).one()
|
||||
@@ -492,13 +502,13 @@ def comparison_records_summary(
|
||||
success_duration_stats = _comparison_duration_aggregates(
|
||||
db,
|
||||
conditions=conditions,
|
||||
status="success",
|
||||
status_filter=admin_success_sql(),
|
||||
quantiles=(0.05, 0.5, 0.95, 0.99),
|
||||
)
|
||||
cancelled_duration_stats = _comparison_duration_aggregates(
|
||||
db,
|
||||
conditions=conditions,
|
||||
status="cancelled",
|
||||
status_filter=(ComparisonRecord.status == "cancelled"),
|
||||
quantiles=(0.05, 0.5, 0.95),
|
||||
)
|
||||
success_rate_denominator = started - cancelled
|
||||
@@ -523,12 +533,13 @@ def comparison_records_summary(
|
||||
|
||||
|
||||
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
|
||||
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
|
||||
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname + admin 口径瞬态)。"""
|
||||
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])
|
||||
rec.admin_status, rec.outcome_hint = derive_admin_outcome(rec.raw_payload, rec.status)
|
||||
return rec
|
||||
|
||||
|
||||
@@ -557,8 +568,8 @@ def _heartbeat_seconds_ago(last: datetime | None) -> int | None:
|
||||
if last is None:
|
||||
return None
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
return int((datetime.now(timezone.utc) - last).total_seconds())
|
||||
last = last.replace(tzinfo=UTC)
|
||||
return int((datetime.now(UTC) - last).total_seconds())
|
||||
|
||||
|
||||
def _device_model_from_id(device_id: str) -> str:
|
||||
@@ -611,7 +622,7 @@ def _attach_device_user_info(db: Session, devices: list[DeviceLiveness]) -> None
|
||||
def _liveness_cutoff() -> datetime:
|
||||
"""掉线判定分界:此刻 - HEARTBEAT_TIMEOUT_MINUTES。心跳早于它 = 掉线(同 list_overdue 口径)。"""
|
||||
timeout_min = max(1, int(settings.HEARTBEAT_TIMEOUT_MINUTES))
|
||||
return datetime.now(timezone.utc) - timedelta(minutes=timeout_min)
|
||||
return datetime.now(UTC) - timedelta(minutes=timeout_min)
|
||||
|
||||
|
||||
def list_device_liveness(
|
||||
@@ -787,11 +798,11 @@ def list_all_withdraw_orders(
|
||||
stmt = stmt.where(date_col <= _as_utc(date_to))
|
||||
|
||||
# tz-aware:列为 timestamptz,比较绝对时刻、与 DB 会话时区无关(同 _as_utc / stats.py)
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
today_start = (
|
||||
datetime.now(ZoneInfo("Asia/Shanghai"))
|
||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
.astimezone(timezone.utc)
|
||||
.astimezone(UTC)
|
||||
)
|
||||
if quick_filter == "abnormal":
|
||||
stmt = stmt.where(
|
||||
@@ -838,8 +849,8 @@ def _as_utc(value: datetime) -> datetime:
|
||||
生产会话非 UTC 时筛选边界会整体偏移——故统一 tz-aware(与 stats.py / withdraw_summary 一致)。
|
||||
无时区入参按 UTC 解释。"""
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def withdraw_list_enrichment(
|
||||
@@ -1004,7 +1015,7 @@ def withdraw_summary(db: Session, *, source: str | None = None) -> dict:
|
||||
today_start = (
|
||||
datetime.now(ZoneInfo("Asia/Shanghai"))
|
||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
.astimezone(timezone.utc)
|
||||
.astimezone(UTC)
|
||||
)
|
||||
|
||||
def _today_count(status: str) -> int:
|
||||
@@ -1179,8 +1190,8 @@ def withdraw_risk_flags(
|
||||
if user and user.status != "active":
|
||||
flags.append(f"账号状态:{user.status}")
|
||||
if user and user.created_at:
|
||||
created_at = user.created_at.replace(tzinfo=timezone.utc) if user.created_at.tzinfo is None else user.created_at
|
||||
if datetime.now(timezone.utc) - created_at < timedelta(hours=24):
|
||||
created_at = user.created_at.replace(tzinfo=UTC) if user.created_at.tzinfo is None else user.created_at
|
||||
if datetime.now(UTC) - created_at < timedelta(hours=24):
|
||||
flags.append("新注册用户")
|
||||
# 历史异常提现拆「拒绝」「失败」两类(口径不同:拒绝=人工驳回退款,失败=打款失败退款)
|
||||
rejected_n = sum(1 for item in recent_withdraws if item.status == "rejected")
|
||||
@@ -1391,7 +1402,7 @@ def _cn_wall_to_utc(dt: datetime) -> datetime:
|
||||
"""coin_transaction 存的是北京 wall-clock(naive,见 wallet.grant_coins「存北京 wall-clock」),转成 UTC naive,
|
||||
与广告表(func.now() UTC)统一 —— 让本函数按同一绝对时刻排序、且前端 apiTime(把无时区时间当 UTC 再 +8 展示)
|
||||
口径一致;否则签到会比实际多显示 8 小时(北京时间又被 +8)。"""
|
||||
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _coin_record_sort_key(row: dict) -> datetime:
|
||||
|
||||
@@ -12,6 +12,7 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.comparison_outcome import admin_success_sql
|
||||
from app.admin.repositories.coupon_data import _percentile
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
@@ -257,13 +258,14 @@ def dashboard_overview(
|
||||
ComparisonRecord.created_at >= start_local,
|
||||
ComparisonRecord.created_at < end_local,
|
||||
)
|
||||
_period_success = admin_success_sql()
|
||||
period_comparison_stats = db.execute(
|
||||
select(
|
||||
func.count(ComparisonRecord.id),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(ComparisonRecord.status.in_(("success", "failed")), 1),
|
||||
(_period_success | (ComparisonRecord.status == "failed"), 1),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
@@ -276,7 +278,7 @@ def dashboard_overview(
|
||||
0,
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
|
||||
func.sum(case((_period_success, 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0),
|
||||
@@ -566,6 +568,10 @@ def dashboard_overview(
|
||||
)
|
||||
).all()
|
||||
coupon_started = len(period_coupon_sessions)
|
||||
coupon_abandoned = sum(s.status == "abandoned" for s in period_coupon_sessions)
|
||||
# 用户主动中途退出不代表领券流程失败,不进入整场成功率样本。
|
||||
# started / failed 仍留在分母:前者是尚未形成终态的流失,后者是实际执行失败。
|
||||
coupon_success_denominator = coupon_started - coupon_abandoned
|
||||
coupon_completed_elapsed = sorted(
|
||||
s.elapsed_ms
|
||||
for s in period_coupon_sessions
|
||||
@@ -731,9 +737,13 @@ def dashboard_overview(
|
||||
},
|
||||
"coupon": {
|
||||
"started": coupon_started,
|
||||
"abandoned": coupon_abandoned,
|
||||
"success_denominator": coupon_success_denominator,
|
||||
"all_success": coupon_all_success,
|
||||
"success_rate": (
|
||||
round(coupon_all_success / coupon_started, 4) if coupon_started else None
|
||||
round(coupon_all_success / coupon_success_denominator, 4)
|
||||
if coupon_success_denominator
|
||||
else None
|
||||
),
|
||||
"point_success": coupon_point_success,
|
||||
"points_per_session": coupon_points_per_session,
|
||||
|
||||
@@ -35,7 +35,7 @@ def list_comparison_records(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
|
||||
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
|
||||
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled|running)$")] = None,
|
||||
business_type: Annotated[str | None, Query()] = None,
|
||||
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
|
||||
product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None,
|
||||
@@ -66,7 +66,7 @@ def comparison_records_summary(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
|
||||
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
|
||||
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled|running)$")] = None,
|
||||
business_type: Annotated[str | None, Query()] = None,
|
||||
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
|
||||
product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None,
|
||||
|
||||
@@ -13,14 +13,19 @@ class AdminComparisonListItem(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
# 软鉴权/匿名下帧0 建行时 user_id 可能暂缺(见 models.comparison 注释);admin 全看含孤儿行,故可空。
|
||||
user_id: int | None = None
|
||||
phone: str | None = None # join User 瞬态(非 DB 列)
|
||||
nickname: str | None = None # join User 瞬态
|
||||
business_type: str
|
||||
trace_id: str
|
||||
# admin 是 debug 工具,无条件下发 trace_url(不看 user.debug_trace_enabled)
|
||||
trace_url: str | None = None
|
||||
status: str
|
||||
status: str # success / failed / cancelled / running;旧细分值由前端兼容映射
|
||||
# admin 展示口径(见 repositories/comparison_outcome):成功含「跑完但外部缺失」,
|
||||
# 纯技术故障才 failed;outcome_hint 非空=有缺失,前端标感叹号。
|
||||
admin_status: str = "success"
|
||||
outcome_hint: str | None = None
|
||||
information: str | None = None
|
||||
store_name: str | None = None
|
||||
product_names: str | None = None # 下单商品名派生串(顿号分隔;「商品」列展示 + 商品搜索)
|
||||
@@ -83,6 +88,7 @@ class AdminComparisonDetail(AdminComparisonListItem):
|
||||
skipped_dish_count: int | None = None
|
||||
device_id: str | None = None
|
||||
items: list = []
|
||||
platforms: list = [] # pricebot 渲染就绪的逐平台卡片模型(status=ok/业务失败细分)
|
||||
comparison_results: list = [] # 逐平台对比(价格/rank/coupon/打烊...)
|
||||
skipped_dish_names: list = []
|
||||
# 全量环境
|
||||
|
||||
@@ -79,10 +79,16 @@ class CouponDataRow(BaseModel):
|
||||
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
|
||||
claimed_count: int | None = None
|
||||
point_success_count: int | None = Field(
|
||||
None, description="本次成功单券数(success+already_claimed);无逐券事件为空"
|
||||
None,
|
||||
description="本次成功单券数(success+already_claimed);中途退出且无逐券结果为0,其它无事件为空",
|
||||
)
|
||||
point_total_count: int | None = Field(
|
||||
None, description="本次尝试单券数(success+already_claimed+failed,不含 skipped);无逐券事件为空"
|
||||
None,
|
||||
description="本次尝试单券数(success+already_claimed+failed,不含 skipped);中途退出且无逐券结果为0,其它无事件为空",
|
||||
)
|
||||
point_event_count: int = Field(
|
||||
0,
|
||||
description="本次全部逐券事件数(含 skipped);用于区分无有效计分事件与完全无事件",
|
||||
)
|
||||
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
|
||||
ad_revenue_yuan: float = Field(
|
||||
|
||||
@@ -70,6 +70,9 @@ class DashboardPeriodCoupon(BaseModel):
|
||||
成功口径 success+already_claimed(与「我的」页累计领券一致)。"""
|
||||
|
||||
started: int = 0
|
||||
# 用户主动中途退出,不计入整场成功率分母。
|
||||
abandoned: int = 0
|
||||
success_denominator: int = 0
|
||||
# 全部领成功的次数:completed 且当日该设备全部点位成功
|
||||
all_success: int = 0
|
||||
success_rate: float | None = None
|
||||
|
||||
@@ -21,7 +21,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -32,6 +31,7 @@ from app.api.deps import DbSession, OptionalUser
|
||||
from app.core.config import settings
|
||||
from app.core.logging import trace_id_ctx
|
||||
from app.core.pricebot_client import get_pricebot_client
|
||||
from app.core.trace_ids import new_trace_id
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import comparison as crud_compare
|
||||
@@ -142,7 +142,7 @@ async def _forward(
|
||||
trace_id = meta.get("trace_id")
|
||||
minted = False
|
||||
if not trace_id:
|
||||
trace_id = str(uuid.uuid4())
|
||||
trace_id = new_trace_id()
|
||||
meta["trace_id"] = trace_id
|
||||
raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes
|
||||
minted = True
|
||||
|
||||
@@ -17,9 +17,11 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import limit_policy
|
||||
from app.core.trace_ids import new_trace_id
|
||||
from app.repositories import comparison as crud_compare
|
||||
from app.repositories import risk as risk_repo
|
||||
from app.schemas.compare_record import (
|
||||
CompareQuotaOut,
|
||||
CompareStartReserveIn,
|
||||
CompareStartReserveOut,
|
||||
CompareStatsOut,
|
||||
@@ -53,6 +55,10 @@ def reserve_compare_start(
|
||||
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用")
|
||||
# trace_id 统一由服务端签发(客户端不带时):预占额度本就是任务的第一个请求,
|
||||
# 签发与建 running 行合一,此后 Phase1/Phase2/记录/前端日志全链用同一个 id。
|
||||
# 客户端带了则沿用——老客户端兼容 + 同 trace 重试幂等(reserve_daily_start 按 trace_id 去重)。
|
||||
trace_id = payload.trace_id or new_trace_id()
|
||||
try:
|
||||
policy = limit_policy.resolve(
|
||||
db,
|
||||
@@ -63,7 +69,7 @@ def reserve_compare_start(
|
||||
rec, used = crud_compare.reserve_daily_start(
|
||||
db,
|
||||
user_id=user.id,
|
||||
trace_id=payload.trace_id,
|
||||
trace_id=trace_id,
|
||||
business_type=payload.business_type,
|
||||
device_id=payload.device_id,
|
||||
limit=policy.limit,
|
||||
@@ -94,6 +100,7 @@ def reserve_compare_start(
|
||||
limit=policy.limit,
|
||||
used=used,
|
||||
remaining=max(policy.limit - used, 0) if policy.limit is not None else None,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -149,6 +156,25 @@ def stats(user: CurrentUser, db: DbSession) -> CompareStatsOut:
|
||||
return CompareStatsOut(compare_count=count, discovered_saved_cents=saved)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/quota",
|
||||
response_model=CompareQuotaOut,
|
||||
summary="查询今天的比价次数配额(只读,不预占)",
|
||||
)
|
||||
def get_compare_quota(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
device_id: str | None = Query(default=None),
|
||||
) -> CompareQuotaOut:
|
||||
"""按登录用户查今日比价配额,口径与 /compare/start 同源。
|
||||
used 按 user_id 计数;limit/reset_at 按 phone+device 解析(与 /start 一致,device 白名单能命中)。
|
||||
exhausted=true → 已达今日上限。客户端①④入口点击时前置查此,超限就地 toast 不跳转。"""
|
||||
policy = limit_policy.resolve(db, "compare.start.daily", phone=user.phone, device=device_id)
|
||||
used = crud_compare.get_daily_compare_used(db, user.id, reset_at=policy.reset_at)
|
||||
exhausted = policy.limit is not None and used >= policy.limit
|
||||
return CompareQuotaOut(exhausted=exhausted, used=used, limit=policy.limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/records",
|
||||
response_model=ComparisonRecordPage,
|
||||
|
||||
+31
-5
@@ -21,6 +21,7 @@ from fastapi.concurrency import run_in_threadpool
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core.config import settings
|
||||
from app.core.pricebot_client import get_pricebot_client
|
||||
from app.core.trace_ids import new_trace_id
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import coupon_state as coupon_repo
|
||||
@@ -30,6 +31,7 @@ from app.schemas.coupon_state import (
|
||||
CouponPromptShouldShowOut,
|
||||
CouponPromptShownIn,
|
||||
CouponSessionIn,
|
||||
CouponSessionOut,
|
||||
CouponStatsOut,
|
||||
)
|
||||
|
||||
@@ -175,6 +177,12 @@ async def coupon_step(
|
||||
)
|
||||
|
||||
resp_json = resp.json()
|
||||
# 每帧响应顶层回传本次任务 trace_id(对齐 compare _forward 的 setdefault):客户端任一帧
|
||||
# 都能从响应拿到全链 id。**只回显请求里带的、不 mint**——step 是循环接口,每帧签新 id
|
||||
# 会把一次任务打散;领券 trace_id 的唯一签发点在 /coupon/session (status=started)。
|
||||
# pricebot 响应顶层本无 trace_id(只有 trace_url),setdefault 不会覆盖任何上游值。
|
||||
if isinstance(resp_json, dict) and trace_id:
|
||||
resp_json.setdefault("trace_id", trace_id)
|
||||
|
||||
# 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。
|
||||
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
|
||||
@@ -204,15 +212,33 @@ async def coupon_step(
|
||||
return resp_json
|
||||
|
||||
|
||||
@router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)")
|
||||
def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
|
||||
@router.post(
|
||||
"/session",
|
||||
response_model=CouponSessionOut,
|
||||
summary="领券任务流水上报(admin 领券数据看板数据源;started 兼签发本轮 trace_id)",
|
||||
)
|
||||
def coupon_session(payload: CouponSessionIn, db: DbSession) -> CouponSessionOut:
|
||||
"""客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert
|
||||
到 coupon_session。不鉴权(同领券循环 MVP,按 device_id/trace_id);供 admin「领券数据」看板算
|
||||
发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。"""
|
||||
发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。
|
||||
|
||||
trace_id 统一由后端签发:started 不带 trace_id → 签发 uuid 并随响应返回,客户端全程用它
|
||||
(领券 step 循环 / 收尾上报 / 前端运行日志)。签发不依赖写库成功——写库失败照样返回 trace_id,
|
||||
后续收尾上报 upsert 会补建行。非 started 缺 trace_id 不签发(收尾没有 id 只能是异常调用,
|
||||
签发新 id 只会造出一行查不到发起信息的孤儿),不写库、trace_id=null 返回。
|
||||
"""
|
||||
trace_id = payload.trace_id or (
|
||||
new_trace_id() if payload.status == "started" else None
|
||||
)
|
||||
if trace_id is None:
|
||||
logger.warning(
|
||||
"coupon session missing trace_id for status=%s (skip write)", payload.status
|
||||
)
|
||||
return CouponSessionOut(ok=True, trace_id=None)
|
||||
try:
|
||||
coupon_repo.upsert_coupon_session(
|
||||
db,
|
||||
trace_id=payload.trace_id,
|
||||
trace_id=trace_id,
|
||||
device_id=payload.device_id,
|
||||
status=payload.status,
|
||||
started_at_ms=payload.started_at_ms,
|
||||
@@ -229,7 +255,7 @@ def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("coupon session write failed: %s", e)
|
||||
return {"ok": True}
|
||||
return CouponSessionOut(ok=True, trace_id=trace_id)
|
||||
|
||||
|
||||
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
|
||||
|
||||
@@ -54,11 +54,13 @@ def _app_status(db_status: str) -> str:
|
||||
|
||||
|
||||
def _record_out(fb) -> FeedbackRecordOut:
|
||||
images = fb.images or []
|
||||
return FeedbackRecordOut(
|
||||
id=fb.id,
|
||||
content=fb.content,
|
||||
scene=getattr(fb, "scene", None),
|
||||
images=fb.images or [],
|
||||
images=images,
|
||||
image_thumbnails=[media.feedback_thumbnail_url(url) for url in images],
|
||||
status=_app_status(fb.status),
|
||||
reject_reason=getattr(fb, "reject_reason", None),
|
||||
reward_coins=getattr(fb, "reward_coins", None),
|
||||
@@ -84,7 +86,7 @@ async def submit_feedback(
|
||||
device_model: str = Form(default=""),
|
||||
rom_name: str = Form(default=""),
|
||||
android_version: str = Form(default=""),
|
||||
images: list[UploadFile] = File(default=[]),
|
||||
images: list[UploadFile] = File(default=[]), # noqa: B008 - FastAPI dependency declaration
|
||||
) -> FeedbackOut:
|
||||
content = content.strip()
|
||||
contact = contact.strip()
|
||||
|
||||
@@ -227,7 +227,11 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
# 编辑框;set_value 不校验类型,嵌套 JSON 照存。
|
||||
"llm_token_price": {
|
||||
"default": {
|
||||
"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}},
|
||||
"per_model": {
|
||||
"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0},
|
||||
# DashScope 华北 2 公网调用原价;必须显式配置,不能落到 3/15 的未知模型兜底价。
|
||||
"deepseek-v4-flash": {"input_per_1m": 1.0, "output_per_1m": 2.0},
|
||||
},
|
||||
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
|
||||
"currency": "CNY", "unit": "per_1m_tokens",
|
||||
},
|
||||
|
||||
+90
-2
@@ -11,6 +11,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
@@ -18,8 +19,17 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("shagua.media")
|
||||
|
||||
_FEEDBACK_DIR = "feedback"
|
||||
_FEEDBACK_THUMB_DIR = "feedback_thumbs"
|
||||
_FEEDBACK_THUMB_MAX_PX = 256
|
||||
_FEEDBACK_THUMB_QUALITY = 78
|
||||
|
||||
|
||||
class MediaError(Exception):
|
||||
"""上传文件不合法(类型/大小)。调用方转 400。"""
|
||||
@@ -68,8 +78,86 @@ def save_avatar(user_id: int, data: bytes) -> str:
|
||||
|
||||
|
||||
def save_feedback_image(user_id: int, data: bytes) -> str:
|
||||
"""保存反馈截图,返回相对 URL(`/media/feedback/<file>`)。"""
|
||||
return _save_image("feedback", user_id, data)
|
||||
"""保存反馈截图并预生成历史页缩略图,返回原图相对 URL。"""
|
||||
url = _save_image(_FEEDBACK_DIR, user_id, data)
|
||||
# 缩略图失败不影响反馈受理;读取缩略图 URL 时会按需重试并回退原图。
|
||||
ensure_feedback_thumbnail(url)
|
||||
return url
|
||||
|
||||
|
||||
def feedback_thumbnail_url(image_url: str) -> str:
|
||||
"""把反馈原图 URL 映射成确定的缩略图 URL,不在 records 接口内做图片解码。
|
||||
|
||||
上传文件名由服务端生成且不会覆盖;旧数据在客户端真正请求可见图片时按需补图。
|
||||
"""
|
||||
paths = _feedback_thumbnail_paths(image_url)
|
||||
return paths[2] if paths is not None else image_url
|
||||
|
||||
|
||||
def _feedback_thumbnail_paths(image_url: str) -> tuple[Path, Path, str] | None:
|
||||
prefix = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_DIR}/"
|
||||
if not image_url.startswith(prefix):
|
||||
return None
|
||||
|
||||
filename = image_url.removeprefix(prefix)
|
||||
# 只接受当前目录下的单个文件名,避免数据库脏数据造成路径穿越。
|
||||
if not filename or Path(filename).name != filename:
|
||||
return None
|
||||
|
||||
source = _media_dir(_FEEDBACK_DIR) / filename
|
||||
thumb_name = f"{Path(filename).stem}.jpg"
|
||||
thumb = _media_dir(_FEEDBACK_THUMB_DIR) / thumb_name
|
||||
thumb_url = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_THUMB_DIR}/{thumb_name}"
|
||||
return source, thumb, thumb_url
|
||||
|
||||
|
||||
def ensure_feedback_thumbnail(image_url: str) -> Path | None:
|
||||
"""确保缩略图存在并返回文件;生成失败时回退原图,供动态缩略图路由使用。"""
|
||||
paths = _feedback_thumbnail_paths(image_url)
|
||||
if paths is None:
|
||||
return None
|
||||
source, thumb, _ = paths
|
||||
if thumb.is_file():
|
||||
return thumb
|
||||
if not source.is_file():
|
||||
return None
|
||||
|
||||
temp = thumb.with_name(f".{thumb.name}.{secrets.token_hex(4)}.tmp")
|
||||
try:
|
||||
with Image.open(source) as opened:
|
||||
image = ImageOps.exif_transpose(opened)
|
||||
image.thumbnail(
|
||||
(_FEEDBACK_THUMB_MAX_PX, _FEEDBACK_THUMB_MAX_PX),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
if image.mode != "RGB":
|
||||
image = image.convert("RGB")
|
||||
image.save(
|
||||
temp,
|
||||
format="JPEG",
|
||||
quality=_FEEDBACK_THUMB_QUALITY,
|
||||
optimize=True,
|
||||
)
|
||||
os.replace(temp, thumb)
|
||||
return thumb
|
||||
except (Image.DecompressionBombError, OSError, ValueError):
|
||||
logger.warning("生成反馈缩略图失败: %s", source, exc_info=True)
|
||||
return source
|
||||
finally:
|
||||
temp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def feedback_thumbnail_file(filename: str) -> Path | None:
|
||||
"""由缩略图文件名找到原反馈图并按需生成,非法/不存在返回 None。"""
|
||||
if not filename or Path(filename).name != filename or Path(filename).suffix.lower() != ".jpg":
|
||||
return None
|
||||
stem = Path(filename).stem
|
||||
for ext in (".jpg", ".png", ".webp"):
|
||||
original = _media_dir(_FEEDBACK_DIR) / f"{stem}{ext}"
|
||||
if original.is_file():
|
||||
original_url = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_DIR}/{original.name}"
|
||||
return ensure_feedback_thumbnail(original_url)
|
||||
return None
|
||||
|
||||
|
||||
def save_report_image(user_id: int, data: bytes) -> str:
|
||||
|
||||
+8
-4
@@ -234,13 +234,17 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i
|
||||
会铸天量金币;钳在这唯一入口,feed 与 reward_video 回退客户端 eCPM 的路径都护住,且阈值高于
|
||||
所有真实值,不影响正规发奖。
|
||||
|
||||
下限(2026-07):有真实正 eCPM 时单份至少 1 金币——低 eCPM 单份收益四舍五入成 0 时兜底为 1,
|
||||
避免用户看了广告却因数值太小被记 too_short 零发。eCPM 缺失/为 0/非法(没有真实广告价值)仍返 0,
|
||||
不凭空铸币、不破坏 ecpm_missing 语义;防刷上限仍由 AD_ECPM_MAX_FEN 钳顶把守。
|
||||
下限(2026-08,产品口径「看了就保底 1」):**任何输入**都至少 1 金币,与前端展示公式
|
||||
FeedRewardFormula.singleUnitCoin 完全对齐(那边注释:"无论 eCPM 是否为空、非法或非正数,
|
||||
单条广告最低都发 1 金币,不能出现 +0")。此前 eCPM 缺失/为 0 返 0,造成两端不一致:
|
||||
小球显示 +1、后端信息流记 too_short 零发;激励视频侧 "0" 字符串还是 truthy、绕过
|
||||
ecpm_missing 的 `if not ecpm_raw` 判定,落成 granted 0 币且白占当日额度/LT 计数。
|
||||
防刷影响:伪造 eCPM≤0 每天至多多骗 每日上限×1 金币(500 金币=0.05 元),量级可控;
|
||||
天价伪造仍由 AD_ECPM_MAX_FEN 钳顶把守。
|
||||
"""
|
||||
ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0)
|
||||
if ecpm_yuan <= 0:
|
||||
return 0
|
||||
return 1 # 保底:缺失/为 0/非法也发 1(镜像前端 validEcpmFen 判非法 → 直接返 1)
|
||||
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this)
|
||||
return max(1, round(yuan * COIN_PER_YUAN))
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""trace_id 签发(全后端唯一签发口径, 2026-07 起替代裸 uuid4)。
|
||||
|
||||
格式: "YYYYMMDD_HHMMSS_" + 12 位小写 hex 随机, 共 28 字符, 如
|
||||
20260731_162254_a1b2c3d4e5f6
|
||||
|
||||
Why 带时间前缀: pricebot 落盘目录名/trace_url 尾段**直接用 trace_id 本身**
|
||||
(见 pricebot app/utils/trace_ids.py), id/目录/URL 三者合一——此前 uuid trace_id
|
||||
与 {首帧时刻}_{uuid[:16]} 目录名是两套标识, URL 只有 pricebot 能拼、按前缀反查
|
||||
还有同秒歧义。时间用北京时间(CN_TZ)——不依赖各机器 TZ 配置, 与业务时区一致。
|
||||
|
||||
唯一性: 秒级前缀 + 48bit 随机(hex12), 同一秒内碰撞概率可忽略(比价/领券发起 QPS
|
||||
远低于产生生日碰撞的量级); pricebot 侧同秒多 trace 靠随机段区分(目录精确匹配、
|
||||
llm jsonl 按尾 12 分文件, 不做前缀模糊匹配)。
|
||||
|
||||
兼容: 三个签发点(compare/start、coupon/session started、compare.py _forward mint)
|
||||
统一走这里; 客户端自带 trace_id(老客户端/重试幂等)仍原样沿用——pricebot 对老
|
||||
uuid 格式保持既有目录/短标识行为, 两代 id 并行不冲突。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
|
||||
|
||||
def new_trace_id() -> str:
|
||||
"""签发一个自描述 trace_id: 北京时间前缀 + 12 位 hex 随机。"""
|
||||
return f"{datetime.now(CN_TZ):%Y%m%d_%H%M%S}_{uuid.uuid4().hex[:12]}"
|
||||
+32
-2
@@ -10,7 +10,7 @@ from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -44,6 +44,7 @@ from app.api.v1.tasks import router as tasks_router
|
||||
from app.api.v1.user import router as user_router
|
||||
from app.api.v1.wallet import router as wallet_router
|
||||
from app.api.v1.wxpay import router as wxpay_router
|
||||
from app.core import media
|
||||
from app.core.config import settings
|
||||
from app.core.cps_reconcile_worker import (
|
||||
start_cps_reconcile_worker,
|
||||
@@ -82,6 +83,19 @@ setup_logging(debug=settings.APP_DEBUG)
|
||||
logger = logging.getLogger("shagua.main")
|
||||
|
||||
|
||||
class FeedbackMediaStaticFiles(StaticFiles):
|
||||
"""反馈原图/缩略图文件名不可变,可长期缓存,避免列表反复回源。"""
|
||||
|
||||
async def get_response(self, path: str, scope):
|
||||
response = await super().get_response(path, scope)
|
||||
media_path = path.replace("\\", "/").lstrip("/")
|
||||
if response.status_code == 200 and media_path.startswith(
|
||||
("feedback/", "feedback_thumbs/")
|
||||
):
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
# 提示而非强制建表:生产用 alembic upgrade head,本地 dev 也建议先跑一次 migration。
|
||||
@@ -212,8 +226,24 @@ def download_apk() -> FileResponse:
|
||||
)
|
||||
|
||||
|
||||
@app.get(
|
||||
f"{settings.MEDIA_URL_PREFIX}/feedback_thumbs/{{filename}}",
|
||||
tags=["feedback"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
def feedback_thumbnail(filename: str) -> FileResponse:
|
||||
"""旧反馈图按首次可见请求补缩略图;新图上传时已预生成。"""
|
||||
path = media.feedback_thumbnail_file(filename)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="图片不存在")
|
||||
return FileResponse(
|
||||
path,
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||
)
|
||||
|
||||
|
||||
app.mount(
|
||||
settings.MEDIA_URL_PREFIX,
|
||||
StaticFiles(directory=str(_media_root)),
|
||||
FeedbackMediaStaticFiles(directory=str(_media_root)),
|
||||
name="media",
|
||||
)
|
||||
|
||||
@@ -97,7 +97,7 @@ class ComparisonRecord(Base):
|
||||
total_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
skipped_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# success(拿到有效对比)/ failed(出错或没采到目标价)
|
||||
# success(流程正常完成,含 below_minimum)/ failed(技术异常或未形成可比报价,含店铺打烊等)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="success")
|
||||
# done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…";
|
||||
# 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。
|
||||
|
||||
@@ -76,6 +76,30 @@ _BIZ_STATUS_PRIORITY = (
|
||||
)
|
||||
|
||||
|
||||
def _normalize_record_status(status: str | None) -> str | None:
|
||||
"""Map granular business outcomes onto the record lifecycle status.
|
||||
|
||||
``record_status`` describes the business outcome, while
|
||||
``comparison_record.status`` is also the completed-comparison flag used by
|
||||
milestones, stats and idempotent rewards. ``below_minimum`` is a completed
|
||||
success because the target cart produced a trustworthy conclusion. Other
|
||||
known target-side outcomes did not produce a comparable quote and belong
|
||||
to the failed record bucket. The granular outcome remains in ``raw_payload``
|
||||
and ``platform_results`` for result rendering.
|
||||
"""
|
||||
if status == "below_minimum":
|
||||
return "success"
|
||||
if status in {
|
||||
"store_closed",
|
||||
"store_not_found",
|
||||
"items_not_found",
|
||||
"no_delivery",
|
||||
"unsupported",
|
||||
}:
|
||||
return "failed"
|
||||
return status
|
||||
|
||||
|
||||
def _store_closed_text(reason: str | None) -> str:
|
||||
"""打烊/暂停营业/休息类 reason 常带脏店名元数据 → 只留结论,套简短模板。"""
|
||||
r = reason or ""
|
||||
@@ -164,9 +188,10 @@ def _derive(payload: ComparisonRecordIn) -> dict:
|
||||
|
||||
is_source_best = best.is_source if best is not None else None
|
||||
|
||||
# status:优先 pricebot record_status(区分 below_minimum/store_closed) → 客户端显式 status
|
||||
# → 兜底"非源且有价"=success/否则 failed。record_status 让"未满起送"不再塌缩成 failed。
|
||||
status = payload.record_status or payload.status
|
||||
# status:优先 pricebot record_status → 客户端显式 status → 兜底派生。
|
||||
# below_minimum 是已形成可信结论的正常完成态,记录级归 success;细分结局仍完整保留在
|
||||
# raw_payload/platform_results,供结果卡展示"未满起送"。
|
||||
status = _normalize_record_status(payload.record_status or payload.status)
|
||||
if status is None:
|
||||
has_valid_target = any(
|
||||
(not r.is_source) and r.price is not None for r in results
|
||||
@@ -201,7 +226,9 @@ def upsert_record(
|
||||
# 单源派生: 与 harvest_done 一致, payload 带 platforms 时从它派生(唯一真相源
|
||||
# _derive_from_platforms), 老客户端不带 platforms 时回退 _derive(从 comparison_results)。
|
||||
if payload.platforms:
|
||||
derived = _derive_from_platforms(payload.platforms, payload.record_status)
|
||||
derived = _derive_from_platforms(
|
||||
payload.platforms, payload.record_status or payload.status
|
||||
)
|
||||
# 对齐 _derive 返回键(#189 fail_reason): 两路径 fields 键集一致, 覆盖已有行时不残留旧值
|
||||
derived["fail_reason"] = (
|
||||
_derive_fail_display(payload.information, payload.platform_results or {})
|
||||
@@ -356,10 +383,10 @@ def _derive_from_results(
|
||||
"saved_amount_cents": saved_amount_cents,
|
||||
"is_source_best": best.get("is_source") if best else None,
|
||||
"store_name": (src_row or {}).get("store_name") or None,
|
||||
# 记录级结局: 优先用 pricebot 下发的 record_status(区分 below_minimum/store_closed,
|
||||
# 不再把"未满起送"塌缩成 failed → 记录页不再误报"网络开小差"); 旧 pricebot 未下发时
|
||||
# 回退老的 success/failed 二态派生, 向后兼容。
|
||||
"status": record_status or ("success" if has_valid_target else "failed"),
|
||||
# below_minimum 已完成到购物车并形成可信结论,记录级计 success;细分结局仍在
|
||||
# raw_payload/platform_results。旧 pricebot 未下发 record_status 时回退二态派生。
|
||||
"status": _normalize_record_status(record_status)
|
||||
or ("success" if has_valid_target else "failed"),
|
||||
}
|
||||
|
||||
|
||||
@@ -412,7 +439,8 @@ def _derive_from_platforms(
|
||||
"saved_amount_cents": saved_amount_cents,
|
||||
"is_source_best": (best.get("role") == "source") if best else None,
|
||||
"store_name": store_name or None,
|
||||
"status": record_status or ("success" if has_valid_target else "failed"),
|
||||
"status": _normalize_record_status(record_status)
|
||||
or ("success" if has_valid_target else "failed"),
|
||||
}
|
||||
|
||||
|
||||
@@ -520,6 +548,33 @@ def reserve_daily_start(
|
||||
return rec, int(used) + 1
|
||||
|
||||
|
||||
def get_daily_compare_used(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
reset_at: datetime | None = None,
|
||||
) -> int:
|
||||
"""今日(北京时间自然日)该用户已发起的比价次数。只读,不改任何数据。
|
||||
口径必须与 reserve_daily_start 完全一致(同 day_start/day_end/reset_at)。"""
|
||||
current = datetime.now(CN_TZ)
|
||||
if current.tzinfo is not None:
|
||||
current = current.astimezone(CN_TZ).replace(tzinfo=None)
|
||||
day_start = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if reset_at is not None:
|
||||
reset_start = reset_at
|
||||
if reset_start.tzinfo is not None:
|
||||
reset_start = reset_start.astimezone(CN_TZ).replace(tzinfo=None)
|
||||
day_start = max(day_start, reset_start)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
used = db.scalar(
|
||||
select(func.count(ComparisonRecord.id)).where(
|
||||
ComparisonRecord.user_id == user_id,
|
||||
ComparisonRecord.created_at >= day_start,
|
||||
ComparisonRecord.created_at < day_end,
|
||||
)
|
||||
) or 0
|
||||
return int(used)
|
||||
|
||||
|
||||
def harvest_running(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -584,7 +639,8 @@ def harvest_done(
|
||||
行不存在(理论上帧0已建;防御)则新建。"""
|
||||
results = done_params.get("comparison_results") or []
|
||||
# 展示模型统一数组(pricebot 新增, 每平台一行自带 status/is_best): 原样存, 记录页据此直渲染。
|
||||
# record_status: 记录级结局(success/below_minimum/store_closed/failed), 覆盖老二态派生。
|
||||
# record_status: 记录级业务结局(success/below_minimum/store_closed/failed)。其中
|
||||
# below_minimum 是正常完成态,持久化 status 归 success,原值仍随 done_params 落 raw_payload。
|
||||
platforms = done_params.get("platforms") or []
|
||||
record_status = done_params.get("record_status")
|
||||
# 单源派生: platforms(含 pricebot 权威 is_best)是唯一真相源, best_*/source_*/saved/status
|
||||
|
||||
@@ -25,6 +25,7 @@ from datetime import datetime, timedelta
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed
|
||||
@@ -54,7 +55,7 @@ _SEED_MAX_CENTS = 100000
|
||||
# 展示层随机(抽样/金额/时间/名字合成)仍每次重算,缓存只省查询;新记录最多晚 30s 进轮播,可接受。
|
||||
_REAL_ROWS_TTL_SECONDS = 30
|
||||
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
|
||||
_real_rows_cache: dict = {"at": None, "rows": None}
|
||||
_real_rows_cache: dict = {"at": None, "rows": None, "test_phones": None}
|
||||
|
||||
# ===== 用户标识脱敏(对齐 PRD) + 种子无真实昵称时的假名合成 =====
|
||||
# 脱敏规则(按字符数,中英文皆适用):有昵称→n≥5「首+***+末」、n=4「首+**+末」、n≤3「首+**」;
|
||||
@@ -198,10 +199,16 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
|
||||
返回纯元组(脱离 session),可安全跨请求复用。极端并发下偶尔多查一次(无锁、幂等),纯门面无副作用。
|
||||
"""
|
||||
now = datetime.now(CN_TZ)
|
||||
test_phones = tuple(sorted(settings.test_account_phones))
|
||||
cached, at = _real_rows_cache["rows"], _real_rows_cache["at"]
|
||||
if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS:
|
||||
if (
|
||||
cached is not None
|
||||
and at is not None
|
||||
and _real_rows_cache["test_phones"] == test_phones
|
||||
and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS
|
||||
):
|
||||
return cached
|
||||
rows = db.execute(
|
||||
stmt = (
|
||||
select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents, User.nickname)
|
||||
.join(User, User.id == ComparisonRecord.user_id)
|
||||
.where(
|
||||
@@ -211,9 +218,12 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
|
||||
)
|
||||
.order_by(ComparisonRecord.created_at.desc())
|
||||
.limit(_REAL_ROWS_FETCH_CAP)
|
||||
).all()
|
||||
)
|
||||
if test_phones:
|
||||
stmt = stmt.where(User.phone.not_in(test_phones))
|
||||
rows = db.execute(stmt).all()
|
||||
out = [(int(uid), int(sc), nick) for uid, sc, nick in rows]
|
||||
_real_rows_cache["rows"], _real_rows_cache["at"] = out, now
|
||||
_real_rows_cache.update(rows=out, at=now, test_phones=test_phones)
|
||||
return out
|
||||
|
||||
|
||||
@@ -340,7 +350,7 @@ def list_real_records(
|
||||
# pool: [(cluster_key, item)];cluster_key 供去连簇——真实=user_id、种子=各自唯一负数(互不聚簇)
|
||||
pool: list[tuple[int, dict]] = []
|
||||
if mode != "seed":
|
||||
rows = db.execute(
|
||||
stmt = (
|
||||
select(
|
||||
ComparisonRecord.user_id,
|
||||
ComparisonRecord.saved_amount_cents,
|
||||
@@ -355,7 +365,11 @@ def list_real_records(
|
||||
)
|
||||
.order_by(ComparisonRecord.created_at.desc())
|
||||
.limit(_REAL_BROWSE_CAP)
|
||||
).all()
|
||||
)
|
||||
test_phones = tuple(sorted(settings.test_account_phones))
|
||||
if test_phones:
|
||||
stmt = stmt.where(User.phone.not_in(test_phones))
|
||||
rows = db.execute(stmt).all()
|
||||
for uid, sc, nick, ca in rows:
|
||||
pool.append((
|
||||
int(uid),
|
||||
|
||||
@@ -111,8 +111,9 @@ class ComparisonRecordIn(BaseModel):
|
||||
# status/is_best/display/display_order,记录页据此直渲染。宽松 list[dict] 存(结构由
|
||||
# pricebot 定,server 只原样落库),前端读它、老记录空时回退 comparison_results。
|
||||
platforms: list[dict] = Field(default_factory=list)
|
||||
# 记录级结局(pricebot 下发): success/below_minimum/store_closed/failed。让"未满起送"不再
|
||||
# 被塌缩成 failed。_derive 优先用它、其次客户端 status、再兜底二态派生。
|
||||
# 记录级业务结局(pricebot 下发): success/below_minimum/store_closed/failed。
|
||||
# below_minimum 表示流程正常完成,持久化主状态归 success;store_closed/items_not_found 等
|
||||
# 已知无报价结局归 failed。原值仍随 raw_payload 落库,admin/记录页从 platform_results 展示细分结论。
|
||||
record_status: str | None = None
|
||||
# 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/
|
||||
# unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列),
|
||||
@@ -214,9 +215,13 @@ class ComparisonRecordCreatedOut(BaseModel):
|
||||
|
||||
|
||||
class CompareStartReserveIn(BaseModel):
|
||||
"""Reserve one authenticated comparison start before the agent begins."""
|
||||
"""Reserve one authenticated comparison start before the agent begins.
|
||||
|
||||
trace_id: str = Field(..., min_length=1, max_length=64)
|
||||
trace_id 可选:不带 = 请服务端签发(统一 trace_id 由后端下发,前端/SLS 日志/
|
||||
pricebot 全链用同一个 id);带 = 沿用客户端值(老客户端兼容 + 网络重试幂等)。
|
||||
"""
|
||||
|
||||
trace_id: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
business_type: str = Field(default="food", min_length=1, max_length=16)
|
||||
device_id: str | None = Field(default=None, max_length=64)
|
||||
|
||||
@@ -225,6 +230,9 @@ class CompareStartReserveOut(BaseModel):
|
||||
limit: int | None
|
||||
used: int
|
||||
remaining: int | None
|
||||
# 本次比价全链 trace_id(服务端签发的,或回显客户端带来的)。客户端必须以它为准,
|
||||
# 贯穿 Phase1/Phase2 step、比价记录、trace 收尾与前端运行日志上报。
|
||||
trace_id: str
|
||||
|
||||
|
||||
class CompareStatsOut(BaseModel):
|
||||
@@ -252,3 +260,11 @@ class MilestoneClaimResultOut(BaseModel):
|
||||
milestone: int = Field(..., description="本次领取的档位序号")
|
||||
coin_awarded: int = Field(..., description="本次发放金币")
|
||||
coin_balance: int = Field(..., description="领奖后金币余额")
|
||||
|
||||
|
||||
class CompareQuotaOut(BaseModel):
|
||||
"""今天的比价次数配额状态(只读)。"""
|
||||
|
||||
exhausted: bool = Field(..., description="是否已达今日上限,无法再比价")
|
||||
used: int = Field(..., description="今天已用次数")
|
||||
limit: int | None = Field(..., description="今天的配额上限(None=无限制)")
|
||||
|
||||
@@ -58,9 +58,13 @@ class CouponSessionIn(BaseModel):
|
||||
- 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)。
|
||||
- 收尾(completed/failed/abandoned):带 elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count。
|
||||
不鉴权(同领券循环 MVP,按 device_id/trace_id),user_id 登录态带上做留痕(可空)。
|
||||
|
||||
trace_id 可选:started 不带 = 请服务端签发本轮领券 trace_id(统一 trace_id 由后端下发,
|
||||
响应 CouponSessionOut.trace_id 返回,客户端全程用它);带 = 沿用客户端值(老客户端兼容)。
|
||||
非 started 缺 trace_id 不签发(防孤儿行),返回 trace_id=null 且不写库。
|
||||
"""
|
||||
|
||||
trace_id: str
|
||||
trace_id: str | None = None
|
||||
device_id: str
|
||||
status: str # started / completed / failed / abandoned
|
||||
started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis)
|
||||
@@ -74,3 +78,16 @@ class CouponSessionIn(BaseModel):
|
||||
platform_elapsed: dict[str, int] | None = None
|
||||
claimed_count: int | None = None
|
||||
trace_url: str | None = None
|
||||
|
||||
|
||||
class CouponSessionOut(BaseModel):
|
||||
"""POST /api/v1/coupon/session 响应。
|
||||
|
||||
trace_id = 本轮领券全链 id(服务端签发的,或回显客户端带来的);客户端以它为准贯穿
|
||||
/coupon/step 循环、收尾上报与前端运行日志。⚠️ 不能沿用旧的 dict[str, bool] 返回注解——
|
||||
FastAPI 会按注解校验响应,字符串 trace_id 过 bool 校验必炸,故显式建模。
|
||||
非 started 且缺 trace_id 时为 null(不签发防孤儿行)。
|
||||
"""
|
||||
|
||||
ok: bool = True
|
||||
trace_id: str | None = None
|
||||
|
||||
@@ -33,6 +33,8 @@ class FeedbackRecordOut(BaseModel):
|
||||
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
|
||||
scene: str | None = None
|
||||
images: list[str] = Field(default_factory=list)
|
||||
# 与 images 下标一一对应;生成失败时该项回退原图 URL,兼容历史数据。
|
||||
image_thumbnails: list[str] = Field(default_factory=list)
|
||||
status: str
|
||||
reject_reason: str | None = None
|
||||
reward_coins: int | None = None
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# 比价结果卡片 · 状态口径与交互参考
|
||||
|
||||
> **定位**:把「比价结果页每张平台卡片」的 11 种展示分类,逐一映射到后端状态与判定字段,供 **App 端做互动/文案调整时参考**。
|
||||
> **适用**:外卖比价结果页(`CompResultScreen`)。
|
||||
> **数据源**:基于三仓代码梳理(pricebot 判定 → app-server 透传/落库 → android 映射/渲染)。
|
||||
> **整理日期**:2026-07-31。代码行号会漂移,改动后以实际代码为准。
|
||||
|
||||
---
|
||||
|
||||
## 0. 快速须知(三个容易踩的坑)
|
||||
|
||||
1. **`ok` 不是 `success`**:逐平台卡片的成功态字符串是 **`ok`**(端侧 `rowToUi` 判 `row.status == "ok"`);`success/failed/cancelled/running` 是**记录级** `ComparisonRecord.status`(admin 后台用)。两层状态串不同,别混。
|
||||
2. **未知状态一律兜底成 `Failed`(#9)**:端侧映射用 `else -> CompareResultStatus.Failed`,所以后端 `unsupported`、`failed`、以及**任何端侧没显式处理的新状态**都会显示成 #9。pricebot 以后加状态,端侧不同步就会「消失」进 #9。
|
||||
3. **状态判定分三层来源**(见 §2):不是所有卡都由后端 status 决定,#10/#11 完全是端侧本地判定,后端零感知。
|
||||
|
||||
---
|
||||
|
||||
## 1. 主对照表:11 类卡片 ↔ 后端状态
|
||||
|
||||
映射函数:`CompareProgressRepository.kt` — 新路径 `rowToUi:339-375`、老路径 `summaryToUi:509-544`、端侧补齐 `onComparisonResultsReady:266-315`。
|
||||
|
||||
| # | 分类 | Android 枚举 | 后端逐平台 `status` | `Found` 内细分依据 | 状态来源 | 当前交互 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | 全网最低赢家 | `Found` | `ok`(有价) | `is_best && !has_dish_diff` → isLowest | pricebot | 去购买(黄) |
|
||||
| 2 | 没有可用优惠(原选择) | `Found` | `ok` | `role=source`/is_user_original + 无可用优惠 | pricebot | 查看(灰) |
|
||||
| 3 | 其他成功 | `Found` | `ok` | `role=target`,非 best | pricebot | 查看(灰) |
|
||||
| 4 | 少菜 / 部分缺 | `Found` + 提示条 | `ok` | `skipped_dish_names` 非空 / `skipped_dish_count>0` | pricebot | 随主卡 |
|
||||
| 5 | 未满起送 | `BelowMinimum` | `below_minimum` | — | pricebot | 查看(灰) |
|
||||
| 6 | 门店打烊 | `StoreClosed` | `store_closed` | — | pricebot | 查看(灰) |
|
||||
| 7 | 没有您点的商品 | `ItemsNotFound` | `items_not_found` | — | pricebot | 查看(灰) |
|
||||
| 8 | 无对应商家 | `StoreNotFound` | `store_not_found` | — | pricebot **或端侧补齐** | 查看(灰) |
|
||||
| 9 | 比价失败兜底 | `Failed` | `failed`/`unsupported`/**任何未知值**(`else`) | — | pricebot **或端侧**(整场没跑成) | 无 |
|
||||
| 10 | 未安装 | `NotInstalled` | **无**(后端零感知) | 端侧:该平台没装(`getPackageInfo`) | **端侧本地** | 去安装 |
|
||||
| 11 | 未选择 / 本次未比 | `NotComparedThisTime` | **无**(后端零感知) | 端侧:`selectedPlatformIds` 没勾这家 | **端侧本地** | 无(设计要「重试」,未接) |
|
||||
| 表外 | 单点不配送 | `NoDelivery` | `no_delivery` | — | pricebot | 查看(灰) |
|
||||
|
||||
> **表外提醒**:`no_delivery`(单点不配送/需搭配主食)在代码里是独立状态(复用未起送灰框样式),但**不在原 11 类分类表内**——做分类时需给它安个位置或明确并入 #5/#7。
|
||||
|
||||
---
|
||||
|
||||
## 2. 状态来源三分层(做互动最该记住的)
|
||||
|
||||
**A. pricebot 逐平台 `status` 直接决定(5/6/7/8/9 + no_delivery)**
|
||||
端侧只做「字符串 → 枚举」映射,互动依附后端判定。
|
||||
|
||||
**B. `ok` 成功态再靠 `platforms[]` 字段细分(1/2/3/4)**
|
||||
同一个 `Found`,靠 `is_best`、`has_dish_diff`、`role`、`skipped_dish_names` 分出四种。这些字段都是 pricebot 在 `platforms[]` 下发的。
|
||||
|
||||
**C. 纯端侧本地判定,后端完全无感知(10/11,以及 8 的补齐分支)**
|
||||
依据:**装机态**(`getPackageInfo`)+ **勾选态**(`selectedPlatformIds`)+ **整场是否跑成**(`failureReason`)。
|
||||
见 `onComparisonResultsReady:266-315`:走了选平台弹窗没勾 → #11;装了 App 没找到店 → #8;都没装 → #10(`marketPackage` 指向子 App 包名,引导下载子 App)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 菜品差异口径(有相似菜品 / 少菜 / 规格近似)
|
||||
|
||||
「有相似菜品」**不是独立卡**,而是 `status=Found`(有价)成功卡上、因「菜品对不齐」触发的黄色提醒模块 `MismatchBox`。
|
||||
|
||||
### 3.1 开关:`hasDishDiff`
|
||||
定义在 `CompareMockResults.kt:101-109`。`status=Found` 且满足**任一**即为 true:
|
||||
- `items` 里有 `similar=true` 的菜(近似替代)
|
||||
- `approxDishNames` 非空(规格近似)
|
||||
- `skippedDishNames` 非空(完全缺失)
|
||||
- `skippedDishCount > 0`(只有数量)
|
||||
|
||||
**权威值优先取后端** `platforms[].has_dish_diff`(端侧 `dishDiffOverride`,`CompareMockResults.kt:91-93`),老路径才端侧本地算。与后端 `is_best` 竞选同判据。
|
||||
|
||||
### 3.2 `MismatchBox` 的三种行(`CompRowCard.kt:236-250` 触发 / `460-516` 渲染)
|
||||
|
||||
| 展示文案 | 触发字段 | 语义 |
|
||||
|---|---|---|
|
||||
| **本平台没有「orig」 / 已换成近似「name」** | `items` 里 `similar=true && orig!=null`(similarPairs) | **已发生近似替换**(有 orig→name 替换对) |
|
||||
| 规格近似「X」,请下单前核对 | `approxDishNames` | 已近似,但提示核对 |
|
||||
| 本平台没有「X」,也无相似菜 | `skippedDishNames` | 完全缺失、无替代 |
|
||||
| 本平台缺少 N 个菜品,已按可购买商品计价 | `skippedDishCount>0` 且无菜名 | 只有数量时的兜底 |
|
||||
|
||||
> **易混近亲**:`complexSpecDishNames`(`CompareMockResults.kt:86-88`)走的是**另一个浅黄条** `ComplexSpecNotice`,文案「规格较复杂,请核对」,语义是"需人工核对规格、**不改价格有效性**",与"已换成近似"不是一回事。
|
||||
|
||||
### 3.3 连锁后果(为什么降级成"查看")
|
||||
`has_dish_diff=true` → `isLowest = is_best && !has_dish_diff` 必为 false → **踢出「全网最低」评定**,排到"仅供参考"分组,不给绶带/省¥红章/去购买黄按钮,只能灰"查看"。
|
||||
|
||||
### 3.4 边界
|
||||
- **vs #4「完全缺失」**:同一个 `MismatchBox` 的不同行,**可并存**(一个平台既有近似替换又有完全缺失)。它们是同一张成功卡里分段列出,不是两张卡。
|
||||
- **vs #7「该店没有您点的商品(items_not_found)」**:分水岭是**有没有比出价**。能凑出单、有价 → `Found` + MismatchBox;整单找不到菜、无价 → `items_not_found` 灰框无价卡。
|
||||
- **vs #1 赢家**:互斥。
|
||||
|
||||
---
|
||||
|
||||
## 4. 交互调整参考要点
|
||||
|
||||
1. **文案/提示权已在前端**:卡片副文案由端侧 `fullReasonForStatus(status)` 按状态本地生成(`CompareProgressRepository.kt:51-59`,注释明写"标题/文案决策收回前端")。后端逐平台原因字段 `notFoundReason`(= `platform_results[].reason`)**线上常空**,端侧兜底。→ **改提示文案、加互动引导、加动效,纯端侧就能做,不用改 pricebot。**
|
||||
2. **可空字段决定互动形态**:`price` 可空(6/7/8/9/10/11 无价 → `¥??` 或隐藏);`store_name` 可空(退化平台名)。`LOCATED_STATUSES`(`CompareProgressRepository.kt:46-47`:`ok/below_minimum/no_delivery/store_closed/items_not_found`)决定 header 用店名还是平台名。做点击/跳转互动前要判空。
|
||||
3. **#10/#11 端侧完全自主**:引导安装、引导勾选、重试这类互动无需后端配合,随便调。
|
||||
4. **区分"真没店"vs"没跑成"**:`failureReason` 非空(网络/上游 502)时,补齐卡统一兜底文案而非"未找到店"(`CompareProgressRepository.kt:104-107`,避免谎报)。"重试整场"的互动应挂这个场景,而不是 #8。
|
||||
5. **带动态数字的文案要后端补字段**:如"还差 ¥8 起送"——`platforms[]` 目前不下发起送差额,想要这种提示得让 pricebot 补字段。
|
||||
6. **#4 少菜 / 有相似菜品是成功卡上的模块,不是独立卡**:它们的互动依附主卡(1/2/3)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键代码位置索引
|
||||
|
||||
### Android(`shaguabijia-app-android`)
|
||||
| 关注点 | 文件:行 |
|
||||
|---|---|
|
||||
| 后端 status → 枚举映射(新/老路径) | `CompareProgressRepository.kt:339-375` / `509-544` |
|
||||
| 端侧文案本地生成 | `CompareProgressRepository.kt:51-59`(`fullReasonForStatus`) |
|
||||
| header 店名/平台名切换 | `CompareProgressRepository.kt:46-47`(`LOCATED_STATUSES`) |
|
||||
| 端侧补齐 #8/#10/#11(装机+勾选+failureReason) | `CompareProgressRepository.kt:266-315` |
|
||||
| 卡片 when 主分支 | `CompRowCard.kt:83-128` |
|
||||
| `MismatchBox`(菜品差异条) | `CompRowCard.kt:236-250`(触发)/ `460-516`(渲染) |
|
||||
| 未安装卡 / 本次未比卡 | `CompRowCard.kt:596-697` / `720-790` |
|
||||
| 按钮(去购买/查看/重试) | `CompRowCard.kt:1148-1180` / `1210` / `1214`;枚举 `GrayCardCta:132` |
|
||||
| 数据模型 `CompareResult` / `hasDishDiff` / `CompareDish` | `CompareMockResults.kt:34-110` / `101-109` / `116` |
|
||||
| 网络 DTO 解析 | `Protocol.kt:530-549` / `576-665` |
|
||||
| 生产结果页 / VM | `CompResultScreen.kt` / `CompResultViewModel.kt` |
|
||||
|
||||
### 后端(`shaguabijia-app-server`)
|
||||
| 关注点 | 文件:行 |
|
||||
|---|---|
|
||||
| 逐平台 `status` 枚举 | `app/schemas/compare_record.py:81-85` |
|
||||
| 记录模型 / `status` / `skipped_dish_names` / `raw_payload` | `app/models/comparison.py:39-169` / `:101` / `:121` / `:123` |
|
||||
| 落库与状态派生(running/done/abort) | `app/repositories/comparison.py`(`harvest_*`、`_derive*`) |
|
||||
| `has_dish_diff` 兜底判 is_best | `app/repositories/comparison.py:379` |
|
||||
| 记录级失败原因派生 | `app/repositories/comparison.py:118-127`(`_derive_fail_display`) |
|
||||
| admin 读取接口 | `app/admin/routers/comparison.py` / `app/admin/repositories/queries.py` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 当前实现现状与「目标设计」的差异(待办)
|
||||
|
||||
11 类骨架**都已实现、生产页 `CompResultScreen` 真实可达**(非 mock)。但对照原 11 类目标设计,仍有以下差异:
|
||||
|
||||
| 项 | 现状 | 差异 / 待办 |
|
||||
|---|---|---|
|
||||
| #11「重试」按钮 | `NotComparedCard` 无任何 CTA;`GrayCardCta.Retry`+`RetryCta` 定义了但**没挂到任何卡**(死代码) | 唯一**功能级缺口**:要么接上,要么把设计改成"无按钮" |
|
||||
| 各类文案措辞 | 偏功能陈述(如"该店当前已打烊,暂时无法比价") | 与目标口语+emoji("…打烊休息啦😴…")不一致;改端侧 `fullReasonForStatus` 即可(后端 `reason` 常空、无需动 pricebot) |
|
||||
| #1 优惠区 | 已改**聚合**"已自动帮您应用优惠,共减 ¥X" | 与"优惠 tags(逐项标签)"不同(产品已决策聚合) |
|
||||
| #4 少菜 | 成功卡上的 `MismatchBox` 提示条 | 非独立卡(按"原生为准"这多为有意) |
|
||||
| `no_delivery` | 代码有独立状态 | 原 11 类表未含,需补位或并入 |
|
||||
|
||||
---
|
||||
|
||||
## 7. admin 记录页口径(与 C 端不同,勿混)
|
||||
|
||||
admin 比价记录页 / 概览 / 大盘用**技术完成率**口径:`below_minimum / store_closed /
|
||||
store_not_found / items_not_found / no_delivery / unsupported`(流程跑完、只是外部原因致结果缺失)
|
||||
**记为成功**(前端标绿「成功」+ 感叹号,hover 显示缺失原因),只有纯技术故障 `failed` 才算失败。
|
||||
派生见 `app/admin/repositories/comparison_outcome.py`(原始结局取 `raw_payload.record_status`,
|
||||
`admin_success_sql()` 供概览/大盘/列表筛选共用,`derive_admin_outcome()` 供列表逐行下发 `outcome_hint`)。
|
||||
|
||||
这**刻意宽于** C 端 / #209 落库口径(那边 not_found 类归 `failed`)——admin 关心「系统有没有跑成」,
|
||||
C 端关心「有没有省到钱」。故 **admin 成功率 ≠ C 端 / 首页轮播口径**,对不上是设计使然、非 bug。
|
||||
|
||||
---
|
||||
|
||||
*本文档为跨端口径参考,非契约。字段/行号以三仓实际代码为准。*
|
||||
@@ -35,6 +35,9 @@ dependencies = [
|
||||
# multipart form (FastAPI 表单上传依赖)
|
||||
"python-multipart>=0.0.9",
|
||||
|
||||
# 用户反馈截图缩略图,避免 App 历史页为 48dp 小图下载数 MB 原图
|
||||
"pillow>=11.0.0",
|
||||
|
||||
# admin 后台账号密码 hash(用户侧是手机号+验证码登录,不需要密码;admin 才用)
|
||||
"bcrypt>=4.0.0",
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"""发奖公式下限:有真实正 eCPM 时最低发 1 金币,eCPM 缺失/为 0 仍发 0。
|
||||
"""发奖公式下限:看了就保底 1 金币,与前端展示公式完全对齐。
|
||||
|
||||
针对「eCPM 过低时公式四舍五入成 0 金币 → 被记 too_short 不发」的问题:只要广告有真实
|
||||
正 eCPM,单份金币至少 1(不再 0);但 eCPM 缺失/为 0/非法(没有真实广告价值)仍发 0,
|
||||
不凭空铸币、不破坏 ecpm_missing 语义。公式是发奖与后台审计对账的唯一口径,改这一处两边同源。
|
||||
产品口径(2026-08「看了就保底 1」):`calculate_ad_reward_coin` 对**任何输入**都至少返回 1,
|
||||
镜像客户端 FeedRewardFormula.singleUnitCoin(那边:eCPM 空/非法/非正数都返 1)。此前 eCPM
|
||||
缺失/为 0 返 0,与前端小球显示的 +1 不一致,且信息流侧把这类看满一份的广告记成 too_short 零发。
|
||||
公式是发奖与后台审计对账的唯一口径,改这一处两边同源。
|
||||
|
||||
注:激励视频(S2S 回调)路径在公式之前还有一道 `if not ecpm_raw` 早退——**完全没上报 eCPM**
|
||||
的回调仍记 ecpm_missing 零发(见 test_ad_reward.test_callback_without_ecpm_records_exception),
|
||||
那是"回调缺字段"的数据完整性闸,区别于"广告如实上报 eCPM=0"(=看了真广告 → 保底 1)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,19 +15,23 @@ from app.core.rewards import calculate_ad_reward_coin
|
||||
|
||||
|
||||
def test_low_positive_ecpm_floors_to_one_coin() -> None:
|
||||
"""真实但极低的 eCPM 原本四舍五入成 0 金币,现在兜底为 1(线上 record 4667 的 43 分场景)。"""
|
||||
"""真实但极低的 eCPM 原本四舍五入成 0 金币,兜底为 1(线上 record 4667 的 43 分场景)。"""
|
||||
# 43 分 = ¥0.43 CPM,因子1=0.1,重度用户 LT 第 69 条=1.0 → 0.43/1000×0.1×1.0×10000=0.43 → 旧口径 round=0
|
||||
assert calculate_ad_reward_coin("43", 69) == 1
|
||||
# 更低的 5 分同理:算出来 <0.5,旧口径也是 0
|
||||
assert calculate_ad_reward_coin("5", 11) == 1
|
||||
|
||||
|
||||
def test_zero_or_missing_ecpm_stays_zero() -> None:
|
||||
"""eCPM 缺失 / 为 0 / 非法(没有真实广告价值)不兜底,仍发 0。"""
|
||||
assert calculate_ad_reward_coin("0", 1) == 0
|
||||
assert calculate_ad_reward_coin(None, 1) == 0
|
||||
assert calculate_ad_reward_coin("", 1) == 0
|
||||
assert calculate_ad_reward_coin("abc", 1) == 0
|
||||
def test_zero_or_missing_ecpm_also_floors_to_one() -> None:
|
||||
"""eCPM 为 0 / 缺失 / 非法都保底 1(2026-08「看了就保底 1」,与前端 FeedRewardFormula 对齐)。
|
||||
|
||||
尤其 "0"(广告如实上报零价值)此前返 0,导致小球显示 +1、后端信息流记 too_short 零发的
|
||||
前后端不一致 —— 现在两端都 1。
|
||||
"""
|
||||
assert calculate_ad_reward_coin("0", 1) == 1
|
||||
assert calculate_ad_reward_coin(None, 1) == 1
|
||||
assert calculate_ad_reward_coin("", 1) == 1
|
||||
assert calculate_ad_reward_coin("abc", 1) == 1
|
||||
|
||||
|
||||
def test_normal_ecpm_value_unchanged() -> None:
|
||||
@@ -57,3 +66,31 @@ def test_feed_reward_low_ecpm_grants_one_coin_instead_of_too_short() -> None:
|
||||
assert rec.coin == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_feed_reward_zero_ecpm_grants_one_coin() -> None:
|
||||
"""端到端:eCPM 如实上报 0(用户反馈的真实广告返回 eCPM=0 场景),看满一份也保底 1、
|
||||
状态 granted —— 与前端小球显示的 +1 一致,不再前显示后零发。"""
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
from app.repositories.ad_feed_reward import grant_feed_reward
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = User(phone="19900000044", username="feedzero44", register_channel="sms")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
rec = grant_feed_reward(
|
||||
db, user.id,
|
||||
client_event_id="feed-zero-ecpm-0001",
|
||||
ecpm="0", # 广告如实上报 eCPM=0
|
||||
duration_seconds=15, # 看满一份
|
||||
ad_type="draw",
|
||||
feed_scene="comparison",
|
||||
)
|
||||
assert rec.status == "granted"
|
||||
assert rec.coin == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""admin 展示口径派生单测:记录级原始结局 → (admin_status, outcome_hint)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.admin.repositories.comparison_outcome import admin_success_sql, derive_admin_outcome
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_payload", "status", "expected"),
|
||||
[
|
||||
# 真实形态:status 已 normalize,细分在 raw_payload.record_status
|
||||
({"record_status": "success"}, "success", ("success", None)),
|
||||
({"record_status": "below_minimum"}, "success", ("success", "未满起送")),
|
||||
({"record_status": "store_closed"}, "failed", ("success", "门店打烊")),
|
||||
({"record_status": "store_not_found"}, "failed", ("success", "未找到店")),
|
||||
({"record_status": "items_not_found"}, "failed", ("success", "未找到菜")),
|
||||
({"record_status": "no_delivery"}, "failed", ("success", "单点不配送")),
|
||||
({"record_status": "unsupported"}, "failed", ("success", "平台·场景不支持")),
|
||||
({"record_status": "failed"}, "failed", ("failed", None)), # 纯技术故障
|
||||
# 空字符串视作缺失,兜到下一级(与 SQL nullif 对齐)
|
||||
({"record_status": "", "status": "store_closed"}, "failed", ("success", "门店打烊")),
|
||||
# POST 路径:细分在 raw_payload.status
|
||||
({"status": "store_not_found"}, "failed", ("success", "未找到店")),
|
||||
# 兜底 status 列:raw_payload 缺失(极老记录)或残留细分值
|
||||
(None, "success", ("success", None)),
|
||||
(None, "failed", ("failed", None)),
|
||||
(None, "store_closed", ("success", "门店打烊")), # 迁移未覆盖的残留
|
||||
# 生命周期态优先,不看结局
|
||||
({}, "cancelled", ("cancelled", None)),
|
||||
({"record_status": "success"}, "running", ("running", None)),
|
||||
],
|
||||
)
|
||||
def test_derive_admin_outcome(raw_payload, status, expected):
|
||||
assert derive_admin_outcome(raw_payload, status) == expected
|
||||
|
||||
|
||||
def test_admin_success_sql_matches_python_on_empty_string() -> None:
|
||||
"""record_status 为空串时,SQL 侧(nullif)与 Python 侧(or)都应兜到 status 列结局、判为成功。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rec = ComparisonRecord(
|
||||
trace_id="outcome-empty-record-status",
|
||||
status="failed",
|
||||
raw_payload={"record_status": "", "status": "store_closed"},
|
||||
)
|
||||
db.add(rec)
|
||||
db.flush()
|
||||
matched = db.execute(
|
||||
select(ComparisonRecord.id).where(
|
||||
ComparisonRecord.trace_id == "outcome-empty-record-status",
|
||||
admin_success_sql(),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
assert matched is not None # SQL 侧判成功
|
||||
assert derive_admin_outcome(rec.raw_payload, rec.status) == ("success", "门店打烊") # Python 侧一致
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
+193
-13
@@ -1,7 +1,7 @@
|
||||
"""Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -12,6 +12,7 @@ from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.repositories import queries
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.coupon_state import CouponClaimRecord, CouponSession
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.invite import InviteRelation
|
||||
from app.models.savings import SavingsRecord
|
||||
@@ -81,21 +82,25 @@ def test_dashboard_period_comparison_is_aggregated_by_backend(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
created_at = datetime(2037, 1, 15, 12)
|
||||
# (trace_id, status 列, record_status, total_ms, cost)
|
||||
rows = [
|
||||
("dashboard-aggregate-success", "success", 101, 0.1),
|
||||
("dashboard-aggregate-failed", "failed", 200, 0.2),
|
||||
("dashboard-aggregate-cancelled", "cancelled", 300, 0.3),
|
||||
("dashboard-aggregate-running", "running", 400, 0.4),
|
||||
("dashboard-success", "success", "success", 101, 0.1),
|
||||
("dashboard-below-min", "success", "below_minimum", 120, 0.2),
|
||||
("dashboard-store-not-found", "failed", "store_not_found", 130, 0.0),
|
||||
("dashboard-failed", "failed", "failed", 200, 0.3),
|
||||
("dashboard-cancelled", "cancelled", None, 300, 0.4),
|
||||
("dashboard-running", "running", None, 400, 0.5),
|
||||
]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for trace_id, status, total_ms, llm_cost_yuan in rows:
|
||||
for trace_id, status, record_status, total_ms, llm_cost_yuan in rows:
|
||||
db.add(
|
||||
ComparisonRecord(
|
||||
trace_id=trace_id,
|
||||
status=status,
|
||||
total_ms=total_ms,
|
||||
llm_cost_yuan=llm_cost_yuan,
|
||||
raw_payload={"record_status": record_status} if record_status else None,
|
||||
created_at=created_at,
|
||||
)
|
||||
)
|
||||
@@ -110,14 +115,95 @@ def test_dashboard_period_comparison_is_aggregated_by_backend(
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
comparison = response.json()["period"]["comparison"]
|
||||
assert comparison["total"] == 4
|
||||
assert comparison["completed"] == 2
|
||||
assert comparison["total"] == 6
|
||||
# admin 成功 = success + below_minimum + store_not_found = 3
|
||||
assert comparison["success"] == 3
|
||||
# completed = 3 成功 + 1 纯 failed = 4
|
||||
assert comparison["completed"] == 4
|
||||
assert comparison["cancelled"] == 1
|
||||
assert comparison["success"] == 1
|
||||
assert comparison["success_rate"] == 0.3333
|
||||
assert comparison["median_duration_ms"] == 151
|
||||
assert comparison["p95_duration_ms"] == 195
|
||||
assert comparison["token_cost_total_yuan"] == pytest.approx(1.0)
|
||||
# 分母 = total - cancelled = 5
|
||||
assert comparison["success_rate"] == 0.6
|
||||
assert comparison["token_cost_total_yuan"] == pytest.approx(1.5)
|
||||
|
||||
|
||||
def test_dashboard_coupon_success_rate_excludes_abandoned_sessions(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
started_date = date(2038, 1, 16)
|
||||
started_at = datetime(2038, 1, 16, 8, tzinfo=UTC)
|
||||
sessions = [
|
||||
("coupon-rate-completed-1", "coupon-rate-device-1", "completed"),
|
||||
("coupon-rate-completed-2", "coupon-rate-device-2", "completed"),
|
||||
("coupon-rate-failed", "coupon-rate-device-3", "failed"),
|
||||
("coupon-rate-abandoned", "coupon-rate-device-4", "abandoned"),
|
||||
]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for trace_id, device_id, status in sessions:
|
||||
db.add(
|
||||
CouponSession(
|
||||
trace_id=trace_id,
|
||||
device_id=device_id,
|
||||
status=status,
|
||||
app_env="prod",
|
||||
platforms=["meituan-waimai"],
|
||||
started_at=started_at,
|
||||
started_date=started_date,
|
||||
)
|
||||
)
|
||||
for index, device_id in enumerate(("coupon-rate-device-1", "coupon-rate-device-2")):
|
||||
db.add(
|
||||
CouponClaimRecord(
|
||||
device_id=device_id,
|
||||
coupon_id=f"mt_dashboard_rate_{index}",
|
||||
claim_date=started_date,
|
||||
status="success",
|
||||
app_env="prod",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
response = admin_client.get(
|
||||
"/admin/api/stats/overview",
|
||||
params={"date_from": "2038-01-16", "date_to": "2038-01-16"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
coupon = response.json()["period"]["coupon"]
|
||||
assert coupon["started"] == 4
|
||||
assert coupon["abandoned"] == 1
|
||||
assert coupon["success_denominator"] == 3
|
||||
assert coupon["all_success"] == 2
|
||||
assert coupon["success_rate"] == pytest.approx(2 / 3, abs=0.0001)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(
|
||||
CouponSession(
|
||||
trace_id="coupon-rate-only-abandoned",
|
||||
device_id="coupon-rate-device-only-abandoned",
|
||||
status="abandoned",
|
||||
app_env="prod",
|
||||
platforms=["meituan-waimai"],
|
||||
started_at=datetime(2038, 1, 17, 8, tzinfo=UTC),
|
||||
started_date=date(2038, 1, 17),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
empty_denominator_response = admin_client.get(
|
||||
"/admin/api/stats/overview",
|
||||
params={"date_from": "2038-01-17", "date_to": "2038-01-17"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert empty_denominator_response.status_code == 200
|
||||
only_abandoned = empty_denominator_response.json()["period"]["coupon"]
|
||||
assert only_abandoned["success_denominator"] == 0
|
||||
assert only_abandoned["success_rate"] is None
|
||||
|
||||
|
||||
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
|
||||
@@ -720,6 +806,16 @@ def test_comparison_records_show_readable_device_and_rom_version(
|
||||
rom_name="OriginOS",
|
||||
rom_version=4,
|
||||
android_version="14",
|
||||
platforms=[
|
||||
{
|
||||
"platform_id": "meituan",
|
||||
"platform_name": "美团",
|
||||
"status": "ok",
|
||||
"role": "target",
|
||||
"price": 18.8,
|
||||
"is_best": True,
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
@@ -747,6 +843,38 @@ def test_comparison_records_show_readable_device_and_rom_version(
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert detail.json()["device_model_name"] == "vivo Y77e"
|
||||
assert detail.json()["rom_version"] == 4
|
||||
assert detail.json()["platforms"][0]["status"] == "ok"
|
||||
assert detail.json()["platforms"][0]["is_best"] is True
|
||||
|
||||
|
||||
def test_comparison_records_list_tolerates_orphan_null_user(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
"""帧0 建行但 user_id 暂缺的孤儿记录(软鉴权/匿名),admin 列表必须能序列化、不 500。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(
|
||||
ComparisonRecord(
|
||||
user_id=None,
|
||||
trace_id="comparison-orphan-null-user",
|
||||
status="success",
|
||||
store_name="孤儿比价专用店ZZZ",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
response = admin_client.get(
|
||||
"/admin/api/comparison-records",
|
||||
params={"store": "孤儿比价专用店ZZZ"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
items = response.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["user_id"] is None
|
||||
assert items[0]["trace_id"] == "comparison-orphan-null-user"
|
||||
|
||||
|
||||
def test_comparison_records_show_real_order_status(
|
||||
@@ -1079,3 +1207,55 @@ def test_period_signin_boost_moves_to_reward_video_without_double_count(
|
||||
assert coins["reward_video_coin_total"] == 300
|
||||
assert coins["regular_task_coin_total"] == 50
|
||||
assert coins["signin_boost_coin_total"] == 200
|
||||
|
||||
|
||||
def test_comparison_records_expose_admin_outcome(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
"""列表 / 详情下发 admin_status + outcome_hint:外部缺失=成功⚠,纯故障=失败。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = user_repo.upsert_user_for_login(
|
||||
db, phone="13800009051", register_channel="sms"
|
||||
)
|
||||
# 未找到店:status 已 normalize 成 failed,细分在 raw_payload.record_status
|
||||
gap_rec = ComparisonRecord(
|
||||
user_id=user.id, trace_id="admin-outcome-store-not-found",
|
||||
status="failed", store_name="缺店排查店ZZZ",
|
||||
raw_payload={"record_status": "store_not_found"},
|
||||
)
|
||||
db.add(gap_rec)
|
||||
# 纯技术故障
|
||||
db.add(ComparisonRecord(
|
||||
user_id=user.id, trace_id="admin-outcome-tech-failed",
|
||||
status="failed", store_name="缺店排查店ZZZ",
|
||||
raw_payload={"record_status": "failed"},
|
||||
))
|
||||
db.commit()
|
||||
uid = user.id
|
||||
gap_id = gap_rec.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = admin_client.get(
|
||||
"/admin/api/comparison-records",
|
||||
params={"user_id": uid, "store": "缺店排查店ZZZ"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
by_trace = {it["trace_id"]: it for it in resp.json()["items"]}
|
||||
|
||||
gap = by_trace["admin-outcome-store-not-found"]
|
||||
assert gap["admin_status"] == "success"
|
||||
assert gap["outcome_hint"] == "未找到店"
|
||||
|
||||
tech = by_trace["admin-outcome-tech-failed"]
|
||||
assert tech["admin_status"] == "failed"
|
||||
assert tech["outcome_hint"] is None
|
||||
|
||||
detail = admin_client.get(
|
||||
f"/admin/api/comparison-records/{gap_id}", headers=_auth(admin_token)
|
||||
)
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert detail.json()["admin_status"] == "success"
|
||||
assert detail.json()["outcome_hint"] == "未找到店"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.limit_policy import MODE_UNLIMITED
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.core.security import decode_token
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.limit_policy import LimitPolicyOverride
|
||||
|
||||
|
||||
def _login(client) -> tuple[str, int]:
|
||||
@@ -48,7 +50,10 @@ def test_compare_start_is_idempotent_by_trace_id(client) -> None:
|
||||
retry = client.post("/api/v1/compare/start", json=payload, headers=_headers(token))
|
||||
|
||||
assert first.status_code == 200, first.text
|
||||
assert first.json() == {"limit": 100, "used": 1, "remaining": 99}
|
||||
# trace_id 回显客户端带来的值(老协议幂等路径)
|
||||
assert first.json() == {
|
||||
"limit": 100, "used": 1, "remaining": 99, "trace_id": payload["trace_id"],
|
||||
}
|
||||
assert retry.status_code == 200, retry.text
|
||||
assert retry.json() == first.json()
|
||||
with SessionLocal() as db:
|
||||
@@ -69,6 +74,28 @@ def test_compare_start_is_idempotent_by_trace_id(client) -> None:
|
||||
assert record.device_id == "quota-device"
|
||||
|
||||
|
||||
def test_compare_start_issues_trace_id_when_absent(client) -> None:
|
||||
"""新客户端不带 trace_id → 服务端签发并随响应返回,running 行以签发 id 建。"""
|
||||
token, user_id = _login(client)
|
||||
response = client.post(
|
||||
"/api/v1/compare/start",
|
||||
json={"business_type": "food", "device_id": "quota-device-issue"},
|
||||
headers=_headers(token),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
issued = body["trace_id"]
|
||||
assert issued # 非空签发
|
||||
assert body["used"] == 1
|
||||
with SessionLocal() as db:
|
||||
record = db.execute(
|
||||
select(ComparisonRecord).where(ComparisonRecord.trace_id == issued)
|
||||
).scalar_one()
|
||||
assert record.user_id == user_id
|
||||
assert record.status == "running"
|
||||
assert record.device_id == "quota-device-issue"
|
||||
|
||||
|
||||
def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
|
||||
token, user_id = _login(client)
|
||||
now = datetime.now(CN_TZ).replace(tzinfo=None)
|
||||
@@ -101,7 +128,9 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
|
||||
headers=_headers(token),
|
||||
)
|
||||
assert allowed.status_code == 200, allowed.text
|
||||
assert allowed.json() == {"limit": 100, "used": 100, "remaining": 0}
|
||||
assert allowed.json() == {
|
||||
"limit": 100, "used": 100, "remaining": 0, "trace_id": final_allowed_trace,
|
||||
}
|
||||
|
||||
rejected_trace = f"quota-rejected-{user_id}"
|
||||
response = client.post(
|
||||
@@ -118,3 +147,125 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
|
||||
ComparisonRecord.trace_id == rejected_trace
|
||||
)
|
||||
) == 0
|
||||
|
||||
|
||||
def test_compare_quota_fresh_user(client) -> None:
|
||||
"""新用户今天没有比价记录 → exhausted=False, used=0, limit=100。"""
|
||||
token, _user_id = _login(client)
|
||||
response = client.get("/api/v1/compare/quota", headers=_headers(token))
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {"exhausted": False, "used": 0, "limit": 100}
|
||||
|
||||
|
||||
def test_compare_quota_exhausted(client) -> None:
|
||||
"""今日已有 100 条记录 → exhausted=True, used=100, limit=100。"""
|
||||
token, user_id = _login(client)
|
||||
now = datetime.now(CN_TZ).replace(tzinfo=None)
|
||||
with SessionLocal() as db:
|
||||
db.add_all(
|
||||
[
|
||||
ComparisonRecord(
|
||||
user_id=user_id,
|
||||
trace_id=f"quota-exhausted-{user_id}-{i}",
|
||||
status="failed",
|
||||
created_at=now,
|
||||
)
|
||||
for i in range(100)
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
response = client.get("/api/v1/compare/quota", headers=_headers(token))
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {"exhausted": True, "used": 100, "limit": 100}
|
||||
|
||||
|
||||
def test_compare_quota_yesterday_rows_not_counted(client) -> None:
|
||||
"""昨天的记录不计入今日配额 → exhausted=False, used=0。"""
|
||||
token, user_id = _login(client)
|
||||
yesterday = datetime.now(CN_TZ).replace(tzinfo=None) - timedelta(days=1)
|
||||
with SessionLocal() as db:
|
||||
db.add_all(
|
||||
[
|
||||
ComparisonRecord(
|
||||
user_id=user_id,
|
||||
trace_id=f"quota-yesterday-window-{user_id}-{i}",
|
||||
status="success",
|
||||
created_at=yesterday,
|
||||
)
|
||||
for i in range(100)
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
response = client.get("/api/v1/compare/quota", headers=_headers(token))
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {"exhausted": False, "used": 0, "limit": 100}
|
||||
|
||||
|
||||
def test_compare_quota_device_whitelist_parity(client) -> None:
|
||||
"""device 白名单下 /quota?device_id=X 与 /start 的 policy 完全一致。
|
||||
|
||||
场景:
|
||||
- 设备 whitelisted-device-001 有 unlimited 覆盖 → /quota?device_id= 应报
|
||||
exhausted=False, limit=null,哪怕同用户今天已发起 ≥100 次。
|
||||
- 不带 device_id(或带非白名单设备) → 同用户走全局 100 上限,exhausted=True。
|
||||
"""
|
||||
token, user_id = _login(client)
|
||||
device_id = f"whitelisted-device-{user_id}"
|
||||
|
||||
# 种 100 条今日记录:此时不带白名单设备 /quota 应报 exhausted=True
|
||||
now = datetime.now(CN_TZ).replace(tzinfo=None)
|
||||
with SessionLocal() as db:
|
||||
db.add_all(
|
||||
[
|
||||
ComparisonRecord(
|
||||
user_id=user_id,
|
||||
trace_id=f"quota-parity-{user_id}-{i}",
|
||||
status="failed",
|
||||
created_at=now,
|
||||
)
|
||||
for i in range(100)
|
||||
]
|
||||
)
|
||||
# 种 device 白名单覆盖:unlimited、无失效时间(永久白名单用 expires_at=None)
|
||||
# 注意:validate_override 要求 unlimited+有 expires_at,但这里直接写 ORM 跳过
|
||||
# 该验证——测试意图是覆盖"设备白名单已存在"的生产状态,expires_at=None 代表永久。
|
||||
db.add(
|
||||
LimitPolicyOverride(
|
||||
subject_type="device",
|
||||
subject_value=device_id,
|
||||
rule_code="compare.start.daily",
|
||||
mode=MODE_UNLIMITED,
|
||||
enabled=True,
|
||||
expires_at=None,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# 带白名单 device_id → unlimited,不受 100 条记录限制
|
||||
resp_with_device = client.get(
|
||||
f"/api/v1/compare/quota?device_id={device_id}",
|
||||
headers=_headers(token),
|
||||
)
|
||||
assert resp_with_device.status_code == 200, resp_with_device.text
|
||||
body_with = resp_with_device.json()
|
||||
assert body_with["exhausted"] is False, f"whitelisted device should not be exhausted: {body_with}"
|
||||
assert body_with["limit"] is None, f"whitelisted device should have null limit: {body_with}"
|
||||
assert body_with["used"] == 100
|
||||
|
||||
# 不带 device_id → 走全局 100 上限,已有 100 条 → exhausted=True
|
||||
resp_no_device = client.get("/api/v1/compare/quota", headers=_headers(token))
|
||||
assert resp_no_device.status_code == 200, resp_no_device.text
|
||||
body_no = resp_no_device.json()
|
||||
assert body_no["exhausted"] is True, f"without device should be exhausted: {body_no}"
|
||||
assert body_no["limit"] == 100
|
||||
assert body_no["used"] == 100
|
||||
|
||||
# 带非白名单 device_id → 同样走全局 100 上限
|
||||
resp_other_device = client.get(
|
||||
"/api/v1/compare/quota?device_id=unknown-device-xyz",
|
||||
headers=_headers(token),
|
||||
)
|
||||
assert resp_other_device.status_code == 200, resp_other_device.text
|
||||
body_other = resp_other_device.json()
|
||||
assert body_other["exhausted"] is True, f"non-whitelisted device should be exhausted: {body_other}"
|
||||
assert body_other["limit"] == 100
|
||||
|
||||
@@ -13,6 +13,7 @@ import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
@@ -25,6 +26,26 @@ def _tid() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_status", "record_status"),
|
||||
[
|
||||
("success", "success"),
|
||||
("below_minimum", "success"),
|
||||
("failed", "failed"),
|
||||
("store_closed", "failed"),
|
||||
("store_not_found", "failed"),
|
||||
("items_not_found", "failed"),
|
||||
("no_delivery", "failed"),
|
||||
("unsupported", "failed"),
|
||||
("cancelled", "cancelled"),
|
||||
("running", "running"),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
def test_record_status_normalization(raw_status, record_status) -> None:
|
||||
assert crud._normalize_record_status(raw_status) == record_status
|
||||
|
||||
|
||||
def _done_params() -> dict:
|
||||
"""一份典型 done 帧 params:美团 25 元 vs 源淘宝闪购 30 元 → 省 5 元、success。"""
|
||||
return {
|
||||
@@ -111,6 +132,116 @@ def test_harvest_done_derives_and_newly_success_once(client) -> None:
|
||||
assert newly2 is False
|
||||
|
||||
|
||||
def test_harvest_done_below_minimum_counts_as_completed_success(client) -> None:
|
||||
"""未达起送是可信业务结论:主状态/完成奖励归 success,细分结局仍留在 raw_payload。"""
|
||||
tid = _tid()
|
||||
done_below_minimum = {
|
||||
"record_status": "below_minimum",
|
||||
"comparison_results": [
|
||||
{
|
||||
"platform_id": "meituan",
|
||||
"platform_name": "美团",
|
||||
"package": "com.sankuai.meituan",
|
||||
"price": 59.0,
|
||||
"is_source": True,
|
||||
"rank": 1,
|
||||
"store_name": "测试店",
|
||||
"items": [{"name": "红乌苏", "qty": 1}],
|
||||
},
|
||||
],
|
||||
"platform_results": {
|
||||
"meituan": {"is_source": True, "status": "source", "price": 59.0},
|
||||
"taobao_flash": {
|
||||
"is_source": False,
|
||||
"status": "below_minimum",
|
||||
"reason": "购物车未达起送门槛(差 ¥25.2)",
|
||||
},
|
||||
},
|
||||
"information": "淘宝未达起送门槛,可加菜凑单后下单",
|
||||
}
|
||||
with SessionLocal() as db:
|
||||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||||
rec, newly = crud.harvest_done(
|
||||
db, trace_id=tid, user_id=None, done_params=done_below_minimum
|
||||
)
|
||||
|
||||
assert rec.status == "success"
|
||||
assert newly is True
|
||||
assert rec.fail_reason is None
|
||||
assert rec.raw_payload["record_status"] == "below_minimum"
|
||||
assert (
|
||||
rec.raw_payload["platform_results"]["taobao_flash"]["status"]
|
||||
== "below_minimum"
|
||||
)
|
||||
|
||||
|
||||
def test_harvest_done_platforms_below_minimum_counts_as_success(client) -> None:
|
||||
"""新 platforms 单源派生路径也必须执行同一 below_minimum → success 归一化。"""
|
||||
tid = _tid()
|
||||
with SessionLocal() as db:
|
||||
rec, newly = crud.harvest_done(
|
||||
db,
|
||||
trace_id=tid,
|
||||
user_id=None,
|
||||
done_params={
|
||||
"record_status": "below_minimum",
|
||||
"platforms": [
|
||||
{
|
||||
"role": "source",
|
||||
"platform_id": "meituan",
|
||||
"platform_name": "美团",
|
||||
"price": 59.0,
|
||||
"store_name": "测试店",
|
||||
"items": [{"name": "红乌苏", "qty": 1}],
|
||||
},
|
||||
{
|
||||
"role": "target",
|
||||
"platform_id": "taobao_flash",
|
||||
"platform_name": "淘宝",
|
||||
"status": "below_minimum",
|
||||
"price": None,
|
||||
},
|
||||
],
|
||||
"platform_results": {
|
||||
"taobao_flash": {
|
||||
"is_source": False,
|
||||
"status": "below_minimum",
|
||||
"reason": "购物车未达起送门槛",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert rec.status == "success"
|
||||
assert newly is True
|
||||
assert rec.raw_payload["record_status"] == "below_minimum"
|
||||
|
||||
|
||||
def test_legacy_upsert_below_minimum_counts_as_success(client) -> None:
|
||||
"""灰度期客户端直报路径无论走 status 还是 record_status 都不能落第四种主状态。"""
|
||||
tid = _tid()
|
||||
payload = ComparisonRecordIn(
|
||||
trace_id=tid,
|
||||
business_type="food",
|
||||
status="below_minimum",
|
||||
comparison_results=[],
|
||||
platform_results={
|
||||
"taobao_flash": {
|
||||
"is_source": False,
|
||||
"status": "below_minimum",
|
||||
"reason": "购物车未达起送门槛",
|
||||
}
|
||||
},
|
||||
information="淘宝未达起送门槛,可加菜凑单后下单",
|
||||
)
|
||||
with SessionLocal() as db:
|
||||
rec = crud.upsert_record(db, user_id=987654, payload=payload)
|
||||
|
||||
assert rec.status == "success"
|
||||
assert rec.fail_reason is None
|
||||
assert rec.raw_payload["status"] == "below_minimum"
|
||||
|
||||
|
||||
def test_harvest_done_failed_derives_fail_reason(client) -> None:
|
||||
"""failed 记录:记录级 information 笼统,但 fail_reason 从 platform_results 救出具体原因
|
||||
(id 3030 型:美团系统失败 + 京东 items_not_found → 展示京东那条)。"""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""比价记录页后端分页与概览聚合。"""
|
||||
"""比价记录页后端概览聚合(admin 口径:外部缺失记为成功)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
@@ -7,13 +7,14 @@ import pytest
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from app.admin.repositories import queries
|
||||
from app.admin.repositories.comparison_outcome import admin_success_sql
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
|
||||
|
||||
def test_postgresql_duration_summary_uses_ordered_set_aggregates() -> None:
|
||||
stmt = queries._comparison_duration_aggregate_stmt(
|
||||
[], "success", (0.05, 0.5, 0.95, 0.99)
|
||||
[], admin_success_sql(), (0.05, 0.5, 0.95, 0.99)
|
||||
)
|
||||
sql = str(
|
||||
stmt.compile(
|
||||
@@ -21,61 +22,106 @@ def test_postgresql_duration_summary_uses_ordered_set_aggregates() -> None:
|
||||
compile_kwargs={"literal_binds": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert sql.count("percentile_cont") == 4
|
||||
assert "comparison_record.status = 'success'" in sql
|
||||
# 口径按原始结局 coalesce,而非直接读 status 列
|
||||
assert "coalesce" in sql.lower()
|
||||
|
||||
|
||||
def test_summary_uses_only_success_durations_and_filters_beijing_date() -> None:
|
||||
def test_summary_counts_external_gaps_as_success() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# (trace_id, status 列[已 normalize], record_status[原始结局], total_ms, cost, saved)
|
||||
rows = [
|
||||
("summary-success-a", "success", 1000, 1.0, 100),
|
||||
("summary-success-b", "success", 3000, 2.0, 0),
|
||||
("summary-failed", "failed", 100_000, 3.0, 0),
|
||||
("summary-cancelled", "cancelled", 5000, 4.0, 0),
|
||||
("sum-success", "success", "success", 1000, 1.0, 100),
|
||||
("sum-below-min", "success", "below_minimum", 2000, 2.0, 0),
|
||||
("sum-store-closed", "failed", "store_closed", 3000, None, 0),
|
||||
("sum-store-not-found", "failed", "store_not_found", 4000, None, 0),
|
||||
("sum-failed", "failed", "failed", 100_000, 3.0, 0),
|
||||
("sum-cancelled", "cancelled", None, 5000, None, 0),
|
||||
("sum-running", "running", None, 6000, None, 0),
|
||||
]
|
||||
for trace_id, status, total_ms, cost, saved in rows:
|
||||
for trace_id, status, record_status, total_ms, cost, saved in rows:
|
||||
db.add(ComparisonRecord(
|
||||
trace_id=trace_id,
|
||||
status=status,
|
||||
total_ms=total_ms,
|
||||
llm_cost_yuan=cost,
|
||||
saved_amount_cents=saved,
|
||||
raw_payload={"record_status": record_status} if record_status else None,
|
||||
created_at=datetime(2038, 1, 15, 12, tzinfo=UTC),
|
||||
))
|
||||
db.add(ComparisonRecord(
|
||||
trace_id="summary-outside-day",
|
||||
status="success",
|
||||
total_ms=999_999,
|
||||
created_at=datetime(2038, 1, 16, 16, tzinfo=UTC),
|
||||
))
|
||||
db.flush()
|
||||
|
||||
summary = queries.comparison_records_summary(
|
||||
db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15)
|
||||
)
|
||||
|
||||
assert summary["started"] == 4
|
||||
assert summary["completed"] == 3
|
||||
assert summary["success"] == 2
|
||||
assert summary["success_rate"] == pytest.approx(2 / 3)
|
||||
assert summary["avg_token_cost"] == pytest.approx(2.5)
|
||||
assert summary["lower_price_rate"] == 0.5
|
||||
assert summary["avg_duration_ms"] == 2000
|
||||
assert summary["p5_duration_ms"] == 1100
|
||||
assert summary["p50_duration_ms"] == 2000
|
||||
assert summary["p95_duration_ms"] == 2900
|
||||
assert summary["p99_duration_ms"] == 2980
|
||||
# admin 成功 = success + below_minimum + store_closed + store_not_found = 4
|
||||
assert summary["started"] == 7
|
||||
assert summary["success"] == 4
|
||||
assert summary["completed"] == 5 # 4 成功 + 1 纯 failed
|
||||
assert summary["cancelled"] == 1
|
||||
assert summary["cancelled_rate"] == 0.25
|
||||
assert summary["success_rate"] == pytest.approx(4 / 6) # 分母 started - cancelled
|
||||
assert summary["avg_token_cost"] == pytest.approx(2.0) # (1+2+3)/3
|
||||
assert summary["lower_price_rate"] == pytest.approx(1 / 4) # 仅 sum-success saved>0
|
||||
# 耗时统计集 = admin 成功的 total_ms [1000,2000,3000,4000]
|
||||
assert summary["avg_duration_ms"] == 2500
|
||||
assert summary["p5_duration_ms"] == 1150
|
||||
assert summary["p50_duration_ms"] == 2500
|
||||
assert summary["p95_duration_ms"] == 3850
|
||||
assert summary["p99_duration_ms"] == 3970
|
||||
assert summary["cancelled_p50_ms"] == 5000
|
||||
|
||||
items, _next_cursor, total = queries.list_comparison_records(
|
||||
db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15), limit=20
|
||||
)
|
||||
assert total == 4
|
||||
assert {item.trace_id for item in items} == {row[0] for row in rows}
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_list_status_filter_uses_admin_outcome() -> None:
|
||||
"""列表「状态」筛选走 admin 口径:筛成功含 6 类、筛失败仅纯技术故障;并验日期边界排除。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = [
|
||||
("flt-success", "success", "success"),
|
||||
("flt-below-min", "success", "below_minimum"),
|
||||
("flt-store-not-found", "failed", "store_not_found"),
|
||||
("flt-failed", "failed", "failed"),
|
||||
("flt-cancelled", "cancelled", None),
|
||||
]
|
||||
for trace_id, status, record_status in rows:
|
||||
db.add(ComparisonRecord(
|
||||
trace_id=trace_id,
|
||||
status=status,
|
||||
raw_payload={"record_status": record_status} if record_status else None,
|
||||
created_at=datetime(2039, 3, 10, 12, tzinfo=UTC),
|
||||
))
|
||||
# 日期边界:窗口外一条 admin 成功记录(次日),应被日期筛选排除
|
||||
db.add(ComparisonRecord(
|
||||
trace_id="flt-out-of-window",
|
||||
status="success",
|
||||
raw_payload={"record_status": "success"},
|
||||
created_at=datetime(2039, 3, 11, 12, tzinfo=UTC),
|
||||
))
|
||||
db.flush()
|
||||
|
||||
succ, _c1, succ_total = queries.list_comparison_records(
|
||||
db, status="success", date_from=date(2039, 3, 10), date_to=date(2039, 3, 10)
|
||||
)
|
||||
# 3 条当天 admin 成功(含 store_not_found);窗口外 flt-out-of-window 被日期排除
|
||||
assert succ_total == 3
|
||||
assert {it.trace_id for it in succ} == {
|
||||
"flt-success", "flt-below-min", "flt-store-not-found",
|
||||
}
|
||||
|
||||
fail, _c2, fail_total = queries.list_comparison_records(
|
||||
db, status="failed", date_from=date(2039, 3, 10), date_to=date(2039, 3, 10)
|
||||
)
|
||||
assert fail_total == 1
|
||||
assert {it.trace_id for it in fail} == {"flt-failed"}
|
||||
|
||||
_canc, _c3, canc_total = queries.list_comparison_records(
|
||||
db, status="cancelled", date_from=date(2039, 3, 10), date_to=date(2039, 3, 10)
|
||||
)
|
||||
assert canc_total == 1
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Regression coverage for comparison terminal-status normalization."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
|
||||
def _migration_module():
|
||||
path = (
|
||||
Path(__file__).parents[1]
|
||||
/ "alembic"
|
||||
/ "versions"
|
||||
/ "comparison_below_minimum_as_success.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_business_status_migration_upgrade_and_downgrade() -> None:
|
||||
engine = sa.create_engine("sqlite:///:memory:")
|
||||
metadata = sa.MetaData()
|
||||
records = sa.Table(
|
||||
"comparison_record",
|
||||
metadata,
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("status", sa.String(16), nullable=False),
|
||||
sa.Column("fail_reason", sa.String(256)),
|
||||
sa.Column("raw_payload", sa.JSON, nullable=False),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
module = _migration_module()
|
||||
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
records.insert(),
|
||||
[
|
||||
{
|
||||
"status": "below_minimum",
|
||||
"fail_reason": "旧失败原因",
|
||||
"raw_payload": {"record_status": "below_minimum"},
|
||||
},
|
||||
{
|
||||
"status": "success",
|
||||
"fail_reason": None,
|
||||
"raw_payload": {"record_status": "success"},
|
||||
},
|
||||
{
|
||||
"status": "failed",
|
||||
"fail_reason": "技术异常",
|
||||
"raw_payload": {"status": "failed"},
|
||||
},
|
||||
{
|
||||
"status": "store_closed",
|
||||
"fail_reason": "店铺打烊",
|
||||
"raw_payload": {"record_status": "store_closed"},
|
||||
},
|
||||
{
|
||||
"status": "items_not_found",
|
||||
"fail_reason": "商品未找到",
|
||||
"raw_payload": {"status": "items_not_found"},
|
||||
},
|
||||
],
|
||||
)
|
||||
module.op = Operations(MigrationContext.configure(connection))
|
||||
|
||||
module.upgrade()
|
||||
upgraded = connection.execute(
|
||||
sa.select(records.c.status, records.c.fail_reason).order_by(records.c.id)
|
||||
).all()
|
||||
assert upgraded == [
|
||||
("success", None),
|
||||
("success", None),
|
||||
("failed", "技术异常"),
|
||||
("failed", "店铺打烊"),
|
||||
("failed", "商品未找到"),
|
||||
]
|
||||
|
||||
module.downgrade()
|
||||
downgraded = connection.execute(
|
||||
sa.select(records.c.status, records.c.fail_reason).order_by(records.c.id)
|
||||
).all()
|
||||
assert downgraded == [
|
||||
("below_minimum", None),
|
||||
("success", None),
|
||||
("failed", "技术异常"),
|
||||
("store_closed", "店铺打烊"),
|
||||
("items_not_found", "商品未找到"),
|
||||
]
|
||||
@@ -10,6 +10,7 @@ from app.admin.repositories.coupon_data import (
|
||||
_point_scores_by_trace,
|
||||
coupon_data_report,
|
||||
coupon_point_details,
|
||||
coupon_user_records,
|
||||
)
|
||||
from app.admin.security import create_admin_token
|
||||
from app.db.session import SessionLocal
|
||||
@@ -38,6 +39,7 @@ def test_point_scores_by_trace() -> None:
|
||||
stats = _point_scores_by_trace(db, [trace])[trace]
|
||||
assert stats["succeeded"] == 2
|
||||
assert stats["tried"] == 3
|
||||
assert stats["events"] == 4
|
||||
details = coupon_point_details(db, trace_id=trace)
|
||||
assert [item["status"] for item in details] == [
|
||||
"success", "already_claimed", "failed", "skipped"
|
||||
@@ -48,8 +50,8 @@ def test_point_scores_by_trace() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_skipped_detail_does_not_create_a_score() -> None:
|
||||
"""仅有 skipped 时按需明细仍可查到,但列表没有虚假的 0/0 分数。"""
|
||||
def test_skipped_detail_is_distinguished_from_no_events() -> None:
|
||||
"""仅有 skipped 时分数仍为0/0,但保留事件数供前端开放明细。"""
|
||||
db = SessionLocal()
|
||||
trace = "point-score-skipped"
|
||||
try:
|
||||
@@ -63,7 +65,7 @@ def test_skipped_detail_does_not_create_a_score() -> None:
|
||||
db.flush()
|
||||
|
||||
scores = _point_scores_by_trace(db, [trace, "missing-trace"])
|
||||
assert trace not in scores
|
||||
assert scores[trace] == {"succeeded": 0, "tried": 0, "events": 1}
|
||||
assert "missing-trace" not in scores
|
||||
assert coupon_point_details(db, trace_id=trace)[0]["status"] == "skipped"
|
||||
finally:
|
||||
@@ -114,6 +116,112 @@ def test_coupon_data_report_returns_scores_without_embedding_details() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_coupon_data_report_marks_abandoned_without_point_results() -> None:
|
||||
"""中途退出且没有逐券终态时返回0/0,其他状态缺埋点仍保持为空。"""
|
||||
db = SessionLocal()
|
||||
report_date = date(2020, 1, 6)
|
||||
user_id = 910006
|
||||
try:
|
||||
db.add_all([
|
||||
CouponSession(
|
||||
trace_id="point-score-abandoned-without-result",
|
||||
device_id="score-abandoned-device",
|
||||
user_id=user_id,
|
||||
status="abandoned",
|
||||
app_env="prod",
|
||||
platforms=["meituan-waimai"],
|
||||
started_at=datetime(2020, 1, 6, tzinfo=UTC),
|
||||
started_date=report_date,
|
||||
),
|
||||
CouponSession(
|
||||
trace_id="point-score-completed-without-result",
|
||||
device_id="score-completed-device",
|
||||
user_id=user_id,
|
||||
status="completed",
|
||||
app_env="prod",
|
||||
platforms=["meituan-waimai"],
|
||||
started_at=datetime(2020, 1, 6, 1, tzinfo=UTC),
|
||||
started_date=report_date,
|
||||
),
|
||||
CouponSession(
|
||||
trace_id="point-score-abandoned-with-result",
|
||||
device_id="score-abandoned-result-device",
|
||||
user_id=user_id,
|
||||
status="abandoned",
|
||||
app_env="prod",
|
||||
platforms=["meituan-waimai"],
|
||||
started_at=datetime(2020, 1, 6, 2, tzinfo=UTC),
|
||||
started_date=report_date,
|
||||
),
|
||||
CouponSession(
|
||||
trace_id="point-score-abandoned-skipped-only",
|
||||
device_id="score-abandoned-skipped-device",
|
||||
user_id=user_id,
|
||||
status="abandoned",
|
||||
app_env="prod",
|
||||
platforms=["meituan-waimai"],
|
||||
started_at=datetime(2020, 1, 6, 3, tzinfo=UTC),
|
||||
started_date=report_date,
|
||||
),
|
||||
])
|
||||
db.add_all([
|
||||
CouponClaimEvent(
|
||||
trace_id="point-score-abandoned-with-result",
|
||||
device_id="score-abandoned-result-device",
|
||||
coupon_id=f"mt-abandoned-{status}",
|
||||
claim_date=report_date,
|
||||
status=status,
|
||||
)
|
||||
for status in ("success", "failed")
|
||||
])
|
||||
db.add(CouponClaimEvent(
|
||||
trace_id="point-score-abandoned-skipped-only",
|
||||
device_id="score-abandoned-skipped-device",
|
||||
coupon_id="mt-abandoned-skipped",
|
||||
claim_date=report_date,
|
||||
status="skipped",
|
||||
))
|
||||
db.flush()
|
||||
|
||||
report = coupon_data_report(
|
||||
db,
|
||||
date_from=report_date.isoformat(),
|
||||
date_to=report_date.isoformat(),
|
||||
app_env="prod",
|
||||
)
|
||||
rows = {item["trace_id"]: item for item in report["items"]}
|
||||
abandoned = rows["point-score-abandoned-without-result"]
|
||||
assert abandoned["point_success_count"] == 0
|
||||
assert abandoned["point_total_count"] == 0
|
||||
assert abandoned["point_event_count"] == 0
|
||||
|
||||
abandoned_with_result = rows["point-score-abandoned-with-result"]
|
||||
assert abandoned_with_result["point_success_count"] == 1
|
||||
assert abandoned_with_result["point_total_count"] == 2
|
||||
assert abandoned_with_result["point_event_count"] == 2
|
||||
|
||||
abandoned_skipped = rows["point-score-abandoned-skipped-only"]
|
||||
assert abandoned_skipped["point_success_count"] == 0
|
||||
assert abandoned_skipped["point_total_count"] == 0
|
||||
assert abandoned_skipped["point_event_count"] == 1
|
||||
|
||||
completed = rows["point-score-completed-without-result"]
|
||||
assert completed["point_success_count"] is None
|
||||
assert completed["point_total_count"] is None
|
||||
|
||||
user_rows = {
|
||||
item["trace_id"]: item
|
||||
for item in coupon_user_records(db, user_id=user_id)["items"]
|
||||
}
|
||||
assert user_rows["point-score-abandoned-without-result"]["point_total_count"] == 0
|
||||
assert user_rows["point-score-abandoned-with-result"]["point_total_count"] == 2
|
||||
assert user_rows["point-score-abandoned-skipped-only"]["point_event_count"] == 1
|
||||
assert user_rows["point-score-completed-without-result"]["point_total_count"] is None
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_coupon_point_details_endpoint() -> None:
|
||||
"""前端点击使用的接口按约定返回 trace_id 和逐券 items。"""
|
||||
db = SessionLocal()
|
||||
|
||||
@@ -8,6 +8,7 @@ mock 掉对 pricebot 的 httpx 调用,验证:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -62,7 +63,8 @@ def test_coupon_step_no_auth_required(client) -> None:
|
||||
|
||||
|
||||
def test_coupon_step_passes_body_through(client, access_token) -> None:
|
||||
"""带 token + pricebot 200 → 响应原样透传,请求 body 原样转发到 /api/coupon/step。"""
|
||||
"""带 token + pricebot 200 → 请求 body 原样转发到 /api/coupon/step;
|
||||
响应在透传基础上顶层回显本次任务 trace_id(setdefault 注入,其余字段原样)。"""
|
||||
fake_pricebot_resp = {
|
||||
"success": True,
|
||||
"action": {
|
||||
@@ -83,9 +85,12 @@ def test_coupon_step_passes_body_through(client, access_token) -> None:
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_post(self, url, json=None, **kw):
|
||||
# ⚠️ coupon_step 转发用 content=raw(原始字节透传,不重新 dumps),不是 json= ——
|
||||
# fake 必须捕 content。旧 fake 只捕 json= 导致 captured["json"] 恒 None,本测试
|
||||
# 自 content=raw 优化后一直红着(pre-existing),本次顺手修正。
|
||||
async def fake_post(self, url, content=None, **kw):
|
||||
captured["url"] = url
|
||||
captured["json"] = json
|
||||
captured["content"] = content
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json = lambda: fake_pricebot_resp
|
||||
@@ -99,9 +104,10 @@ def test_coupon_step_passes_body_through(client, access_token) -> None:
|
||||
)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == fake_pricebot_resp
|
||||
# 验证请求被原样转发(body 不动 + URL 指向 pricebot)
|
||||
assert captured["json"] == _stub_request_body()
|
||||
# 顶层多出 trace_id 回显(值=请求带的;不 mint,签发点唯一在 /coupon/session started)
|
||||
assert r.json() == {**fake_pricebot_resp, "trace_id": "test-trace-1"}
|
||||
# 验证请求被原样转发(body 字节不动 + URL 指向 pricebot)
|
||||
assert json.loads(captured["content"]) == _stub_request_body()
|
||||
assert captured["url"].endswith("/api/coupon/step")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""POST /api/v1/coupon/session 的 trace_id 签发行为(统一 trace_id 由后端下发)。
|
||||
|
||||
- started 不带 trace_id → 服务端签发并返回,行以签发 id 建;
|
||||
- started 带 trace_id → 回显沿用(老客户端兼容);
|
||||
- 非 started 缺 trace_id → 不签发不写库,trace_id=null(防孤儿行)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.coupon_state import CouponSession
|
||||
|
||||
|
||||
def _base_payload(**overrides) -> dict:
|
||||
payload = {
|
||||
"device_id": "cs-issue-device",
|
||||
"status": "started",
|
||||
"started_at_ms": 1_722_000_000_000,
|
||||
"platforms": ["meituan-waimai"],
|
||||
"app_env": "dev",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_session_started_issues_trace_id_when_absent(client) -> None:
|
||||
response = client.post("/api/v1/coupon/session", json=_base_payload())
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["ok"] is True
|
||||
issued = body["trace_id"]
|
||||
assert issued
|
||||
with SessionLocal() as db:
|
||||
row = db.execute(
|
||||
select(CouponSession).where(CouponSession.trace_id == issued)
|
||||
).scalar_one()
|
||||
assert row.device_id == "cs-issue-device"
|
||||
assert row.status == "started"
|
||||
|
||||
|
||||
def test_session_started_echoes_client_trace_id(client) -> None:
|
||||
response = client.post(
|
||||
"/api/v1/coupon/session", json=_base_payload(trace_id="cs-legacy-1")
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["trace_id"] == "cs-legacy-1"
|
||||
with SessionLocal() as db:
|
||||
count = db.scalar(
|
||||
select(func.count(CouponSession.id)).where(
|
||||
CouponSession.trace_id == "cs-legacy-1"
|
||||
)
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_session_terminal_without_trace_id_skips_write(client) -> None:
|
||||
response = client.post(
|
||||
"/api/v1/coupon/session",
|
||||
json=_base_payload(status="completed", elapsed_ms=1234, claimed_count=2),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["ok"] is True
|
||||
assert body["trace_id"] is None
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _load_migration():
|
||||
path = (
|
||||
Path(__file__).parents[1]
|
||||
/ "alembic"
|
||||
/ "versions"
|
||||
/ "deepseek_v4_flash_price.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("deepseek_v4_flash_price", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_migration_follows_comparison_status_normalization():
|
||||
migration = _load_migration()
|
||||
|
||||
assert migration.down_revision == "comparison_below_min_success"
|
||||
|
||||
|
||||
def test_migration_adds_price_and_corrects_only_mispriced_snapshot(monkeypatch):
|
||||
migration = _load_migration()
|
||||
engine = sa.create_engine("sqlite://")
|
||||
metadata = sa.MetaData()
|
||||
app_config = sa.Table(
|
||||
"app_config",
|
||||
metadata,
|
||||
sa.Column("key", sa.String(64), primary_key=True),
|
||||
sa.Column("value", sa.JSON, nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime),
|
||||
)
|
||||
comparison = sa.Table(
|
||||
"comparison_record",
|
||||
metadata,
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("llm_calls", sa.JSON),
|
||||
sa.Column("llm_cost_yuan", sa.Float),
|
||||
sa.Column("llm_price_snapshot", sa.JSON),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
calls = [
|
||||
{
|
||||
"model": "deepseek-v4-flash",
|
||||
"error": None,
|
||||
"usage": {"prompt_tokens": 12031, "completion_tokens": 125},
|
||||
},
|
||||
{
|
||||
"model": "qwen3.5-flash",
|
||||
"error": None,
|
||||
"usage": {"prompt_tokens": 2416, "completion_tokens": 119},
|
||||
},
|
||||
]
|
||||
snapshot = {
|
||||
"mode": "per_model",
|
||||
"prices": {
|
||||
"deepseek-v4-flash": {
|
||||
"input_per_1m": 3.0,
|
||||
"output_per_1m": 15.0,
|
||||
"_source": "default",
|
||||
},
|
||||
"qwen3.5-flash": {
|
||||
"input_per_1m": 0.2,
|
||||
"output_per_1m": 2.0,
|
||||
"_source": "per_model",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
with engine.begin() as conn:
|
||||
original_updated_at = datetime(2026, 7, 13, 18, 20, 10)
|
||||
conn.execute(
|
||||
app_config.insert().values(
|
||||
key="llm_token_price",
|
||||
value={
|
||||
"per_model": {
|
||||
"qwen3.5-flash": {
|
||||
"input_per_1m": 0.2,
|
||||
"output_per_1m": 2.0,
|
||||
}
|
||||
},
|
||||
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
|
||||
},
|
||||
updated_at=original_updated_at,
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
comparison.insert().values(
|
||||
id=1,
|
||||
llm_calls=calls,
|
||||
llm_cost_yuan=0.038689,
|
||||
llm_price_snapshot=snapshot,
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(migration.op, "get_bind", lambda: conn)
|
||||
|
||||
migration.upgrade()
|
||||
|
||||
config = conn.execute(
|
||||
sa.select(app_config.c.value).where(
|
||||
app_config.c.key == "llm_token_price"
|
||||
)
|
||||
).scalar_one()
|
||||
assert config["per_model"]["deepseek-v4-flash"] == {
|
||||
"input_per_1m": 1.0,
|
||||
"output_per_1m": 2.0,
|
||||
}
|
||||
assert conn.execute(
|
||||
sa.select(app_config.c.updated_at).where(
|
||||
app_config.c.key == "llm_token_price"
|
||||
)
|
||||
).scalar_one() == original_updated_at
|
||||
corrected = conn.execute(sa.select(comparison)).mappings().one()
|
||||
assert corrected["llm_cost_yuan"] == 0.013002
|
||||
assert corrected["llm_price_snapshot"]["prices"]["deepseek-v4-flash"] == {
|
||||
"input_per_1m": 1.0,
|
||||
"output_per_1m": 2.0,
|
||||
"_source": "per_model",
|
||||
}
|
||||
assert corrected["llm_price_snapshot"]["pricing_correction"] == (
|
||||
"deepseek_v4_flash_price"
|
||||
)
|
||||
|
||||
migration.downgrade()
|
||||
|
||||
reverted_config = conn.execute(
|
||||
sa.select(app_config.c.value).where(
|
||||
app_config.c.key == "llm_token_price"
|
||||
)
|
||||
).scalar_one()
|
||||
assert "deepseek-v4-flash" not in reverted_config["per_model"]
|
||||
reverted = conn.execute(sa.select(comparison)).mappings().one()
|
||||
assert reverted["llm_cost_yuan"] == 0.038689
|
||||
assert "pricing_correction" not in reverted["llm_price_snapshot"]
|
||||
|
||||
|
||||
def test_downgrade_preserves_price_that_existed_before_upgrade(monkeypatch):
|
||||
migration = _load_migration()
|
||||
engine = sa.create_engine("sqlite://")
|
||||
metadata = sa.MetaData()
|
||||
app_config = sa.Table(
|
||||
"app_config",
|
||||
metadata,
|
||||
sa.Column("key", sa.String(64), primary_key=True),
|
||||
sa.Column("value", sa.JSON, nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime),
|
||||
)
|
||||
sa.Table(
|
||||
"comparison_record",
|
||||
metadata,
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("llm_calls", sa.JSON),
|
||||
sa.Column("llm_cost_yuan", sa.Float),
|
||||
sa.Column("llm_price_snapshot", sa.JSON),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
explicit_price = {"input_per_1m": 1.0, "output_per_1m": 2.0}
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
app_config.insert().values(
|
||||
key="llm_token_price",
|
||||
value={
|
||||
"per_model": {"deepseek-v4-flash": explicit_price},
|
||||
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
|
||||
},
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(migration.op, "get_bind", lambda: conn)
|
||||
|
||||
migration.upgrade()
|
||||
migration.downgrade()
|
||||
|
||||
config = conn.execute(sa.select(app_config.c.value)).scalar_one()
|
||||
assert config["per_model"]["deepseek-v4-flash"] == explicit_price
|
||||
@@ -108,6 +108,10 @@ def test_get_llm_prices_falls_back_to_default_then_uses_override():
|
||||
# 无 override → CONFIG_DEFS 默认(含 per_model / default)
|
||||
prices = get_llm_prices(db)
|
||||
assert "per_model" in prices and "default" in prices
|
||||
assert prices["per_model"]["deepseek-v4-flash"] == {
|
||||
"input_per_1m": 1.0,
|
||||
"output_per_1m": 2.0,
|
||||
}
|
||||
# 有 override → 用 DB 值
|
||||
app_config.set_value(
|
||||
db, "llm_token_price",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.user import User
|
||||
from app.repositories import ops_marquee
|
||||
|
||||
|
||||
def _clear_real_rows_cache() -> None:
|
||||
ops_marquee._real_rows_cache.update(at=None, rows=None, test_phones=None)
|
||||
|
||||
|
||||
def test_real_marquee_records_exclude_configured_test_account(monkeypatch) -> None:
|
||||
legacy_test_phone = "19900009991"
|
||||
listed_test_phone = "19900009992"
|
||||
real_phone = "19900009993"
|
||||
monkeypatch.setattr(settings, "TEST_ACCOUNT_PHONE", legacy_test_phone)
|
||||
monkeypatch.setattr(settings, "TEST_ACCOUNT_PHONES", listed_test_phone)
|
||||
_clear_real_rows_cache()
|
||||
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
legacy_test_user = User(
|
||||
phone=legacy_test_phone,
|
||||
username="29900009991",
|
||||
register_channel="sms",
|
||||
)
|
||||
listed_test_user = User(
|
||||
phone=listed_test_phone,
|
||||
username="29900009992",
|
||||
register_channel="sms",
|
||||
)
|
||||
real_user = User(
|
||||
phone=real_phone,
|
||||
username="29900009993",
|
||||
register_channel="sms",
|
||||
)
|
||||
db.add_all([legacy_test_user, listed_test_user, real_user])
|
||||
db.flush()
|
||||
db.add_all([
|
||||
ComparisonRecord(
|
||||
user_id=legacy_test_user.id,
|
||||
trace_id="marquee-legacy-test-account-trace",
|
||||
status="success",
|
||||
saved_amount_cents=29_991,
|
||||
items=[],
|
||||
comparison_results=[],
|
||||
),
|
||||
ComparisonRecord(
|
||||
user_id=listed_test_user.id,
|
||||
trace_id="marquee-listed-test-account-trace",
|
||||
status="success",
|
||||
saved_amount_cents=29_992,
|
||||
items=[],
|
||||
comparison_results=[],
|
||||
),
|
||||
ComparisonRecord(
|
||||
user_id=real_user.id,
|
||||
trace_id="marquee-real-account-trace",
|
||||
status="success",
|
||||
saved_amount_cents=29_993,
|
||||
items=[],
|
||||
comparison_results=[],
|
||||
),
|
||||
])
|
||||
db.flush()
|
||||
|
||||
app_rows = ops_marquee._recent_real_rows(db)
|
||||
browse_rows, _ = ops_marquee.list_real_records(db, mode="real", limit=1_000)
|
||||
finally:
|
||||
_clear_real_rows_cache()
|
||||
|
||||
assert all(saved not in {29_991, 29_992} for _uid, saved, _nickname in app_rows)
|
||||
assert any(saved == 29_993 for _uid, saved, _nickname in app_rows)
|
||||
assert all(row["saved_amount_cents"] not in {29_991, 29_992} for row in browse_rows)
|
||||
assert any(row["saved_amount_cents"] == 29_993 for row in browse_rows)
|
||||
Reference in New Issue
Block a user