Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd6c572798 | |||
| f7490e8b3f | |||
| 4d8e8c0fce | |||
| 03129e059f | |||
| bc2ed5de56 | |||
| 9036bc5a08 | |||
| 1a61cb5a65 | |||
| 67ac2dcbbb | |||
| 08a49504fa | |||
| ab2de6ec79 |
@@ -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,6 +21,7 @@ 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,
|
||||
@@ -36,6 +37,8 @@ logger = logging.getLogger("shagua.compare_record")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
|
||||
|
||||
COMPARE_DAILY_LIMIT_MESSAGE = "今日比价额度用完啦,明天再来吧~"
|
||||
|
||||
|
||||
@router.post(
|
||||
"/start",
|
||||
@@ -77,11 +80,7 @@ def reserve_compare_start(
|
||||
except crud_compare.DailyCompareStartLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=(
|
||||
f"今日已比价超过{policy.limit}次,请明天再试"
|
||||
if policy.limit is not None
|
||||
else "今日比价次数已达上限,请明天再试"
|
||||
),
|
||||
detail=COMPARE_DAILY_LIMIT_MESSAGE,
|
||||
) from None
|
||||
except crud_compare.ComparisonTraceOwnershipError:
|
||||
raise HTTPException(
|
||||
@@ -155,6 +154,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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
+2
-1
@@ -58,7 +58,7 @@ WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
|
||||
# 规则(2026-07-09 拍板):
|
||||
# - 新人档(is_newbie):账号历史一次性,"发起就算用过"(任意状态含被拒),用过即不再下发;
|
||||
# 0.1 与 0.3 各自独立同天可各提一次,且不参与常规档"每日选一个额度"互斥。
|
||||
# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1),三档每天只能选一个。
|
||||
# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1 / 100×1),四档每天只能选一个。
|
||||
# invite_cash(邀请页)本轮无档位概念,不在此表。改档位=改这里发版。
|
||||
class WithdrawTier(NamedTuple):
|
||||
amount_cents: int
|
||||
@@ -74,6 +74,7 @@ WITHDRAW_TIERS_COIN_CASH: tuple[WithdrawTier, ...] = (
|
||||
WithdrawTier(50, "0.5", None, 3, False),
|
||||
WithdrawTier(1000, "10", None, 1, False),
|
||||
WithdrawTier(2000, "20", None, 1, False),
|
||||
WithdrawTier(10000, "100", None, 1, False),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -735,8 +735,8 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -
|
||||
- 新人档(0.1/0.3):账号历史一次性——进行中(reviewing/pending)或成功打款(success)即视为
|
||||
已用,直接**从返回列表消失**;被拒/转账失败/解绑退回(均已退款、钱没到手)则恢复可提,不永久
|
||||
占用资格。两档各自独立互不影响,不参与"每日选一个额度"互斥。
|
||||
- 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
|
||||
都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。
|
||||
- 常规档(0.5×3 / 10×1 / 20×1 / 100×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
|
||||
都计入,被拒/失败不退当天名额);四档每天只能选一个,选定后其余三档当天 other_tier_selected。
|
||||
- invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。
|
||||
余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)。
|
||||
"""
|
||||
@@ -785,7 +785,7 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -
|
||||
out.append({
|
||||
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
|
||||
"is_newbie": True, "available": True, "disabled_reason": None,
|
||||
"remaining_today": 1,
|
||||
"remaining_today": 1, "daily_limit": t.daily_limit,
|
||||
})
|
||||
continue
|
||||
used = today_counts.get(t.amount_cents, 0)
|
||||
@@ -798,7 +798,9 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -
|
||||
out.append({
|
||||
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
|
||||
"is_newbie": False, "available": available, "disabled_reason": reason,
|
||||
"remaining_today": remaining,
|
||||
# daily_limit 一起下发:客户端要靠它区分「本档能提 3 次」和「本档只能提 1 次」——
|
||||
# 只看 remaining_today 分不出「0.5 已提两次剩 1」与「10 元一次没提剩 1」。
|
||||
"remaining_today": remaining, "daily_limit": t.daily_limit,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@@ -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(不单列),
|
||||
@@ -259,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=无限制)")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -88,6 +88,14 @@ class WithdrawTierOut(BaseModel):
|
||||
description="不可提原因:quota_exhausted(今日次数满) / other_tier_selected(今日已选其他额度)",
|
||||
)
|
||||
remaining_today: int = Field(0, description="今日剩余可提次数")
|
||||
daily_limit: int = Field(
|
||||
1,
|
||||
description=(
|
||||
"该档每日可提次数上限(rewards.WITHDRAW_TIERS_COIN_CASH.daily_limit)。"
|
||||
"客户端据此判「本档可提多次」——只有 daily_limit>1 且 remaining_today<daily_limit"
|
||||
"(= 今天已提过至少一次)才展示「今日还可提N次」角标。"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class WithdrawInfoOut(BaseModel):
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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]:
|
||||
@@ -138,10 +140,132 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.json()["detail"] == "今日已比价超过100次,请明天再试"
|
||||
assert response.json()["detail"] == "今日比价额度用完啦,明天再来吧~"
|
||||
with SessionLocal() as db:
|
||||
assert db.scalar(
|
||||
select(func.count(ComparisonRecord.id)).where(
|
||||
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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""福利页(coin_cash)提现档位规则测试(7-9提现ui对齐)。
|
||||
|
||||
规则(2026-07-09 拍板):
|
||||
- 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20(日1次)。
|
||||
- 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20/100(日1次)。
|
||||
- 计次口径"发起就算":当天创建的单不论最终状态(含被拒)都占名额;新人档任何状态都算用过。
|
||||
- 常规三档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次。
|
||||
- 常规四档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次。
|
||||
- invite_cash 无档位概念:tiers 为空、下单不走档位闸(邀请页行为不变)。
|
||||
wxpay 调用全部 monkeypatch;现金余额 DB 直灌(同 test_withdraw.py 套路)。
|
||||
"""
|
||||
@@ -74,15 +74,18 @@ def _tiers(client, token: str, source: str = "coin_cash") -> list[dict]:
|
||||
|
||||
|
||||
def test_withdraw_info_tiers_full_and_invite_empty(client, monkeypatch) -> None:
|
||||
"""新用户 coin_cash 下发 5 档(新人角标齐);invite_cash tiers 为空。"""
|
||||
"""新用户 coin_cash 下发 6 档(新人角标齐);invite_cash tiers 为空。"""
|
||||
token = _login(client, "13800006001")
|
||||
tiers = _tiers(client, token)
|
||||
assert [t["amount_cents"] for t in tiers] == [10, 30, 50, 1000, 2000]
|
||||
assert [t["label"] for t in tiers] == ["0.1", "0.3", "0.5", "10", "20"]
|
||||
assert [t["amount_cents"] for t in tiers] == [10, 30, 50, 1000, 2000, 10000]
|
||||
assert [t["label"] for t in tiers] == ["0.1", "0.3", "0.5", "10", "20", "100"]
|
||||
assert tiers[0]["badge"] == "新人福利" and tiers[0]["is_newbie"] is True
|
||||
assert tiers[1]["badge"] == "新人福利" and tiers[1]["is_newbie"] is True
|
||||
assert all(t["available"] for t in tiers)
|
||||
assert tiers[2]["remaining_today"] == 3 # 0.5 日 3 次
|
||||
# daily_limit 一起下发:客户端只有在 daily_limit>1 且 remaining_today<daily_limit
|
||||
# (= 今天已提过)时才画「今日还可提N次」角标,光看 remaining_today 分不出单次档没提的情况。
|
||||
assert [t["daily_limit"] for t in tiers] == [1, 1, 3, 1, 1, 1]
|
||||
assert _tiers(client, token, source="invite_cash") == []
|
||||
|
||||
|
||||
@@ -101,9 +104,9 @@ def test_newbie_tiers_independent_and_once_forever(client, monkeypatch) -> None:
|
||||
amounts = [t["amount_cents"] for t in tiers]
|
||||
assert 10 not in amounts # 0.1 消失
|
||||
assert 30 in amounts # 0.3 还在,同天仍可提
|
||||
# 新人档不参与"选一个额度":常规三档全部仍可提
|
||||
# 新人档不参与"选一个额度":常规四档全部仍可提
|
||||
regular = {t["amount_cents"]: t for t in tiers if not t["is_newbie"]}
|
||||
assert all(regular[a]["available"] for a in (50, 1000, 2000))
|
||||
assert all(regular[a]["available"] for a in (50, 1000, 2000, 10000))
|
||||
|
||||
r = _withdraw(client, token, 30) # 同天 0.3 照提
|
||||
assert r.status_code == 200, r.text
|
||||
@@ -115,7 +118,7 @@ def test_newbie_tiers_independent_and_once_forever(client, monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_regular_daily_select_one_tier(client, monkeypatch) -> None:
|
||||
"""当天提过 0.5 → 10/20 置灰 other_tier_selected,下单 409;0.5 还能继续提(3 次内)。"""
|
||||
"""当天提过 0.5 → 10/20/100 置灰 other_tier_selected,下单 409;0.5 还能继续提(3 次内)。"""
|
||||
_patch_userinfo(monkeypatch, "openid_tier_3")
|
||||
token = _login(client, "13800006003")
|
||||
_seed_balances(client, token, "13800006003", cash=10_000)
|
||||
@@ -129,6 +132,7 @@ def test_regular_daily_select_one_tier(client, monkeypatch) -> None:
|
||||
assert tiers[50]["available"] and tiers[50]["remaining_today"] == 2
|
||||
assert not tiers[1000]["available"] and tiers[1000]["disabled_reason"] == "other_tier_selected"
|
||||
assert not tiers[2000]["available"] and tiers[2000]["disabled_reason"] == "other_tier_selected"
|
||||
assert not tiers[10000]["available"] and tiers[10000]["disabled_reason"] == "other_tier_selected"
|
||||
|
||||
r = _withdraw(client, token, 1000) # 选一额度互斥 → 409
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
Reference in New Issue
Block a user