Compare commits

..

2 Commits

Author SHA1 Message Date
linkeyu dca7773519 test(marquee): isolate test account filter data 2026-08-01 16:59:11 +08:00
linkeyu 04c3c34bdb fix(marquee): exclude test accounts from homepage feed 2026-08-01 16:53:25 +08:00
71 changed files with 230 additions and 7898 deletions
@@ -1,60 +0,0 @@
"""coin_transaction.trace_id (金币记录按会话聚合比价/领券看广告金币)
Revision ID: coin_transaction_trace_id
Revises: savings_record_trace_id
Create Date: 2026-08-07
比价/领券信息流发奖时把本场 trace_id 一并写入 coin_transaction;金币变动记录接口按
trace_id 把一次比价/领券的多条广告金币聚合成一条。历史行从 ad_feed_reward_record
回填(ref_id == client_event_id)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'coin_transaction_trace_id'
down_revision: Union[str, Sequence[str], None] = 'savings_record_trace_id'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# SQLite 下 ADD COLUMN(可空) 与 CREATE INDEX 均原生支持,无需 batch_alter_table。
op.add_column(
'coin_transaction',
sa.Column('trace_id', sa.String(length=64), nullable=True),
)
op.create_index(
op.f('ix_coin_transaction_trace_id'),
'coin_transaction',
['trace_id'],
unique=False,
)
# 历史回填:从 ad_feed_reward_record 按 ref_id==client_event_id 补 trace_id。仅比价/领券两类、
# 仅当前为空、且广告行确有 trace_id 时补(EXISTS 守护);`trace_id IS NULL` 保证重跑幂等。
# 相关子查询 SQLite/PG 通用。两类 biz_type 在应用侧为 rewards.FEED_AD_SESSION_BIZ_TYPES,
# 此处按「迁移不可变」原则硬编码历史快照(勿改为 import 应用常量)。
# 大表(prod PG)如需可改按 id 区间分批;此处一次性 UPDATE。
op.execute(
"""
UPDATE coin_transaction SET trace_id = (
SELECT r.trace_id FROM ad_feed_reward_record r
WHERE r.client_event_id = coin_transaction.ref_id)
WHERE biz_type IN ('feed_ad_reward_comparison', 'feed_ad_reward_coupon')
AND trace_id IS NULL
AND EXISTS (SELECT 1 FROM ad_feed_reward_record r2
WHERE r2.client_event_id = coin_transaction.ref_id
AND r2.trace_id IS NOT NULL)
"""
)
def downgrade() -> None:
op.drop_index(
op.f('ix_coin_transaction_trace_id'),
table_name='coin_transaction',
)
op.drop_column('coin_transaction', 'trace_id')
@@ -1,76 +0,0 @@
"""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)
)
-50
View File
@@ -1,50 +0,0 @@
"""comparison_record 加 updated_at 列(报警水位)+ 回填现有行 + 索引
新增 updated_at:server_default + onupdate = func.now()(DB 时钟)。比价失败报警 worker 用它做
单调水位(WHERE updated_at > watermark)。加列后回填现有行 = created_at,避免冷启动 max(updated_at)
为 NULL;再置 NOT NULL + 建索引 ix_comparison_updated(水位查询按它)。batch 模式兼容 SQLite。
Revision ID: comparison_updated_at
Revises: deepseek_v4_flash_price
Create Date: 2026-08-04 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "comparison_updated_at"
down_revision: str | Sequence[str] | None = "deepseek_v4_flash_price"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# 1) 先加可空列(不带 default,避免各库对 add-column-with-default 的差异)
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
batch_op.add_column(sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True))
# 2) 回填现有行 = created_at(全新环境表为空,回填 no-op)
op.get_bind().execute(
sa.text(
"UPDATE comparison_record SET updated_at = created_at WHERE updated_at IS NULL"
)
)
# 3) 置 NOT NULL + server_default + 建索引
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
batch_op.alter_column(
"updated_at",
existing_type=sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
)
batch_op.create_index("ix_comparison_updated", ["updated_at"], unique=False)
def downgrade() -> None:
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
batch_op.drop_index("ix_comparison_updated")
batch_op.drop_column("updated_at")
-211
View File
@@ -1,211 +0,0 @@
"""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)
@@ -1,34 +0,0 @@
"""savings_record.trace_id (下单归因到的比价 trace_id)
Revision ID: savings_record_trace_id
Revises: comparison_updated_at
Create Date: 2026-08-07 00:00:00.000000
「已下单」从店级改单次级:下单上报带上本次比价的 trace_id,落这一列,读取时按
trace_id 精确对齐 comparison_record.trace_id —— 同一家店比价多次,只有真正下单的
那一条标「已下单」。可空:demo 行 / 老客户端 / 历史订单没有 trace_id(→ 不进任何
记录的「已下单」,不做回填)。见 repositories.comparison._ordered_trace_id_select。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'savings_record_trace_id'
down_revision: Union[str, Sequence[str], None] = 'comparison_updated_at'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.add_column(sa.Column('trace_id', sa.String(length=64), nullable=True))
batch_op.create_index(batch_op.f('ix_savings_record_trace_id'), ['trace_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_savings_record_trace_id'))
batch_op.drop_column('trace_id')
@@ -1,61 +0,0 @@
"""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)
)
+10 -29
View File
@@ -166,18 +166,6 @@ 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,
@@ -194,9 +182,8 @@ def _session_to_row(
"app_env": r.app_env,
"started_at": r.started_at,
"claimed_count": r.claimed_count,
"point_success_count": point_success_count,
"point_total_count": point_total_count,
"point_event_count": point_event_count,
"point_success_count": point_stats["succeeded"] if point_stats else None,
"point_total_count": point_stats["tried"] if point_stats else None,
"trace_url": r.trace_url,
"ad_revenue_yuan": ad_revenue_yuan,
}
@@ -207,24 +194,21 @@ 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"),
tried.label("tried"),
func.count().label("events"),
func.count().label("tried"),
)
.where(
CouponClaimEvent.trace_id.in_(trace_ids),
CouponClaimEvent.status.in_(_SLOT_TRIED),
)
.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_count or 0),
"events": int(event_count or 0),
}
for trace_id, success_count, tried_count, event_count in rows
trace_id: {"succeeded": int(success_count or 0), "tried": int(tried or 0)}
for trace_id, success_count, tried in rows
if trace_id is not None
}
@@ -416,15 +400,12 @@ 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()
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)
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows])
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
],
+39 -50
View File
@@ -5,14 +5,13 @@
"""
from __future__ import annotations
from datetime import UTC, date, datetime, time, timedelta
from datetime import date, datetime, time, timedelta, timezone
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
@@ -40,16 +39,6 @@ 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": "比价信息流",
@@ -76,30 +65,30 @@ def _device_marketing_name(model: str | None) -> str | None:
def _attach_comparison_order_status(db: Session, items: list[ComparisonRecord]) -> None:
"""按 C 端既有口径给比价记录批量补充是否真实下单(按 trace_id 精确对齐真实下单上报)"""
"""按 C 端既有口径给比价记录批量补充是否真实下单。"""
user_ids = {item.user_id for item in items if item.user_id is not None}
trace_ids = {item.trace_id for item in items if item.trace_id}
shop_names = {item.store_name for item in items if item.store_name}
ordered_pairs: set[tuple[int, str]] = set()
if user_ids and trace_ids:
if user_ids and shop_names:
rows = db.execute(
select(SavingsRecord.user_id, SavingsRecord.trace_id)
select(SavingsRecord.user_id, SavingsRecord.shop_name)
.where(
SavingsRecord.user_id.in_(user_ids),
SavingsRecord.source == "compare",
SavingsRecord.trace_id.in_(trace_ids),
SavingsRecord.shop_name.in_(shop_names),
)
.distinct()
).all()
ordered_pairs = {
(row.user_id, row.trace_id)
(row.user_id, row.shop_name)
for row in rows
if row.trace_id is not None
if row.shop_name is not None
}
for item in items:
item.ordered = bool(
item.user_id is not None
and item.trace_id
and (item.user_id, item.trace_id) in ordered_pairs
and item.store_name
and (item.user_id, item.store_name) in ordered_pairs
)
@@ -345,7 +334,7 @@ def _comparison_conditions(
)
)
if status:
conditions.append(_comparison_status_condition(status))
conditions.append(ComparisonRecord.status == status)
if business_type:
conditions.append(ComparisonRecord.business_type == business_type)
if store:
@@ -354,10 +343,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(UTC)
start_utc = datetime.combine(date_from, time.min, tzinfo=beijing).astimezone(timezone.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(UTC)
end_utc = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=beijing).astimezone(timezone.utc)
conditions.append(ComparisonRecord.created_at < end_utc)
return conditions
@@ -397,7 +386,6 @@ 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
@@ -421,8 +409,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_filter, quantiles: tuple[float, ...]):
"""PostgreSQL 耗时聚合语句;status_filter 为已构造的口径条件表达式,每口径只返回一行。"""
def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles: tuple[float, ...]):
"""PostgreSQL 耗时聚合语句;每种状态只返回一行。"""
return select(
func.avg(ComparisonRecord.total_ms),
*(
@@ -431,7 +419,7 @@ def _comparison_duration_aggregate_stmt(conditions: list, status_filter, quantil
),
).where(
*conditions,
status_filter,
ComparisonRecord.status == status,
ComparisonRecord.total_ms.is_not(None),
)
@@ -440,13 +428,13 @@ def _comparison_duration_aggregates(
db: Session,
*,
conditions: list,
status_filter,
status: str,
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_filter, quantiles)
_comparison_duration_aggregate_stmt(conditions, status, quantiles)
).one()
return [_round_duration_ms(value) for value in row]
@@ -456,7 +444,7 @@ def _comparison_duration_aggregates(
select(ComparisonRecord.total_ms)
.where(
*conditions,
status_filter,
ComparisonRecord.status == status,
ComparisonRecord.total_ms.is_not(None),
)
.order_by(ComparisonRecord.total_ms)
@@ -483,14 +471,16 @@ 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((_success | (ComparisonRecord.status == "failed"), 1), else_=0)),
func.sum(case((_success, 1), else_=0)),
func.sum(case((ComparisonRecord.status.in_(("success", "failed")), 1), else_=0)),
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
func.avg(ComparisonRecord.llm_cost_yuan),
func.sum(case((_success & (ComparisonRecord.saved_amount_cents > 0), 1), else_=0)),
func.sum(case((
(ComparisonRecord.status == "success")
& (ComparisonRecord.saved_amount_cents > 0), 1
), else_=0)),
func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)),
).where(*conditions)
).one()
@@ -502,13 +492,13 @@ def comparison_records_summary(
success_duration_stats = _comparison_duration_aggregates(
db,
conditions=conditions,
status_filter=admin_success_sql(),
status="success",
quantiles=(0.05, 0.5, 0.95, 0.99),
)
cancelled_duration_stats = _comparison_duration_aggregates(
db,
conditions=conditions,
status_filter=(ComparisonRecord.status == "cancelled"),
status="cancelled",
quantiles=(0.05, 0.5, 0.95),
)
success_rate_denominator = started - cancelled
@@ -533,13 +523,12 @@ def comparison_records_summary(
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname + admin 口径瞬态)。"""
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
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
@@ -568,8 +557,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=UTC)
return int((datetime.now(UTC) - last).total_seconds())
last = last.replace(tzinfo=timezone.utc)
return int((datetime.now(timezone.utc) - last).total_seconds())
def _device_model_from_id(device_id: str) -> str:
@@ -622,7 +611,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(UTC) - timedelta(minutes=timeout_min)
return datetime.now(timezone.utc) - timedelta(minutes=timeout_min)
def list_device_liveness(
@@ -798,11 +787,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(UTC)
now = datetime.now(timezone.utc)
today_start = (
datetime.now(ZoneInfo("Asia/Shanghai"))
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(UTC)
.astimezone(timezone.utc)
)
if quick_filter == "abnormal":
stmt = stmt.where(
@@ -849,8 +838,8 @@ def _as_utc(value: datetime) -> datetime:
生产会话非 UTC 时筛选边界会整体偏移——故统一 tz-aware(与 stats.py / withdraw_summary 一致)。
无时区入参按 UTC 解释。"""
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def withdraw_list_enrichment(
@@ -1015,7 +1004,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(UTC)
.astimezone(timezone.utc)
)
def _today_count(status: str) -> int:
@@ -1190,8 +1179,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=UTC) if user.created_at.tzinfo is None else user.created_at
if datetime.now(UTC) - created_at < timedelta(hours=24):
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):
flags.append("新注册用户")
# 历史异常提现拆「拒绝」「失败」两类(口径不同:拒绝=人工驳回退款,失败=打款失败退款)
rejected_n = sum(1 for item in recent_withdraws if item.status == "rejected")
@@ -1402,7 +1391,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(UTC).replace(tzinfo=None)
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None)
def _coin_record_sort_key(row: dict) -> datetime:
+6 -15
View File
@@ -12,7 +12,6 @@ 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
@@ -258,14 +257,13 @@ 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(
(_period_success | (ComparisonRecord.status == "failed"), 1),
(ComparisonRecord.status.in_(("success", "failed")), 1),
else_=0,
)
),
@@ -278,7 +276,7 @@ def dashboard_overview(
0,
),
func.coalesce(
func.sum(case((_period_success, 1), else_=0)),
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
0,
),
func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0),
@@ -376,14 +374,15 @@ def dashboard_overview(
.where(
SavingsRecord.user_id == ComparisonRecord.user_id,
SavingsRecord.source == "compare",
SavingsRecord.trace_id.is_not(None),
SavingsRecord.trace_id == ComparisonRecord.trace_id,
SavingsRecord.shop_name.is_not(None),
SavingsRecord.shop_name == ComparisonRecord.store_name,
)
.exists()
)
period_ordered_count = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.store_name.is_not(None),
ordered_exists,
)
@@ -567,10 +566,6 @@ 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
@@ -736,13 +731,9 @@ 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_success_denominator, 4)
if coupon_success_denominator
else None
round(coupon_all_success / coupon_started, 4) if coupon_started else None
),
"point_success": coupon_point_success,
"points_per_session": coupon_points_per_session,
+2 -2
View File
@@ -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|running)$")] = None,
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = 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|running)$")] = None,
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
business_type: Annotated[str | None, Query()] = None,
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None,
+2 -8
View File
@@ -13,19 +13,14 @@ class AdminComparisonListItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
# 软鉴权/匿名下帧0 建行时 user_id 可能暂缺(见 models.comparison 注释);admin 全看含孤儿行,故可空。
user_id: int | None = None
user_id: int
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 # success / failed / cancelled / running;旧细分值由前端兼容映射
# admin 展示口径(见 repositories/comparison_outcome):成功含「跑完但外部缺失」,
# 纯技术故障才 failed;outcome_hint 非空=有缺失,前端标感叹号。
admin_status: str = "success"
outcome_hint: str | None = None
status: str
information: str | None = None
store_name: str | None = None
product_names: str | None = None # 下单商品名派生串(顿号分隔;「商品」列展示 + 商品搜索)
@@ -88,7 +83,6 @@ 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 = []
# 全量环境
+2 -8
View File
@@ -79,16 +79,10 @@ class CouponDataRow(BaseModel):
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
claimed_count: int | None = None
point_success_count: int | None = Field(
None,
description="本次成功单券数(success+already_claimed);中途退出且无逐券结果为0,其它无事件为空",
None, description="本次成功单券数(success+already_claimed);无逐券事件为空"
)
point_total_count: int | None = Field(
None,
description="本次尝试单券数(success+already_claimed+failed,不含 skipped);中途退出且无逐券结果为0,其它无事件为空",
)
point_event_count: int = Field(
0,
description="本次全部逐券事件数(含 skipped);用于区分无有效计分事件与完全无事件",
None, description="本次尝试单券数(success+already_claimed+failed,不含 skipped);无逐券事件为空"
)
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
ad_revenue_yuan: float = Field(
-3
View File
@@ -70,9 +70,6 @@ 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
+5 -23
View File
@@ -21,7 +21,6 @@ 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,
@@ -37,8 +36,6 @@ logger = logging.getLogger("shagua.compare_record")
router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
COMPARE_DAILY_LIMIT_MESSAGE = "今日比价额度用完啦,明天再来吧~"
@router.post(
"/start",
@@ -80,7 +77,11 @@ def reserve_compare_start(
except crud_compare.DailyCompareStartLimitExceeded:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=COMPARE_DAILY_LIMIT_MESSAGE,
detail=(
f"今日已比价超过{policy.limit}次,请明天再试"
if policy.limit is not None
else "今日比价次数已达上限,请明天再试"
),
) from None
except crud_compare.ComparisonTraceOwnershipError:
raise HTTPException(
@@ -154,25 +155,6 @@ 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,
+2 -4
View File
@@ -54,13 +54,11 @@ 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=images,
image_thumbnails=[media.feedback_thumbnail_url(url) for url in images],
images=fb.images or [],
status=_app_status(fb.status),
reject_reason=getattr(fb, "reject_reason", None),
reward_coins=getattr(fb, "reward_coins", None),
@@ -86,7 +84,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=[]), # noqa: B008 - FastAPI dependency declaration
images: list[UploadFile] = File(default=[]),
) -> FeedbackOut:
content = content.strip()
contact = contact.strip()
-329
View File
@@ -1,329 +0,0 @@
"""比价失败报警后台任务:周期扫 comparison_record 新落定记录 → 规则命中 → 飞书汇总。
结构仿 heartbeat_monitor_worker(单实例文件锁 + asyncio 轮询 + 优雅退出);发送与 DB 全同步,
放 asyncio.to_thread。水位存 app_config(key=compare_alert.last_watermark,值=上次处理的最大
updated_at ISO 串),查询用 updated_at 自身比较、规避时区。见 spec 第 5/6 节。
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Iterator
from dataclasses import replace as _dc_replace
from datetime import datetime
from pathlib import Path
from sqlalchemy import func, select
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
from app.core.rewards import CN_TZ
from app.db.session import SessionLocal
from app.integrations import feishu_notifier
from app.models.app_config import AppConfig
from app.models.comparison import ComparisonRecord
from app.models.user import User
from app.services import trace_stuck
from app.services.compare_alert import (
AlertHit,
classify_cancelled_fallback,
classify_record,
make_hit,
)
from app.services.compare_alert_format import format_alert_card
logger = logging.getLogger("shagua.compare_alert")
WATERMARK_KEY = "compare_alert.last_watermark"
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "compare_alert.lock"
def _read_watermark(db) -> datetime | None:
row = db.get(AppConfig, WATERMARK_KEY)
if row is None or not row.value:
return None
try:
return datetime.fromisoformat(row.value)
except (ValueError, TypeError):
return None
def _write_watermark(db, value: datetime) -> None:
iso = value.isoformat()
row = db.get(AppConfig, WATERMARK_KEY)
if row is None:
db.add(AppConfig(key=WATERMARK_KEY, value=iso, updated_by_admin_id=None))
else:
row.value = iso
db.commit()
def _send_card(card: dict) -> None:
"""发飞书交互卡片(webhook 为空则只打日志、不外发)。失败抛 FeishuNotifyError 由调用方处理。"""
webhook = settings.COMPARE_ALERT_FEISHU_WEBHOOK
if not webhook:
title = card.get("header", {}).get("title", {}).get("content", "")
logger.info("[compare-alert] webhook 未配置,仅打印: title=%s", title)
return
feishu_notifier.send_feishu_card(
webhook, card, timeout=settings.COMPARE_ALERT_FEISHU_TIMEOUT_SEC
)
def _trace_dir(base: Path, trace_url: str | None) -> Path | None:
"""URL → trace 目录 Path;trace_url 缺失/无法解析 → None。"""
name = trace_stuck.dir_name_from_trace_url(trace_url)
if not name:
return None
return base / name
def _fmt_stuck(sp: trace_stuck.StuckPoint) -> str:
"""StuckPoint → 「平台·环节 110帧/32s」;stuck_ms 为 None 时省略时长。"""
s = f"{sp.label()} {sp.frames}"
if sp.stuck_ms is not None:
s += f"/{round(sp.stuck_ms / 1000)}s"
return s
def _fmt_last(sp: trace_stuck.StuckPoint) -> str:
"""末帧路径(failed/兜底):环节·页面 + 末段停留时长,不显总帧数(总帧数配末段时长会误导)。
dwell_ms 为 None(缺 ts/时钟回退) → 只显环节。"""
s = sp.label()
if sp.dwell_ms is not None:
sec = round(sp.dwell_ms / 1000)
s += f" 停留{sec}s" if sec >= 1 else " 停留<1s"
return s
def build_hits(
records: list,
*,
work_log_dir: str,
stuck_threshold: int,
max_tail: int,
max_trace_reads: int,
cancelled_ms_threshold: int,
cancelled_step_threshold: int,
timeout_keywords: tuple[str, ...],
unrecognized_keywords: tuple[str, ...],
biz_exclude_keywords: tuple[str, ...],
) -> list[AlertHit]:
"""编排:cancelled 先读 trace,判出原地卡点就带卡点报 T5,否则(可读没卡点/读不到)一律回退
耗时/步数兜底——超长放弃照报(卡点列留空);failed 命中后附末段卡点。
trace 读取限量 max_trace_reads 次/轮;任何 trace 异常都在 trace_stuck 内部降级为「读不到」,
cancelled 因而走耗时兜底、failed 不附卡点,绝不影响报警发送。trace 只做「锦上添花」标注卡点,
绝不因「可读但没判出卡点」把超长放弃吞掉(线上 trace 几乎总可读,否则 total_ms 阈值形同虚设)。
"""
base = Path(work_log_dir) if work_log_dir else None
reads = 0
hits: list = []
for rec in records:
if rec.status == "cancelled":
res = None
if base is not None and reads < max_trace_reads:
td = _trace_dir(base, rec.trace_url)
if td is not None:
res = trace_stuck.read_stuck_points(
td, threshold=stuck_threshold, max_tail=max_tail
)
reads += 1
if res is not None and res.readable and res.points:
stuck = "".join(_fmt_stuck(sp) for sp in res.points)
hit = _dc_replace(make_hit(rec, "T5", "深度放弃"), stuck_point=stuck)
else:
# trace 判出卡点 → 上面带卡点报。其余一律回退耗时/步数兜底:可读但没判出卡点、
# 读不到、无 trace,都过 total_ms/step 阈值——超长放弃照报,不再因「trace 可读但不
# 原地卡」把超长放弃整条吞掉(线上 trace 几乎总可读,否则耗时阈值形同虚设)。
hit = classify_cancelled_fallback(
rec,
cancelled_ms_threshold=cancelled_ms_threshold,
cancelled_step_threshold=cancelled_step_threshold,
)
# 没卡点但命中(超阈值放弃)→ 末帧标「退出前在哪屏 + 在那屏停多久」(dwell-only,不显总帧数)
if hit is not None and res is not None and res.last is not None:
hit = _dc_replace(hit, stuck_point=_fmt_last(res.last))
if hit is not None:
hits.append(hit)
else:
hit = classify_record(
rec,
cancelled_ms_threshold=cancelled_ms_threshold,
cancelled_step_threshold=cancelled_step_threshold,
timeout_keywords=timeout_keywords,
unrecognized_keywords=unrecognized_keywords,
biz_exclude_keywords=biz_exclude_keywords,
)
if (
hit is not None
and hit.alert_type in ("T1", "T2", "T6")
and base is not None
and reads < max_trace_reads
):
td = _trace_dir(base, rec.trace_url)
if td is not None:
sp = trace_stuck.last_step(td, max_tail=max_tail)
reads += 1
if sp is not None:
# failed 是「末帧停在哪 + 在那屏停多久」:只显环节·页面 + 末段停留 dwell,
# 不显总帧数(总帧数配末段时长会误导);缺 ts 时 _fmt_last 自动退化为只显环节
hit = _dc_replace(hit, stuck_point=_fmt_last(sp))
if hit is not None:
hits.append(hit)
return hits
def _scan_and_alert() -> None:
"""一轮:读水位 → 查有更新记录 → 规则 → 有命中发飞书 → 成功推进水位。同步,放 to_thread 调。"""
with SessionLocal() as db:
watermark = _read_watermark(db)
if watermark is None:
# 冷启动:水位 = 当前 max(updated_at),不回溯历史失败。空表则本轮不建水位、下轮再说——
# 不用 datetime.now():那是本地时钟,与 SQLite 的 updated_at(UTC CURRENT_TIMESTAMP)
# 不同源、会差 8h,导致新记录永远追不上水位。只用 DB 产出的 updated_at 值。
max_updated = db.scalar(select(func.max(ComparisonRecord.updated_at)))
if max_updated is None:
logger.info("[compare-alert] 冷启动:表空,待有记录后再建水位")
return
_write_watermark(db, max_updated)
logger.info("[compare-alert] 冷启动,水位初始化=%s", max_updated)
return
records = list(
db.scalars(
select(ComparisonRecord)
.where(ComparisonRecord.updated_at > watermark)
.order_by(ComparisonRecord.updated_at.asc())
)
)
if not records:
return
batch_max = max(r.updated_at for r in records)
hits = build_hits(
records,
work_log_dir=settings.COMPARE_ALERT_PRICEBOT_WORK_LOG_DIR,
stuck_threshold=settings.COMPARE_ALERT_STUCK_FRAME_THRESHOLD,
max_tail=settings.COMPARE_ALERT_TRACE_MAX_TAIL_FRAMES,
max_trace_reads=settings.COMPARE_ALERT_TRACE_MAX_RECORDS,
cancelled_ms_threshold=settings.COMPARE_ALERT_CANCELLED_MS_THRESHOLD,
cancelled_step_threshold=settings.COMPARE_ALERT_CANCELLED_STEP_THRESHOLD,
timeout_keywords=settings.compare_alert_timeout_keywords,
unrecognized_keywords=settings.compare_alert_unrecognized_keywords,
biz_exclude_keywords=settings.compare_alert_biz_exclude_keywords,
)
if hits or settings.COMPARE_ALERT_SEND_EMPTY:
label = datetime.now(CN_TZ).strftime("%Y-%m-%d %H:%M")
interval_min = max(1, settings.COMPARE_ALERT_SCAN_INTERVAL_SEC // 60)
if hits:
# join User 取手机号
uids = {h.user_id for h in hits if h.user_id is not None}
if uids:
users = db.scalars(select(User).where(User.id.in_(uids)))
phone_map = {u.id: u.phone for u in users}
else:
phone_map = {}
else:
phone_map = {}
card = format_alert_card(
hits,
window_label=label,
phone_map=phone_map,
interval_min=interval_min,
max_detail_per_type=settings.COMPARE_ALERT_MAX_DETAIL_PER_TYPE,
max_total=settings.COMPARE_ALERT_MAX_TOTAL,
cancelled_ms_threshold=settings.COMPARE_ALERT_CANCELLED_MS_THRESHOLD,
cancelled_step_threshold=settings.COMPARE_ALERT_CANCELLED_STEP_THRESHOLD,
)
try:
_send_card(card)
except feishu_notifier.FeishuNotifyError:
logger.warning("[compare-alert] 发送失败,水位不推进、下轮补发", exc_info=True)
return # 不推进水位
_write_watermark(db, batch_max)
if hits:
logger.info("[compare-alert] 本轮命中 %d 条,水位推进到 %s", len(hits), batch_max)
# ---- 单实例锁 + 轮询循环(结构同 heartbeat_monitor_worker)---------------------
def _touch_lock() -> None:
with contextlib.suppress(FileNotFoundError):
os.utime(_LOCK_PATH, None)
@contextlib.contextmanager
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
fd: int | None = None
try:
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
try:
age = time.time() - _LOCK_PATH.stat().st_mtime
except FileNotFoundError:
age = stale_after_sec + 1
if age > stale_after_sec:
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
fd = None
if fd is None:
yield False
return
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
yield True
finally:
if fd is not None:
os.close(fd)
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
async def _run_loop() -> None:
interval = max(60, int(settings.COMPARE_ALERT_SCAN_INTERVAL_SEC))
lock_stale_after = max(interval * 3, 600)
with _single_instance_lock(lock_stale_after) as lock_acquired:
if not lock_acquired:
logger.warning("compare-alert skipped: another worker owns lock")
return
logger.info("compare-alert worker started interval=%ss", interval)
try:
while True:
try:
_touch_lock()
await asyncio.to_thread(_scan_and_alert)
except SQLAlchemyError:
logger.exception("compare-alert db error")
except Exception: # noqa: BLE001 - 后台任务不因单次异常退出
logger.exception("compare-alert unexpected error")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("compare-alert worker stopped")
raise
def start_compare_alert_worker() -> asyncio.Task | None:
if not settings.COMPARE_ALERT_ENABLED:
logger.info("compare-alert worker disabled")
return None
return asyncio.create_task(_run_loop(), name="compare-alert-worker")
async def stop_compare_alert_worker(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
-36
View File
@@ -132,26 +132,6 @@ class Settings(BaseSettings):
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
# ===== 比价失败报警(常驻 worker 周期扫 comparison_record → 飞书汇总)=====
COMPARE_ALERT_ENABLED: bool = False # 总开关(默认关;启用用 .env COMPARE_ALERT_ENABLED=true 覆盖,别改这默认值);关时 worker 不启动
COMPARE_ALERT_SCAN_INTERVAL_SEC: int = 900 # 扫描间隔(默认 15min,可配 1800=30min)
COMPARE_ALERT_FEISHU_WEBHOOK: str = "" # 群机器人 webhook(敏感,放 .env 别硬编码进代码);空则 worker 仅打日志不外发
COMPARE_ALERT_FEISHU_TIMEOUT_SEC: float = 10.0 # 飞书 POST 读/连超时
COMPARE_ALERT_CANCELLED_MS_THRESHOLD: int = 90000 # T5 耗时阈值(ms)
COMPARE_ALERT_CANCELLED_STEP_THRESHOLD: int = 30 # T5 步数阈值
COMPARE_ALERT_TIMEOUT_KEYWORDS: str = "超时,启动,加载" # T2 关键词(逗号分隔)
COMPARE_ALERT_UNRECOGNIZED_KEYWORDS: str = "未识别" # T6 关键词
COMPARE_ALERT_BIZ_EXCLUDE_KEYWORDS: str = "未找到,打烊,起送,门店,店内,不配送,这些菜,未入驻,休息" # T1 information 业务词排除
COMPARE_ALERT_MAX_DETAIL_PER_TYPE: int = 20 # 单类型明细截断
COMPARE_ALERT_MAX_TOTAL: int = 50 # 本期总命中截断(超则只给计数)
COMPARE_ALERT_SEND_EMPTY: bool = False # 无命中是否发「本期无异常」简讯
# ===== 卡死定位(读 pricebot trace 末段判原地打转)=====
COMPARE_ALERT_PRICEBOT_WORK_LOG_DIR: str = "" # pricebot work_logs 绝对路径(敏感,放 .env);空=跳过 trace、cancelled 全走保底
COMPARE_ALERT_STUCK_FRAME_THRESHOLD: int = 15 # 末段连续同环节达此帧数判卡死
COMPARE_ALERT_TRACE_MAX_TAIL_FRAMES: int = 40 # 每平台最多往前读多少帧
COMPARE_ALERT_TRACE_MAX_RECORDS: int = 30 # 每轮最多对多少条命中记录读 trace(限量)
# ===== 短信 =====
SMS_MOCK: bool = True
SMS_CODE_TTL_SEC: int = 300
@@ -237,22 +217,6 @@ class Settings(BaseSettings):
phones.add(self.test_account_phone)
return frozenset(phones)
def _csv(self, raw: str) -> tuple[str, ...]:
"""逗号分隔字符串 → 去空白非空元组(报警关键词解析共用)。"""
return tuple(w.strip() for w in raw.split(",") if w.strip())
@property
def compare_alert_timeout_keywords(self) -> tuple[str, ...]:
return self._csv(self.COMPARE_ALERT_TIMEOUT_KEYWORDS)
@property
def compare_alert_unrecognized_keywords(self) -> tuple[str, ...]:
return self._csv(self.COMPARE_ALERT_UNRECOGNIZED_KEYWORDS)
@property
def compare_alert_biz_exclude_keywords(self) -> tuple[str, ...]:
return self._csv(self.COMPARE_ALERT_BIZ_EXCLUDE_KEYWORDS)
# ===== 美团联盟 CPS =====
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
MT_CPS_APP_KEY: str = ""
+1 -5
View File
@@ -227,11 +227,7 @@ 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},
# DashScope 华北 2 公网调用原价;必须显式配置,不能落到 3/15 的未知模型兜底价。
"deepseek-v4-flash": {"input_per_1m": 1.0, "output_per_1m": 2.0},
},
"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
"currency": "CNY", "unit": "per_1m_tokens",
},
+10 -53
View File
@@ -5,14 +5,9 @@
`wallet.daily_auto_exchange`。
健壮性:
- **持久化「当天已兑」标记**(app_config `auto_exchange.last_run_date`):当天已兑则整轮跳过,
**标记跨进程重启不丢** → 同一北京日内多次启动/部署不再重复补扫。这是修 bug 的关键:原来用
内存变量记「今天跑过没」,进程重启即归零,导致每次非 0 点部署都全量补扫一遍、把 0 点后才达标
的用户在**非 0 点**兑成现金(用户反馈的「非 0 点也出现金币转现金记录」)。
- **漏了 0 点即补**:标记 < 今天(如 0 点服务器宕机)时标记 != today → 照常补跑一轮(保留原
timer 的 Persistent 语义)。这类补跑确实落非 0 点,但仅限「真漏了 0 点」的罕见场景,非常态。
- **逐用户幂等**:当天已有 exchange_in 流水的用户跳过(见 wallet._has_exchange_in_on),做兜底
防同机多进程 / 标记写入失败的竞态,不会重复兑。
- **逐用户幂等**:当天已有 exchange_in 流水的用户跳过(见 wallet._has_exchange_in_on),
故启动补跑 / 多次唤醒 / 进程重启都安全,不会重复兑。
- **当天首跑即补**:进程起来时若当天还没兑过,立即兑一轮(等价原 timer 的 Persistent 补跑)。
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑(防跨进程并发导致 TOCTOU 双兑)。
- **开关**:settings.AUTO_EXCHANGE_ENABLED=false 时不启动(与脚本/原 timer 同一开关)。
"""
@@ -32,14 +27,10 @@ from sqlalchemy.exc import SQLAlchemyError
from app.core import rewards
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.app_config import AppConfig
from app.repositories import wallet as wallet_repo
logger = logging.getLogger("shagua.daily_exchange")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "daily_exchange.lock"
# 「上次成功自动兑的北京日」标记,持久化在 app_config(仿 compare_alert 水位,不进 CONFIG_DEFS——
# 它是 worker 内部运行状态,非运营可配项)。用它替代内存 last_run:重启不丢 → 同日不重复补扫。
LAST_RUN_KEY = "auto_exchange.last_run_date"
def _touch_lock() -> None:
@@ -81,44 +72,9 @@ def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
_LOCK_PATH.unlink()
def _read_last_run(db) -> date | None:
"""读持久化的「上次成功自动兑的北京日」标记(app_config,跨进程重启不丢)。脏值当作没跑过。"""
row = db.get(AppConfig, LAST_RUN_KEY)
if row is None or not row.value:
return None
try:
return date.fromisoformat(row.value)
except (ValueError, TypeError):
return None
def _write_last_run(db, day: date) -> None:
"""落库「当天已兑」标记。用专用 key 直接写 AppConfig(仿 compare_alert 水位,不走 set_value)。"""
iso = day.isoformat()
row = db.get(AppConfig, LAST_RUN_KEY)
if row is None:
db.add(AppConfig(key=LAST_RUN_KEY, value=iso, updated_by_admin_id=None))
else:
row.value = iso
db.commit()
def _exchange_if_due(db, today: date) -> dict | None:
"""当天未兑过(持久化标记 != today)才跑一轮并记标记;已兑过返回 None(整轮跳过)。
标记写在 daily_auto_exchange 之后:即便中途崩,标记仍是旧值 → 下次重跑,逐用户幂等会跳过已兑的、
补完剩下的(安全)。真漏了 0 点(标记 < today)时标记 != today,仍会补跑,保留原「当天首跑即补」。
"""
if _read_last_run(db) == today:
return None
result = wallet_repo.daily_auto_exchange(db)
_write_last_run(db, today)
return result
def _exchange_once(today: date) -> dict | None:
def _exchange_once() -> dict:
with SessionLocal() as db:
return _exchange_if_due(db, today)
return wallet_repo.daily_auto_exchange(db)
async def _run_loop() -> None:
@@ -133,15 +89,16 @@ async def _run_loop() -> None:
async def _run_locked_loop(interval: int) -> None:
logger.info("daily auto-exchange worker started interval=%ss", interval)
# 本进程上次跑过的北京日;None=尚未跑过本进程(启动即补当天)。
last_run: date | None = None
try:
while True:
try:
_touch_lock()
today = rewards.cn_today()
# 「今天是否已兑」以持久化标记为准(见 _exchange_if_due),不再用内存变量 →
# 进程重启不会把当天当「没跑过」重复补扫;已兑当天返回 None(整轮跳过)。
result = await asyncio.to_thread(_exchange_once, today)
if result is not None:
if last_run != today:
result = await asyncio.to_thread(_exchange_once)
last_run = today
logger.info("daily auto-exchange done date=%s result=%s", today, result)
except SQLAlchemyError:
logger.exception("daily auto-exchange db error")
+2 -90
View File
@@ -11,7 +11,6 @@
from __future__ import annotations
import json
import logging
import os
import secrets
import subprocess
@@ -19,17 +18,8 @@ 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。"""
@@ -78,86 +68,8 @@ def save_avatar(user_id: int, data: bytes) -> str:
def save_feedback_image(user_id: int, data: bytes) -> str:
"""保存反馈截图并预生成历史页缩略图,返回原图相对 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
"""保存反馈截图,返回相对 URL(`/media/feedback/<file>`)"""
return _save_image("feedback", user_id, data)
def save_report_image(user_id: int, data: bytes) -> str:
+1 -12
View File
@@ -26,16 +26,6 @@ SIGNIN_REWARDS: tuple[int, ...] = (
SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS)
# ===== 信息流广告「按会话聚合」的 biz_type =====
# 比价 / 领券等候期看的信息流广告,每条各写一条 coin_transaction;这两类在「金币变动记录」
# 里按 trace_id 聚合成一条展示(见 repositories/wallet.list_coin_transactions)。其余类型
# (reward_video / guide_video / 通用 feed_ad_reward / signin / task_* 等)不聚合。
FEED_AD_SESSION_BIZ_TYPES: tuple[str, str] = (
"feed_ad_reward_comparison",
"feed_ad_reward_coupon",
)
def signin_reward(cycle_day: int) -> int:
"""cycle_day 取值 1..SIGNIN_CYCLE_LEN。"""
return SIGNIN_REWARDS[cycle_day - 1]
@@ -68,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 / 100×1),档每天只能选一个。
# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1),档每天只能选一个。
# invite_cash(邀请页)本轮无档位概念,不在此表。改档位=改这里发版。
class WithdrawTier(NamedTuple):
amount_cents: int
@@ -84,7 +74,6 @@ 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),
)
-48
View File
@@ -1,48 +0,0 @@
"""飞书群自定义机器人发送(text / post 消息)。
自定义机器人「关键词」验证:消息 content.text 必须含机器人配置的关键词,否则飞书返回 code!=0
(如 19024 Key Words Not Found)——本项目消息由 compare_alert_format 生成,标题已含「比价失败报警」。
不需要签名(sign)/IP 白名单。文档:https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
"""
from __future__ import annotations
import httpx
class FeishuNotifyError(Exception):
"""飞书发送失败(网络错误 / 非 2xx / 业务 code!=0,含关键词不匹配)。"""
def _post_feishu(webhook_url: str, payload: dict, timeout: float) -> None:
"""内部 helper:POST payload 到飞书 webhook 并校验响应。失败抛 FeishuNotifyError。"""
try:
resp = httpx.post(webhook_url, json=payload, timeout=timeout)
except httpx.HTTPError as e:
raise FeishuNotifyError(f"feishu request failed: {e}") from e
if resp.status_code >= 300:
raise FeishuNotifyError(f"feishu http {resp.status_code}: {resp.text[:200]}")
try:
data = resp.json()
except ValueError as e:
raise FeishuNotifyError(f"feishu bad json: {resp.text[:200]}") from e
code = data.get("code", data.get("StatusCode", 0))
if code not in (0, None):
raise FeishuNotifyError(f"feishu code={code} msg={data.get('msg') or data.get('StatusMessage')}")
def send_feishu_text(webhook_url: str, text: str, *, timeout: float = 10.0) -> None:
"""POST 一条 text 消息到飞书群机器人 webhook。失败(网络/HTTP/业务 code)抛 FeishuNotifyError。"""
payload = {"msg_type": "text", "content": {"text": text}}
_post_feishu(webhook_url, payload, timeout)
def send_feishu_post(webhook_url: str, title: str, content: list, *, timeout: float = 10.0) -> None:
"""发飞书富文本(post)。content 是段落数组,每段是元素数组[{tag:text/a,...}]。失败抛 FeishuNotifyError。"""
payload = {"msg_type": "post", "content": {"post": {"zh_cn": {"title": title, "content": content}}}}
_post_feishu(webhook_url, payload, timeout)
def send_feishu_card(webhook_url: str, card: dict, *, timeout: float = 10.0) -> None:
"""发飞书交互卡片(interactive)。card 为 schema 2.0 卡片 dict。失败抛 FeishuNotifyError。"""
payload = {"msg_type": "interactive", "card": card}
_post_feishu(webhook_url, payload, timeout)
+2 -38
View File
@@ -10,7 +10,7 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
@@ -44,11 +44,6 @@ 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.compare_alert_worker import (
start_compare_alert_worker,
stop_compare_alert_worker,
)
from app.core.config import settings
from app.core.cps_reconcile_worker import (
start_cps_reconcile_worker,
@@ -87,19 +82,6 @@ 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。
@@ -127,7 +109,6 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
observe_task = start_observe_worker()
inactivity_task = start_inactivity_reset_worker()
llm_cost_backfill_task = start_llm_cost_backfill_worker()
compare_alert_task = start_compare_alert_worker()
try:
yield
finally:
@@ -138,7 +119,6 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await stop_observe_worker(observe_task)
await stop_inactivity_reset_worker(inactivity_task)
await stop_llm_cost_backfill_worker(llm_cost_backfill_task)
await stop_compare_alert_worker(compare_alert_task)
await aclose_pricebot_client()
mt_meituan.close_client()
logger.info("shutting down")
@@ -232,24 +212,8 @@ 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,
FeedbackMediaStaticFiles(directory=str(_media_root)),
StaticFiles(directory=str(_media_root)),
name="media",
)
+2 -2
View File
@@ -36,8 +36,8 @@ class AdFeedRewardRecord(Base):
# 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流
# 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 本次会话 trace_id:比价一直带;领券自 2026-07-15(客户端 a98cab8)起也带;福利/旧客户端 = NULL。
# 用途:比价记录页按 trace_id 聚合「比价赚 N 金币」;金币记录列表把一次比价/领券的多条广告金币聚合成一条
# 本次比价 trace_id(仅 comparison 场景由客户端带上):把这场广告金币归属到对应比价记录,
# 比价记录页按 trace_id 聚合本场实发金币显示「比价赚 N 金币」。领券/福利/旧客户端 = NULL
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx,由客户端 feed-reward 上报带上。旧数据为 NULL。
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
+1 -13
View File
@@ -49,9 +49,6 @@ class ComparisonRecord(Base):
# 单列 user_id 索引只能过滤,排序仍要把该用户全部记录取出来排一遍;这条复合索引的**反向扫**
# 恰好等于 (created_at DESC, id DESC),PG 直接取前 n 条、免排序。列序不能动。
Index("ix_comparison_user_created", "user_id", "created_at", "id"),
# 比价报警 worker 的水位查询 WHERE updated_at > watermark 走它。显式命名(不用列上
# index=True 的自动名 ix_comparison_record_updated_at)以与迁移 create_index 同名、免 autogenerate 漂移。
Index("ix_comparison_updated", "updated_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -100,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(流程正常完成,含 below_minimum)/ failed(技术异常或未形成可比报价,含店铺打烊等)
# success(拿到有效对比)/ failed(出错或没采到目标价)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="success")
# done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…";
# 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。
@@ -163,15 +160,6 @@ class ComparisonRecord(Base):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
# 记录任一次更新(建 running 行 / done / abort 落终态)的 DB 时钟时间。比价报警 worker 的
# 水位列:按 updated_at 单调推进扫描,任何记录落定/更新都刷新它 > 水位、必被下轮扫到,
# 根治 created_at 水位漏掉「慢失败」(落定延迟 p99 达 7-10min)。onupdate 在 ORM UPDATE 时自动刷新。
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
-3
View File
@@ -56,9 +56,6 @@ class SavingsRecord(Base):
source_deeplink: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 客户端幂等键(UUID);demo 行为 NULL
client_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 本次下单归因到的那次比价的 trace_id(客户端归因时从比价会话缓存带来;demo/老客户端/历史行为 NULL)。
# 「已下单」按它精确对齐 comparison_record.trace_id —— 同店多次比价只标真正下单的那一条。
trace_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
device_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(
-3
View File
@@ -84,9 +84,6 @@ class CoinTransaction(Base):
# 关联业务 id(签到日期、任务 key 等),可空
ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 会话键:仅比价/领券信息流发奖(feed_ad_reward_comparison/coupon)时写入本场 trace_id,
# 金币记录列表据此把一次比价/领券的多条广告金币聚合成一条。其余类型 = NULL。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
+1 -1
View File
@@ -219,7 +219,7 @@ def grant_feed_reward(
crud_wallet.grant_coins(
db, user_id, coin,
biz_type=reward_biz, ref_id=client_event_id,
remark=reward_remark, trace_id=trace_id,
remark=reward_remark,
)
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
+34 -90
View File
@@ -76,30 +76,6 @@ _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 ""
@@ -188,10 +164,9 @@ def _derive(payload: ComparisonRecordIn) -> dict:
is_source_best = best.is_source if best is not None else None
# status:优先 pricebot record_status → 客户端显式 status → 兜底派生。
# below_minimum 是已形成可信结论的正常完成态,记录级归 success;细分结局仍完整保留在
# raw_payload/platform_results,供结果卡展示"未满起送"。
status = _normalize_record_status(payload.record_status or payload.status)
# status:优先 pricebot record_status(区分 below_minimum/store_closed) → 客户端显式 status
# → 兜底"非源且有价"=success/否则 failed。record_status 让"未满起送"不再塌缩成 failed。
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
@@ -226,9 +201,7 @@ 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 or payload.status
)
derived = _derive_from_platforms(payload.platforms, payload.record_status)
# 对齐 _derive 返回键(#189 fail_reason): 两路径 fields 键集一致, 覆盖已有行时不残留旧值
derived["fail_reason"] = (
_derive_fail_display(payload.information, payload.platform_results or {})
@@ -383,10 +356,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,
# below_minimum 已完成到购物车并形成可信结论,记录级计 success;细分结局仍在
# raw_payload/platform_results。旧 pricebot 未下发 record_status 时回退二态派生。
"status": _normalize_record_status(record_status)
or ("success" if has_valid_target else "failed"),
# 记录级结局: 优先用 pricebot 下发的 record_status(区分 below_minimum/store_closed,
# 不再把"未满起送"塌缩成 failed → 记录页不再误报"网络开小差"); 旧 pricebot 未下发时
# 回退老的 success/failed 二态派生, 向后兼容。
"status": record_status or ("success" if has_valid_target else "failed"),
}
@@ -439,8 +412,7 @@ 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": _normalize_record_status(record_status)
or ("success" if has_valid_target else "failed"),
"status": record_status or ("success" if has_valid_target else "failed"),
}
@@ -548,33 +520,6 @@ 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,
*,
@@ -639,8 +584,7 @@ def harvest_done(
行不存在(理论上帧0已建;防御)则新建"""
results = done_params.get("comparison_results") or []
# 展示模型统一数组(pricebot 新增, 每平台一行自带 status/is_best): 原样存, 记录页据此直渲染。
# record_status: 记录级业务结局(success/below_minimum/store_closed/failed)。其中
# below_minimum 是正常完成态,持久化 status 归 success,原值仍随 done_params 落 raw_payload。
# record_status: 记录级结局(success/below_minimum/store_closed/failed), 覆盖老二态派生
platforms = done_params.get("platforms") or []
record_status = done_params.get("record_status")
# 单源派生: platforms(含 pricebot 权威 is_best)是唯一真相源, best_*/source_*/saved/status
@@ -737,18 +681,18 @@ def harvest_abort(
return rec
def _ordered_trace_id_select(user_id: int):
"""该用户「真实下单」(source='compare')覆盖到的比价 trace_id 的 select,给「已下单」筛选当子查询。
def _ordered_shop_name_select(user_id: int):
"""该用户「真实下单」(source='compare')覆盖到的店名 select,给「已下单」筛选当子查询。
口径与 [_ordered_trace_ids] 完全一致,只是时机不同:那边是**拿到本页之后** candidates
口径与 [_ordered_shop_names] 完全一致,只是时机不同:那边是**拿到本页之后** candidates
反查打标;这边是**分页之前**就要过滤,拿不到 candidates,只能整段下推成子查询
没有先捞成集合再展开 IN (...) 字面量 重度用户下单过的 trace_id 可能上千,展开会撞 SQLite
没有先捞成集合再展开 IN (...) 字面量 重度用户下单过的店名可能上千,展开会撞 SQLite
的绑定变量上限,而且又变回了那个随下单量线性变慢的老写法
"""
return select(SavingsRecord.trace_id).where(
return select(SavingsRecord.shop_name).where(
SavingsRecord.user_id == user_id,
SavingsRecord.source == "compare",
SavingsRecord.trace_id.is_not(None),
SavingsRecord.shop_name.is_not(None),
)
@@ -760,27 +704,27 @@ def _like_escape(kw: str) -> str:
return kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _ordered_trace_ids(db: Session, user_id: int, candidates: set[str]) -> set[str]:
"""[candidates] 里哪些 trace_id 被该用户「真实下单」(source='compare')覆盖过,用来打「已下单」。
def _ordered_shop_names(db: Session, user_id: int, candidates: set[str]) -> set[str]:
"""[candidates] 里哪些店名被该用户「真实下单」(source='compare')覆盖过,用来打「已下单」。
只认 compare(归因命中后真实上报),demo 演示数据不算下单上报带上本次比价的 trace_id
trace_id 精确对齐 comparison_record.trace_id:同一家店比价多次,只有真正下单的那一条会被
已下单没带 trace_id 的下单(历史 / 老客户端)对齐不上任何记录 不进已下单
只认 compare(归因命中后真实上报),demo 演示数据不算下单上报不带 trace_id,
只能按店名对齐两边店名同源(都来自比价意图识别阶段的门店名 query),精确相等即视为同店
语义=店级:同一家店比价过多次,这些记录会一并标已下单
只查**本页出现过的 trace_id**(candidates limit ),不再把该用户全部下单 trace_id 捞回
内存:老写法随下单量线性增长,重度用户几千行全读一遍只为跟 50 条记录取交集空集合直接返回
只查**本页出现过的店名**(candidates limit ),不再把该用户全部下单店名捞回内存:
老写法随下单量线性增长,重度用户几千行全读一遍只为跟 50 条记录取交集空集合直接返回
(避免 IN () 非法)
"""
if not candidates:
return set()
rows = db.execute(
select(SavingsRecord.trace_id).where(
select(SavingsRecord.shop_name).where(
SavingsRecord.user_id == user_id,
SavingsRecord.source == "compare",
SavingsRecord.trace_id.in_(candidates),
SavingsRecord.shop_name.in_(candidates),
).distinct()
).scalars().all()
return {t for t in rows if t}
return {s for s in rows if s}
def _ad_coins_by_trace(db: Session, user_id: int, trace_ids: list[str]) -> dict[str, int]:
@@ -832,7 +776,7 @@ def list_records(
ordered: bool | None = None,
keyword: str | None = None,
) -> tuple[list[ComparisonRecord], int | None]:
"""比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」标记(按 trace_id 精确对齐真实下单)+ 「看广告赚的金币」(瞬态,不写库)。"""
"""比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记 + 「看广告赚的金币」(瞬态,不写库)。"""
stmt = (
select(ComparisonRecord)
.where(ComparisonRecord.user_id == user_id)
@@ -844,7 +788,7 @@ def list_records(
# 分页之后一页里可能一条都不命中,列表看着就是空的/卡住的,得翻很多页才蹦出一条。
if ordered:
stmt = stmt.where(
ComparisonRecord.trace_id.in_(_ordered_trace_id_select(user_id))
ComparisonRecord.store_name.in_(_ordered_shop_name_select(user_id))
)
kw = (keyword or "").strip()
if kw:
@@ -865,16 +809,16 @@ def list_records(
items = list(db.execute(stmt).scalars().all())
next_cursor = items[-1].id if len(items) == limit else None
# 「已下单」标记:本页记录的 trace_id 若落在该用户真实下单(带 trace_id)的集合里即 True。
# 「已下单」标记:本页记录的 store_name 若落在该用户真实下单的店名集合里即 True。
# ordered / ad_coins_earned 均非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。
page_traces = {it.trace_id for it in items}
# ordered=True 时上面已按同一口径(_ordered_trace_id_select)筛过,本页必然全是已下单,
# 省掉这次反查;其余情况按本页 trace_id 反查 savings。
ordered_traces = page_traces if ordered else _ordered_trace_ids(db, user_id, page_traces)
page_shops = {it.store_name for it in items if it.store_name}
# ordered=True 时上面已按同一口径(_ordered_shop_name_select)筛过,本页必然全是已下单,
# 省掉这次反查;其余情况照旧按本页店名反查 savings。
ordered_shops = page_shops if ordered else _ordered_shop_names(db, user_id, page_shops)
# 「本次比价看广告赚的金币」:按本页 trace_id 一次性聚合(同 ordered 范式)。
ad_coins = _ad_coins_by_trace(db, user_id, [it.trace_id for it in items])
for it in items:
it.ordered = it.trace_id in ordered_traces
it.ordered = bool(it.store_name and it.store_name in ordered_shops)
it.ad_coins_earned = ad_coins.get(it.trace_id, 0)
return items, next_cursor
-1
View File
@@ -177,7 +177,6 @@ def create_from_report(
source_platform_name=req.source_platform_name,
source_deeplink=req.source_deeplink,
client_event_id=req.client_event_id,
trace_id=req.trace_id,
device_id=req.device_id,
source="compare",
# created_at 显式存 naive 北京 wall-clock(与 demo 行、聚合 _local_date 的 naive 分支一致)。
+13 -88
View File
@@ -10,10 +10,9 @@ import logging
import re
import unicodedata
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import String, and_, case, cast, func, literal, select, update
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -171,7 +170,6 @@ def grant_coins(
biz_type: str,
ref_id: str | None = None,
remark: str | None = None,
trace_id: str | None = None,
) -> tuple[CoinAccount, CoinTransaction]:
"""金币变动入口(正数入账 / 负数出账)。更新余额 + 写流水,不 commit。
@@ -191,7 +189,6 @@ def grant_coins(
biz_type=biz_type,
ref_id=ref_id,
remark=remark,
trace_id=trace_id,
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), # 存北京 wall-clock(客户端原样切片显示)
)
db.add(txn)
@@ -260,94 +257,24 @@ def grant_invite_cash(
return acc, txn
@dataclass(frozen=True)
class CoinLedgerRow:
"""`list_coin_transactions` 的返回行。广告类按 trace_id 聚合后的展示行,
** ORM 对象**(避免把聚合后的 amount 误写回底层流水)"""
id: int
amount: int
balance_after: int
biz_type: str
ref_id: str | None
remark: str | None
created_at: datetime
merged_count: int
def list_coin_transactions(
db: Session,
user_id: int,
*,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[CoinLedgerRow], int | None]:
"""金币流水分页(游标式,按 id 倒序)。
) -> tuple[list[CoinTransaction], int | None]:
"""金币流水分页(按 id 倒序,游标式)。
比价/领券两类信息流广告(rewards.FEED_AD_SESSION_BIZ_TYPES)且带 trace_id 的行,
trace_id 聚合成一条(一次比价/领券 = 一行):金额合计代表行取组内最新一条
(MAX(id))的余额/时间merged_count=组内条数其余每条一行
分组必须在该用户**全量**行上算真实 rep_id 后再按 rep_id 过滤**不可** CTE 输入
裁成 id<cursor,否则会话行交错跨游标时会算出与上页重复的残组
cursor 为上一页最后一条的 id(即其组 rep_id);返回 (本页列表, next_cursor)
cursor 为上一页最后一条的 id;返回 (本页列表, next_cursor)
next_cursor None 表示没有下一页
"""
ct = CoinTransaction
group_key = case(
(
and_(
ct.biz_type.in_(rewards.FEED_AD_SESSION_BIZ_TYPES),
ct.trace_id.is_not(None),
),
literal("T:") + ct.trace_id,
),
else_=literal("I:") + cast(ct.id, String),
).label("group_key")
grp = (
select(
group_key,
func.max(ct.id).label("rep_id"),
func.sum(ct.amount).label("total_amount"),
func.count().label("merged_count"),
)
.where(ct.user_id == user_id)
.group_by(group_key)
.cte("grp")
)
stmt = (
select(
ct.id,
grp.c.total_amount.label("amount"),
ct.balance_after,
ct.biz_type,
ct.ref_id,
ct.remark,
ct.created_at,
grp.c.merged_count,
)
.select_from(grp)
.join(ct, ct.id == grp.c.rep_id)
)
stmt = select(CoinTransaction).where(CoinTransaction.user_id == user_id)
if cursor is not None:
stmt = stmt.where(grp.c.rep_id < cursor)
stmt = stmt.order_by(grp.c.rep_id.desc()).limit(limit)
stmt = stmt.where(CoinTransaction.id < cursor)
stmt = stmt.order_by(CoinTransaction.id.desc()).limit(limit)
rows = db.execute(stmt).all()
items = [
CoinLedgerRow(
id=r.id,
amount=int(r.amount),
balance_after=r.balance_after,
biz_type=r.biz_type,
ref_id=r.ref_id,
remark=r.remark,
created_at=r.created_at,
merged_count=int(r.merged_count),
)
for r in rows
]
items = list(db.execute(stmt).scalars().all())
next_cursor = items[-1].id if len(items) == limit else None
return items, next_cursor
@@ -808,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 / 100×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
都计入,被拒/失败不退当天名额);档每天只能选一个,选定后其余档当天 other_tier_selected
- 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
都计入,被拒/失败不退当天名额);档每天只能选一个,选定后其余档当天 other_tier_selected
- invite_cash 本轮无档位概念 返回空列表(邀请页客户端仍用本地写死档位,行为不变)
余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)
"""
@@ -858,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, "daily_limit": t.daily_limit,
"remaining_today": 1,
})
continue
used = today_counts.get(t.amount_cents, 0)
@@ -871,9 +798,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": False, "available": available, "disabled_reason": reason,
# daily_limit 一起下发:客户端要靠它区分「本档能提 3 次」和「本档只能提 1 次」——
# 只看 remaining_today 分不出「0.5 已提两次剩 1」与「10 元一次没提剩 1」。
"remaining_today": remaining, "daily_limit": t.daily_limit,
"remaining_today": remaining,
})
return out
+2 -11
View File
@@ -111,9 +111,8 @@ 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。
# below_minimum 表示流程正常完成,持久化主状态归 success;store_closed/items_not_found 等
# 已知无报价结局归 failed。原值仍随 raw_payload 落库,admin/记录页从 platform_results 展示细分结论。
# 记录级结局(pricebot 下发): success/below_minimum/store_closed/failed。让"未满起送"不再
# 被塌缩成 failed。_derive 优先用它、其次客户端 status、再兜底二态派生。
record_status: str | None = None
# 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/
# unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列),
@@ -260,11 +259,3 @@ 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=无限制)")
-2
View File
@@ -33,8 +33,6 @@ 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
-3
View File
@@ -12,9 +12,6 @@ class OrderReportRequest(BaseModel):
paid_amount_cents: int = Field(..., ge=0, description="实际支付金额(分)")
device_id: str | None = Field(None, max_length=128)
# ===== 比价时携带的记账信息(客户端从意图识别阶段缓存而来;旧版客户端可能不传,故全部可空)=====
# 本次下单归因到的那次比价的 trace_id。服务端据此把「已下单」精确对齐到该条比价记录
# (同店多次比价只标真正下单的那一条)。旧客户端不传 → None → 该单不进任何记录的「已下单」。
trace_id: str | None = Field(None, max_length=64, description="归因到的比价 trace_id")
shop_name: str | None = Field(None, max_length=128, description="门店名,如 肯德基宅急送(天北路店)")
dishes: list[str] = Field(default_factory=list, description="菜品名列表")
original_price_cents: int | None = Field(None, ge=0, description="源平台原价(分),省额=原价−实付")
-9
View File
@@ -30,7 +30,6 @@ class CoinTransactionOut(BaseModel):
ref_id: str | None = None
remark: str | None = None
created_at: datetime
merged_count: int = Field(1, description="本行合并的底层流水条数(比价/领券按会话聚合;未合并=1)")
class CoinTransactionPage(BaseModel):
@@ -89,14 +88,6 @@ 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):
-127
View File
@@ -1,127 +0,0 @@
"""比价失败报警规则:一条记录 → 命中的 AlertHit(或 None)。
纯函数不碰 DB(阈值/关键词由调用方从 config 传入),便于单测与调阈值判定顺序保证四类互斥:
failed fail_reason ( information 非业务)=T1 / 含未识别=T6 / 含超时词=T2 / 其余业务不报;
cancelled 且耗时或步数超阈值=T5(深度放弃);success/running 不报
口径依据见 docs/superpowers/specs/2026-08-04-compare-fail-alert-design.md 3
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any
ALERT_TYPE_LABELS: dict[str, str] = {
"T1": "系统技术失败",
"T6": "商品识别失败",
"T2": "启动/超时失败",
"T5": "深度放弃(cancelled)",
}
@dataclass(frozen=True)
class AlertHit:
trace_id: str
alert_type: str
reason: str
app_version: str | None
created_at: datetime | None
trace_url: str | None
user_id: int | None
total_ms: int | None = None
step_count: int | None = None
stuck_point: str | None = None
def make_hit(rec: Any, alert_type: str, reason: str) -> AlertHit:
return AlertHit(
trace_id=rec.trace_id,
alert_type=alert_type,
reason=reason,
app_version=getattr(rec, "app_version", None),
created_at=getattr(rec, "created_at", None),
trace_url=getattr(rec, "trace_url", None),
user_id=getattr(rec, "user_id", None),
total_ms=getattr(rec, "total_ms", None),
step_count=getattr(rec, "step_count", None),
)
def classify_cancelled_fallback(
rec: Any,
*,
cancelled_ms_threshold: int,
cancelled_step_threshold: int,
) -> AlertHit | None:
"""cancelled 保底判定(读不到 trace 时用):超耗时或步数阈值 → T5 深度放弃,否则 None。纯函数。"""
ms = rec.total_ms
step = rec.step_count
deep = (ms is not None and ms > cancelled_ms_threshold) or (
step is not None and step > cancelled_step_threshold
)
if deep:
return make_hit(rec, "T5", "深度放弃")
return None
def _target_technical_failure_reason(
rec: Any, biz_exclude_keywords: tuple[str, ...]
) -> str | None:
"""某目标平台「真技术失败」(pricebot 原始 status=='failed' 且 reason 非业务话术)的原因;无 → None。
记录级 fail_reason 展示口径一条比价里若某平台是干净业务结局(京东未找到菜),它会被派生
headline,盖住另一平台的真技术崩溃(淘宝'比价过程出错')这里扫 raw_payload.platform_results
补判:任一目标平台 status='failed' reason 不含业务词(pricebot 偶把打烊/不配送漏标成 failed,
biz_exclude 过滤掉这些业务误标) 真技术崩溃 raw_payload/platform_results / 结构异常 None
"""
raw = getattr(rec, "raw_payload", None)
pr = raw.get("platform_results") if isinstance(raw, dict) else None
if not isinstance(pr, dict):
return None
for v in pr.values():
if not isinstance(v, dict) or v.get("is_source"):
continue
if v.get("status") != "failed":
continue
reason = (v.get("reason") or "").strip()
if not any(w in reason for w in biz_exclude_keywords):
return reason or "比价过程出错"
return None
def classify_record(
rec: Any,
*,
cancelled_ms_threshold: int,
cancelled_step_threshold: int,
timeout_keywords: tuple[str, ...],
unrecognized_keywords: tuple[str, ...],
biz_exclude_keywords: tuple[str, ...],
) -> AlertHit | None:
"""判定单条记录是否触发报警。rec 需有 status/fail_reason/information/total_ms/step_count/
trace_id/app_version 属性(ComparisonRecord 或等价对象)"""
status = rec.status
if status == "failed":
fail_reason = rec.fail_reason
if fail_reason is None:
info = (rec.information or "").strip()
if info and any(w in info for w in biz_exclude_keywords):
return None
return make_hit(rec, "T1", f"技术失败·{info[:80] or '比价过程出错'}")
if any(w in fail_reason for w in unrecognized_keywords):
return make_hit(rec, "T6", f"识别失败·{fail_reason[:80]}")
if any(w in fail_reason for w in timeout_keywords):
return make_hit(rec, "T2", fail_reason[:80])
# fail_reason 是干净业务 headline,但可能盖住某目标平台的真技术失败(比价过程出错)→ 补判 T1。
tech = _target_technical_failure_reason(rec, biz_exclude_keywords)
if tech:
return make_hit(rec, "T1", f"技术失败·{tech[:80]}")
return None # 纯业务失败,不报
if status == "cancelled":
return classify_cancelled_fallback(
rec,
cancelled_ms_threshold=cancelled_ms_threshold,
cancelled_step_threshold=cancelled_step_threshold,
)
return None
-268
View File
@@ -1,268 +0,0 @@
"""AlertHit[] → 飞书群机器人消息。
提供三个格式化函数:
- format_alert_message: 纯文本(保留,已有集成测试依赖)
- format_alert_post: 富文本 post(行式明细:时间手机版本原因trace 超链接)
- format_alert_card: schema 2.0 卡片 + table 组件(正式发送格式)
按触发类型分组,每类给计数 + 明细(trace/版本/原因)两级截断防报警风暴:单类型超
max_detail_per_type 只列前 N + 另有 M ;本期总量超 max_total 只给各类型计数提示去分析库查
标题含关键词比价失败报警飞书自定义机器人用关键词验证,消息必须含它,否则被拒收
"""
from __future__ import annotations
from app.services.compare_alert import ALERT_TYPE_LABELS, AlertHit
ALERT_KEYWORD = "比价失败报警"
_TYPE_ORDER = ("T1", "T6", "T2", "T5")
def _detail_line(h: AlertHit) -> str:
ver = h.app_version or "?"
return f" - trace {h.trace_id} | {ver} | {h.reason}"
def format_alert_message(
hits: list[AlertHit],
*,
window_label: str,
max_detail_per_type: int,
max_total: int,
) -> str:
total = len(hits)
header = f"🚨 {ALERT_KEYWORD} · {window_label} · 本期触发 {total}"
grouped: dict[str, list[AlertHit]] = {t: [] for t in _TYPE_ORDER}
for h in hits:
grouped.setdefault(h.alert_type, []).append(h)
lines = [header]
counts_only = total > max_total
for t in _TYPE_ORDER:
bucket = grouped.get(t) or []
if not bucket:
continue
lines.append(f"{ALERT_TYPE_LABELS[t]} {len(bucket)}")
if counts_only:
continue
shown = bucket[:max_detail_per_type]
lines.extend(_detail_line(h) for h in shown)
if len(bucket) > max_detail_per_type:
lines.append(f" …另有 {len(bucket) - max_detail_per_type}")
if counts_only:
lines.append(f"(本期命中超 {max_total} 条,仅列计数,明细见分析库 comparison_record)")
return "\n".join(lines)
def format_alert_post(
hits: list[AlertHit],
*,
window_label: str,
phone_map: dict[int, str],
max_detail_per_type: int,
max_total: int,
) -> tuple[str, list]:
"""行式富文本:返回 (title, content)。title 含 ALERT_KEYWORD(飞书关键词验证)。
content: 摘要段(各类型计数) + 表头段 + 明细行(每条时间手机版本原因+ trace 超链接 a 元素)
phone_map: {user_id: phone};明细手机号取 phone_map.get(hit.user_id) or "-"
截断规则:总命中 > max_total 只出摘要+各类型计数(不列明细);
否则明细最多列前 max_detail_per_type ,超出加另有 N
"""
total = len(hits)
title = f"🚨 {ALERT_KEYWORD} · {window_label}"
grouped: dict[str, list[AlertHit]] = {t: [] for t in _TYPE_ORDER}
for h in hits:
grouped.setdefault(h.alert_type, []).append(h)
# 摘要段:各类型计数
summary_parts = [f"{ALERT_TYPE_LABELS[t]} {len(grouped[t])}" for t in _TYPE_ORDER if grouped.get(t)]
summary_text = f"合计 {total} 条:" + " ".join(summary_parts)
content: list[list[dict]] = [
[{"tag": "text", "text": summary_text}],
]
counts_only = total > max_total
if counts_only:
content.append([{"tag": "text", "text": f"(本期命中超 {max_total} 条,仅列计数,明细见分析库 comparison_record)"}])
return title, content
# 表头段
content.append([{"tag": "text", "text": "时间 | 手机号 | 版本 | 失败原因 | trace"}])
# 明细行(按类型顺序展开,每条一段)
shown_count = 0
for t in _TYPE_ORDER:
bucket = grouped.get(t) or []
if not bucket:
continue
for h in bucket[:max_detail_per_type]:
time_str = h.created_at.strftime("%m-%d %H:%M") if h.created_at else "-"
phone = phone_map.get(h.user_id) if h.user_id is not None else None
phone = phone or "-"
ver = h.app_version or "-"
row: list[dict] = [{"tag": "text", "text": f"{time_str} {phone} {ver} {h.reason} "}]
if h.trace_url:
row.append({"tag": "a", "text": "trace", "href": h.trace_url})
else:
row.append({"tag": "text", "text": h.trace_id[:16]})
content.append(row)
shown_count += 1
if len(bucket) > max_detail_per_type:
content.append([{"tag": "text", "text": f"…另有 {len(bucket) - max_detail_per_type}"}])
return title, content
# ---- format_alert_card (schema 2.0 卡片 + table 组件) ----
def _cost_cell(total_ms: int | None, step_count: int | None) -> str:
"""组合「用时」列值。有 ms → '{N}s',有 step_count → '{M}',两者用 ' / ' 连;都无 → '-'"""
parts = []
if total_ms is not None:
parts.append(f"{round(total_ms / 1000)}s")
if step_count is not None:
parts.append(f"{step_count}")
return " / ".join(parts) or "-"
def _build_card(title_text: str, elements: list[dict]) -> dict:
"""组装 schema 2.0 红色 header 卡片;三条路径只需决定 elements。"""
return {
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": title_text},
"template": "red",
},
"body": {"elements": elements},
}
def _build_table_rows(
hits: list[AlertHit],
*,
phone_map: dict[int, str],
max_detail_per_type: int,
) -> list[dict]:
"""按 _TYPE_ORDER 顺序展开,每类型最多 max_detail_per_type 条。"""
grouped: dict[str, list[AlertHit]] = {t: [] for t in _TYPE_ORDER}
for h in hits:
if h.alert_type in grouped:
grouped[h.alert_type].append(h)
# 非 _TYPE_ORDER 类型静默跳过(与既有行为一致)
rows = []
for t in _TYPE_ORDER:
bucket = grouped.get(t) or []
for h in bucket[:max_detail_per_type]:
time_str = h.created_at.strftime("%m-%d %H:%M") if h.created_at else "-"
phone = (phone_map.get(h.user_id) if h.user_id is not None else None) or "-"
trace = (
f"[链接]({h.trace_url})" if h.trace_url
else (h.trace_id or "")[:12]
)
rows.append({
"time": time_str,
"phone": phone,
"cost": _cost_cell(h.total_ms, h.step_count),
"reason": h.reason,
"stuck": h.stuck_point or "-",
"ver": h.app_version or "-",
"trace": trace,
})
return rows
_TABLE_COLUMNS = [
{"name": "time", "display_name": "时间", "data_type": "text"},
{"name": "phone", "display_name": "手机号", "data_type": "text"},
{"name": "cost", "display_name": "用时", "data_type": "text"},
{"name": "reason", "display_name": "失败原因", "data_type": "text"},
{"name": "stuck", "display_name": "末帧", "data_type": "text"},
{"name": "ver", "display_name": "版本", "data_type": "text"},
{"name": "trace", "display_name": "trace", "data_type": "lark_md"},
]
def _type_criteria(t: str, cancelled_ms_threshold: int, cancelled_step_threshold: int) -> str:
"""各触发类型的「判据」文案,展示在卡片摘要里让收报警的人一眼知道为什么报。
T5 带当前配置阈值(耗时 mss步数);T1 覆盖两条来源(整场系统错 + 混合单里任一平台 status=failed)"""
if t == "T5":
return f"耗时>{round(cancelled_ms_threshold / 1000)}s 或 步数>{cancelled_step_threshold}"
if t == "T1":
return "无业务原因的系统错 或 任一平台 status=failed"
if t == "T2":
return "原因含 超时/启动/加载"
if t == "T6":
return "原因含 未识别"
return ""
def format_alert_card(
hits: list[AlertHit],
*,
window_label: str,
phone_map: dict[int, str],
interval_min: int,
max_detail_per_type: int,
max_total: int,
cancelled_ms_threshold: int = 90000,
cancelled_step_threshold: int = 30,
) -> dict:
"""返回飞书 schema 2.0 卡片 dict(配合 send_feishu_card 发送)。
- header: template=redtitle ALERT_KEYWORD飞书关键词验证必须
- body 第一个元素: markdown 摘要数据范围 + 合计 + 各类型计数
- hits: 只有摘要本期无异常
- total > max_total: 只有摘要提示去 comparison_record 不加 table
- 否则: 第二个元素为 table列序 time/phone/cost/reason/stuck/ver/trace
"""
total = len(hits)
title_text = f"🚨 {ALERT_KEYWORD} · {window_label}"
# ---------- 空 hits ----------
if total == 0:
md_content = f"数据范围:近 {interval_min} 分钟\n本期无异常"
return _build_card(title_text, [{"tag": "markdown", "content": md_content}])
# ---------- 摘要 ----------
grouped_count: dict[str, int] = {}
for h in hits:
grouped_count[h.alert_type] = grouped_count.get(h.alert_type, 0) + 1
count_parts = [
f"{ALERT_TYPE_LABELS[t]} {grouped_count[t]}"
for t in _TYPE_ORDER
if grouped_count.get(t)
]
md_content = (
f"数据范围:近 {interval_min} 分钟\n"
f"**合计 {total} 条**" + " ".join(count_parts)
)
# ---------- 判据说明(本期出现的类型各给一行判据,T5 带当前配置阈值)----------
criteria_parts = [
f"{ALERT_TYPE_LABELS[t]}={_type_criteria(t, cancelled_ms_threshold, cancelled_step_threshold)}"
for t in _TYPE_ORDER
if grouped_count.get(t)
]
if criteria_parts:
md_content += "\n判据:" + " ".join(criteria_parts)
# ---------- 截断:超 max_total 只出摘要 ----------
if total > max_total:
md_content += f"\n{max_total} 条仅列计数,明细见分析库 comparison_record"
return _build_card(title_text, [{"tag": "markdown", "content": md_content}])
# ---------- 常规:摘要 + table ----------
rows = _build_table_rows(hits, phone_map=phone_map, max_detail_per_type=max_detail_per_type)
table_element = {
"tag": "table",
"page_size": 10,
"row_height": "low",
"header_style": {"background_style": "grey", "bold": True},
"columns": _TABLE_COLUMNS,
"rows": rows,
}
return _build_card(title_text, [{"tag": "markdown", "content": md_content}, table_element])
-182
View File
@@ -1,182 +0,0 @@
"""比价卡死定位:读 pricebot trace 末段,判某平台是否原地打转(卡死)。
同机直读 {WORK_LOG_DIR}/{dir_name}/{platform}/step_*.json,只取头部字段
(pipeline_step/detected_page),不解析后面的无障碍树(windows,占单帧 99% 体积)
判据与降级见 docs/superpowers/specs/2026-08-05-compare-stuck-detection-design.md
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
# pipeline_step / 平台名 → 中文(映射不到原样显示英文,不阻断)。按 pricebot 实际枚举补全。
PIPELINE_STEP_LABELS: dict[str, str] = {
"set_address": "定位",
"enter_store": "进店",
"add_one_dish": "加菜",
"match_dish": "找菜",
"checkout": "结算",
}
PLATFORM_LABELS: dict[str, str] = {
"meituan": "美团",
"eleme": "饿了么",
"jd_waimai": "京东外卖",
}
_PIPE_RE = re.compile(r'"pipeline_step":\s*"([^"]*)"')
_PAGE_RE = re.compile(r'"detected_page":\s*"([^"]*)"')
_TS_RE = re.compile(r'"timestamp":\s*"([^"]*)"')
_STEP_NUM_RE = re.compile(r"step_(\d+)")
def _parse_ts(s: str | None) -> datetime | None:
if not s:
return None
try:
return datetime.fromisoformat(s)
except (ValueError, TypeError):
return None
@dataclass(frozen=True)
class StuckPoint:
platform: str
pipeline_step: str
frames: int # 末段连续困住的帧数(上限 max_tail)
stuck_ms: int | None = None # 末段连续卡住的时长(ms);无 timestamp 时 None
detected_page: str | None = None # 末帧所在页面(pricebot detected_page 原值);无则 None
dwell_ms: int | None = None # 末帧所在屏停留时长(ms);末帧路径(failed/兜底)用,无 ts → None
def label(self) -> str:
p = PLATFORM_LABELS.get(self.platform, self.platform)
s = PIPELINE_STEP_LABELS.get(self.pipeline_step, self.pipeline_step)
base = f"{p}·{s}"
if self.detected_page:
base += f"·{self.detected_page}" # 页面暂用 pricebot 原值(英文),无中文映射
return base
@dataclass(frozen=True)
class StuckResult:
readable: bool # trace 是否读到(区分「读不到」与「读到但没卡」)
points: list[StuckPoint] # 卡死平台列表;readable=True 且空 = 确认没卡死
last: StuckPoint | None = None # 末帧(帧数最多平台的末帧,带 detected_page);读不到 → None
def dir_name_from_trace_url(trace_url: str | None) -> str | None:
""".../traces/{dir_name}/ → dir_name;空/异常 → None。"""
if not trace_url:
return None
name = trace_url.rstrip("/").rsplit("/", 1)[-1]
return name or None
def _step_num(path: Path) -> int:
m = _STEP_NUM_RE.search(path.name)
return int(m.group(1)) if m else -1
def _read_head(path: Path, nbytes: int = 4096) -> tuple[str | None, str | None, str | None]:
"""只读文件头部,抠 (pipeline_step, detected_page, timestamp)。它们在 json 最前面。"""
try:
with open(path, encoding="utf-8", errors="replace") as f:
head = f.read(nbytes)
except OSError:
return None, None, None
ps = _PIPE_RE.search(head)
pg = _PAGE_RE.search(head)
ts = _TS_RE.search(head)
return (ps.group(1) if ps else None, pg.group(1) if pg else None, ts.group(1) if ts else None)
def _last_segment(
step_files: list[Path], max_tail: int
) -> tuple[str, str | None, int, int | None] | None:
"""末帧往前数连续同 (pipeline_step, detected_page) 的一段。
返回 (pipeline_step, detected_page, count, dwell_ms);末帧 pipeline_step 抠不出 None
dwell_ms = 段末帧ts 段首帧ts(ms);两端 ts 不全可解析或负(帧钟回退) None
这是末段停留的唯一算法,卡死判据(threshold)与末帧停留(无门槛)都复用它
"""
tail = step_files[-max_tail:]
heads = [_read_head(p) for p in tail] # [(ps, pg, ts), ...]
last_ps, last_pg, _ = heads[-1]
if last_ps is None:
return None
seg_ts: list[str | None] = [] # 连续段的 timestamp(逆序:末帧在前)
for ps, pg, ts in reversed(heads):
if ps == last_ps and pg == last_pg:
seg_ts.append(ts)
else:
break
t_last, t_first = _parse_ts(seg_ts[0]), _parse_ts(seg_ts[-1])
dwell_ms = round((t_last - t_first).total_seconds() * 1000) if t_last and t_first else None
if dwell_ms is not None and dwell_ms < 0:
dwell_ms = None # 时钟不单调(帧 timestamp 回退)→ 降级为不显示时长
return last_ps, last_pg, len(seg_ts), dwell_ms
def read_stuck_points(trace_dir: Path, *, threshold: int, max_tail: int) -> StuckResult:
"""逐平台判卡死。目录不存在/异常/无平台帧 → StuckResult(readable=False, points=[])。"""
try:
if not trace_dir.is_dir():
return StuckResult(readable=False, points=[])
points: list[StuckPoint] = []
any_frames = False
last: StuckPoint | None = None
best_n = -1
for pdir in sorted(trace_dir.iterdir()):
if not pdir.is_dir():
continue
step_files = sorted(pdir.glob("step_*.json"), key=_step_num)
if not step_files:
continue
any_frames = True
seg = _last_segment(step_files, max_tail) # 每平台只扫一次末段
if seg is None:
# 末帧抠不出:整段跳过(不判卡死、也不当末帧候选)——与旧版 _platform_stuck→None
# + 独立 _read_head(末帧)→ps None 两处一并跳过等价(旧版两者都 key off 末帧)
continue
ps, pg, count, dwell_ms = seg
if count >= threshold:
points.append(StuckPoint(pdir.name, ps, count, dwell_ms, detected_page=pg))
# 末帧:取帧数最多平台的末帧(与 last_step 同口径),带 detected_page + 末段停留
if len(step_files) > best_n:
best_n = len(step_files)
last = StuckPoint(
pdir.name, ps, len(step_files), detected_page=pg, dwell_ms=dwell_ms
)
if not any_frames:
return StuckResult(readable=False, points=[])
return StuckResult(readable=True, points=points, last=last)
except OSError:
return StuckResult(readable=False, points=[])
def last_step(trace_dir: Path, *, max_tail: int) -> StuckPoint | None:
"""failed 用:取帧数最多平台的末帧环节(不要求原地打转)。读不到 → None。
附末段停留 dwell_ms(末帧所在屏停留时长); ts/时钟回退 None"""
try:
if not trace_dir.is_dir():
return None
best: tuple[int, str, list[Path]] | None = None
for pdir in sorted(trace_dir.iterdir()):
if not pdir.is_dir():
continue
step_files = sorted(pdir.glob("step_*.json"), key=_step_num)
# 平局(同帧数)时取字典序第一个平台(sorted 保证稳定)
if step_files and (best is None or len(step_files) > best[0]):
best = (len(step_files), pdir.name, step_files)
if best is None:
return None
_, platform, step_files = best
seg = _last_segment(step_files, max_tail)
if seg is None:
return None
ps, pg, _count, dwell_ms = seg
# frames 仍=总帧数(选平台口径不变);dwell_ms=末帧所在屏停留
return StuckPoint(platform, ps, len(step_files), detected_page=pg, dwell_ms=dwell_ms)
except OSError:
return None
+1 -5
View File
@@ -5,13 +5,9 @@
## 现状(2026-06 起):已改为 App 进程内任务,不再需要 systemd timer
「0 点自动兑换」现由 **App 进程内后台任务** `app.core.daily_exchange_worker` 负责:App 一起来就
每 10 分钟检查、跨过北京 0 点自动跑一轮(逐用户幂等、文件锁互斥)。**无需再装 / 启用
每 10 分钟检查、跨过北京 0 点自动跑一轮(逐用户幂等、文件锁互斥、重启补跑当天遗漏)。**无需再装 / 启用
`daily-exchange.timer`**。
- **「当天已兑」标记持久化在 app_config**(key=`auto_exchange.last_run_date`):当天兑过后,同一北京日
内进程重启 / 部署**不再重复补扫**——避免把 0 点后才达标的用户在非 0 点兑现金。只有真漏了 0 点
(标记 < 今天,如 0 点服务器宕机)才会在重启后补跑一轮。
- 开关仍是 `.env``AUTO_EXCHANGE_ENABLED`(false → worker 不启动);间隔由 `AUTO_EXCHANGE_CHECK_INTERVAL_SEC`(默认 600s=10min)控。
- 下方 systemd timer / 脚本属**遗留 + 手动应急**:逻辑同一套且幂等,可手动 `--once` 补跑;
但**不要再 `enable` timer 与进程内 worker 并存**(虽幂等不会双兑,纯属多余)。
@@ -1,156 +0,0 @@
# 比价结果卡片 · 状态口径与交互参考
> **定位**:把「比价结果页每张平台卡片」的 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。
---
*本文档为跨端口径参考,非契约。字段/行号以三仓实际代码为准。*
@@ -1,735 +0,0 @@
# 比价卡死定位报警增强 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让 cancelled 报警用 trace 末段「原地打转」判卡死并定位卡在哪个环节,读不到 trace 回退耗时/帧数保底;failed 类附卡点。
**Architecture:** 判定保持纯函数(`compare_alert.py`),trace 读取单独成层(`trace_stuck.py`,同机直读 pricebot work_logs、只读帧头部字段),worker 编排(cancelled trace 优先 + 保底、failed 附卡点)。卡点拼进 `AlertHit.reason`,复用现有 `format_alert_post` 展示,不依赖卡片 table 固化。
**Tech Stack:** Python 3.11+、FastAPI、SQLAlchemy、pytest。无新依赖(仅标准库 `re`/`json`/`pathlib`/`dataclasses`)。
参考 spec`docs/superpowers/specs/2026-08-05-compare-stuck-detection-design.md`
---
## File Structure
- **Create** `app/services/trace_stuck.py` — 卡死判据 + 薄 IO。`StuckPoint`/`StuckResult``read_stuck_points``last_step``dir_name_from_trace_url`、文案表。
- **Create** `tests/test_trace_stuck.py` — trace_stuck 单测。
- **Modify** `app/core/config.py` — 加 4 个配置项。
- **Modify** `app/services/compare_alert.py``_hit` 改公开 `make_hit`;新增纯函数 `classify_cancelled_fallback``classify_record` 的 cancelled 分支改调它(行为不变)。
- **Create** `tests/test_compare_alert_fallback.py``classify_cancelled_fallback`/`make_hit` 单测。
- **Modify** `app/core/compare_alert_worker.py` — 新增 `build_hits`/`_trace_dir` 编排;`_scan_and_alert``build_hits` 替换 `classify_batch`
- **Create** `tests/test_compare_alert_stuck_worker.py``build_hits` 集成测。
---
## Task 1: 配置项
**Files:**
- Modify: `app/core/config.py:147`(在 `COMPARE_ALERT_SEND_EMPTY` 行后追加)
- [ ] **Step 1: 加 4 个配置字段**
`app/core/config.py` 第 147 行 `COMPARE_ALERT_SEND_EMPTY: bool = False ...` 之后,紧接着追加:
```python
# ===== 卡死定位(读 pricebot trace 末段判原地打转)=====
COMPARE_ALERT_PRICEBOT_WORK_LOG_DIR: str = "" # pricebot work_logs 绝对路径(敏感,放 .env);空=跳过 trace、cancelled 全走保底
COMPARE_ALERT_STUCK_FRAME_THRESHOLD: int = 15 # 末段连续同环节达此帧数判卡死
COMPARE_ALERT_TRACE_MAX_TAIL_FRAMES: int = 40 # 每平台最多往前读多少帧
COMPARE_ALERT_TRACE_MAX_RECORDS: int = 30 # 每轮最多对多少条命中记录读 trace(限量)
```
- [ ] **Step 2: 跑现有测试确认不破**
Run: `pytest tests/ -q -k "config or defaults"`
Expected: PASS(新增字段都有默认值,不影响 `test_defaults`
- [ ] **Step 3: Commit**
```bash
git add app/core/config.py
git commit -m "feat(compare-alert): 卡死定位 4 个配置项
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 2: trace_stuck 模块
**Files:**
- Create: `app/services/trace_stuck.py`
- Test: `tests/test_trace_stuck.py`
- [ ] **Step 1: 写失败测试**
创建 `tests/test_trace_stuck.py`
```python
"""trace_stuck 单测:用 tmp 造 step_*.json(只含头部字段)验证卡死判据。"""
import json
from pathlib import Path
from app.services.trace_stuck import (
StuckPoint,
dir_name_from_trace_url,
last_step,
read_stuck_points,
)
def _frame(pdir: Path, idx: int, step: str, page: str) -> None:
"""造一帧 step json:头部放 pipeline_step/detected_page,尾部塞大 windows 模拟真实。"""
pdir.mkdir(parents=True, exist_ok=True)
body = {
"trace_id": "t", "step": idx, "platform": pdir.name,
"pipeline_step": step, "detected_page": page,
"windows": [{"nodes": ["x" * 200]}],
}
(pdir / f"step_{idx:03d}.json").write_text(
json.dumps(body, ensure_ascii=False), encoding="utf-8"
)
def test_stuck_when_tail_repeats_same_step(tmp_path):
pdir = tmp_path / "meituan"
for i in range(20):
_frame(pdir, i, "add_one_dish", "meal_detail_popup")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is True
assert res.points == [StuckPoint("meituan", "add_one_dish", 20)]
def test_not_stuck_when_progressing(tmp_path):
pdir = tmp_path / "eleme"
_frame(pdir, 0, "set_address", "home")
for i in range(1, 8):
_frame(pdir, i, "enter_store", "store")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is True
assert res.points == []
def test_adding_many_dishes_not_stuck_when_page_changes(tmp_path):
# add_one_dish 重复但 detected_page 在跳(换菜)=推进,不判卡死
pdir = tmp_path / "meituan"
for i in range(20):
_frame(pdir, i, "add_one_dish", "menu" if i % 2 == 0 else "dish_popup")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.points == []
def test_below_threshold_not_stuck(tmp_path):
pdir = tmp_path / "meituan"
for i in range(10): # < 15
_frame(pdir, i, "add_one_dish", "meal_detail_popup")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.points == []
def test_missing_dir_not_readable(tmp_path):
res = read_stuck_points(tmp_path / "nope", threshold=15, max_tail=40)
assert res.readable is False
assert res.points == []
def test_empty_dir_no_platform_frames_not_readable(tmp_path):
(tmp_path / "emptysub").mkdir()
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is False
def test_per_platform_one_stuck_one_normal(tmp_path):
m = tmp_path / "meituan"
for i in range(18):
_frame(m, i, "add_one_dish", "meal_detail_popup")
e = tmp_path / "eleme"
_frame(e, 0, "set_address", "home")
for i in range(1, 6):
_frame(e, i, "enter_store", "store")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is True
assert res.points == [StuckPoint("meituan", "add_one_dish", 18)]
def test_last_step_returns_busiest_platform_last_env(tmp_path):
m = tmp_path / "meituan"
for i in range(20):
_frame(m, i, "add_one_dish", "meal_detail_popup")
e = tmp_path / "eleme"
for i in range(3):
_frame(e, i, "enter_store", "store")
sp = last_step(tmp_path)
assert sp == StuckPoint("meituan", "add_one_dish", 20)
def test_dir_name_from_trace_url():
assert dir_name_from_trace_url("https://x/traces/20260804_1_abc/") == "20260804_1_abc"
assert dir_name_from_trace_url("https://x/traces/20260804_1_abc") == "20260804_1_abc"
assert dir_name_from_trace_url("") is None
assert dir_name_from_trace_url(None) is None
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_trace_stuck.py -q`
Expected: FAIL`ModuleNotFoundError: app.services.trace_stuck`
- [ ] **Step 3: 实现 trace_stuck.py**
创建 `app/services/trace_stuck.py`
```python
"""比价卡死定位:读 pricebot trace 末段,判某平台是否原地打转(卡死)。
同机直读 {WORK_LOG_DIR}/{dir_name}/{platform}/step_*.json,只取头部字段
(pipeline_step/detected_page),不解析后面的无障碍树(windows,占单帧 99% 体积)。
判据与降级见 docs/superpowers/specs/2026-08-05-compare-stuck-detection-design.md。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
# pipeline_step / 平台名 → 中文(映射不到原样显示英文,不阻断)。按 pricebot 实际枚举补全。
PIPELINE_STEP_LABELS: dict[str, str] = {
"set_address": "定位",
"enter_store": "进店",
"add_one_dish": "加菜",
"match_dish": "找菜",
"checkout": "结算",
}
PLATFORM_LABELS: dict[str, str] = {
"meituan": "美团",
"eleme": "饿了么",
"jd_waimai": "京东外卖",
}
_PIPE_RE = re.compile(r'"pipeline_step":\s*"([^"]*)"')
_PAGE_RE = re.compile(r'"detected_page":\s*"([^"]*)"')
_STEP_NUM_RE = re.compile(r"step_(\d+)")
@dataclass(frozen=True)
class StuckPoint:
platform: str
pipeline_step: str
frames: int # 末段连续困住的帧数(上限 max_tail)
def label(self) -> str:
p = PLATFORM_LABELS.get(self.platform, self.platform)
s = PIPELINE_STEP_LABELS.get(self.pipeline_step, self.pipeline_step)
return f"{p}·{s}"
@dataclass(frozen=True)
class StuckResult:
readable: bool # trace 是否读到(区分「读不到」与「读到但没卡」)
points: list[StuckPoint] # 卡死平台列表;readable=True 且空 = 确认没卡死
def dir_name_from_trace_url(trace_url: str | None) -> str | None:
""".../traces/{dir_name}/ → dir_name;空/异常 → None。"""
if not trace_url:
return None
name = trace_url.rstrip("/").rsplit("/", 1)[-1]
return name or None
def _step_num(path: Path) -> int:
m = _STEP_NUM_RE.search(path.name)
return int(m.group(1)) if m else -1
def _read_head(path: Path, nbytes: int = 4096) -> tuple[str | None, str | None]:
"""只读文件头部,抠 (pipeline_step, detected_page)。它们在 json 最前面。"""
with open(path, "r", encoding="utf-8") as f:
head = f.read(nbytes)
ps = _PIPE_RE.search(head)
pg = _PAGE_RE.search(head)
return (ps.group(1) if ps else None, pg.group(1) if pg else None)
def _platform_stuck(
platform: str, step_files: list[Path], threshold: int, max_tail: int
) -> StuckPoint | None:
"""末帧往前数连续同 (pipeline_step, detected_page) 的帧数 ≥threshold → 卡死。"""
tail = step_files[-max_tail:]
heads = [_read_head(p) for p in tail]
last_ps, last_pg = heads[-1]
if last_ps is None:
return None
count = 0
for ps, pg in reversed(heads):
if ps == last_ps and pg == last_pg:
count += 1
else:
break
if count >= threshold:
return StuckPoint(platform, last_ps, count)
return None
def read_stuck_points(trace_dir: Path, *, threshold: int, max_tail: int) -> StuckResult:
"""逐平台判卡死。目录不存在/异常/无平台帧 → StuckResult(readable=False, points=[])。"""
try:
if not trace_dir.is_dir():
return StuckResult(readable=False, points=[])
points: list[StuckPoint] = []
any_frames = False
for pdir in sorted(trace_dir.iterdir()):
if not pdir.is_dir():
continue
step_files = sorted(pdir.glob("step_*.json"), key=_step_num)
if not step_files:
continue
any_frames = True
sp = _platform_stuck(pdir.name, step_files, threshold, max_tail)
if sp is not None:
points.append(sp)
if not any_frames:
return StuckResult(readable=False, points=[])
return StuckResult(readable=True, points=points)
except OSError:
return StuckResult(readable=False, points=[])
def last_step(trace_dir: Path) -> StuckPoint | None:
"""failed 用:取帧数最多平台的末帧环节(不要求原地打转)。读不到 → None。"""
try:
if not trace_dir.is_dir():
return None
best: tuple[int, str, list[Path]] | None = None
for pdir in sorted(trace_dir.iterdir()):
if not pdir.is_dir():
continue
step_files = sorted(pdir.glob("step_*.json"), key=_step_num)
if step_files and (best is None or len(step_files) > best[0]):
best = (len(step_files), pdir.name, step_files)
if best is None:
return None
_, platform, step_files = best
ps, _pg = _read_head(step_files[-1])
if ps is None:
return None
return StuckPoint(platform, ps, len(step_files))
except OSError:
return None
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pytest tests/test_trace_stuck.py -q`
Expected: PASS9 passed
- [ ] **Step 5: Commit**
```bash
git add app/services/trace_stuck.py tests/test_trace_stuck.py
git commit -m "feat(compare-alert): trace_stuck 卡死判据(末段原地打转)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 3: compare_alert 抽出 fallback + 公开 make_hit
**Files:**
- Modify: `app/services/compare_alert.py``_hit``make_hit`;新增 `classify_cancelled_fallback`cancelled 分支改调它)
- Test: `tests/test_compare_alert_fallback.py`
- [ ] **Step 1: 写失败测试**
创建 `tests/test_compare_alert_fallback.py`
```python
"""classify_cancelled_fallback / make_hit 单测。"""
from app.services.compare_alert import classify_cancelled_fallback, make_hit
class _Rec:
def __init__(self, **kw):
self.trace_id = kw.get("trace_id", "t")
self.status = kw.get("status", "cancelled")
self.total_ms = kw.get("total_ms")
self.step_count = kw.get("step_count")
self.fail_reason = kw.get("fail_reason")
self.information = kw.get("information")
self.app_version = kw.get("app_version")
self.created_at = kw.get("created_at")
self.trace_url = kw.get("trace_url")
self.user_id = kw.get("user_id")
def test_fallback_deep_by_ms():
hit = classify_cancelled_fallback(
_Rec(total_ms=95000, step_count=5),
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
)
assert hit is not None and hit.alert_type == "T5" and "深度放弃" in hit.reason
def test_fallback_deep_by_step():
hit = classify_cancelled_fallback(
_Rec(total_ms=1000, step_count=35),
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
)
assert hit is not None and hit.alert_type == "T5"
def test_fallback_shallow_none():
hit = classify_cancelled_fallback(
_Rec(total_ms=5000, step_count=3),
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
)
assert hit is None
def test_make_hit_carries_fields():
hit = make_hit(_Rec(trace_id="tx", app_version="0.6.0"), "T5", "卡在 美团·加菜")
assert hit.trace_id == "tx"
assert hit.reason == "卡在 美团·加菜"
assert hit.app_version == "0.6.0"
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_compare_alert_fallback.py -q`
Expected: FAIL`ImportError: cannot import name 'classify_cancelled_fallback'`
- [ ] **Step 3: 改 compare_alert.py**
`app/services/compare_alert.py`
(a) 把 `def _hit(` 改名为 `def make_hit(`(第 33 行),并把 `classify_record` 内 4 处 `_hit(` 调用改成 `make_hit(`(原 T1/T6/T2/T5 分支)。
(b) 在 `make_hit` 之后、`classify_record` 之前,新增:
```python
def classify_cancelled_fallback(
rec: Any,
*,
cancelled_ms_threshold: int,
cancelled_step_threshold: int,
) -> AlertHit | None:
"""cancelled 保底判定(读不到 trace 时用):超耗时或步数阈值 → T5 深度放弃,否则 None。纯函数。"""
ms = rec.total_ms
step = rec.step_count
deep = (ms is not None and ms > cancelled_ms_threshold) or (
step is not None and step > cancelled_step_threshold
)
if deep:
return make_hit(
rec, "T5",
f"深度放弃·等待 {round((ms or 0) / 1000)}s / {step or 0} 步后退出",
)
return None
```
(c) 把 `classify_record` 里的 cancelled 分支(原 `if status == "cancelled":` 那整段)替换为:
```python
if status == "cancelled":
return classify_cancelled_fallback(
rec,
cancelled_ms_threshold=cancelled_ms_threshold,
cancelled_step_threshold=cancelled_step_threshold,
)
return None
```
`classify_record` 行为不变,只是把 cancelled 逻辑抽到 `classify_cancelled_fallback`。)
- [ ] **Step 4: 跑测试确认通过(含现有 rules 测试不回归)**
Run: `pytest tests/test_compare_alert_fallback.py tests/test_compare_alert_rules.py -q`
Expected: PASS(新测试 4 passed,现有 rules 测试仍全 PASS
- [ ] **Step 5: Commit**
```bash
git add app/services/compare_alert.py tests/test_compare_alert_fallback.py
git commit -m "feat(compare-alert): 抽出 classify_cancelled_fallback + 公开 make_hit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 4: worker 编排 build_hits
**Files:**
- Modify: `app/core/compare_alert_worker.py`(加 imports、`_trace_dir``build_hits``_scan_and_alert` 改用 `build_hits`
- Test: `tests/test_compare_alert_stuck_worker.py`
- [ ] **Step 1: 写失败测试**
创建 `tests/test_compare_alert_stuck_worker.py`
```python
"""build_hits 集成测:cancelled trace 优先/保底切换、failed 附卡点、限量。"""
import json
from pathlib import Path
from app.core.compare_alert_worker import build_hits
class _Rec:
def __init__(self, **kw):
self.trace_id = kw.get("trace_id", "t")
self.status = kw.get("status", "cancelled")
self.total_ms = kw.get("total_ms")
self.step_count = kw.get("step_count")
self.fail_reason = kw.get("fail_reason")
self.information = kw.get("information")
self.app_version = kw.get("app_version")
self.created_at = kw.get("created_at")
self.trace_url = kw.get("trace_url")
self.user_id = kw.get("user_id")
def _frame(pdir: Path, idx: int, step: str, page: str) -> None:
pdir.mkdir(parents=True, exist_ok=True)
body = {"pipeline_step": step, "detected_page": page, "windows": [{"n": ["x" * 200]}]}
(pdir / f"step_{idx:03d}.json").write_text(
json.dumps(body, ensure_ascii=False), encoding="utf-8"
)
_KW = dict(
stuck_threshold=15, max_tail=40, max_trace_reads=30,
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
timeout_keywords=("超时",), unrecognized_keywords=("未识别",), biz_exclude_keywords=(),
)
def test_cancelled_stuck_reports_via_trace(tmp_path):
for i in range(18):
_frame(tmp_path / "20260804_x" / "meituan", i, "add_one_dish", "meal_detail_popup")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260804_x/",
total_ms=5000, step_count=3) # 保底不会中,靠 trace 判卡死
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T5"
assert "卡在" in hits[0].reason and "美团·加菜" in hits[0].reason
def test_cancelled_readable_not_stuck_no_report(tmp_path):
# trace 确认没卡(在推进);即便 total_ms/step 超阈值也不报(信 trace,不回退保底)
p = tmp_path / "20260804_y" / "eleme"
_frame(p, 0, "set_address", "home")
for i in range(1, 6):
_frame(p, i, "enter_store", "store")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260804_y/",
total_ms=95000, step_count=40)
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert hits == []
def test_cancelled_unreadable_falls_back(tmp_path):
rec = _Rec(status="cancelled", trace_url="https://x/traces/nope/",
total_ms=95000, step_count=3)
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T5" and "深度放弃" in hits[0].reason
def test_no_work_log_dir_uses_fallback(tmp_path):
rec = _Rec(status="cancelled", trace_url="https://x/traces/y/",
total_ms=95000, step_count=3)
hits = build_hits([rec], work_log_dir="", **_KW)
assert len(hits) == 1 and "深度放弃" in hits[0].reason
def test_failed_gets_stuck_point_appended(tmp_path):
for i in range(20):
_frame(tmp_path / "20260804_f" / "meituan", i, "add_one_dish", "meal_detail_popup")
rec = _Rec(status="failed", fail_reason="启动超时",
trace_url="https://x/traces/20260804_f/")
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T2"
assert "卡在 美团·加菜" in hits[0].reason
def test_max_trace_reads_zero_skips_trace(tmp_path):
for i in range(18):
_frame(tmp_path / "20260804_z" / "meituan", i, "add_one_dish", "meal_detail_popup")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260804_z/",
total_ms=95000, step_count=3)
kw = {**_KW, "max_trace_reads": 0}
hits = build_hits([rec], work_log_dir=str(tmp_path), **kw)
# 没读 trace → 回退保底 → deep(95s) → 深度放弃
assert len(hits) == 1 and "深度放弃" in hits[0].reason
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_compare_alert_stuck_worker.py -q`
Expected: FAIL`ImportError: cannot import name 'build_hits'`
- [ ] **Step 3: 改 compare_alert_worker.py**
(a) 顶部 imports 段,把
```python
from app.services.compare_alert import classify_batch
from app.services.compare_alert_format import ALERT_KEYWORD, format_alert_post
```
改为
```python
from dataclasses import replace as _dc_replace
from app.services import trace_stuck
from app.services.compare_alert import (
classify_cancelled_fallback,
classify_record,
make_hit,
)
from app.services.compare_alert_format import ALERT_KEYWORD, format_alert_post
```
(b) 在 `_scan_and_alert` 之前新增两个函数:
```python
def _trace_dir(base: Path, trace_url: str | None) -> Path | None:
name = trace_stuck.dir_name_from_trace_url(trace_url)
if not name:
return None
return base / name
def build_hits(
records: list,
*,
work_log_dir: str,
stuck_threshold: int,
max_tail: int,
max_trace_reads: int,
cancelled_ms_threshold: int,
cancelled_step_threshold: int,
timeout_keywords: tuple[str, ...],
unrecognized_keywords: tuple[str, ...],
biz_exclude_keywords: tuple[str, ...],
) -> list:
"""编排:cancelled 走 trace 优先(读到确认没卡则不报,读不到回退保底);failed 附卡点。
trace 读取限量 max_trace_reads 次/轮;任何 trace 异常都在 trace_stuck 内部降级为
「读不到」,cancelled 因而回退保底、failed 不附卡点,绝不影响报警发送。
"""
base = Path(work_log_dir) if work_log_dir else None
reads = 0
hits: list = []
for rec in records:
if rec.status == "cancelled":
res = None
if base is not None and reads < max_trace_reads:
td = _trace_dir(base, rec.trace_url)
if td is not None:
res = trace_stuck.read_stuck_points(
td, threshold=stuck_threshold, max_tail=max_tail
)
reads += 1
if res is not None and res.readable:
if res.points:
reason = "卡在 " + "、".join(sp.label() for sp in res.points)
hit = make_hit(rec, "T5", reason)
else:
hit = None # 读到且确认没卡 → 不报
else:
hit = classify_cancelled_fallback(
rec,
cancelled_ms_threshold=cancelled_ms_threshold,
cancelled_step_threshold=cancelled_step_threshold,
)
if hit is not None:
hits.append(hit)
else:
hit = classify_record(
rec,
cancelled_ms_threshold=cancelled_ms_threshold,
cancelled_step_threshold=cancelled_step_threshold,
timeout_keywords=timeout_keywords,
unrecognized_keywords=unrecognized_keywords,
biz_exclude_keywords=biz_exclude_keywords,
)
if (
hit is not None
and hit.alert_type in ("T1", "T2", "T6")
and base is not None
and reads < max_trace_reads
):
td = _trace_dir(base, rec.trace_url)
if td is not None:
sp = trace_stuck.last_step(td)
reads += 1
if sp is not None:
hit = _dc_replace(hit, reason=f"{hit.reason}|卡在 {sp.label()}")
if hit is not None:
hits.append(hit)
return hits
```
(c) 在 `_scan_and_alert` 里,把
```python
hits = classify_batch(
records,
cancelled_ms_threshold=settings.COMPARE_ALERT_CANCELLED_MS_THRESHOLD,
cancelled_step_threshold=settings.COMPARE_ALERT_CANCELLED_STEP_THRESHOLD,
timeout_keywords=settings.compare_alert_timeout_keywords,
unrecognized_keywords=settings.compare_alert_unrecognized_keywords,
biz_exclude_keywords=settings.compare_alert_biz_exclude_keywords,
)
```
替换为
```python
hits = build_hits(
records,
work_log_dir=settings.COMPARE_ALERT_PRICEBOT_WORK_LOG_DIR,
stuck_threshold=settings.COMPARE_ALERT_STUCK_FRAME_THRESHOLD,
max_tail=settings.COMPARE_ALERT_TRACE_MAX_TAIL_FRAMES,
max_trace_reads=settings.COMPARE_ALERT_TRACE_MAX_RECORDS,
cancelled_ms_threshold=settings.COMPARE_ALERT_CANCELLED_MS_THRESHOLD,
cancelled_step_threshold=settings.COMPARE_ALERT_CANCELLED_STEP_THRESHOLD,
timeout_keywords=settings.compare_alert_timeout_keywords,
unrecognized_keywords=settings.compare_alert_unrecognized_keywords,
biz_exclude_keywords=settings.compare_alert_biz_exclude_keywords,
)
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pytest tests/test_compare_alert_stuck_worker.py -q`
Expected: PASS6 passed
- [ ] **Step 5: 跑报警相关全量测试确认不回归**
Run: `pytest tests/ -q -k "compare_alert or trace_stuck"`
Expected: PASS(全绿)
- [ ] **Step 6: Commit**
```bash
git add app/core/compare_alert_worker.py tests/test_compare_alert_stuck_worker.py
git commit -m "feat(compare-alert): worker 编排 build_hits(cancelled trace 优先+保底、failed 附卡点)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## 收尾
- [ ] **全量测试**`pytest -q`(对齐 preexisting 失败基线,不新增失败)
- [ ] **lint**`ruff check app/services/trace_stuck.py app/core/compare_alert_worker.py app/services/compare_alert.py`
- [ ] **本地联调(可选)**:把 `.env``COMPARE_ALERT_PRICEBOT_WORK_LOG_DIR` 指向本地 `E:\project\pricebot-backend\data\work_logs`,用真实 cancelled trace 目录验证卡点文案。
- [ ] **清理临时脚本**`git rm --cached` 无关,直接删 `scripts/_probe_trace_timing.py`(若确认不再用,另行确认 `scripts/_test_alert_card.py`)。
## 不在本 plan(后续单独排)
- 卡片 schema 2.0 table 组件固化(`format_alert_card` + `send_feishu_card`,当前仍在 `scripts/_test_alert_card.py`)——卡点已随 `reason` 在现有 `format_alert_post` 展示,不阻塞本功能。
- pricebot 侧改动(本方案零改 pricebot)。
- `PIPELINE_STEP_LABELS` 全枚举补全(映射不到原样英文,可随线上观察增量补)。
@@ -1,761 +0,0 @@
# 金币记录按会话汇总比价/领券看广告金币 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** App「金币变动记录」里,一次比价 / 一次领券连续看广告获得的多条金币,按 `trace_id` 聚合成一条展示。
**Architecture:** 纯后端。给 `coin_transaction``trace_id` 列并在发奖时写入;`GET /api/v1/wallet/coin-transactions` 改为按 `trace_id` 分组的游标分页(比价/领券两类 feed 广告按会话合并,其余每条一行);历史行从 `ad_feed_reward_record` 回填。领券自 2026-07-15 起结算已带 trace_id,无需改 Android。仅动 App 用户接口,admin 审计接口不变。
**Tech Stack:** FastAPI · SQLAlchemy 2.0 · Alembic · Pydantic v2 · pytestSQLite)。
**Spec:** `docs/superpowers/specs/2026-08-07-coin-ledger-aggregate-ad-rewards-design.md`
**约定:** dev / pytest 均 **SQLite**prod PostgreSQL——所有 SQL 保持 SQLite/PG 通用(不用 PG 专有语法)。金额单位为金币(非分)。
---
## File Structure
| 文件 | 改动 | 职责 |
|---|---|---|
| `app/core/rewards.py` | 加常量 `FEED_AD_SESSION_BIZ_TYPES` | 「按会话聚合」的两类 biz_type 单一来源(查询 + 写入 + 测试共用) |
| `app/models/wallet.py` | `CoinTransaction``trace_id` 列 | 金币流水会话键 |
| `app/repositories/wallet.py` | `grant_coins``trace_id` 参;`list_coin_transactions` 改分组分页;加 `CoinLedgerRow` | 写入透传 + 聚合读取 |
| `app/repositories/ad_feed_reward.py` | `grant_feed_reward``grant_coins` 时传 `trace_id` | 把本场 trace_id 写进金币流水 |
| `app/schemas/welfare.py` | `CoinTransactionOut``merged_count` | 下发合并条数 |
| `app/models/ad_feed_reward.py` | 更正 `trace_id` 注释 | 文档(领券自 2026-07-15 也带) |
| `alembic/versions/coin_transaction_trace_id.py` | 新建迁移 | 加列 + 索引 + 回填历史 |
| `tests/test_welfare.py` | 追加测试 | 覆盖写入 / 回填 / 发奖透传 / 聚合 / 分页防残组 |
---
## Task 1: 常量 + `CoinTransaction.trace_id` 列 + `grant_coins` 透传
**Files:**
- Modify: `app/core/rewards.py`(加常量)
- Modify: `app/models/wallet.py:86``remark` 后加 `trace_id` 列)
- Modify: `app/repositories/wallet.py:165-196``grant_coins` 加参数 + 写入)
- Test: `tests/test_welfare.py`(追加)
- [ ] **Step 1: 写失败测试**
追加到 `tests/test_welfare.py` 末尾:
```python
def test_grant_coins_persists_trace_id(client) -> None:
"""grant_coins 传 trace_id 落库;不传则为 None。"""
phone = "13800002001"
_login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_, txn1 = crud_wallet.grant_coins(
db, user.id, 5, biz_type="feed_ad_reward_comparison",
ref_id="evt1", remark="比价奖励", trace_id="trace-A",
)
_, txn2 = crud_wallet.grant_coins(
db, user.id, 30, biz_type="signin", remark="每日签到奖励",
)
db.commit()
assert txn1.trace_id == "trace-A"
assert txn2.trace_id is None
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_welfare.py::test_grant_coins_persists_trace_id -q`
Expected: FAIL —— `TypeError: grant_coins() got an unexpected keyword argument 'trace_id'`
- [ ] **Step 3: 加常量**
`app/core/rewards.py` 的签到常量段之后(约第 32 行 `SIGNIN_CYCLE_LEN` 之后)插入:
```python
# ===== 信息流广告「按会话聚合」的 biz_type =====
# 比价 / 领券等候期看的信息流广告,每条各写一条 coin_transaction;这两类在「金币变动记录」
# 里按 trace_id 聚合成一条展示(见 repositories/wallet.list_coin_transactions)。其余类型
# (reward_video / guide_video / 通用 feed_ad_reward / signin / task_* 等)不聚合。
FEED_AD_SESSION_BIZ_TYPES: tuple[str, str] = (
"feed_ad_reward_comparison",
"feed_ad_reward_coupon",
)
```
- [ ] **Step 4: 加模型列**
`app/models/wallet.py`,把 `remark` 行(第 86 行)后面补一列:
```python
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 会话键:仅比价/领券信息流发奖(feed_ad_reward_comparison/coupon)时写入本场 trace_id,
# 金币记录列表据此把一次比价/领券的多条广告金币聚合成一条。其余类型 = NULL。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
```
`String` 在该文件已导入。`index=True` 会在测试 `create_all` 时自动建 `ix_coin_transaction_trace_id`。)
- [ ] **Step 5: `grant_coins` 加参数 + 写入**
`app/repositories/wallet.py``grant_coins`:签名加 `trace_id`,构造 `CoinTransaction` 时带上。
签名(第 165-173 行)改为:
```python
def grant_coins(
db: Session,
user_id: int,
amount: int,
*,
biz_type: str,
ref_id: str | None = None,
remark: str | None = None,
trace_id: str | None = None,
) -> tuple[CoinAccount, CoinTransaction]:
```
`CoinTransaction(...)` 构造(第 185-193 行)改为:
```python
txn = CoinTransaction(
user_id=user_id,
amount=amount,
balance_after=acc.coin_balance,
biz_type=biz_type,
ref_id=ref_id,
remark=remark,
trace_id=trace_id,
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), # 存北京 wall-clock(客户端原样切片显示)
)
```
- [ ] **Step 6: 跑测试确认通过**
Run: `pytest tests/test_welfare.py::test_grant_coins_persists_trace_id -q`
Expected: PASS
- [ ] **Step 7: 回归 + lint**
Run: `pytest tests/test_welfare.py -q && ruff check app/core/rewards.py app/models/wallet.py app/repositories/wallet.py tests/test_welfare.py`
Expected: 原有用例仍 PASS,无新增 lint。
- [ ] **Step 8: 提交**
```bash
git add app/core/rewards.py app/models/wallet.py app/repositories/wallet.py tests/test_welfare.py
git commit -m "feat(wallet): coin_transaction 增 trace_id 列 + grant_coins 透传
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: 迁移(加列 + 索引 + 回填历史)
**Files:**
- Create: `alembic/versions/coin_transaction_trace_id.py`
- Test: `tests/test_welfare.py`(追加回填语义测试)
- [ ] **Step 1: 写失败测试(回填语义)**
`tests/test_welfare.py` 顶部 import 区补两行(若尚无):
```python
from sqlalchemy import text
from app.models.ad_feed_reward import AdFeedRewardRecord
```
追加测试:
```python
def test_backfill_coin_trace_id_from_ad_record(client) -> None:
"""回填:coin_transaction(trace_id 空)按 ref_id==client_event_id 从 ad_feed_reward_record 补 trace_id;
只补比价/领券两类,无关类型与无匹配的不动。SQL 与迁移 coin_transaction_trace_id 保持同步。"""
phone = "13800002002"
_login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
# 广告表:两条带 trace_id 的记录(模拟客户端已上报)
db.add(AdFeedRewardRecord(
client_event_id="evt-cmp", user_id=user.id, reward_date="2026-08-07",
duration_seconds=10, unit_count=1, ecpm_raw="1000",
feed_scene="comparison", trace_id="trace-CMP", coin=5, status="granted",
))
db.add(AdFeedRewardRecord(
client_event_id="evt-cpn", user_id=user.id, reward_date="2026-08-07",
duration_seconds=10, unit_count=1, ecpm_raw="1000",
feed_scene="coupon", trace_id="trace-CPN", coin=7, status="granted",
))
db.commit()
# 老金币流水:trace_id 全空(模拟改动前),ref_id 指向上面的广告事件
_, c1 = crud_wallet.grant_coins(db, user.id, 5, biz_type="feed_ad_reward_comparison", ref_id="evt-cmp", remark="比价奖励")
_, c2 = crud_wallet.grant_coins(db, user.id, 7, biz_type="feed_ad_reward_coupon", ref_id="evt-cpn", remark="领券奖励")
_, c3 = crud_wallet.grant_coins(db, user.id, 30, biz_type="signin", remark="每日签到奖励")
db.commit()
assert c1.trace_id is None and c2.trace_id is None
# 执行与迁移 upgrade() 等价的回填 SQL(务必与迁移保持一致)
db.execute(text(
"""
UPDATE coin_transaction SET trace_id = (
SELECT r.trace_id FROM ad_feed_reward_record r
WHERE r.client_event_id = coin_transaction.ref_id)
WHERE biz_type IN ('feed_ad_reward_comparison', 'feed_ad_reward_coupon')
AND trace_id IS NULL
AND EXISTS (SELECT 1 FROM ad_feed_reward_record r2
WHERE r2.client_event_id = coin_transaction.ref_id
AND r2.trace_id IS NOT NULL)
"""
))
db.commit()
db.refresh(c1); db.refresh(c2); db.refresh(c3)
assert c1.trace_id == "trace-CMP" # 比价补上
assert c2.trace_id == "trace-CPN" # 领券补上
assert c3.trace_id is None # 签到不动
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_welfare.py::test_backfill_coin_trace_id_from_ad_record -q`
Expected: FAIL —— `sqlite3.OperationalError: no such column: trace_id`(Task 1 已加列则此步应改为直接 PASS;若已 PASS 说明 create_all 已含列,跳到 Step 4 建迁移)。
> 说明:本测试验证的是回填 SQL 的 JOIN 语义,不经 Alembic。Task 1 完成后列已在测试库存在,测试可能直接 PASS——这是预期的(回填 SQL 本身是正确逻辑)。真正要新建的产物是迁移文件(Step 4),供 dev/prod 使用。
- [ ] **Step 3: 跑测试确认通过**
Run: `pytest tests/test_welfare.py::test_backfill_coin_trace_id_from_ad_record -q`
Expected: PASS
- [ ] **Step 4: 建迁移文件**
Create `alembic/versions/coin_transaction_trace_id.py`
```python
"""coin_transaction.trace_id (金币记录按会话聚合比价/领券看广告金币)
Revision ID: coin_transaction_trace_id
Revises: comparison_updated_at
Create Date: 2026-08-07
比价/领券信息流发奖时把本场 trace_id 一并写入 coin_transaction;金币变动记录接口按
trace_id 把一次比价/领券的多条广告金币聚合成一条。历史行从 ad_feed_reward_record
回填(ref_id == client_event_id)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'coin_transaction_trace_id'
down_revision: Union[str, Sequence[str], None] = 'comparison_updated_at'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# SQLite 下 ADD COLUMN(可空) 与 CREATE INDEX 均原生支持,无需 batch_alter_table。
op.add_column(
'coin_transaction',
sa.Column('trace_id', sa.String(length=64), nullable=True),
)
op.create_index(
op.f('ix_coin_transaction_trace_id'),
'coin_transaction',
['trace_id'],
unique=False,
)
# 历史回填:从 ad_feed_reward_record 按 ref_id==client_event_id 补 trace_id。仅比价/领券两类、
# 仅当前为空、且广告行确有 trace_id 时补(EXISTS 守护);`trace_id IS NULL` 保证重跑幂等。
# 相关子查询 SQLite/PG 通用。两类 biz_type 在应用侧为 rewards.FEED_AD_SESSION_BIZ_TYPES,
# 此处按「迁移不可变」原则硬编码历史快照(勿改为 import 应用常量)。
# 大表(prod PG)如需可改按 id 区间分批;此处一次性 UPDATE。
op.execute(
"""
UPDATE coin_transaction SET trace_id = (
SELECT r.trace_id FROM ad_feed_reward_record r
WHERE r.client_event_id = coin_transaction.ref_id)
WHERE biz_type IN ('feed_ad_reward_comparison', 'feed_ad_reward_coupon')
AND trace_id IS NULL
AND EXISTS (SELECT 1 FROM ad_feed_reward_record r2
WHERE r2.client_event_id = coin_transaction.ref_id
AND r2.trace_id IS NOT NULL)
"""
)
def downgrade() -> None:
op.drop_index(
op.f('ix_coin_transaction_trace_id'),
table_name='coin_transaction',
)
op.drop_column('coin_transaction', 'trace_id')
```
- [ ] **Step 5: 验证迁移可正反向应用(用一次性 scratch SQLite,不碰 dev 库)**
Run:
```bash
DATABASE_URL="sqlite:///./data/_migcheck.db" python -m alembic upgrade head && \
DATABASE_URL="sqlite:///./data/_migcheck.db" python -m alembic downgrade -1 && \
DATABASE_URL="sqlite:///./data/_migcheck.db" python -m alembic upgrade head && \
rm -f ./data/_migcheck.db
```
Expected: 三条 alembic 命令均无报错,最终 head 落在 `coin_transaction_trace_id``rm` 清掉临时库。
- [ ] **Step 6: lint**
Run: `ruff check alembic/versions/coin_transaction_trace_id.py tests/test_welfare.py`
Expected: 无新增 lint。
- [ ] **Step 7: 提交**
```bash
git add alembic/versions/coin_transaction_trace_id.py tests/test_welfare.py
git commit -m "feat(wallet): 迁移加 coin_transaction.trace_id + 索引 + 回填历史
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: `grant_feed_reward` 把 trace_id 写进金币流水
**Files:**
- Modify: `app/repositories/ad_feed_reward.py:219-223``grant_coins` 调用加 `trace_id`
- Test: `tests/test_welfare.py`(追加)
- [ ] **Step 1: 写失败测试**
`tests/test_welfare.py` 顶部 import 区补(若尚无):
```python
from sqlalchemy import select
from app.models.wallet import CoinTransaction
from app.repositories import ad_feed_reward as crud_feed
```
追加测试:
```python
def test_grant_feed_reward_sets_coin_trace_id(client) -> None:
"""grant_feed_reward(comparison) 把 trace_id 透传给 grant_coins,coin_transaction 带上本场 trace_id。"""
phone = "13800002003"
_login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
rec = crud_feed.grant_feed_reward(
db, user.id,
client_event_id="evt-fr-1", ecpm="1000", duration_seconds=10,
feed_scene="comparison", trace_id="trace-FR", display_coin=5,
)
assert rec.status == "granted", rec.status
txn = db.execute(
select(CoinTransaction).where(
CoinTransaction.user_id == user.id,
CoinTransaction.ref_id == "evt-fr-1",
)
).scalar_one()
assert txn.biz_type == "feed_ad_reward_comparison"
assert txn.trace_id == "trace-FR"
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_welfare.py::test_grant_feed_reward_sets_coin_trace_id -q`
Expected: FAIL —— `AssertionError: assert None == 'trace-FR'``grant_coins` 尚未收到 trace_id)。
- [ ] **Step 3: 传 trace_id**
`app/repositories/ad_feed_reward.py` 第 219-223 行 `grant_coins` 调用改为:
```python
crud_wallet.grant_coins(
db, user_id, coin,
biz_type=reward_biz, ref_id=client_event_id,
remark=reward_remark, trace_id=trace_id,
)
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pytest tests/test_welfare.py::test_grant_feed_reward_sets_coin_trace_id -q`
Expected: PASS
- [ ] **Step 5: lint**
Run: `ruff check app/repositories/ad_feed_reward.py tests/test_welfare.py`
Expected: 无新增 lint。
- [ ] **Step 6: 提交**
```bash
git add app/repositories/ad_feed_reward.py tests/test_welfare.py
git commit -m "feat(ad-feed): 发奖把本场 trace_id 写入金币流水
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: 聚合查询 —— `list_coin_transactions` 分组 + `CoinLedgerRow` + `merged_count`
**Files:**
- Modify: `app/repositories/wallet.py`imports + 加 `CoinLedgerRow` + 重写 `list_coin_transactions`
- Modify: `app/schemas/welfare.py:23-32``CoinTransactionOut``merged_count`
- Test: `tests/test_welfare.py`(追加 3 个聚合测试 + 一个 `_seed_coin` 帮手)
- [ ] **Step 1: 写失败测试**
`tests/test_welfare.py` 追加帮手 + 测试:
```python
def _seed_coin(db, user_id, amount, biz_type, *, trace_id=None, ref_id=None, remark=None):
crud_wallet.grant_coins(
db, user_id, amount, biz_type=biz_type, ref_id=ref_id, remark=remark, trace_id=trace_id
)
def test_coin_transactions_aggregate_by_trace(client) -> None:
"""一次比价的多条广告金币聚合成一条:金额合计、merged_count=条数、balance_after 取最后一条。"""
phone = "13800002004"
token = _login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="t1", ref_id="e1", remark="比价奖励")
_seed_coin(db, user.id, 3, "feed_ad_reward_comparison", trace_id="t1", ref_id="e2", remark="比价奖励")
_seed_coin(db, user.id, 4, "feed_ad_reward_comparison", trace_id="t1", ref_id="e3", remark="比价奖励")
db.commit()
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
row = items[0]
assert row["biz_type"] == "feed_ad_reward_comparison"
assert row["amount"] == 12 # 5+3+4
assert row["merged_count"] == 3
assert row["balance_after"] == 12 # 末条到账后余额(本用户从 0 起)
def test_coin_transactions_distinct_traces_stay_separate(client) -> None:
"""不同 trace(两次比价 / 一次领券)各成一条;不同会话不合并。"""
phone = "13800002005"
token = _login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="cmpA", ref_id="a1", remark="比价奖励")
_seed_coin(db, user.id, 6, "feed_ad_reward_comparison", trace_id="cmpB", ref_id="b1", remark="比价奖励")
_seed_coin(db, user.id, 6, "feed_ad_reward_comparison", trace_id="cmpB", ref_id="b2", remark="比价奖励")
_seed_coin(db, user.id, 7, "feed_ad_reward_coupon", trace_id="cpnC", ref_id="c1", remark="领券奖励")
db.commit()
items = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)).json()["items"]
assert len(items) == 3
assert sorted(i["amount"] for i in items) == [5, 7, 12]
cpn = next(i for i in items if i["biz_type"] == "feed_ad_reward_coupon")
assert cpn["amount"] == 7 and cpn["merged_count"] == 1
def test_coin_transactions_non_session_rows_stay_per_row(client) -> None:
"""签到 / 无 trace 的通用信息流各自一行,不被聚合;夹在比价广告中间的签到不影响比价聚合。"""
phone = "13800002006"
token = _login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="t1", ref_id="e1", remark="比价奖励")
_seed_coin(db, user.id, 30, "signin", remark="每日签到奖励") # 夹在两条比价广告中间
_seed_coin(db, user.id, 4, "feed_ad_reward_comparison", trace_id="t1", ref_id="e2", remark="比价奖励")
_seed_coin(db, user.id, 8, "feed_ad_reward", trace_id=None, ref_id="w1", remark="信息流广告奖励")
db.commit()
items = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)).json()["items"]
assert len(items) == 3
cmp_row = next(i for i in items if i["biz_type"] == "feed_ad_reward_comparison")
assert cmp_row["amount"] == 9 and cmp_row["merged_count"] == 2
signin_row = next(i for i in items if i["biz_type"] == "signin")
assert signin_row["amount"] == 30 and signin_row["merged_count"] == 1
feed_row = next(i for i in items if i["biz_type"] == "feed_ad_reward")
assert feed_row["amount"] == 8 and feed_row["merged_count"] == 1
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_welfare.py::test_coin_transactions_aggregate_by_trace -q`
Expected: FAIL —— 未聚合,返回 3 条 / `merged_count` 字段缺失(`KeyError``len(items)==3`)。
- [ ] **Step 3: schema 加 `merged_count`**
`app/schemas/welfare.py``CoinTransactionOut`(第 23-32 行)在 `created_at` 后加一行:
```python
class CoinTransactionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
amount: int = Field(..., description="正=入账,负=出账")
balance_after: int
biz_type: str
ref_id: str | None = None
remark: str | None = None
created_at: datetime
merged_count: int = Field(1, description="本行合并的底层流水条数(比价/领券按会话聚合;未合并=1)")
```
- [ ] **Step 4: 补 imports + 加 `CoinLedgerRow`**
`app/repositories/wallet.py` 顶部:把 `from sqlalchemy import func, select, update`(第 15 行)改为:
```python
from sqlalchemy import String, and_, case, cast, func, literal, select, update
```
在 stdlib import 区(约第 7-13 行)加一行:
```python
from dataclasses import dataclass
```
`list_coin_transactions` 之前加返回行类型:
```python
@dataclass(frozen=True)
class CoinLedgerRow:
"""`list_coin_transactions` 的返回行。广告类按 trace_id 聚合后的展示行,
**非 ORM 对象**(避免把聚合后的 amount 误写回底层流水)。"""
id: int
amount: int
balance_after: int
biz_type: str
ref_id: str | None
remark: str | None
created_at: datetime
merged_count: int
```
- [ ] **Step 5: 重写 `list_coin_transactions`**
`app/repositories/wallet.py` 第 260-279 行整体替换为:
```python
def list_coin_transactions(
db: Session,
user_id: int,
*,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[CoinLedgerRow], int | None]:
"""金币流水分页(游标式,按 id 倒序)。
比价/领券两类信息流广告(rewards.FEED_AD_SESSION_BIZ_TYPES)且带 trace_id 的行,
按 trace_id 聚合成一条(一次比价/领券 = 一行):金额合计、代表行取组内最新一条
(MAX(id))的余额/时间、merged_count=组内条数。其余每条一行。
分组必须在该用户**全量**行上算真实 rep_id 后再按 rep_id 过滤——**不可**把 CTE 输入
裁成 id<cursor,否则会话行交错跨游标时会算出与上页重复的「残组」。
cursor 为上一页最后一条的 id(即其组 rep_id);返回 (本页列表, next_cursor)。
"""
ct = CoinTransaction
group_key = case(
(
and_(
ct.biz_type.in_(rewards.FEED_AD_SESSION_BIZ_TYPES),
ct.trace_id.is_not(None),
),
literal("T:") + ct.trace_id,
),
else_=literal("I:") + cast(ct.id, String),
).label("group_key")
grp = (
select(
group_key,
func.max(ct.id).label("rep_id"),
func.sum(ct.amount).label("total_amount"),
func.count().label("merged_count"),
)
.where(ct.user_id == user_id)
.group_by(group_key)
.cte("grp")
)
stmt = (
select(
ct.id,
grp.c.total_amount.label("amount"),
ct.balance_after,
ct.biz_type,
ct.ref_id,
ct.remark,
ct.created_at,
grp.c.merged_count,
)
.select_from(grp)
.join(ct, ct.id == grp.c.rep_id)
)
if cursor is not None:
stmt = stmt.where(grp.c.rep_id < cursor)
stmt = stmt.order_by(grp.c.rep_id.desc()).limit(limit)
rows = db.execute(stmt).all()
items = [
CoinLedgerRow(
id=r.id,
amount=int(r.amount),
balance_after=r.balance_after,
biz_type=r.biz_type,
ref_id=r.ref_id,
remark=r.remark,
created_at=r.created_at,
merged_count=int(r.merged_count),
)
for r in rows
]
next_cursor = items[-1].id if len(items) == limit else None
return items, next_cursor
```
(端点 `app/api/v1/wallet.py` 无需改:`CoinTransactionOut.model_validate(it)``CoinLedgerRow` dataclass 按 `from_attributes` 读取即可。)
- [ ] **Step 6: 跑三个聚合测试确认通过**
Run: `pytest tests/test_welfare.py -k "coin_transactions_aggregate_by_trace or distinct_traces_stay_separate or non_session_rows_stay_per_row" -q`
Expected: 3 PASS
- [ ] **Step 7: 全量回归 + lint**
Run: `pytest tests/test_welfare.py -q && ruff check app/repositories/wallet.py app/schemas/welfare.py tests/test_welfare.py`
Expected: 原有用例(含 `test_signin_flow` / `test_exchange_flow` 等对 coin-transactions 的断言)仍 PASS;无新增 lint。
- [ ] **Step 8: 提交**
```bash
git add app/repositories/wallet.py app/schemas/welfare.py tests/test_welfare.py
git commit -m "feat(wallet): 金币记录按 trace_id 聚合比价/领券看广告金币
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: 分页防残组回归测试
**Files:**
- Test: `tests/test_welfare.py`(追加)
- [ ] **Step 1: 写回归测试**
追加:
```python
def test_coin_transactions_pagination_no_phantom_regroup(client) -> None:
"""交错跨游标不产生残组:会话广告成员被其它记录隔开、rep_id 在游标上、成员在游标下时,
翻到下一页该会话不得以「残组」重复出现(锁死 spec §11 反面优化警示)。"""
phone = "13800002007"
token = _login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="t1", ref_id="e1", remark="比价奖励") # id=n+1
_seed_coin(db, user.id, 30, "signin", remark="每日签到奖励") # id=n+2
_seed_coin(db, user.id, 40, "signin", remark="每日签到奖励") # id=n+3
_seed_coin(db, user.id, 4, "feed_ad_reward_comparison", trace_id="t1", ref_id="e2", remark="比价奖励") # id=n+4 = t1 的 rep
db.commit()
# 第 1 页(limit=2):按 rep 降序 = [t1(rep=n+4, 合计 9), signin(n+3, 40)]
p1 = client.get("/api/v1/wallet/coin-transactions?limit=2", headers=_auth(token)).json()
assert len(p1["items"]) == 2
assert p1["items"][0]["biz_type"] == "feed_ad_reward_comparison"
assert p1["items"][0]["amount"] == 9 and p1["items"][0]["merged_count"] == 2
assert p1["items"][1]["biz_type"] == "signin" and p1["items"][1]["amount"] == 40
assert p1["next_cursor"] is not None
# 第 2 页:只剩另一条 signin(30);t1 的 rep 在游标上,成员虽在游标下也不得成残组重复
p2 = client.get(
f"/api/v1/wallet/coin-transactions?limit=2&cursor={p1['next_cursor']}",
headers=_auth(token),
).json()
assert len(p2["items"]) == 1
assert p2["items"][0]["biz_type"] == "signin" and p2["items"][0]["amount"] == 30
assert all(i["biz_type"] != "feed_ad_reward_comparison" for i in p2["items"])
assert p2["next_cursor"] is None
```
- [ ] **Step 2: 跑测试确认通过(锁定不变量)**
Run: `pytest tests/test_welfare.py::test_coin_transactions_pagination_no_phantom_regroup -q`
Expected: PASSTask 4 的全量分组实现已正确;若 FAIL 说明 CTE 被错误地按 cursor 裁剪,回到 Task 4 Step 5 修正)。
- [ ] **Step 3: lint**
Run: `ruff check tests/test_welfare.py`
Expected: 无新增 lint。
- [ ] **Step 4: 提交**
```bash
git add tests/test_welfare.py
git commit -m "test(wallet): 锁死金币记录分组分页不产生残组
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 6: 更正 `ad_feed_reward_record.trace_id` 过时注释
**Files:**
- Modify: `app/models/ad_feed_reward.py:39-40`
- [ ] **Step 1: 改注释**
`app/models/ad_feed_reward.py` 第 39-40 行两行注释替换为:
```python
# 本次会话 trace_id:比价一直带;领券自 2026-07-15(客户端 a98cab8)起也带;福利/旧客户端 = NULL。
# 用途:比价记录页按 trace_id 聚合「比价赚 N 金币」;金币记录列表把一次比价/领券的多条广告金币聚合成一条。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
```
- [ ] **Step 2: 冒烟 + lint**
Run: `python -c "import app.models.ad_feed_reward" && ruff check app/models/ad_feed_reward.py`
Expected: 无报错、无 lint。
- [ ] **Step 3: 提交**
```bash
git add app/models/ad_feed_reward.py
git commit -m "docs(ad-feed): 更正 trace_id 注释(领券自 2026-07-15 也带)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## 收尾验证
- [ ] **全量测试**`pytest -q`(对齐 memory「preexisting-test-lint-debt」:先前已知失败数不变、无本改动引入的新失败)。
- [ ] **lint**`ruff check .`(无本改动引入的新问题)。
- [ ] **迁移落 dev**`python -m alembic upgrade head`(把 dev SQLite 迁到最新,确认 `./run.sh` 正常)。
---
## Self-Review(写完计划后自查)
**Spec 覆盖:**
- §4 加列 → Task 1 Step 4 + Task 2。✓
- §5 写入透传 → Task 1 Step 5 + Task 3。✓
- §6 聚合查询(CASE 分组键、MAX(id) 代表/游标、biz_type 门控、常量)→ Task 4 Step 4-5 + Task 1 常量。✓
- §7 `merged_count` 下发 → Task 4 Step 3。✓
- §8 回填(幂等 + EXISTS 守护 + 通用 SQL)→ Task 2 Step 4。✓
- §9 注释更正 → Task 6。✓
- §10 App 端无需改 → 端点未改(Task 4 Step 5 注)。✓
- §11 反面优化警示(残组)→ Task 4 docstring + Task 5 回归测试。✓
- §3 只改用户接口、admin 不动 → 全程只碰 `crud_wallet.list_coin_transactions`,未触 `app/admin`。✓
- §12 测试(分组/隔离/空 trace/分页/回填)→ Task 2/4/5。✓
**占位扫描:** 无 TBD / TODO;每个代码步骤均给出完整代码与确切命令。✓
**类型一致:** `grant_coins(..., trace_id=...)`Task 1)↔ `grant_feed_reward` 调用(Task 3)↔ 查询 `rewards.FEED_AD_SESSION_BIZ_TYPES`(Task 4)↔ 迁移硬编码同两值(Task 2,有意快照)一致;`CoinLedgerRow` 字段(Task 4 Step 4)↔ `CoinTransactionOut` 字段(Task 4 Step 3)逐一对应(id/amount/balance_after/biz_type/ref_id/remark/created_at/merged_count)。✓
@@ -1,538 +0,0 @@
# 比价报警「末帧停留时长」实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 飞书报警卡片「末帧」列给 failed(T1/T2/T6) 与 cancelled 兜底两种末帧路径补上「末帧所在屏停留 Xs」,让运营一眼区分「一到结算页就崩(停留<1s)」vs「在结算页干转 40s 才放弃」。
**Architecture:** 在 `trace_stuck.py` 抽一个末段扫描 helper `_last_segment`(复用现有 `_platform_stuck` 段扫描+时长逻辑,去掉 threshold 门槛),供 `_platform_stuck`/`last_step`/`read_stuck_points` 三处复用;`StuckPoint` 新增 `dwell_ms` 字段承载「末帧所在屏停留」,与 `stuck_ms`(T5 卡死段)语义分离;worker 新增 dwell-only 格式化 `_fmt_last`(只显环节·页面+停留,**不显总帧数**,规避原注释担心的误导)。
**Tech Stack:** Python 3.11 / FastAPI / pytest。纯 CPU+本地文件读,无 DB、无迁移、无外部调用。
关联 spec`docs/superpowers/specs/2026-08-07-compare-alert-last-frame-dwell-design.md`
---
## File Structure
| 文件 | 职责 | 本计划改动 |
|---|---|---|
| `app/services/trace_stuck.py` | trace 末段读取+卡死判定(薄 IO+纯逻辑) | `StuckPoint``dwell_ms`;抽 `_last_segment``_platform_stuck`/`last_step`/`read_stuck_points` 复用它 |
| `app/core/compare_alert_worker.py` | 报警编排 | 新增 `_fmt_last`failed/cancelled 兜底两处末帧格式化换成它;`last_step` 调用传 `max_tail` |
| `tests/test_trace_stuck.py` | trace_stuck 单测 | 新增 dwell 用例;`last_step` 调用加 `max_tail=40` |
| `tests/test_compare_alert_stuck_worker.py` | worker 集成测 | 新增 `_frame_ts` helper + failed/兜底带 dwell 用例 + `_fmt_last` 单测 |
`app/services/compare_alert_format.py` **不改**(末帧列是自由字符串)。
**基线命令**(每个 Task 前后跑,避免全量 pytest 的先前债干扰):
```bash
pytest tests/test_trace_stuck.py tests/test_compare_alert_stuck_worker.py -q
```
---
## Task 1: `StuckPoint``dwell_ms` + 抽 `_last_segment`(重构,行为不变)
**Files:**
- Modify: `app/services/trace_stuck.py`(`StuckPoint` 定义 :43-57`_platform_stuck` :93-116)
- Test: `tests/test_trace_stuck.py`(现有测试作回归网,本 Task 不新增)
> 纯重构 + 加一个默认 `None` 的新字段。无新外部行为,靠现有测试保绿。
- [ ] **Step 1: 跑基线,确认现有测试全绿**
Run: `pytest tests/test_trace_stuck.py -q`
Expected: PASS(全绿)
- [ ] **Step 2: `StuckPoint``dwell_ms` 字段**
`app/services/trace_stuck.py``StuckPoint`(:43-57) 的字段区改为(仅加最后一行,`label()` 不动)
```python
@dataclass(frozen=True)
class StuckPoint:
platform: str
pipeline_step: str
frames: int # 末段连续困住的帧数(上限 max_tail)
stuck_ms: int | None = None # 末段连续卡住的时长(ms);无 timestamp 时 None
detected_page: str | None = None # 末帧所在页面(pricebot detected_page 原值);无则 None
dwell_ms: int | None = None # 末帧所在屏停留时长(ms);末帧路径(failed/兜底)用,无 ts → None
```
- [ ] **Step 3: 新增 `_last_segment` helper**
`app/services/trace_stuck.py``_platform_stuck` **之前**插入(紧跟 `_read_head` 之后)
```python
def _last_segment(
step_files: list[Path], max_tail: int
) -> tuple[str, str | None, int, int | None] | None:
"""末帧往前数连续同 (pipeline_step, detected_page) 的一段。
返回 (pipeline_step, detected_page, count, dwell_ms);末帧 pipeline_step 抠不出 → None。
dwell_ms = 段末帧ts 段首帧ts(ms);两端 ts 不全可解析、或负(帧钟回退) → None。
这是「末段停留」的唯一算法,卡死判据(≥threshold)与末帧停留(无门槛)都复用它。
"""
tail = step_files[-max_tail:]
heads = [_read_head(p) for p in tail] # [(ps, pg, ts), ...]
last_ps, last_pg, _ = heads[-1]
if last_ps is None:
return None
seg_ts: list[str | None] = [] # 连续段的 timestamp(逆序:末帧在前)
for ps, pg, ts in reversed(heads):
if ps == last_ps and pg == last_pg:
seg_ts.append(ts)
else:
break
t_last, t_first = _parse_ts(seg_ts[0]), _parse_ts(seg_ts[-1])
dwell_ms = round((t_last - t_first).total_seconds() * 1000) if t_last and t_first else None
if dwell_ms is not None and dwell_ms < 0:
dwell_ms = None # 时钟不单调(帧 timestamp 回退)→ 降级为不显示时长
return last_ps, last_pg, len(seg_ts), dwell_ms
```
- [ ] **Step 4: `_platform_stuck` 改为复用 `_last_segment`**
`app/services/trace_stuck.py` 的整个 `_platform_stuck`(:93-116) 替换为:
```python
def _platform_stuck(
platform: str, step_files: list[Path], threshold: int, max_tail: int
) -> StuckPoint | None:
"""末帧往前数连续同 (pipeline_step, detected_page) 的帧数 ≥threshold → 卡死。"""
seg = _last_segment(step_files, max_tail)
if seg is None:
return None
ps, pg, count, dwell_ms = seg
if count < threshold:
return None
# 卡死:frames=末段帧数、stuck_ms=末段时长(同段自洽);dwell_ms 字段留默认 None(T5 用 stuck_ms)
return StuckPoint(platform, ps, count, dwell_ms, detected_page=pg)
```
> `stuck_ms` 收的就是 `_last_segment``dwell_ms` 值——T5 场景「末段=卡死段」,二者本是同一个量,故行为与改前完全一致。
- [ ] **Step 5: 跑测试,确认行为不变(仍全绿)**
Run: `pytest tests/test_trace_stuck.py -q`
Expected: PASS(全绿;`test_stuck_ms_computed_from_timestamps` 等对 `stuck_ms`/`frames` 的断言不变)
- [ ] **Step 6: Commit**
```bash
git add app/services/trace_stuck.py
git commit -m "refactor(compare-alert): 抽 _last_segment、StuckPoint 加 dwell_ms 字段
末段扫描+时长算法抽成 _last_segment 供三处复用;StuckPoint 新增
dwell_ms(默认 None、承载末帧所在屏停留),T5 行为不变。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: `last_step``dwell_ms`(failed 路径数据)
**Files:**
- Modify: `app/services/trace_stuck.py`(`last_step` :151-172)
- Modify: `app/core/compare_alert_worker.py`(`last_step` 调用 :159,本 Task 只传 `max_tail`、仍用 `label()`)
- Test: `tests/test_trace_stuck.py`
- [ ] **Step 1: 写失败测试(带 timestamp 的 dwell)**
`tests/test_trace_stuck.py` 末尾追加:
```python
def test_last_step_computes_dwell_ms(tmp_path):
# 帧数最多平台末段 5 帧都在 checkout,ts :00→:08(每帧+2s) → 停留 8s
m = tmp_path / "meituan"
_frame_ts(m, 0, "enter_store", "store", "2026-08-07T12:00:00.000000")
_frame_ts(m, 1, "enter_store", "store", "2026-08-07T12:00:02.000000")
_frame_ts(m, 2, "enter_store", "store", "2026-08-07T12:00:04.000000")
for i in range(3, 8): # step3..7 checkout,末段 5 帧
_frame_ts(m, i, "checkout", "checkout_page",
f"2026-08-07T12:00:{(i - 3) * 2:02d}.000000")
sp = last_step(tmp_path, max_tail=40)
assert sp.platform == "meituan"
assert sp.pipeline_step == "checkout"
assert sp.frames == 8 # 总帧数(非末段)
assert sp.dwell_ms == 8000 # 末段 checkout :00→:08 = 8s
def test_last_step_dwell_none_without_ts(tmp_path):
m = tmp_path / "meituan"
for i in range(5):
_frame(m, i, "checkout", "checkout_page") # 无 timestamp
sp = last_step(tmp_path, max_tail=40)
assert sp.dwell_ms is None
def test_last_step_dwell_zero_single_frame_segment(tmp_path):
# 末帧与前一帧不同屏 → 末段只有末帧 1 帧 → 停留 0(一到就是末屏)
m = tmp_path / "meituan"
for i in range(4):
_frame_ts(m, i, "enter_store", "store", f"2026-08-07T12:00:{i:02d}.000000")
_frame_ts(m, 4, "checkout", "checkout_page", "2026-08-07T12:00:10.000000")
sp = last_step(tmp_path, max_tail=40)
assert sp.pipeline_step == "checkout"
assert sp.dwell_ms == 0
```
同时把现有 `test_last_step_returns_busiest_platform_last_env`(约 :87-95) 里的调用改为传 `max_tail`
```python
sp = last_step(tmp_path, max_tail=40)
```
(断言不变——该 fixture 无 timestamp`dwell_ms=None`=字段默认,精确 `StuckPoint(...)` 相等仍成立。)
- [ ] **Step 2: 跑测试,确认新用例失败**
Run: `pytest tests/test_trace_stuck.py -q`
Expected: FAIL — `last_step() got an unexpected keyword argument 'max_tail'`(签名还没加 `max_tail`)
- [ ] **Step 3: 改 `last_step`**
`app/services/trace_stuck.py` 的整个 `last_step`(:151-172) 替换为:
```python
def last_step(trace_dir: Path, *, max_tail: int) -> StuckPoint | None:
"""failed 用:取帧数最多平台的末帧环节(不要求原地打转)。读不到 → None。
附末段停留 dwell_ms(末帧所在屏停留时长);无 ts/时钟回退 → None。"""
try:
if not trace_dir.is_dir():
return None
best: tuple[int, str, list[Path]] | None = None
for pdir in sorted(trace_dir.iterdir()):
if not pdir.is_dir():
continue
step_files = sorted(pdir.glob("step_*.json"), key=_step_num)
# 平局(同帧数)时取字典序第一个平台(sorted 保证稳定)
if step_files and (best is None or len(step_files) > best[0]):
best = (len(step_files), pdir.name, step_files)
if best is None:
return None
_, platform, step_files = best
seg = _last_segment(step_files, max_tail)
if seg is None:
return None
ps, pg, _count, dwell_ms = seg
# frames 仍=总帧数(选平台口径不变);dwell_ms=末帧所在屏停留
return StuckPoint(platform, ps, len(step_files), detected_page=pg, dwell_ms=dwell_ms)
except OSError:
return None
```
- [ ] **Step 4: 同步 worker 的 `last_step` 调用(保持 `label()` 不变,避免签名破坏 worker 测试)**
`app/core/compare_alert_worker.py` 把 :159 一行:
```python
sp = trace_stuck.last_step(td)
```
改为:
```python
sp = trace_stuck.last_step(td, max_tail=max_tail)
```
(本 Task 只改调用签名;末帧格式化换成 `_fmt_last` 留到 Task 4。)
- [ ] **Step 5: 跑测试,确认全绿**
Run: `pytest tests/test_trace_stuck.py tests/test_compare_alert_stuck_worker.py -q`
Expected: PASS(新 dwell 用例过;worker 集成测因 `last_step` 仍用 `label()`、行为不变,全绿)
- [ ] **Step 6: Commit**
```bash
git add app/services/trace_stuck.py app/core/compare_alert_worker.py tests/test_trace_stuck.py
git commit -m "feat(compare-alert): last_step 附末段停留 dwell_ms
failed 末帧路径拿到「末帧所在屏停留」;frames 仍为总帧数、口径不变。
worker 调用同步传 max_tail(格式化留待接线)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: `read_stuck_points``last``dwell_ms`(cancelled 兜底数据)
**Files:**
- Modify: `app/services/trace_stuck.py`(`read_stuck_points` :119-148)
- Test: `tests/test_trace_stuck.py`
- [ ] **Step 1: 写失败测试**
`tests/test_trace_stuck.py` 末尾追加:
```python
def test_read_stuck_points_last_has_dwell(tmp_path):
# 没卡死(末段<threshold),但末帧末段带 ts → last.dwell_ms 有值
m = tmp_path / "meituan"
_frame_ts(m, 0, "enter_store", "store", "2026-08-07T12:00:00.000000")
_frame_ts(m, 1, "enter_store", "store", "2026-08-07T12:00:02.000000")
for i in range(2, 5): # 末段 checkout 3 帧 :00→:04
_frame_ts(m, i, "checkout", "checkout_page",
f"2026-08-07T12:00:{(i - 2) * 2:02d}.000000")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.points == [] # 末段 3<15,没卡死
assert res.last.pipeline_step == "checkout"
assert res.last.frames == 5 # 总帧数
assert res.last.dwell_ms == 4000 # 末段 :00→:04 = 4s
```
- [ ] **Step 2: 跑测试,确认失败**
Run: `pytest tests/test_trace_stuck.py::test_read_stuck_points_last_has_dwell -q`
Expected: FAIL — `assert None == 4000`(`last.dwell_ms` 还没填)
- [ ] **Step 3: 改 `read_stuck_points`(每平台一次 `_last_segment`,零增量 IO)**
`app/services/trace_stuck.py` 的整个 `read_stuck_points`(:119-148) 替换为:
```python
def read_stuck_points(trace_dir: Path, *, threshold: int, max_tail: int) -> StuckResult:
"""逐平台判卡死。目录不存在/异常/无平台帧 → StuckResult(readable=False, points=[])。"""
try:
if not trace_dir.is_dir():
return StuckResult(readable=False, points=[])
points: list[StuckPoint] = []
any_frames = False
last: StuckPoint | None = None
best_n = -1
for pdir in sorted(trace_dir.iterdir()):
if not pdir.is_dir():
continue
step_files = sorted(pdir.glob("step_*.json"), key=_step_num)
if not step_files:
continue
any_frames = True
seg = _last_segment(step_files, max_tail) # 每平台只扫一次末段
if seg is None:
continue # 末帧抠不出:不判卡死、也不当末帧候选
ps, pg, count, dwell_ms = seg
if count >= threshold:
points.append(StuckPoint(pdir.name, ps, count, dwell_ms, detected_page=pg))
# 末帧:取帧数最多平台的末帧(与 last_step 同口径),带 detected_page + 末段停留
if len(step_files) > best_n:
best_n = len(step_files)
last = StuckPoint(
pdir.name, ps, len(step_files), detected_page=pg, dwell_ms=dwell_ms
)
if not any_frames:
return StuckResult(readable=False, points=[])
return StuckResult(readable=True, points=points, last=last)
except OSError:
return StuckResult(readable=False, points=[])
```
> 行为等价校验:`points``StuckPoint` 仍是 `frames=末段count / stuck_ms=末段时长`(T5 卡死,同改前)`last` 仍是 `frames=总帧数`,只是多带 `dwell_ms`。末帧 `ps is None` 的平台整段跳过(不判卡死、不更新 `last`),与改前 `_platform_stuck→None` + `if ps is not None` 一致。
- [ ] **Step 4: 跑测试,确认全绿**
Run: `pytest tests/test_trace_stuck.py -q`
Expected: PASS(新用例过;`test_read_stuck_points_returns_last_frame``test_per_platform_one_stuck_one_normal` 等精确断言仍相等)
- [ ] **Step 5: Commit**
```bash
git add app/services/trace_stuck.py tests/test_trace_stuck.py
git commit -m "feat(compare-alert): read_stuck_points.last 附 dwell_ms
cancelled 兜底末帧拿到末段停留;循环改为每平台一次 _last_segment,
判卡死与末帧候选共用同一次末段扫描,零增量 IO。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: worker `_fmt_last` + 末帧列接线(dwell-only 显示)
**Files:**
- Modify: `app/core/compare_alert_worker.py`(新增 `_fmt_last`cancelled 兜底 :138-139、failed :161-164)
- Test: `tests/test_compare_alert_stuck_worker.py`
- [ ] **Step 1: 写失败测试(`_fmt_last` 三态 + failed/兜底集成)**
`tests/test_compare_alert_stuck_worker.py` 顶部把 import 改为(加 `_fmt_last`)
```python
from app.core.compare_alert_worker import _fmt_last, _fmt_stuck, build_hits
```
`_frame` helper(约 :23-28) 之后新增带 timestamp 的 fixture helper
```python
def _frame_ts(pdir: Path, idx: int, step: str, page: str, ts: str) -> None:
pdir.mkdir(parents=True, exist_ok=True)
body = {"pipeline_step": step, "detected_page": page, "timestamp": ts,
"windows": [{"n": ["x" * 200]}]}
(pdir / f"step_{idx:03d}.json").write_text(
json.dumps(body, ensure_ascii=False), encoding="utf-8"
)
```
在文件末尾追加:
```python
# ---- _fmt_last 单测(末帧路径:环节·页面 + 停留,不显总帧数)----
def test_fmt_last_with_dwell():
sp = StuckPoint("meituan", "checkout", frames=480,
detected_page="checkout_page", dwell_ms=8000)
assert _fmt_last(sp) == "美团·结算·checkout_page 停留8s"
def test_fmt_last_sub_second():
sp = StuckPoint("meituan", "checkout", frames=480,
detected_page="checkout_page", dwell_ms=300)
assert _fmt_last(sp) == "美团·结算·checkout_page 停留<1s"
def test_fmt_last_without_dwell():
sp = StuckPoint("meituan", "checkout", frames=480,
detected_page="checkout_page", dwell_ms=None)
assert _fmt_last(sp) == "美团·结算·checkout_page"
# ---- 末帧路径带 dwell 集成 ----
def test_failed_stuck_point_has_dwell(tmp_path):
# failed 末帧 5 帧都在 checkout,ts :00→:08 → stuck_point 附「停留8s」
p = tmp_path / "20260807_f" / "meituan"
for i in range(5):
_frame_ts(p, i, "checkout", "checkout_page",
f"2026-08-07T12:00:{i * 2:02d}.000000")
rec = _Rec(status="failed", fail_reason="启动超时",
trace_url="https://x/traces/20260807_f/")
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T2"
assert hits[0].stuck_point == "美团·结算·checkout_page 停留8s"
def test_cancelled_fallback_stuck_point_has_dwell(tmp_path):
# cancelled 超阈值(95s)但末段 5<15 不卡死 → 兜底,末帧带 ts → 附「停留8s」
p = tmp_path / "20260807_c" / "eleme"
_frame_ts(p, 0, "set_address", "home", "2026-08-07T12:00:00.000000")
for i in range(1, 6): # enter_store 5 帧 :02→:10
_frame_ts(p, i, "enter_store", "store",
f"2026-08-07T12:00:{i * 2:02d}.000000")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260807_c/",
total_ms=95000, step_count=40)
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T5"
assert hits[0].stuck_point == "饿了么·进店·store 停留8s"
```
- [ ] **Step 2: 跑测试,确认失败**
Run: `pytest tests/test_compare_alert_stuck_worker.py -q`
Expected: FAIL — `ImportError: cannot import name '_fmt_last'`
- [ ] **Step 3: 新增 `_fmt_last`**
`app/core/compare_alert_worker.py``_fmt_stuck`(:84-89) **之后**插入:
```python
def _fmt_last(sp: trace_stuck.StuckPoint) -> str:
"""末帧路径(failed/兜底):环节·页面 + 末段停留时长,不显总帧数(总帧数配末段时长会误导)。
dwell_ms 为 None(缺 ts/时钟回退) → 只显环节。"""
s = sp.label()
if sp.dwell_ms is not None:
sec = round(sp.dwell_ms / 1000)
s += f" 停留{sec}s" if sec >= 1 else " 停留<1s"
return s
```
- [ ] **Step 4: cancelled 兜底接线**
`app/core/compare_alert_worker.py` 把 :137-139
```python
# 没卡点但命中(超阈值放弃)→ 用末帧标「退出前在哪屏」(平台·环节·页面)
if hit is not None and res is not None and res.last is not None:
hit = _dc_replace(hit, stuck_point=res.last.label())
```
替换为:
```python
# 没卡点但命中(超阈值放弃)→ 末帧标「退出前在哪屏 + 在那屏停多久」(dwell-only,不显总帧数)
if hit is not None and res is not None and res.last is not None:
hit = _dc_replace(hit, stuck_point=_fmt_last(res.last))
```
- [ ] **Step 5: failed 接线 + 改注释**
`app/core/compare_alert_worker.py` 把 :161-164
```python
if sp is not None:
# failed 是「末帧停在哪」:last_step 返回的 frames 是该平台总帧数(非"卡住"帧数)、
# stuck_ms=None,带上帧数/时长会误导,故只用 label() 显示环节
hit = _dc_replace(hit, stuck_point=sp.label())
```
替换为:
```python
if sp is not None:
# failed 是「末帧停在哪 + 在那屏停多久」:只显环节·页面 + 末段停留 dwell,
# 不显总帧数(总帧数配末段时长会误导);缺 ts 时 _fmt_last 自动退化为只显环节
hit = _dc_replace(hit, stuck_point=_fmt_last(sp))
```
- [ ] **Step 6: 跑测试,确认全绿**
Run: `pytest tests/test_compare_alert_stuck_worker.py -q`
Expected: PASS(新 dwell 用例过;`test_failed_gets_stuck_point_appended``test_cancelled_readable_not_stuck_long_duration_reports` 等无 ts 用例因 `dwell_ms=None`→只显环节,断言仍成立)
- [ ] **Step 7: Commit**
```bash
git add app/core/compare_alert_worker.py tests/test_compare_alert_stuck_worker.py
git commit -m "feat(compare-alert): 末帧列显「停留Xs」(dwell-only,不显总帧数)
failed 与 cancelled 兜底两条末帧路径用 _fmt_last 显示环节·页面+末段停留;
只带 dwell、不带总帧数,规避原注释担心的「总帧数配末段时长」误导;
缺 ts/时钟回退降级只显环节。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: 全量回归 + lint
**Files:** 无(仅校验)
- [ ] **Step 1: 跑两测试文件全绿**
Run: `pytest tests/test_trace_stuck.py tests/test_compare_alert_stuck_worker.py -q`
Expected: PASS(全绿)
- [ ] **Step 2: 跑其余 compare_alert 相关测试(确认没连带破坏)**
Run: `pytest tests/test_compare_alert_format.py tests/test_compare_alert_rules.py tests/test_compare_alert_fallback.py -q`
Expected: PASS(本计划未碰这些路径,应全绿)
- [ ] **Step 3: ruff 检查改动文件**
Run: `ruff check app/services/trace_stuck.py app/core/compare_alert_worker.py tests/test_trace_stuck.py tests/test_compare_alert_stuck_worker.py`
Expected: `All checks passed!`(如有可自动修的用 `ruff check --fix` 同名文件;有则改后重跑 Step 1)
- [ ] **Step 4: 如 Step 3 有 `--fix` 改动则 commit**
```bash
git add -A
git commit -m "style(compare-alert): ruff 清理
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## 完成标准(Definition of Done)
- 飞书卡片「末帧」列:failed 与 cancelled 兜底两种路径显示 `平台·环节·页面 停留Xs`(有 ts 时)或 `平台·环节·页面`(缺 ts)。
- T5 卡死路径显示不变(`平台·环节 N帧/Xs`)。
- `pytest tests/test_trace_stuck.py tests/test_compare_alert_stuck_worker.py -q` 全绿。
- `ruff check` 改动文件通过。
- 无新增列、无落库、无迁移。
@@ -1,197 +0,0 @@
# 比价失败报警机制 · 设计文档
- **日期**2026-08-04
- **状态**:设计待评审(v2,含数据复审修订)
- **范围**app-server`shaguabijia-app-server`
- **数据源**`comparison_record` 单表(线上快照已导入本地 `cr_analysis` 分析库,3867 行,覆盖 2026-06-09 ~ 08-04
## 复审修订记录(2026-08-04, v2
结合 `cr_analysis` 实测数据复审后的改动:
1. **[必修] 水位改用 `updated_at`**:原 `created_at` 水位会系统性漏报慢失败(落定延迟 p99 达 7.5–10min)。改为给 `comparison_record``updated_at` 列、水位按 `updated_at` 单调推进(§5/§6)。
2. **新增规则 T6「商品识别失败」**:「未识别到商品」96 条纳入报警,作为识别能力信号(§3)。
3. **T1 加业务词排除**:清掉 `fail_reason IS NULL` 里 7 条 `information` 实为业务的误报(§3)。
4. 补充:NULL 语义、时区口径、`business_type` 复核、单窗口截断阈值(§7/§9)。
5. 数据证伪、未采纳的改动:T2 关键词已完备(35 条技术词全被「超时/启动/加载」覆盖),不扩。
---
## 1. 背景与目标
比价(外卖 `business_type=food`)由客户端无障碍自动化 + pricebot 多平台 LLM 驱动,链路长、失败形态多。目前**没有任何主动发现失败的手段**——只能人工翻库或等用户反馈。
**目标**:新增一个**近实时、记录级**的比价失败报警机制。每隔 15–30 分钟扫描新落定的比价记录,逐条按预定规则判定「是否属于需要关注的失败」,把命中的记录**汇总成一条飞书消息**发到告警群,并**报出每条触发的原因**。
**关键设计取向**(均由数据分析与评审确认):
- **粒度是「记录」不是「失败率」**:逐条判定,不算比率、不设样本量门槛、不做基线对比。日均比价量小(完成约 45 条/天),比率方案在小窗口会剧烈抖动;记录级方案规避了这个问题。
- **只报「技术性失败」「识别失败」与「深度放弃」**,不报正常业务结局。
- **有触发才发,无触发静默**:不刷屏。
## 2. 数据分析依据(基线)
全量 3867 条记录级 `status` 分布(详见附录 A):
| status | 数量 | 占比 | 说明 |
|---|---|---|---|
| success | 1501 | 38.8% | 成功(含 `below_minimum` 未满起送,被归一为 success) |
| cancelled | 1394 | 36.0% | 用户中途退出 |
| failed | 957 | 24.7% | 失败(T1 技术 386 + T2 超时 35 + T6 识别 96 + 业务 440 |
| running | 15 | 0.4% | 悬挂未收尾 |
支撑规则设计的关键事实:
- **`failed` 是混合桶**`fail_reason IS NULL` 的 393 条是纯系统技术失败(其中「比价过程出错,请稍后重试」占 294,是 `_GENERIC_INFO` 兜底黑话);`fail_reason` 非空的多为业务结局,但夹杂「启动淘宝超时」等技术问题 35 条、「未识别到商品」96 条。
- **业务失败不该报**:打烊、无此店、无此菜、未起送、单点不配送是正常结局。
- **落定延迟很长**`total_ms`(≈ 记录从建行到落定的时长)p99 = failed 449s、cancelled 602smax 16min。**这是水位必须用 `updated_at` 而非 `created_at` 的直接依据**。
- **cancelled 缺退出上下文**:99.3% 终止原因就一句「用户终止比价」,且 100% 没有 `platforms`/结果数据(都在 `running` 阶段被中止)。唯一可用信号是退出时机(`total_ms`/`step_count`)。cancelled 的 `step_count` 中位 5、p90 31`total_ms` 中位 24s、p90 124s。参照系:一次成功比价中位 113s / 38 步。
## 3. 报警规则(v1
worker 每轮查询「上次水位之后有更新」的记录,对每条按下表判定;命中任一即计入本期汇总。四个规则互斥(一条记录最多归一类)。
| 类型 | 判定条件(SQL 语义) | 触发原因文案 | 历史量(2月) |
|---|---|---|---|
| **T1 系统技术失败** | `status='failed' AND fail_reason IS NULL AND (information IS NULL OR information !~ 业务词)` | `技术失败·{information 去空白截断; 空则"比价过程出错"}` | ≈386 |
| **T6 商品识别失败** | `status='failed' AND fail_reason ~ '未识别'` | `识别失败·未识别到商品` | ≈96 |
| **T2 超时/启动失败** | `status='failed' AND fail_reason IS NOT NULL AND fail_reason ~ 超时关键词` | `{fail_reason}`(如「启动淘宝超时」) | ≈35 |
| **T5 cancelled 深度放弃** | `status='cancelled' AND (total_ms > 90000 OR step_count > 30)` | `深度放弃·等待 {total_ms/1000 取整}s / {step_count} 步后退出` | ≈250 |
**判定顺序(保证互斥)**
1. `status='failed'`
- `fail_reason IS NULL` → 若 `information` 命中**业务词**`未找到|打烊|起送|门店|店内|不配送|这些菜|未入驻|休息`)则**不报**(业务失败漏派生 fail_reason,约 7 条);否则 **T1**
- `fail_reason` 含「未识别」→ **T6**
- `fail_reason` 含超时关键词(`超时|启动|加载`)→ **T2**
- 其余(干净业务原因)→ **不报**
2. `status='cancelled'` 且(`total_ms>90000``step_count>30`)→ **T5**;否则不报。
3. `status IN ('success','running')` → 不报。
**阈值/关键词(可配初值)**:T5 的 `90000ms`/`30步` 取自 cancelled 分布约 p90(评审选定「B 中档」)。超时关键词 `超时,启动,加载`、识别关键词 `未识别`、业务排除词均可配。
每条命中记录在汇总里附带:`trace_id``app_version``business_type`、触发原因文案、`created_at`
## 4. 非目标与暂缓项
| 项 | 处理 | 原因 |
|---|---|---|
| 业务失败(打烊/无店/无菜/未起送/单点不配送) | **不报** | 正常业务结局 |
| success、早退 cancelled(≤90s 且 ≤30 步) | **不报** | 无报警价值 |
| **T3 running 悬挂** | **本期暂缓** | 评审决定先不报;但见下方 🔴 |
| **T4 单平台适配失效**`platforms[].status='failed'`) | 暂不纳入(未来增强) | 量大、与整体失败重叠、噪音高 |
| 失败率 / cancelled 率等**比率型**指标 | 不做 | 本设计是记录级 |
> 🔴 **待独立排查的回归线索**(非本报警范围,留档):`running` 悬挂 15 条**全部集中在 2026-07-28 之后**,此前两个月几乎为 0。强烈提示某次发版后 `harvest_done`/`harvest_abort` 收尾链路(`app/repositories/comparison.py`)回归,建议单独开 issue。排查确认后可在 v2 把 T3 作为独立高频告警加回。
## 5. 架构与组件
沿用项目现有**常驻 asyncio worker** 范式(与 `heartbeat_monitor_worker` 等一致)。各组件单一职责、可独立测试:
| 组件 | 路径 | 职责 |
|---|---|---|
| **数据模型改动** | `app/models/comparison.py` + alembic 迁移 | `comparison_record` 新增 `updated_at``server_default=func.now()`, `onupdate=func.now()`+ 索引 `ix_comparison_updated`。为水位提供单调递增的落定时间。 |
| **规则模块** | `app/services/compare_alert.py` | 纯函数:输入一批 ORM 记录 → 输出 `[(记录, 触发类型, 原因文案)]`。判定逻辑与阈值全在此,无 I/O,易测易调。 |
| **扫描 worker** | `app/core/compare_alert_worker.py` | 仿 `heartbeat_monitor_worker`:单实例文件锁 + `asyncio` 轮询 + 优雅退出。每轮:读水位 → 查有更新记录 → 调规则 → 有命中则格式化并发飞书 → 推进水位。 |
| **飞书通知器** | `app/integrations/feishu_notifier.py` | 实现群机器人 webhook 发送。发送失败抛异常由 worker 处理。 |
| **水位存储** | 复用 `app_config` 表 | key=`compare_alert.last_watermark`value=上次处理的最大 `updated_at`。 |
| **启停挂载** | `app/main.py` lifespan | `start_compare_alert_worker()` / `stop_compare_alert_worker()`,与现有 worker 同处注册。 |
> **`onupdate` 生效前提**:现有 `harvest_done`/`harvest_abort`/`upsert_record` 均走 ORM `setattr`+`commit` 更新,`onupdate=func.now()` 会自动刷新 `updated_at`,无需改写路径。
## 6. 数据流与水位管理(updated_at 方案)
```
每 interval 秒:
读 app_config['compare_alert.last_watermark'] → watermark
(空 → 冷启动:watermark = 当前 max(updated_at),只报之后新落定的,不回溯历史)
查 comparison_record
WHERE updated_at > watermark
ORDER BY updated_at ASC
逐条套 T1/T6/T2/T5 规则 → 命中集合(按类型分组)
若命中集合非空:
格式化飞书消息 → feishu_notifier.send()
成功 → 水位 = 本批 max(updated_at)
失败 → 不更新水位(log),下一轮重扫补发
若命中集合为空:
水位 = 本批 max(updated_at)(无记录则不动;可选 SEND_EMPTY 发简讯)
```
- **零漏报**:任何记录落定/更新时 `updated_at` 刷新为当前 DB 时钟 > 水位,必被下一轮扫到——无论 `created_at` 多早、落定多慢(根治了 `created_at` 水位漏掉慢失败的问题)。
- **规避时区**:水位存的是 DB 产出的 `updated_at` 值,查询用 `updated_at` 自身比较,**不依赖 worker 本地时钟与 DB 时钟对齐**(`created_at` 存 naive 北京、`func.now()` 为 DB 时钟,二者口径不同,但本方案只用 `updated_at` 自比较,不受影响)。
- **发送失败不推进水位**:保证不漏;恢复后一次补发。
- **一条记录可能被扫多次**running 更新→落定更新,`updated_at` 变两次):但只有落定后 `status` 才命中规则,running 阶段扫到不命中,无副作用;不会重复报。
## 7. 飞书消息格式
群机器人消息(文本或富文本 `post`),按类型分组:
```
🚨 比价失败报警 · 2026-08-04 08:0008:30 · 本期触发 7 条
• 系统技术失败 3 条
- trace abc123 | v0.3.4 | 比价过程出错
• 商品识别失败 2 条
- trace abc200 | v0.5.1 | 未识别到商品
• 启动/超时失败 1 条
- trace def456 | v0.3.4 | 启动淘宝超时
• 深度放弃(cancelled) 1 条
- trace ghi789 | v0.6.3 | 等待 98s / 26 步后退出
```
- **截断阈值**:单类型明细超 `MAX_DETAIL_PER_TYPE`(默认 20)条时,只列前 20 条 + 「另有 N 条」;本期总命中超 `MAX_TOTAL`(默认 50)条时降级为只给各类型计数,提示去分析库查(防报警风暴,如 07-14 那种高失败日)。
## 8. 配置项
`app/core/config.py``pydantic-settings`):
| 配置 | 默认 | 说明 |
|---|---|---|
| `COMPARE_ALERT_ENABLED` | `False` | 总开关;关时 worker 不启动 |
| `COMPARE_ALERT_SCAN_INTERVAL_SEC` | `1800` | 扫描间隔,可配 90015min |
| `COMPARE_ALERT_FEISHU_WEBHOOK` | `""` | 群机器人 webhook;空则 worker 仅打日志不外发 |
| `COMPARE_ALERT_CANCELLED_MS_THRESHOLD` | `90000` | T5 耗时阈值(ms |
| `COMPARE_ALERT_CANCELLED_STEP_THRESHOLD` | `30` | T5 步数阈值 |
| `COMPARE_ALERT_TIMEOUT_KEYWORDS` | `"超时,启动,加载"` | T2 关键词 |
| `COMPARE_ALERT_UNRECOGNIZED_KEYWORDS` | `"未识别"` | T6 关键词 |
| `COMPARE_ALERT_BIZ_EXCLUDE_KEYWORDS` | `"未找到,打烊,起送,门店,店内,不配送,这些菜,未入驻,休息"` | T1 的 information 业务词排除 |
| `COMPARE_ALERT_MAX_DETAIL_PER_TYPE` | `20` | 单类型明细截断 |
| `COMPARE_ALERT_MAX_TOTAL` | `50` | 本期总命中截断(超则只给计数) |
| `COMPARE_ALERT_SEND_EMPTY` | `False` | 无命中是否发「本期无异常」简讯 |
> **`business_type` 复核**:当前数据全为 `food`,规则未按 `business_type` 限定。接入 `ecom`/`coupon` 时需复核各规则(尤其 T2/T6 关键词与 T5 阈值是否仍适用)。
## 9. 错误处理与边界
- **worker 单轮异常吞掉不退出**`except Exception: logger.exception`),仿 heartbeat。
- **DB / 飞书发送异常**:log,本轮不推进水位,下轮重试补发。
- **单实例锁**:文件锁 `data/compare_alert.lock`O_CREAT|O_EXCL + stale 检测)。
- **NULL 语义**T5 中 `total_ms`/`step_count` 为 NULL 的 cancelledSQL 比较 `NULL>90000` 为 false → 不命中(无数据不报,符合预期)。T1 中 `information IS NULL` 时业务词排除不触发(视为非业务)→ 仍属 T1,原因文案兜底「比价过程出错」。
- **冷启动不回溯历史**:首次启动水位=当前 `max(updated_at)`,避免把历史失败一次性全报。
## 10. 测试策略
仿现有 `tests/` 风格(`TestClient` + monkeypatch 外部依赖,SQLite 临时库):
- `test_compare_alert_rules.py`:喂各类记录(T1/T6/T2/T5 命中样本 + 业务失败/success/早退 cancelled/running 反例 + T1 业务词误入反例),断言分类与原因文案;覆盖阈值边界(`total_ms=90000` 不命中、`90001` 命中)与 NULL 语义。
- `test_compare_alert_worker.py`monkeypatch notifier 与 `SessionLocal`,验证 `updated_at` 水位推进、发送失败不推进、冷启动=max、命中汇总、截断逻辑。
- `test_feishu_notifier.py`monkeypatch HTTP,验证消息体格式与发送失败抛异常。
- 迁移测试:`updated_at` 列 + 索引存在,`onupdate` 在 ORM 更新时刷新。
## 11. 未来增强
1. **T3 running 悬挂告警**:待第 4 节 🔴 回归排查后,作为独立高频告警加回。
2. **T4 单平台适配失效**`platforms[].status='failed'` 逐平台维度。
3. **cancelled 退出上下文埋点**:客户端终止时上报退出阶段、已比出平台数、是否已看到中间结果——让 cancelled 从「只有时机」升级为「可归因」。
4. **分维度统计**:汇总附带按 `app_version`/`source_platform` 的命中分布,辅助定位回归版本/平台。
5. **趋势型报警**:记录级之上叠加比率/环比(需另设样本量保护)。
## 附录 A:分析数据来源与复现
- **来源**:线上 PostgreSQL 16 `pg_dump` 单表 `comparison_record`plain SQL187MB)。
- **本地环境**Docker 容器 `shaguabijia-pg`postgres:16-alpine),独立分析库 `cr_analysis`(用户 `shaguabijia_app`)。导入:`docker cp` dump 进容器后 `psql -f`(末尾外键引用 `public.user` 报错属预期,单表 dump 无 user 表,不影响数据与索引)。
- **样本**3867 行,2026-06-09 ~ 08-04。
- **关键分布**(供实现期回归对照):
| 指标 | success | failed | cancelled |
|---|---|---|---|
| 数量 | 1501 | 957 | 1394 |
| step_count 中位 / p90 | 38 / 61 | 22 / 47 | 5 / 31 |
| total_ms 中位 / p90 / p99 | 113s / 200s / 391s | 77s / 168s / 449s | 24s / 124s / 602s |
- **failed 细分**(合计 957):T1 系统技术 386(`fail_reason IS NULL` 393 − 业务误入 7)、T2 超时/启动 35、T6 识别失败 96、业务失败 440(含误入的 7 条)。
@@ -1,168 +0,0 @@
# 比价「卡死定位」报警增强设计
- 日期:2026-08-05
- 分支:feat-compare-fail-alert(延续一期)
- 关联:`docs/superpowers/specs/2026-08-04-compare-fail-alert-design.md`(一期报警)
## 1. 背景与问题
一期报警对 **cancelled(中途退出)** 的判据(`app/services/compare_alert.py``classify_record`):
```
cancelled 且 (total_ms > 90s 或 step_count > 30步) → T5 深度放弃
```
这个判据量的是「投入多少」,不是「卡没卡」,两头都错:
- **漏报**:一进平台就卡在登录墙 / 加载失败,5 秒 2 步就退 → 判「不深度」→ 不报。但这是真卡死。
- **误报**:用户正常挑了 100 秒、点了 40 步,比完价不满意退了 → `>90s` → 报「深度放弃」。但根本没卡。
根因:`total_ms`/`step_count` 是**整场**的量,把「卡在一步反复失败」和「正常深度使用」混为一谈。
### 1.1 数据佐证(真实 trace
- **卡死例**`20260804_114703` meituan):`pipeline_step``set_address`(step 0-1) → `enter_store`(3-8) → **`add_one_dish`(9 一路到 120+,110+ 帧全困在这一个环节)**。且 `timing.json` 里根本没有 meituan——`step_profiler` 只在平台 `is_done` 时落 timing,卡死平台永不 done。
- **正常例**`20260803_165239` eleme):`set_address`(0) → `enter_store`(1-7) → done,每个环节 ≤7 帧就推进走了。
**卡死的结构特征**:某 `pipeline_step` 连续几十上百帧不变(原地打转);正常则是逐环节推进、单环节 ≤7 帧。两者空档极大(7 vs 110+),可用一个帧数阈值干净区分,且**不需要大量数据归纳环节语义**。
## 2. 目标
- cancelled 判据:从「整场耗时/帧数阈值」→「trace 末段原地打转」,抓真卡死(含短时卡死)、不误报正常深度使用。
- **判定与展示一体**:直接报「卡在 平台·环节」。
- failed 类(T1/T2/T6):判定不变,best-effort 补卡点定位。
- 稳:读不到 trace 回退原耗时/帧数保底,**绝不阻断报警发送**。
## 3. 取数:同机直读(不改 pricebot)
app-server 与 pricebot **同机**。trace 落盘在 `{WORK_LOG_DIR}/{dir_name}/`
- **dir_name 从 `comparison_record.trace_url` 尾段抠**`trace_url = {base}/traces/{dir_name}/`,尾段就是磁盘目录名,新老格式都对得上,规避从 `trace_id` 反推老格式「首帧时刻」的难题。
- 只读末段帧的**头部字段**`pipeline_step` / `detected_page`),不解析后面的无障碍树(`windows`,占单帧 99% 体积)。
- 不改 pricebot、不需要 `INTERNAL_API_SECRET`、不走网络。
> 备选途径(已否决):pricebot 加内部接口(要改两仓 + secret)、公网 `trace_url` GET timing.json(本地 SSL 大面积超时 + timing.json 缺卡死平台)。同机直读最优。
## 4. 架构分层
保持判定纯函数、IO 单独成层:
| 模块 | 职责 | 性质 |
|---|---|---|
| `services/compare_alert.py`(微调) | failed 判定不变;cancelled 只保留**回退保底**判定(`>90s`/`>30步` | 纯函数 |
| `services/trace_stuck.py`(新) | 给定 trace 目录 → 逐平台读末段 → 判「原地打转」→ 返回卡点列表 | 薄 IO + 纯逻辑 |
| `core/compare_alert_worker.py`(编排) | 先跑纯 `classify_batch` 出候选,再对候选调 `trace_stuck` 增强 | 编排 |
## 5. trace_stuck 模块
### 5.1 卡死判据(逐平台)
对某平台的 `step_*.json` 序列,从**末帧往前**数,连续 `(pipeline_step, detected_page)` 都相同的帧数 ≥ N → 判该平台卡死,卡点 = 该 `pipeline_step`
- `N = COMPARE_ALERT_STUCK_FRAME_THRESHOLD`(默认 **15**;正常环节 ≤7 帧、卡死 110+ 帧,空档极大)。
- **「无推进」= `(pipeline_step, detected_page)` 双不变**(页面没跳转、环节没变)。这样能区分:
- 「加多菜」:`pipeline_step` 相同但 `detected_page` 在跳(换菜/回菜单)= 推进 → 不判卡死;
- 「卡在一步」:两者都不变 = 原地打转 → 卡死。
- 从末帧往前最多读 `COMPARE_ALERT_TRACE_MAX_TAIL_FRAMES`(默认 **40**)帧,够判 ≥N 即停,防超长 trace 全读。
### 5.2 逐平台聚合(B 方案:不漏)
一条 trace **逐平台**判,所有卡死平台都收集——不只「帧数最多」的那个。因为「帧数最多」会在**卡死平台帧数不是最多**时漏报(如另一平台正常加了 8 道菜跑了 30 帧、卡死平台一进就卡登录 5 帧退),而那恰是短时卡死。多个卡死平台都列进 reason。
### 5.3 接口
```python
@dataclass(frozen=True)
class StuckPoint:
platform: str
pipeline_step: str
frames: int # 末段连续困住的帧数
@dataclass(frozen=True)
class StuckResult:
readable: bool # trace 是否读到(区分「读不到」与「读到但没卡」)
points: list[StuckPoint] # 卡死平台列表;readable=True 且空 = 确认没卡死
def read_stuck_points(trace_dir: Path, *, threshold: int, max_tail: int) -> StuckResult:
"""逐平台判卡死。目录不存在/异常/无平台帧 → StuckResult(readable=False, points=[])。"""
def last_step(trace_dir: Path) -> StuckPoint | None:
"""failed 用:取帧数最多平台的末帧环节(不要求原地打转)。读不到 → None。"""
```
## 6. 判定流
### 6.1 cancelledtrace 优先 → 保底)
```
worker 对 cancelled 候选:
res = read_stuck_points(dir)
if res.readable and res.points: # 读到且有卡死平台 → 报卡死(带卡点环节)
报 T5, reason="深度放弃", stuck_point = "、".join(f"{平台}·{环节}" for res.points)
else: # 其余一律回退耗时/步数兜底(见下方 2026-08-06 修订)
>90s或>30步 → T5「深度放弃」(卡点列留空); 否则不报
```
> **2026-08-06 修订(compare-fail-alert 排查)**:原设计「读到且没卡死 → 不报」在线上是死路——
> **线上 trace 几乎总可读**(WORK_LOG_DIR 已配、同机直读),于是耗时兜底那条分支基本永不触发,
> `total_ms>90s` 阈值形同虚设,**超长放弃(实测 113s / 516s)一条都报不出来**。改为:只有「判出卡点」
> 独占带卡点的 T5;**其余(可读没卡点 / 读不到 / 无 trace)一律回退耗时兜底**,超长照报(卡点留空)。
> 权衡:这会重新引入第 1 节「误报」——用户正常浏览 90s+ 后退出也会报。若噪音大,调高
> `COMPARE_ALERT_CANCELLED_MS_THRESHOLD`(如 180s/300s)收敛,不动代码。
### 6.2 failedT1/T2/T6,判定不变 + 附卡点)
```
worker 对 failed 命中:
sp = last_step(dir) # 读不到 → None
if sp: reason += f"|卡在 {平台}·{环节}"
```
`failed` 只取「末帧停在哪」,不要求原地打转(它已失败、末帧即失败点)。intent 阶段就失败(无平台目录,典型 T6)→ 不附,reason 原样。
## 7. 卡点文案映射
`PIPELINE_STEP_LABELS`(小映射表,映射不到原样显示英文、不阻断):
| pipeline_step | 中文 |
|---|---|
| `set_address` | 定位 |
| `enter_store` | 进店 |
| `add_one_dish` | 加菜 |
| …(实现时按 pricebot 实际枚举补全) | |
平台名同样映射(`meituan`→美团、`eleme`→饿了么、`jd_waimai`→京东外卖)。
## 8. 配置(`app/core/config.py`;路径敏感项放 `.env`
| 配置 | 默认 | 说明 |
|---|---|---|
| `COMPARE_ALERT_PRICEBOT_WORK_LOG_DIR` | `""` | pricebot work_logs 绝对路径;**空 = 跳过 trace、全走保底**(行为等同一期) |
| `COMPARE_ALERT_STUCK_FRAME_THRESHOLD` | `15` | N:末段连续同环节达此帧数判卡死 |
| `COMPARE_ALERT_TRACE_MAX_TAIL_FRAMES` | `40` | 每平台最多往前读多少帧 |
| `COMPARE_ALERT_TRACE_MAX_RECORDS` | `30` | 每轮最多对多少条命中记录读 trace(限量) |
## 9. 展示
卡片「失败原因」列下附一行卡点小字,**不新增列**。cancelled 卡死时卡点即 reason 本身;failed 的卡点附在原因后。
> 依赖:本期展示复用一期卡片的「失败原因」列。若一期卡片(schema 2.0 table 组件,当前仍在临时脚本 `scripts/_test_alert_card.py`)尚未固化为正式 `format_alert_card` + `send_feishu_card`,本期实现时一并固化。
## 10. 降级与成本
- 只对命中记录读、限量 `MAX_RECORDS`、每平台只读末段头部字段、单文件读加超时。
- **任何异常降级**cancelled 回退保底、failed 不附卡点,绝不阻断报警。
- `work_log_dir` 未配 → 整个 trace 增强跳过,行为等同一期(纯保底)。
## 11. 测试
- **trace_stuck 单测**:卡死正例(meituan 目录 → 判出 `add_one_dish`)、正常负例(eleme → 不判卡死)、加多菜不误判(`detected_page` 在变)、末段不足 N 帧、读不到目录降级。
- **worker 集成**:trace 优先命中 vs 读不到回退保底切换;failed 附卡点;限量 `MAX_RECORDS` 生效。
- fixture 用 tmp 造 `step_*.json`**只含头部字段**trace_id/step/platform/pipeline_step/detected_page)即可,不需无障碍树。
## 12. 不做(YAGNI
- 不改 pricebot(不加内部接口)。
- 不落库(不加 `comparison_record` 列、不做迁移)。
- 不做每帧耗时(`timing.json` 缺卡死平台,且报警用不上逐帧耗时)。
- 不做环节黑白名单 / 语义分类(结构判据已够,且需大数据)。
@@ -1,190 +0,0 @@
# 金币记录:比价/领券看广告金币按会话汇总成一条 设计
- 日期:2026-08-07
- 分支:feat/coin-ledger-aggregate-ad-rewards
- 相关:`app/repositories/wallet.py`(发币/流水)、`app/repositories/ad_feed_reward.py`(信息流发奖)、`app/api/v1/wallet.py`(金币流水接口)、Android `CoinHistoryViewModel`(列表渲染)
## 1. 背景与问题
App「金币变动记录」列表按**每看一次广告一条**展示。一次比价的等候期会连续看多条信息流广告,一次领券流程同理,于是列表里一次比价/领券会刷出一长串「比价奖励 +x」「领券奖励 +x」,淹没其它记录、观感差。
需求:**一次比价的所有看广告金币合并成一条、一次领券的合并成一条**,金额取该次会话合计。
## 2. 现状(关键事实)
- 发币唯一入口 `crud_wallet.grant_coins``app/repositories/wallet.py:165`):每条广告写一条 `CoinTransaction`。金币账本 `coin_transaction` 与现金账本物理分离。
- 信息流发奖 `grant_feed_reward``app/repositories/ad_feed_reward.py:71`)按点位场景拆 `biz_type`
- `feed_ad_reward_comparison`(比价,remark「比价奖励」)
- `feed_ad_reward_coupon`(领券,remark「领券奖励」)
- `feed_ad_reward`(通用/福利/旧端)
- **trace_id 已全链贯通到结算**
- 比价:客户端结算上报早已带 trace_id。
- 领券:`CouponForegroundService.reportFeedReward`**2026-07-15**commit `a98cab8`)起带 `feedScene="coupon" + traceId=sessionTraceId`
- 两者的 trace_id 都落到了 `ad_feed_reward_record.trace_id``grant_feed_reward` 写入)。
- **唯一缺口**`grant_feed_reward``grant_coins` 时**没把 trace_id 透传下去**`coin_transaction` 表本身**没有 trace_id 列** → 金币流水层无法按会话分组。
- 列表接口 `GET /api/v1/coin-transactions``app/api/v1/wallet.py:61``list_coin_transactions` `app/repositories/wallet.py:260`):按 `id` 倒序游标分页。
- 展示文案权在客户端:Android `CoinHistoryViewModel.coinTitle``bizType` 直显固定标题(比价奖励/领券奖励),后端 `remark` 只作兜底。
结论:这是**纯后端**改动,领券不需要额外 Android 改动即可生效。
## 3. 目标与范围
- 目标:金币记录列表里,同一次比价的多条「比价奖励」合并成一条、同一次领券的多条「领券奖励」合并成一条;金额为该次会话合计。
- 会话键:`trace_id`(一次比价/一次领券 = 一个 trace_id)。与「比价记录页」现有的 trace_id 聚合口径一致。
- **范围**:只圈两类 `biz_type` —— `feed_ad_reward_comparison``feed_ad_reward_coupon`。激励视频 `reward_video`、引导视频 `guide_video`、通用信息流 `feed_ad_reward`、签到、任务、兑换等**一律不动**,仍每条一行。
- 聚合位置:**后端**`/coin-transactions` 直接返回合并后的行)。客户端聚合被否决(见 §11)。
- **只改 App 用户接口**:聚合仅作用于 `crud_wallet.list_coin_transactions``GET /api/v1/wallet/coin-transactions`)。**admin 接口不动**——`app/admin/routers/wallet.py` 走独立的 `queries.list_all_coin_transactions`(跨用户、可按 `biz_type` 筛),审计/客服必须能看到**每一条**广告发币,保持每条一行。两者物理分离,聚合天然不波及 admin,此处显式声明防误改。
- 历史:**加列 + 一次性回填**,历史记录也合并。
## 4. 数据模型改动
`coin_transaction` 新增列:
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `trace_id` | `String(64)` | nullable, index | 会话键;仅比价/领券信息流发奖时写入,其余为 NULL |
- 新增 Alembic 迁移(`render_as_batch` 兼容 SQLite):加列 + 索引 `ix_coin_transaction_trace_id`
- 可选复合索引 `(user_id, trace_id)` 辅助分组扫描(视线上量级决定,MVP 可先只加单列索引)。
## 5. 写入路径改动
- `grant_coins(...)` 增加可选参数 `trace_id: str | None = None`,写入 `CoinTransaction.trace_id`。默认 None → 其余调用方(签到/任务/兑换/激励视频)零改动、保持 NULL。
- `grant_feed_reward``grant_coins` 时透传 `trace_id=trace_id`(比价/领券自然有值;welfare/旧端为 None)。
- 幂等不变:`client_event_id` 仍是幂等键;`grant_coins` 仍不 commit,由 `_commit_record` 同事务提交。
## 6. 读取/聚合查询(核心)
`list_coin_transactions` 改为「分组游标分页」。用 CTE 先分组、再 JOIN 回代表行取展示字段。
分组规则:
```
group_key =
若 biz_type ∈ (feed_ad_reward_comparison, feed_ad_reward_coupon) 且 trace_id 非空
→ 'T:' || trace_id # 同一会话所有广告归一组
否则
→ 'I:' || id # 每条自成一组(行为等同现状)
```
每组取:`rep_id = MAX(id)``total = SUM(amount)``merged_count = COUNT(*)`;再 JOIN `coin_transaction``rep_id` 那条的 `balance_after / biz_type / ref_id / remark / created_at / trace_id`
等价 SQLSQLite / PostgreSQL 通用):
```sql
WITH grp AS (
SELECT
CASE WHEN biz_type IN ('feed_ad_reward_comparison','feed_ad_reward_coupon')
AND trace_id IS NOT NULL
THEN 'T:' || trace_id
ELSE 'I:' || CAST(id AS TEXT) END AS group_key,
MAX(id) AS rep_id,
SUM(amount) AS total_amount,
COUNT(*) AS merged_count
FROM coin_transaction
WHERE user_id = :uid
GROUP BY group_key
)
SELECT ct.id, grp.total_amount AS amount, ct.balance_after,
ct.biz_type, ct.ref_id, ct.remark, ct.trace_id, ct.created_at,
grp.merged_count
FROM grp
JOIN coin_transaction ct ON ct.id = grp.rep_id
WHERE (:cursor IS NULL OR grp.rep_id < :cursor)
ORDER BY grp.rep_id DESC
LIMIT :limit;
```
返回行字段口径:
- `id` = 组内 `MAX(id)`:做列表 key + 下一页游标。组间不重复(每个 id 只属一组),`rep_id DESC` 是全序,游标 `rep_id < cursor` 干净。
- `amount` = 组内合计(该次会话总金币;这些广告行金额恒正)。
- `balance_after` / `created_at` / `remark` / `ref_id` = 代表行(最后一条)的值。`balance_after` 即该次会话最后一条广告到账后的余额,正确。
- `merged_count` = 合并条数(未合并 = 1)。
- `next_cursor` = 本页最后一行的 `id`= 其组 rep_id),够 limit 才给,否则 None。
非分组类型、`trace_id` 为空的旧广告行 → 各自成组(`'I:'||id`),行为与现状完全一致。
> 说明(分组安全性):分组键按 `biz_type` **门控**——通用 `feed_ad_reward`(福利/旧端)即便偶带 trace_id 也走 `'I:'||id` 保持每条一行,只有比价/领券两类才按 trace_id 合并。trace_id 由后端按会话签发、全局唯一(一个 trace = 一次比价**或**一次领券),故仅按 trace_id 分组不会把两类混并,代表行的 `biz_type` 唯一确定展示标题。
>
> 实现约定:这两类 biz_type 抽成模块常量 `FEED_AD_SESSION_BIZ_TYPES`,**查询 / 回填 / 测试共用一处**,避免字符串散落三地漂移。
## 7. 接口契约
`GET /api/v1/coin-transactions` 响应新增 `merged_count``CoinTransactionOut` 加字段,Pydantic 默认 1):
```jsonc
{
"items": [
{ "id": 1520, "amount": 12, "balance_after": 3380,
"biz_type": "feed_ad_reward_comparison", "ref_id": "evt_...",
"remark": "比价奖励", "created_at": "2026-08-07T12:03:11",
"merged_count": 5 }, // 本次比价看了 5 条广告,合计 +12
{ "id": 1512, "amount": 30, "balance_after": 3368,
"biz_type": "signin", "remark": "每日签到奖励",
"merged_count": 1 }
],
"next_cursor": 1490
}
```
`trace_id` 是否在响应里下发:**可选**,MVP 不下发(客户端用不到,标题按 biz_type、金额已合计)。若后续要做「点开看明细」再补。
## 8. 历史回填
迁移里一次性把历史 `coin_transaction.trace_id``ad_feed_reward_record` 补齐(相关子查询 UPDATESQLite/PG 通用):
```sql
UPDATE coin_transaction SET trace_id = (
SELECT r.trace_id FROM ad_feed_reward_record r
WHERE r.client_event_id = coin_transaction.ref_id)
WHERE biz_type IN ('feed_ad_reward_comparison','feed_ad_reward_coupon')
AND trace_id IS NULL
AND EXISTS (SELECT 1 FROM ad_feed_reward_record r2
WHERE r2.client_event_id = coin_transaction.ref_id
AND r2.trace_id IS NOT NULL);
```
- 关联键:`coin_transaction.ref_id == ad_feed_reward_record.client_event_id``grant_feed_reward` 里两者同源,见 `app/repositories/ad_feed_reward.py:221`)。`client_event_id` 有唯一约束 → 相关子查询至多一行,无「子查询多行」风险。
- 只补两类 biz_type;只在广告行确有 trace_id 时补(`EXISTS` 守护)。
- **幂等**`AND trace_id IS NULL` 保证重跑不重复改。
- **大表守护**`coin_transaction` 线上若很大,单条全表 UPDATE 在 PG 上是一次性长事务/行锁;必要时按 `id` 区间**分批**(每批数万行)跑。SQLite(dev)无所谓。
- 2026-07-15 前的领券老行 `ad_feed_reward_record.trace_id` 本就为空 → 回填后仍为空 → 保持每条一行(可接受,会随时间自然淘汰)。
## 9. 顺手更正
更新 `app/models/ad_feed_reward.py:39``trace_id` 注释:现「仅 comparison 场景由客户端带上……领券/福利/旧客户端 = NULL」已过时,改为「比价一直带;领券自 2026-07-15 起也带;福利/旧客户端仍 NULL」。
## 10. App 端
- **核心无需改**:接口返回合并后的行,`CoinHistoryViewModel.coinTitle` 仍按 `bizType` 显示「比价奖励/领券奖励」,金额显示合计值。
- **可选增强(后续、非本次必须)**:客户端读到 `merged_count > 1` 时把标题渲染成「比价奖励 ×5」或副行「看了 5 条广告」。做不做都不影响本功能生效。
## 11. 边界与取舍
- **时间交错重排**:一次比价的广告中间夹了签到等其它记录时,合并行锚定在「该会话最后一条广告」的 id 上,夹在中间的签到会排到合并行之后。这是折叠的固有效果,可接受。
- **余额不连续**:相邻两展示行的 `balance_after` 之间存在被折叠的中间变动,属聚合固有现象;每行 `balance_after` 仍是该点真实运行余额。
- **迟到广告**:会话已过、同 trace 的迟到广告到账后并入该组、金额变大、合并行上移(因 rep_id 变大);用户未刷新则短暂 stale。会话结束后广告即时结算,迟到罕见,可接受。
- **单条会话**:一次会话只看了 1 条广告 → 组内 1 行、`merged_count=1`、展示与现状一致,无需特判。
- **查询成本**:分组 CTE 每页对该用户**全部** `coin_transaction` 行分组后再分页,O(N)/页,比原「`id < cursor LIMIT n`」的索引 seek 重。金币记录非热路径(偶尔打开、翻几页);粗估重度用户万级行/年、单页分组数毫秒级(估算,非实测),可接受。
- **索引口径**:分组按 CASE 表达式(`'T:'||trace_id` / `'I:'||id`)在内存做,`(user_id, trace_id)` 复合索引**并不加速**该 group-by;真正需要的只是「按 user_id 取该用户全部行」,现有 `ix_coin_transaction_user_id` 已够。故本设计**只加 `trace_id` 单列索引**(回填 / 潜在按 trace 查用),不加 `(user_id, trace_id)`;若要 index-only 可选覆盖索引 `(user_id, id, biz_type, trace_id, amount)`MVP 不加。
- ⚠️ **反面优化警示****不要**把 CTE 输入裁成 `WHERE id < :cursor` 求快。会话行若被其它记录交错、成员跨越游标(部分成员 `id < cursor`、但组的 `rep_id ≥ cursor`),裁剪后会算出一个 rep_id 更小的「残组」→ 与上一页已展示的整组**重复出现**。分组必须在该用户全量行上算出真实 `rep_id` 后,再 `rep_id < cursor` 过滤。
- **未来读优化(YAGNI,暂不做)**:若量级压力显现,加只读会话聚合投影表 `coin_ad_session_agg(user_id, trace_id, total_coin, cnt, last_txn_id, last_created_at)`,写广告流水时 upsert,列表用「非广告行 UNION 该投影」走索引分页。这是**读优化**(明细账本仍每条一行、审计不动),与 §13 否决的「写时账本聚合」不同。
## 12. 测试
- **分组**:同 trace 多条比价广告 → 一行(`amount` 合计 / `merged_count=N` / `balance_after` 取最后 / `id=MAX`);两次不同 trace 的比价 → 两行;领券同 trace → 一行。
- **隔离**:比价广告序列中间夹一条签到 → 比价折叠成一行、签到独立成行;`reward_video` / `signin` / `exchange_out` → 仍 1:1`merged_count=1`
- **空 trace_id**`feed_ad_reward`(通用)或 trace_id 为 NULL 的广告行 → 每条一行。
- **分页**:多组跨页时按 `rep_id` 游标翻页不重不漏;一个大组(跨越 limit 边界的多条底层行)不被拆成两页;`next_cursor` 到底返回 None。
- **交错跨游标不重复(防残组回归)**:造一个会话,其广告成员 id 被一条非广告行隔开(部分成员在游标下、`rep_id` 在游标上),翻到下一页时该组**不得**再以「残组」重复出现——锁死 §11 的反面优化警示。
- **回填迁移**:造 `coin_transaction`(比价/领券行,ref_id 指向带 trace_id 的 `ad_feed_reward_record`)+ 无关行,跑迁移后仅两类被正确补 trace_id、无关行不动。
- **既有测试**`/coin-transactions` 现有用例(非广告类型 1:1、分页)保持通过。
## 13. 不做(YAGNI
- 不做写时聚合(会破坏实时到账 / 幂等 / 余额连续性)。
- 不做客户端聚合(SWR 缓存 + 游标分页下,一个会话可能跨页,跨页分组脆弱)。
- 不动激励视频 / 引导视频 / 通用信息流 / 签到等其它类型。
- 不动「比价记录页」——它的「比价赚 N 金币」是独立查询(直接聚合 `ad_feed_reward_record`),不受本改动影响。
- MVP 不下发 trace_id、不做「点开看明细」。
@@ -1,121 +0,0 @@
# 比价报警「末帧停留时长」增强设计
- 日期:2026-08-07
- 分支:feat-compare-alert-last-frame-dwell
- 关联:
- `docs/superpowers/specs/2026-08-04-compare-fail-alert-design.md`(一期报警)
- `docs/superpowers/specs/2026-08-05-compare-stuck-detection-design.md`(卡死定位,本期母 spec
## 1. 背景与问题
飞书报警卡片已有独立的「末帧」列(`app/services/compare_alert_format.py` `_TABLE_COLUMNS`,值 = `AlertHit.stuck_point`)。当前三种末帧口径里,**只有 T5 卡死带时长**,另两种只显「停在哪屏」、不显「在那屏停了多久」:
| 场景 | 「末帧」列现状 | 有时长? |
|---|---|---|
| T5 cancelled·判出原地卡死 | `平台·环节 N帧/Xs``_fmt_stuck` | ✅ `stuck_ms` |
| cancelled·兜底(超阈值放弃、没判出卡点) | `平台·环节·页面``res.last.label()` | ❌ |
| failedT1/T2/T6 | `平台·环节·页面``last_step().label()` | ❌ |
问题:后两种看不出「碰一下结算页就崩」和「在结算页干转 40s 才放弃」的区别——而这个区别对定位 failed / 深度放弃很关键。卡片已有的「用时」列量的是**整场**耗时,不是**末屏**停留,二者互补不重复。
### 1.1 为什么现在没有
不是缺数据,是当初**故意**没算:`last_step()` / `read_stuck_points().last` 返回的 `frames` 是该平台**总帧数**(非末段停留帧),`stuck_ms=None``compare_alert_worker.py` 里有注释明确「带上帧数/时长会误导」,所以只用了 `label()`。数据其实现成——每帧 `timestamp` 已被 `_read_head` 抠出,末段时长算法已在 `_platform_stuck` 里(`stuck_ms`)。
## 2. 目标
- 给 **failed****cancelled 兜底** 两种末帧路径补上「末帧所在屏停留 Xs」。
- 口径与 T5 的 `stuck_ms` 一致(同一种「末段连续同屏时长」),一个卡片里不出现两种「时长含义」。
- **规避原注释担心的误导**:末帧路径只显停留时长、**不显总帧数**。
- 稳:缺时间戳 / 时钟回退 / 读不到 trace → 降级只显环节,**绝不阻断报警**(延续母 spec 铁律)。
## 3. 口径定义
**末段停留 `dwell_ms`** = 从末帧往前、连续 `(pipeline_step, detected_page)` 都与末帧相同的那一段的时长(= 段末帧 `timestamp` 段首帧 `timestamp`round 到 ms)。
- 与卡死判据 `stuck_ms` **同一算法**,唯一区别:**去掉 `count ≥ threshold` 门槛**(末帧路径不要求原地打转,只问「末屏停了多久」)。
- 两端 `timestamp` 都能解析才有值;负时长(帧钟非单调/回退)→ `None`(沿用 `_platform_stuck` 现有降级)。
- failed 与 cancelled 兜底都取「帧数最多平台」的末段停留(与既有 `last` / `last_step` 选平台口径一致)。
## 4. 数据模型
`app/services/trace_stuck.py``StuckPoint` 新增一个字段:
```python
@dataclass(frozen=True)
class StuckPoint:
platform: str
pipeline_step: str
frames: int # 末段连续困住的帧数(上限 max_tail)
stuck_ms: int | None = None # 判为卡死那段的时长;T5 用
detected_page: str | None = None
dwell_ms: int | None = None # 新增:末帧所在屏停留时长;末帧路径用
```
`stuck_ms``dwell_ms` **并存、语义分离**
| 字段 | 含义 | 谁填/谁用 | 与 `frames` 关系 |
|---|---|---|---|
| `stuck_ms` | 判为卡死那段的时长 | `_platform_stuck` 填、T5 显 | `frames`=末段卡住帧数,**同段自洽** |
| `dwell_ms` | 末帧所在屏停留 | 末帧路径填、末帧列显 | `frames`=平台总帧数,**不参与显示** |
> 为什么不复用 `stuck_ms`:末帧路径返回的 `StuckPoint``frames` 是**总帧数**,若把末段停留塞进 `stuck_ms`,对象内部「帧数(总)」与「时长(末段)」不同段、不自洽,且会诱使误用 `_fmt_stuck` 打印出「总帧数 / 末段时长」——正是第 1.1 节要规避的误导。新加独立字段 + 专用 dwell-only 格式化,语义干净。
## 5. 改动
### 5.1 `services/trace_stuck.py`
- 抽末段扫描逻辑(复用现有 `_platform_stuck` 的段扫描 + 时长计算,去掉 `count ≥ threshold` 门槛),产出末段 `(count, dwell_ms, detected_page)`
- `last_step()`:除末帧 head 外,读该平台末段几帧 head,算 `dwell_ms` 填入返回的 `StuckPoint``frames` 仍 = 总帧数,语义不变)。
- `read_stuck_points()``last`:补 `dwell_ms`。该平台 tail 在逐平台判卡死时已读过,几乎零增量 IO。
### 5.2 `core/compare_alert_worker.py`
新增 dwell-only 格式化(**不显总帧数**是规避误导的关键):
```python
def _fmt_last(sp: trace_stuck.StuckPoint) -> str:
"""末帧路径:环节·页面 + 停留时长,不显总帧数。dwell_ms 为 None → 只显环节。"""
s = sp.label()
if sp.dwell_ms is not None:
sec = round(sp.dwell_ms / 1000)
s += f" 停留{sec}s" if sec >= 1 else " 停留<1s"
return s
```
- failed 路径:`sp.label()``_fmt_last(sp)`
- cancelled 兜底:`res.last.label()``_fmt_last(res.last)`
- 改掉原「带上帧数/时长会误导」注释(现在只带 dwell、不带总帧数,不再误导)。
### 5.3 `services/compare_alert_format.py`
**不改**。「末帧」列本就是自由字符串。
## 6. 显示效果
| 场景 | 改前 | 改后 |
|---|---|---|
| failed 结算页秒崩 | 美团·结算·checkout | 美团·结算·checkout **停留<1s** |
| failed 结算页干转 | 美团·结算·checkout | 美团·结算·checkout **停留40s** |
| cancelled 兜底 | 饿了么·进店·store | 饿了么·进店·store **停留8s** |
| 缺 ts / 时钟回退 | 只环节 | 只环节(无停留) |
| T5 卡死 | 美团·加菜 110帧/32s | 不变 |
## 7. 降级与物理边界
- **降级(不阻断报警)**:缺 `timestamp` / 帧钟非单调 / 读不到 trace → `dwell_ms=None` → 只显环节。任何 trace 异常仍在 `trace_stuck` 内降级。
- **物理边界**:帧 `timestamp` 只到末帧。若比价在**写完末帧之后**才彻底冻死(不再落帧),这段测不到 → `dwell≈0`。要覆盖它得用 `abort时间 末帧ts`,但那是 **DB `updated_at`SQLite UTCvs pricebot 帧钟**、跨源跨时区——worker 对「别混钟」很谨慎(见冷启动水位注释),**不引入混钟**。所以报的是「帧级末段停留」:`dwell≈0` = 一到这屏就死,`dwell=30s` = 在这屏干转——低估本身也是信号。
## 8. 测试
- **`trace_stuck` 单测**
- 新增:带 `timestamp` 的末帧 `dwell_ms` 用例(仿 `test_stuck_ms_computed_from_timestamps`)——覆盖末段多帧算出停留、末段单帧 → 0、无 ts → None、时钟回退 → None。
- 更新:`test_last_step_returns_busiest_platform_last_env``test_read_stuck_points_returns_last_frame` 的精确 `StuckPoint` 断言(多 `dwell_ms` 字段)。
- **worker 集成**:新增「failed / cancelled 兜底带 dwell」用例(fixture 帧带 `timestamp`);现有不带 ts 的用例不受影响(`dwell=None` → 只显环节)。
- **`_fmt_last` 单测**:有 dwell(≥1s/ `<1s`(sec 四舍五入为 0)/ 无 dwell 三态。
## 9. 不做(YAGNI
- 不混 DB 钟补「纯末尾冻死」。
- 不动 T5 卡死路径 / `timing.json` / 逐帧 profile(母 spec 第 12 节「不做每帧耗时」指 `timing.json` 逐帧 profile;本期用帧 `timestamp` 算的段时长是两回事,数据现成、报警用得上)。
- 不加列、不落库、不迁移。
-3
View File
@@ -35,9 +35,6 @@ dependencies = [
# multipart form (FastAPI 表单上传依赖)
"python-multipart>=0.0.9",
# 用户反馈截图缩略图,避免 App 历史页为 48dp 小图下载数 MB 原图
"pillow>=11.0.0",
# admin 后台账号密码 hash(用户侧是手机号+验证码登录,不需要密码;admin 才用)
"bcrypt>=4.0.0",
-1
View File
@@ -31,7 +31,6 @@ os.environ.setdefault("WXPAY_MCH_ID", "test-mch")
os.environ.setdefault("WXPAY_MCH_SERIAL_NO", "test-serial")
os.environ.setdefault("WXPAY_PUBLIC_KEY_ID", "test-pubkey-id")
os.environ.setdefault("RATE_LIMIT_ENABLED", "false") # 限流内存计数会跨用例累加,测试关掉
os.environ.setdefault("COMPARE_ALERT_ENABLED", "false") # 报警 worker 测试不启动(避免 .env 的 true 干扰 test_defaults)
# 穿山甲发奖回调:测试里开启 + 给个 mock 验签密钥,test 内自签自验闭环
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest")
-62
View File
@@ -1,62 +0,0 @@
"""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()
+13 -194
View File
@@ -1,7 +1,7 @@
"""Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。"""
from __future__ import annotations
from datetime import UTC, date, datetime
from datetime import UTC, datetime
import pytest
from fastapi.testclient import TestClient
@@ -12,7 +12,6 @@ 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
@@ -82,25 +81,21 @@ 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-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),
("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),
]
db = SessionLocal()
try:
for trace_id, status, record_status, total_ms, llm_cost_yuan in rows:
for trace_id, 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,
)
)
@@ -115,95 +110,14 @@ def test_dashboard_period_comparison_is_aggregated_by_backend(
)
assert response.status_code == 200, response.text
comparison = response.json()["period"]["comparison"]
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["total"] == 4
assert comparison["completed"] == 2
assert comparison["cancelled"] == 1
# 分母 = 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
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)
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
@@ -806,16 +720,6 @@ 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()
@@ -843,38 +747,6 @@ 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(
@@ -906,7 +778,6 @@ def test_comparison_records_show_real_order_status(
order_amount_cents=1800,
saved_amount_cents=300,
shop_name="真实下单店",
trace_id="comparison-ordered-shop",
source="compare",
client_event_id="admin-comparison-real-order",
),
@@ -1208,55 +1079,3 @@ 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"] == "未找到店"
-17
View File
@@ -1,17 +0,0 @@
"""比价报警配置默认值与关键词解析。"""
from __future__ import annotations
from app.core.config import settings
def test_defaults() -> None:
assert settings.COMPARE_ALERT_ENABLED is False
assert settings.COMPARE_ALERT_SCAN_INTERVAL_SEC == 900
assert settings.COMPARE_ALERT_CANCELLED_MS_THRESHOLD == 90000
assert settings.COMPARE_ALERT_CANCELLED_STEP_THRESHOLD == 30
def test_keyword_parsing() -> None:
assert settings.compare_alert_timeout_keywords == ("超时", "启动", "加载")
assert settings.compare_alert_unrecognized_keywords == ("未识别",)
assert "打烊" in settings.compare_alert_biz_exclude_keywords
-62
View File
@@ -1,62 +0,0 @@
"""classify_cancelled_fallback / make_hit 单测。"""
from app.services.compare_alert import classify_cancelled_fallback, make_hit
class _Rec:
def __init__(self, **kw):
self.trace_id = kw.get("trace_id", "t")
self.status = kw.get("status", "cancelled")
self.total_ms = kw.get("total_ms")
self.step_count = kw.get("step_count")
self.fail_reason = kw.get("fail_reason")
self.information = kw.get("information")
self.app_version = kw.get("app_version")
self.created_at = kw.get("created_at")
self.trace_url = kw.get("trace_url")
self.user_id = kw.get("user_id")
def test_fallback_deep_by_ms():
hit = classify_cancelled_fallback(
_Rec(total_ms=95000, step_count=5),
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
)
assert hit is not None and hit.alert_type == "T5" and "深度放弃" in hit.reason
def test_fallback_deep_by_step():
hit = classify_cancelled_fallback(
_Rec(total_ms=1000, step_count=35),
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
)
assert hit is not None and hit.alert_type == "T5"
def test_fallback_shallow_none():
hit = classify_cancelled_fallback(
_Rec(total_ms=5000, step_count=3),
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
)
assert hit is None
def test_make_hit_carries_fields():
hit = make_hit(_Rec(trace_id="tx", app_version="0.6.0"), "T5", "卡在 美团·加菜")
assert hit.trace_id == "tx"
assert hit.reason == "卡在 美团·加菜"
assert hit.app_version == "0.6.0"
def test_make_hit_carries_total_ms_and_step():
hit = make_hit(_Rec(trace_id="tx", total_ms=602000, step_count=157), "T5", "深度放弃")
assert hit.total_ms == 602000
assert hit.step_count == 157
def test_make_hit_total_ms_step_default_none():
# rec 没有这两个属性时安全降级为 None(不报错)
class _Bare:
trace_id = "b"; status = "cancelled"; reason = None
app_version = None; created_at = None; trace_url = None; user_id = None
hit = make_hit(_Bare(), "T1", "技术失败")
assert hit.total_ms is None and hit.step_count is None
-598
View File
@@ -1,598 +0,0 @@
"""AlertHit[] → 飞书消息:分组 / 截断 / 含关键词(text + post 两种格式)。"""
from __future__ import annotations
from datetime import datetime
from app.services.compare_alert import AlertHit
from app.services.compare_alert_format import ALERT_KEYWORD, format_alert_message, format_alert_post
def _hits(n, alert_type="T1"):
return [
AlertHit(
trace_id=f"t{i}",
alert_type=alert_type,
reason="比价过程出错",
app_version="v0.3.4",
created_at=None,
trace_url=None,
user_id=None,
)
for i in range(n)
]
# ---- format_alert_message(纯文本,保留原有测试) ----
def test_contains_keyword_and_count():
msg = format_alert_message(
_hits(2), window_label="2026-08-04 08:0008:30",
max_detail_per_type=20, max_total=50,
)
assert ALERT_KEYWORD in msg
assert "本期触发 2 条" in msg
assert "系统技术失败 2 条" in msg
assert "t0" in msg and "v0.3.4" in msg
def test_group_by_type():
hits = _hits(1, "T1") + _hits(1, "T6") + _hits(1, "T5")
msg = format_alert_message(hits, window_label="w", max_detail_per_type=20, max_total=50)
assert "系统技术失败 1 条" in msg
assert "商品识别失败 1 条" in msg
assert "深度放弃(cancelled) 1 条" in msg
def test_per_type_truncation():
msg = format_alert_message(_hits(25), window_label="w", max_detail_per_type=20, max_total=50)
assert msg.count("t0") == 1
assert "另有 5 条" in msg
def test_total_truncation_counts_only():
msg = format_alert_message(_hits(60), window_label="w", max_detail_per_type=20, max_total=50)
assert "系统技术失败 60 条" in msg
assert "t0" not in msg
assert "分析库" in msg
# ---- format_alert_post(富文本 post) ----
def _hits_with_meta(n, alert_type="T1", *, user_id=None, trace_url=None, created_at=None):
return [
AlertHit(
trace_id=f"tr{i}",
alert_type=alert_type,
reason="比价过程出错",
app_version="v1.2.3",
created_at=created_at or datetime(2026, 8, 4, 10, 30),
trace_url=trace_url,
user_id=user_id,
)
for i in range(n)
]
def test_post_title_contains_keyword():
hits = _hits_with_meta(1)
title, content = format_alert_post(
hits, window_label="2026-08-04 10:00", phone_map={}, max_detail_per_type=20, max_total=50,
)
assert ALERT_KEYWORD in title
def test_post_content_is_list():
hits = _hits_with_meta(2)
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
assert isinstance(content, list)
assert len(content) >= 1
# 每个段落是 list[dict]
for para in content:
assert isinstance(para, list)
for elem in para:
assert "tag" in elem
def test_post_summary_count():
hits = _hits_with_meta(3, "T1") + _hits_with_meta(2, "T6")
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
# 摘要第一段含合计数和类型计数
first_para_text = "".join(e.get("text", "") for e in content[0])
assert "合计 5 条" in first_para_text
assert "系统技术失败 3" in first_para_text
assert "商品识别失败 2" in first_para_text
def test_post_phone_map_applied():
hits = _hits_with_meta(1, user_id=42)
title, content = format_alert_post(
hits, window_label="w", phone_map={42: "13800138000"}, max_detail_per_type=20, max_total=50,
)
# 找明细行(非摘要非表头)中含手机号
all_text = " ".join(
e.get("text", "") for para in content for e in para
)
assert "13800138000" in all_text
def test_post_no_user_id_shows_dash():
hits = _hits_with_meta(1, user_id=None)
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
all_text = " ".join(e.get("text", "") for para in content for e in para)
assert " - " in all_text
def test_post_trace_url_becomes_a_element():
hits = _hits_with_meta(1, trace_url="https://trace.example.com/tr0")
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
# 找 tag=a 的元素
a_elements = [e for para in content for e in para if e.get("tag") == "a"]
assert len(a_elements) == 1
assert a_elements[0]["href"] == "https://trace.example.com/tr0"
assert a_elements[0]["text"] == "trace"
def test_post_no_trace_url_shows_trace_id_prefix():
hits = _hits_with_meta(1, trace_url=None)
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
# 无 trace_url 时:tag=text, text=trace_id[:16]
detail_texts = [e.get("text", "") for para in content for e in para if e.get("tag") == "text"]
# trace_id 是 tr0,截 16 位
assert any("tr0" in t for t in detail_texts)
def test_post_total_truncation_no_detail():
hits = _hits_with_meta(60)
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
# 超 max_total:只有摘要+截断提示,无表头、无明细
all_text = " ".join(e.get("text", "") for para in content for e in para)
assert "合计 60 条" in all_text
assert "时间 手机号" not in all_text
assert "tr0" not in all_text
assert "分析库" in all_text
def test_post_per_type_truncation():
hits = _hits_with_meta(25)
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
all_text = " ".join(e.get("text", "") for para in content for e in para)
assert "另有 5 条" in all_text
def test_post_created_at_formatting():
hits = [
AlertHit(
trace_id="tx1",
alert_type="T1",
reason="测试",
app_version="v2.0",
created_at=datetime(2026, 8, 4, 10, 30),
trace_url=None,
user_id=None,
)
]
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
all_text = " ".join(e.get("text", "") for para in content for e in para)
assert "08-04 10:30" in all_text
def test_post_no_created_at_shows_dash():
hits = [
AlertHit(
trace_id="tx2",
alert_type="T1",
reason="测试",
app_version=None,
created_at=None,
trace_url=None,
user_id=None,
)
]
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
all_text = " ".join(e.get("text", "") for para in content for e in para)
# 无 created_at 时间显示 "-"
assert "- " in all_text
# ---- format_alert_card(schema 2.0 卡片) ----
from app.services.compare_alert_format import format_alert_card # noqa: E402
def _card_hits(n, alert_type="T1", *, total_ms=None, step_count=None, user_id=None,
trace_url=None, created_at=None):
return [
AlertHit(
trace_id=f"card{i}",
alert_type=alert_type,
reason="比价过程出错",
app_version="v1.5.0",
created_at=created_at or datetime(2026, 8, 4, 10, 30),
trace_url=trace_url,
user_id=user_id,
total_ms=total_ms,
step_count=step_count,
)
for i in range(n)
]
def test_card_schema_and_header():
card = format_alert_card(
_card_hits(1),
window_label="2026-08-04 10:00",
phone_map={},
interval_min=15,
max_detail_per_type=20,
max_total=50,
)
assert card["schema"] == "2.0"
assert card["header"]["template"] == "red"
assert ALERT_KEYWORD in card["header"]["title"]["content"]
def test_card_body_markdown_summary():
card = format_alert_card(
_card_hits(3),
window_label="2026-08-04 10:00",
phone_map={},
interval_min=15,
max_detail_per_type=20,
max_total=50,
)
elements = card["body"]["elements"]
md = elements[0]
assert md["tag"] == "markdown"
assert "近 15 分钟" in md["content"]
assert "合计 3 条" in md["content"]
def test_card_has_table_seven_columns():
card = format_alert_card(
_card_hits(2),
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
elements = card["body"]["elements"]
# 第二个元素是 table
assert len(elements) >= 2
table = elements[1]
assert table["tag"] == "table"
cols = table["columns"]
assert len(cols) == 7
display_names = [c["display_name"] for c in cols]
assert display_names == ["时间", "手机号", "用时", "失败原因", "末帧", "版本", "trace"]
# 「末帧」列在「失败原因」后、「版本」前
names = [c["name"] for c in cols]
reason_idx = names.index("reason")
stuck_idx = names.index("stuck")
ver_idx = names.index("ver")
assert reason_idx < stuck_idx < ver_idx
# trace 列用 lark_md
trace_col = next(c for c in cols if c["name"] == "trace")
assert trace_col["data_type"] == "lark_md"
# 其余列 data_type 均为 text
for c in cols:
if c["name"] != "trace":
assert c["data_type"] == "text"
def test_card_cost_cell_format():
"""cost 格式: total_ms 和 step_count 均有值时 '{Ns} / {M步}'"""
hits = [
AlertHit(
trace_id="t1",
alert_type="T1",
reason="r",
app_version="v1",
created_at=datetime(2026, 8, 4, 10, 0),
trace_url=None,
user_id=None,
total_ms=602_000,
step_count=157,
)
]
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
assert row["cost"] == "602s / 157步"
def test_card_cost_only_ms():
hits = [
AlertHit(
trace_id="t2",
alert_type="T1",
reason="r",
app_version="v1",
created_at=None,
trace_url=None,
user_id=None,
total_ms=30_000,
step_count=None,
)
]
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
assert row["cost"] == "30s"
def test_card_cost_only_steps():
hits = [
AlertHit(
trace_id="t3",
alert_type="T1",
reason="r",
app_version="v1",
created_at=None,
trace_url=None,
user_id=None,
total_ms=None,
step_count=42,
)
]
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
assert row["cost"] == "42步"
def test_card_cost_none_when_both_missing():
hits = _card_hits(1, total_ms=None, step_count=None)
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
assert row["cost"] == "-"
def test_card_total_truncation_no_table():
"""超 max_total 时只有 markdown 摘要,没有 table 元素"""
card = format_alert_card(
_card_hits(60),
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
elements = card["body"]["elements"]
assert len(elements) == 1
assert elements[0]["tag"] == "markdown"
assert "comparison_record" in elements[0]["content"]
def test_card_empty_hits():
"""空 hits 返回含「本期无异常」的卡片(无 table)"""
card = format_alert_card(
[],
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
assert card["schema"] == "2.0"
elements = card["body"]["elements"]
assert len(elements) == 1
assert elements[0]["tag"] == "markdown"
assert "本期无异常" in elements[0]["content"]
def test_card_rows_count_respects_per_type_limit():
"""每类型最多 max_detail_per_type 条"""
hits = _card_hits(25, "T1")
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
assert len(table["rows"]) == 20
def test_card_trace_url_becomes_markdown_link():
hits = _card_hits(1, trace_url="https://trace.example.com/t0")
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
assert row["trace"] == "[链接](https://trace.example.com/t0)"
def test_card_no_trace_url_shows_trace_id_prefix():
hits = _card_hits(1, trace_url=None)
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
# trace_id = "card0"[:12]
assert row["trace"] == "card0"
def test_card_phone_from_map():
hits = _card_hits(1, user_id=7)
card = format_alert_card(
hits,
window_label="w",
phone_map={7: "13912345678"},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
assert row["phone"] == "13912345678"
def test_card_version_not_truncated():
"""版本完整显示,不缩写"""
hits = [
AlertHit(
trace_id="tv1",
alert_type="T1",
reason="r",
app_version="v2.15.3-release",
created_at=None,
trace_url=None,
user_id=None,
)
]
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
assert row["ver"] == "v2.15.3-release"
def test_card_table_has_page_size_and_header_style():
card = format_alert_card(
_card_hits(1),
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
assert "page_size" in table
assert "header_style" in table
assert table["header_style"].get("background_style") == "grey"
assert table["header_style"].get("bold") is True
def test_card_stuck_point_shown_in_row():
"""stuck_point 有值时行中 stuck 列正确显示;无值时显示「-」。"""
import dataclasses
from app.services.compare_alert import make_hit
class _Rec:
trace_id = "sp1"
status = "cancelled"
total_ms = 90000
step_count = 20
fail_reason = None
information = None
app_version = "v1.0"
created_at = datetime(2026, 8, 5, 10, 0)
trace_url = None
user_id = None
hit_with = dataclasses.replace(
make_hit(_Rec(), "T5", "深度放弃"),
stuck_point="美团·加菜 110帧/32s",
)
hit_without = make_hit(_Rec(), "T5", "深度放弃") # stuck_point=None
card_with = format_alert_card(
[hit_with],
window_label="w", phone_map={}, interval_min=5,
max_detail_per_type=20, max_total=50,
)
row_with = card_with["body"]["elements"][1]["rows"][0]
assert row_with["stuck"] == "美团·加菜 110帧/32s"
card_without = format_alert_card(
[hit_without],
window_label="w", phone_map={}, interval_min=5,
max_detail_per_type=20, max_total=50,
)
row_without = card_without["body"]["elements"][1]["rows"][0]
assert row_without["stuck"] == "-"
def test_card_shows_criteria_legend_with_config_thresholds():
"""卡片摘要含「判据」说明:深度放弃带当前配置阈值、技术失败说明任一平台 status=failed。"""
hits = _card_hits(1, "T5", total_ms=95000) + _card_hits(1, "T1")
card = format_alert_card(
hits, window_label="w", phone_map={}, interval_min=15,
max_detail_per_type=20, max_total=50,
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
)
md = card["body"]["elements"][0]["content"]
assert "判据" in md
assert "耗时>90s 或 步数>30" in md # 深度放弃判据带配置值
assert "任一平台 status=failed" in md # 技术失败判据
def test_card_criteria_reflects_custom_thresholds():
"""判据里的阈值随配置变化(不是写死 90/30)。"""
card = format_alert_card(
_card_hits(1, "T5", total_ms=200000),
window_label="w", phone_map={}, interval_min=15,
max_detail_per_type=20, max_total=50,
cancelled_ms_threshold=180000, cancelled_step_threshold=45,
)
md = card["body"]["elements"][0]["content"]
assert "耗时>180s 或 步数>45" in md
-46
View File
@@ -1,46 +0,0 @@
"""updated_at 列存在、回填 = created_at、onupdate 在 ORM 更新时刷新。"""
from __future__ import annotations
import time
from sqlalchemy import inspect
from app.db.session import SessionLocal, engine
from app.models.comparison import ComparisonRecord
def test_updated_at_column_and_index_exist() -> None:
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("comparison_record")}
assert "updated_at" in cols
idx_names = {i["name"] for i in insp.get_indexes("comparison_record")}
# 迁移环境手动建索引名为 ix_comparison_updated;
# 测试环境 create_all() 按 SQLAlchemy 命名约定生成 ix_comparison_record_updated_at。
# 两种环境都验通过即可。
assert (
"ix_comparison_updated" in idx_names
or "ix_comparison_record_updated_at" in idx_names
), f"updated_at index not found; available indexes: {idx_names}"
def test_onupdate_refreshes_updated_at() -> None:
db = SessionLocal()
try:
rec = ComparisonRecord(trace_id="alert-updated-at-onupdate", status="running")
db.add(rec)
db.commit()
db.refresh(rec)
first = rec.updated_at
assert first is not None
time.sleep(1.1) # SQLite CURRENT_TIMESTAMP 秒级精度,睡过 1 秒才看得出变化
rec.status = "failed"
db.commit()
db.refresh(rec)
assert rec.updated_at > first
finally:
db.rollback()
db.query(ComparisonRecord).filter(
ComparisonRecord.trace_id == "alert-updated-at-onupdate"
).delete()
db.commit()
db.close()
-92
View File
@@ -1,92 +0,0 @@
"""比价报警规则分类:记录 → AlertHit | None(纯函数,不碰 DB)。"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.services.compare_alert import classify_record
KW = dict(
cancelled_ms_threshold=90000,
cancelled_step_threshold=30,
timeout_keywords=("超时", "启动", "加载"),
unrecognized_keywords=("未识别",),
biz_exclude_keywords=("未找到", "打烊", "起送", "门店", "店内", "不配送", "这些菜", "未入驻", "休息"),
)
def _rec(**kw):
base = dict(
status="failed", fail_reason=None, information=None,
total_ms=None, step_count=None, trace_id="t", app_version=None, business_type="food",
)
base.update(kw)
return SimpleNamespace(**base)
@pytest.mark.parametrize(
("rec", "expected_type"),
[
(_rec(status="failed", fail_reason=None, information="比价过程出错,请稍后重试"), "T1"),
(_rec(status="failed", fail_reason=None, information=None), "T1"),
(_rec(status="failed", fail_reason=None, information="美团外卖门店已打烊,无法比价"), None),
(_rec(status="failed", fail_reason="未识别到商品"), "T6"),
(_rec(status="failed", fail_reason="启动淘宝超时, 请稍后重试"), "T2"),
(_rec(status="failed", fail_reason="淘宝闪购店内未找到这些菜品"), None),
(_rec(status="cancelled", total_ms=98000, step_count=10), "T5"),
(_rec(status="cancelled", total_ms=20000, step_count=31), "T5"),
(_rec(status="cancelled", total_ms=20000, step_count=5), None),
(_rec(status="cancelled", total_ms=None, step_count=None), None),
(_rec(status="success"), None),
(_rec(status="running"), None),
],
)
def test_classify_record_type(rec, expected_type):
hit = classify_record(rec, **KW)
assert (hit.alert_type if hit else None) == expected_type
def test_t5_boundary_exclusive():
assert classify_record(_rec(status="cancelled", total_ms=90000, step_count=30), **KW) is None
assert classify_record(_rec(status="cancelled", total_ms=90001, step_count=30), **KW).alert_type == "T5"
def test_reason_texts():
t1 = classify_record(_rec(status="failed", fail_reason=None, information=" 比价过程出错 "), **KW)
assert t1.reason == "技术失败·比价过程出错"
t1_empty = classify_record(_rec(status="failed", fail_reason=None, information=None), **KW)
assert t1_empty.reason == "技术失败·比价过程出错"
t5 = classify_record(_rec(status="cancelled", total_ms=98000, step_count=26), **KW)
assert t5.reason == "深度放弃"
# ---- failed 混合单:业务 headline 盖住某平台真技术失败(线上 trace 20260806_144544)----
def test_failed_business_headline_masks_target_technical_failure_reports_t1():
# fail_reason 被派生成京东业务原因(未找到),却盖住淘宝 status=failed 的真技术崩溃 → 补判 T1。
rec = _rec(
status="failed", fail_reason="京东此店内未找到这些菜品",
information="比价过程出错,请稍后重试",
raw_payload={"platform_results": {
"meituan": {"status": "source", "is_source": True},
"jd_waimai": {"status": "items_not_found", "reason": "京东此店内未找到这些菜品", "is_source": False},
"taobao_flash": {"status": "failed", "reason": "比价过程出错,请稍后重试", "is_source": False},
}},
)
hit = classify_record(rec, **KW)
assert hit is not None
assert hit.alert_type == "T1"
def test_failed_target_failed_but_business_reason_no_false_positive():
# pricebot 把打烊漏标成 status=failed,但 reason 是业务话术 → 不算技术崩溃,不报(不误报)。
rec = _rec(
status="failed", fail_reason="京东此店内未找到这些菜品",
information="比价过程出错,请稍后重试",
raw_payload={"platform_results": {
"jd_waimai": {"status": "items_not_found", "reason": "京东此店内未找到这些菜品", "is_source": False},
"taobao_flash": {"status": "failed", "reason": "门店已打烊,无法比价", "is_source": False},
}},
)
assert classify_record(rec, **KW) is None
-213
View File
@@ -1,213 +0,0 @@
"""build_hits 集成测:cancelled trace 优先/保底切换、failed 附卡点、限量。"""
import json
from pathlib import Path
from app.core.compare_alert_worker import _fmt_last, _fmt_stuck, build_hits
from app.services.trace_stuck import StuckPoint
class _Rec:
def __init__(self, **kw):
self.trace_id = kw.get("trace_id", "t")
self.status = kw.get("status", "cancelled")
self.total_ms = kw.get("total_ms")
self.step_count = kw.get("step_count")
self.fail_reason = kw.get("fail_reason")
self.information = kw.get("information")
self.app_version = kw.get("app_version")
self.created_at = kw.get("created_at")
self.trace_url = kw.get("trace_url")
self.user_id = kw.get("user_id")
def _frame(pdir: Path, idx: int, step: str, page: str) -> None:
pdir.mkdir(parents=True, exist_ok=True)
body = {"pipeline_step": step, "detected_page": page, "windows": [{"n": ["x" * 200]}]}
(pdir / f"step_{idx:03d}.json").write_text(
json.dumps(body, ensure_ascii=False), encoding="utf-8"
)
def _frame_ts(pdir: Path, idx: int, step: str, page: str, ts: str) -> None:
pdir.mkdir(parents=True, exist_ok=True)
body = {"pipeline_step": step, "detected_page": page, "timestamp": ts,
"windows": [{"n": ["x" * 200]}]}
(pdir / f"step_{idx:03d}.json").write_text(
json.dumps(body, ensure_ascii=False), encoding="utf-8"
)
_KW = dict(
stuck_threshold=15, max_tail=40, max_trace_reads=30,
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
timeout_keywords=("超时",), unrecognized_keywords=("未识别",), biz_exclude_keywords=(),
)
def test_cancelled_stuck_reports_via_trace(tmp_path):
for i in range(18):
_frame(tmp_path / "20260804_x" / "meituan", i, "add_one_dish", "meal_detail_popup")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260804_x/",
total_ms=5000, step_count=3) # 保底不会中,靠 trace 判卡死
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T5"
assert hits[0].reason == "深度放弃"
assert "美团·加菜" in hits[0].stuck_point
assert "" in hits[0].stuck_point
assert "meal_detail_popup" in hits[0].stuck_point # 卡点带末帧页面
def test_cancelled_readable_not_stuck_long_duration_reports(tmp_path):
# trace 可读但没判出原地卡点:仍过耗时/步数阈值兜底,超长(>90s)照报 T5;没卡点也用末帧标"退出前在哪屏"。
# (线上 trace 几乎总可读,若不回退则 total_ms 阈值形同虚设、超长放弃永不报——见 compare-fail-alert 排查。)
p = tmp_path / "20260804_y" / "eleme"
_frame(p, 0, "set_address", "home")
for i in range(1, 6):
_frame(p, i, "enter_store", "store")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260804_y/",
total_ms=95000, step_count=40)
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T5"
assert hits[0].reason == "深度放弃"
assert hits[0].stuck_point == "饿了么·进店·store" # 无卡点 → 用末帧(平台·环节·页面)标退出前在哪屏
def test_cancelled_readable_not_stuck_short_no_report(tmp_path):
# trace 可读没卡点、且耗时/步数都没超阈值 → 正常早退,不报(兜底阈值把住,不误报)。
p = tmp_path / "20260804_ys" / "eleme"
_frame(p, 0, "set_address", "home")
for i in range(1, 6):
_frame(p, i, "enter_store", "store")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260804_ys/",
total_ms=5000, step_count=3)
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert hits == []
def test_cancelled_unreadable_falls_back(tmp_path):
rec = _Rec(status="cancelled", trace_url="https://x/traces/nope/",
total_ms=95000, step_count=3)
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T5" and "深度放弃" in hits[0].reason
def test_no_work_log_dir_uses_fallback(tmp_path):
rec = _Rec(status="cancelled", trace_url="https://x/traces/y/",
total_ms=95000, step_count=3)
hits = build_hits([rec], work_log_dir="", **_KW)
assert len(hits) == 1 and "深度放弃" in hits[0].reason
def test_failed_gets_stuck_point_appended(tmp_path):
for i in range(20):
_frame(tmp_path / "20260804_f" / "meituan", i, "add_one_dish", "meal_detail_popup")
rec = _Rec(status="failed", fail_reason="启动超时",
trace_url="https://x/traces/20260804_f/")
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T2"
assert "美团·加菜" in hits[0].stuck_point
assert "卡在" not in hits[0].reason
def test_max_trace_reads_zero_skips_trace(tmp_path):
for i in range(18):
_frame(tmp_path / "20260804_z" / "meituan", i, "add_one_dish", "meal_detail_popup")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260804_z/",
total_ms=95000, step_count=3)
kw = {**_KW, "max_trace_reads": 0}
hits = build_hits([rec], work_log_dir=str(tmp_path), **kw)
# 没读 trace → 回退保底 → deep(95s) → 深度放弃
assert len(hits) == 1 and "深度放弃" in hits[0].reason
def test_shared_reads_budget_across_branches(tmp_path):
# cancelled 和 failed 共用 max_trace_reads 预算;预算=1 时 cancelled 先消耗,failed 拿不到卡点
for i in range(18):
_frame(tmp_path / "20260804_c" / "meituan", i, "add_one_dish", "meal_detail_popup")
for i in range(20):
_frame(tmp_path / "20260804_d" / "meituan", i, "add_one_dish", "meal_detail_popup")
cancelled = _Rec(status="cancelled", trace_url="https://x/traces/20260804_c/",
total_ms=5000, step_count=3)
failed = _Rec(status="failed", fail_reason="启动超时",
trace_url="https://x/traces/20260804_d/")
kw = {**_KW, "max_trace_reads": 1}
hits = build_hits([cancelled, failed], work_log_dir=str(tmp_path), **kw)
assert len(hits) == 2
# cancelled 消耗了唯一预算 → 报卡死,卡点在 stuck_point
assert hits[0].alert_type == "T5" and hits[0].stuck_point is not None
# failed 超预算 → 仍报 T2,但 stuck_point 为 None
assert hits[1].alert_type == "T2" and hits[1].stuck_point is None
# ---- _fmt_stuck 单测 ----
def test_fmt_stuck_with_ms():
sp = StuckPoint(platform="meituan", pipeline_step="add_one_dish", frames=110, stuck_ms=32000)
assert _fmt_stuck(sp) == "美团·加菜 110帧/32s"
def test_fmt_stuck_without_ms():
sp = StuckPoint(platform="meituan", pipeline_step="add_one_dish", frames=110, stuck_ms=None)
assert _fmt_stuck(sp) == "美团·加菜 110帧"
# ---- _fmt_last 单测(末帧路径:环节·页面 + 停留,不显总帧数)----
def test_fmt_last_with_dwell():
sp = StuckPoint("meituan", "checkout", frames=480,
detected_page="checkout_page", dwell_ms=8000)
assert _fmt_last(sp) == "美团·结算·checkout_page 停留8s"
def test_fmt_last_sub_second():
sp = StuckPoint("meituan", "checkout", frames=480,
detected_page="checkout_page", dwell_ms=300)
assert _fmt_last(sp) == "美团·结算·checkout_page 停留<1s"
# <1s 阈值边界:round(500/1000)=0 → <1s;round(999/1000)=1 → 停留1s
sp500 = StuckPoint("meituan", "checkout", frames=480,
detected_page="checkout_page", dwell_ms=500)
assert _fmt_last(sp500) == "美团·结算·checkout_page 停留<1s"
sp999 = StuckPoint("meituan", "checkout", frames=480,
detected_page="checkout_page", dwell_ms=999)
assert _fmt_last(sp999) == "美团·结算·checkout_page 停留1s"
def test_fmt_last_without_dwell():
sp = StuckPoint("meituan", "checkout", frames=480,
detected_page="checkout_page", dwell_ms=None)
assert _fmt_last(sp) == "美团·结算·checkout_page"
# ---- 末帧路径带 dwell 集成 ----
def test_failed_stuck_point_has_dwell(tmp_path):
# failed 末帧 5 帧都在 checkout,ts :00→:08 → stuck_point 附「停留8s」
p = tmp_path / "20260807_f" / "meituan"
for i in range(5):
_frame_ts(p, i, "checkout", "checkout_page",
f"2026-08-07T12:00:{i * 2:02d}.000000")
rec = _Rec(status="failed", fail_reason="启动超时",
trace_url="https://x/traces/20260807_f/")
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T2"
assert hits[0].stuck_point == "美团·结算·checkout_page 停留8s"
def test_cancelled_fallback_stuck_point_has_dwell(tmp_path):
# cancelled 超阈值(95s)但末段 5<15 不卡死 → 兜底,末帧带 ts → 附「停留8s」
p = tmp_path / "20260807_c" / "eleme"
_frame_ts(p, 0, "set_address", "home", "2026-08-07T12:00:00.000000")
for i in range(1, 6): # enter_store 5 帧 :02→:10
_frame_ts(p, i, "enter_store", "store",
f"2026-08-07T12:00:{i * 2:02d}.000000")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260807_c/",
total_ms=95000, step_count=40)
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T5"
assert hits[0].stuck_point == "饿了么·进店·store 停留8s"
-93
View File
@@ -1,93 +0,0 @@
"""扫描 worker 的同步核心 _scan_and_alert:水位读写 / 冷启动 / 发送成功推进、失败不推进。
monkeypatch 掉真正的飞书发送;用真实 SQLite(SessionLocal)插入几条记录
"""
from __future__ import annotations
import time
import pytest
from app.core import compare_alert_worker as w
from app.db.session import SessionLocal
from app.models.app_config import AppConfig
from app.models.comparison import ComparisonRecord
WM_KEY = w.WATERMARK_KEY
@pytest.fixture()
def clean_db():
db = SessionLocal()
db.query(ComparisonRecord).delete()
db.query(AppConfig).filter(AppConfig.key == WM_KEY).delete()
db.commit()
yield db
db.query(ComparisonRecord).delete()
db.query(AppConfig).filter(AppConfig.key == WM_KEY).delete()
db.commit()
db.close()
def _add(db, trace, status, **kw):
rec = ComparisonRecord(trace_id=trace, status=status, **kw)
db.add(rec)
db.commit()
db.refresh(rec)
return rec
def _card_title(card: dict) -> str:
return card.get("header", {}).get("title", {}).get("content", "")
def _card_md_content(card: dict) -> str:
elements = card.get("body", {}).get("elements", [])
return elements[0].get("content", "") if elements else ""
def test_cold_start_sets_watermark_no_alert(clean_db, monkeypatch):
# 冷启动:表非空 → 水位=当前 max(updated_at),不回溯已有历史失败、不报
_add(clean_db, "old-fail", "failed", fail_reason=None, information="比价过程出错")
sent = []
monkeypatch.setattr(w, "_send_card", lambda card: sent.append(card))
w._scan_and_alert()
assert sent == [] # 冷启动不报历史
row = clean_db.get(AppConfig, WM_KEY)
assert row is not None # 水位已初始化
def test_alerts_on_new_failed_and_advances(clean_db, monkeypatch):
# seed 一条 + 冷启动建水位;sleep 1.1s 拉开时间(SQLite 秒级精度,否则新记录同秒、追不上水位)
_add(clean_db, "seed", "running")
monkeypatch.setattr(w, "_send_card", lambda card: None)
w._scan_and_alert() # 冷启动,水位=seed.updated_at
time.sleep(1.1)
sent = []
monkeypatch.setattr(w, "_send_card", lambda card: sent.append(card))
_add(clean_db, "new-fail", "failed", fail_reason=None, information="比价过程出错")
w._scan_and_alert()
assert len(sent) == 1
card = sent[0]
# title 含关键词
assert "比价失败报警" in _card_title(card)
# markdown 摘要含"系统技术失败"
assert "系统技术失败 1" in _card_md_content(card)
def test_send_failure_does_not_advance_watermark(clean_db, monkeypatch):
_add(clean_db, "seed2", "running")
monkeypatch.setattr(w, "_send_card", lambda card: None)
w._scan_and_alert() # 冷启动建水位
wm_before = clean_db.get(AppConfig, WM_KEY).value
time.sleep(1.1)
_add(clean_db, "fail-send", "failed", fail_reason=None, information="比价过程出错")
def boom(card):
raise w.feishu_notifier.FeishuNotifyError("down")
monkeypatch.setattr(w, "_send_card", boom)
w._scan_and_alert() # 发送失败
clean_db.expire_all()
wm_after = clean_db.get(AppConfig, WM_KEY).value
assert wm_after == wm_before # 未推进,下轮补发
+2 -126
View File
@@ -1,16 +1,14 @@
from __future__ import annotations
import time
from datetime import UTC, datetime, timedelta
from datetime import 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]:
@@ -140,132 +138,10 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
)
assert response.status_code == 429
assert response.json()["detail"] == "今日比价额度用完啦,明天再来吧~"
assert response.json()["detail"] == "今日比价超过100次,请明天再试"
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
-131
View File
@@ -13,7 +13,6 @@ import uuid
from unittest.mock import MagicMock, patch
import httpx
import pytest
from sqlalchemy import select
from app.db.session import SessionLocal
@@ -26,26 +25,6 @@ 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 {
@@ -132,116 +111,6 @@ 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 展示京东那条)"""
+31 -122
View File
@@ -223,10 +223,10 @@ def test_stats_compare_count_and_saved(client) -> None:
def test_records_ordered_flag(client) -> None:
"""「已下单」按 trace_id 标记:下单上报带上本次比价的 trace_id,精确命中该条记录才 True。
"""「已下单」店级标记:店名命中该用户 source='compare' 的下单记录才 True。
覆盖 list_records 只按**本页 trace_id**反查 savings 的写法(把该用户全部下单 trace_id 捞回
内存再取交集)两种写法结果必须一致,故这里逐条断言
覆盖 list_records 只按**本页店名**反查 savings 的写法(原来是把该用户全部下单店名捞回内存
再取交集,随下单量线性变慢)两种写法结果必须一致,故这里按店名逐条断言
"""
token = _login(client, "13800002010")
@@ -236,15 +236,14 @@ def test_records_ordered_flag(client) -> None:
other["store_name"] = "没下过单的店"
client.post("/api/v1/compare/record", json=other, headers=_auth(token))
# 下单前:两条都不该带「已下单」。按 trace_id 断言本测试自己的两条(不受同库其它用例数据干扰)。
flags = {
it["trace_id"]: it["ordered"]
for it in client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
# 下单前:两条都不该带「已下单」
items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
assert {it["store_name"]: it["ordered"] for it in items} == {
"海底捞(朝阳店)": False,
"没下过单的店": False,
}
assert flags["ord-1"] is False
assert flags["ord-2"] is False
# 对海底捞那次比价真实下单一笔:上报带上该次比价的 trace_id(order/report 写 source='compare')
# 对海底捞真实下单一笔(order/report 写 source='compare' 的 savings_record)
r = client.post(
"/api/v1/order/report",
json={
@@ -256,28 +255,23 @@ def test_records_ordered_flag(client) -> None:
"paid_amount_cents": 12350,
"shop_name": "海底捞(朝阳店)",
"original_price_cents": 12850,
"trace_id": "ord-1",
},
headers=_auth(token),
)
assert r.status_code == 200, r.text
# 下单后:只有命中 trace_id(ord-1)那条翻成 True,同店的另一次比价(ord-2)不受影响
flags = {
it["trace_id"]: it["ordered"]
for it in client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
# 下单后:只有同店名那条翻成 True,另一条不受影响
items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
assert {it["store_name"]: it["ordered"] for it in items} == {
"海底捞(朝阳店)": True,
"没下过单的店": False,
}
assert flags["ord-1"] is True
assert flags["ord-2"] is False
# 别人的下单不该影响本人标记(_ordered_trace_ids 按 user_id 过滤)
# 别人的下单不该影响本人标记(_ordered_shop_names 按 user_id 过滤)
token_b = _login(client, "13800002011")
client.post("/api/v1/compare/record", json=_food_payload("ord-b"), headers=_auth(token_b))
flags_b = {
it["trace_id"]: it["ordered"]
for it in client.get("/api/v1/compare/records", headers=_auth(token_b)).json()["items"]
}
assert flags_b["ord-b"] is False
items_b = client.get("/api/v1/compare/records", headers=_auth(token_b)).json()["items"]
assert [it["ordered"] for it in items_b] == [False]
def test_records_list_omits_raw_payload(client) -> None:
@@ -322,24 +316,20 @@ def test_records_ordered_filter(client) -> None:
q["store_name"] = "没下过单的店"
client.post("/api/v1/compare/record", json=q, headers=_auth(token))
# 对这 3 条「下过单的店」比价分别真实下单(各带自己的 trace_id);2 条「没下过单的店」不下单
for i in range(3):
r = client.post(
"/api/v1/order/report",
json={
"client_event_id": f"evt-ordered-filter-{i}",
"platform": "美团",
"platform_package": "com.sankuai.meituan",
"pay_channel": "wechat",
"compared_price_cents": 12350,
"paid_amount_cents": 12350,
"shop_name": "下过单的店",
"original_price_cents": 12850,
"trace_id": f"of-ordered-{i}",
},
headers=_auth(token),
)
assert r.status_code == 200, r.text
client.post(
"/api/v1/order/report",
json={
"client_event_id": "evt-ordered-filter",
"platform": "美团",
"platform_package": "com.sankuai.meituan",
"pay_channel": "wechat",
"compared_price_cents": 12350,
"paid_amount_cents": 12350,
"shop_name": "下过单的店",
"original_price_cents": 12850,
},
headers=_auth(token),
)
# 不传 ordered:5 条全出(「全部记录」tab 口径不变)
assert len(client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]) == 5
@@ -449,84 +439,3 @@ def test_trace_id_required(client) -> None:
token = _login(client, "13800002009")
r = client.post("/api/v1/compare/record", json={"business_type": "food"}, headers=_auth(token))
assert r.status_code == 422
def test_records_ordered_by_trace_id(client) -> None:
"""「已下单」精确到单次比价:同一家店比价多次,只有真实下单的那一次(下单上报带 trace_id)才算已下单。
下单上报带上本次比价的 trace_id,服务端按 trace_id 对齐 同店其它比价(哪怕成功)不再被一并
已下单这是从"店级""单次级"的核心契约
"""
token = _login(client, "13800002016")
# 同一家店(默认 payload 店名都是「海底捞(朝阳店)」)比价两次,不同 trace_id
client.post("/api/v1/compare/record", json=_food_payload("ord-trace-a"), headers=_auth(token))
client.post("/api/v1/compare/record", json=_food_payload("ord-trace-b"), headers=_auth(token))
# 只对其中一次(trace-a)真实下单,上报带上该次比价的 trace_id
r = client.post(
"/api/v1/order/report",
json={
"client_event_id": "evt-trace-a",
"platform": "美团",
"platform_package": "com.sankuai.meituan",
"pay_channel": "wechat",
"compared_price_cents": 12350,
"paid_amount_cents": 12350,
"shop_name": "海底捞(朝阳店)",
"original_price_cents": 12850,
"trace_id": "ord-trace-a",
},
headers=_auth(token),
)
assert r.status_code == 200, r.text
# ordered=true 只出下单的那条(trace-a);同店没下单的 trace-b 不进来
page = client.get("/api/v1/compare/records?ordered=true", headers=_auth(token)).json()
assert [it["trace_id"] for it in page["items"]] == ["ord-trace-a"]
# 全部记录里,只有 trace-a 带 ordered=true,同店的 trace-b 仍是 False
items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
assert {it["trace_id"]: it["ordered"] for it in items} == {
"ord-trace-a": True,
"ord-trace-b": False,
}
def test_records_ordered_ignores_order_without_trace_id(client) -> None:
"""无 trace_id 的下单(老客户端/历史)即使店名相同也不进「已下单」—— 锁定「无店名回退」契约。
下单上报不带 trace_id savings_record.trace_id 为空 对齐不上任何比价记录老逻辑靠店名
会把同店比价一并标已下单,这条正是要防它被改回去
"""
token = _login(client, "13800002017")
# 一条比价记录(默认店名「海底捞(朝阳店)」)
client.post("/api/v1/compare/record", json=_food_payload("no-trace-order"), headers=_auth(token))
# 同店名、但**不带 trace_id** 的下单(模拟老客户端)
r = client.post(
"/api/v1/order/report",
json={
"client_event_id": "evt-no-trace",
"platform": "美团",
"platform_package": "com.sankuai.meituan",
"pay_channel": "wechat",
"compared_price_cents": 12350,
"paid_amount_cents": 12350,
"shop_name": "海底捞(朝阳店)",
"original_price_cents": 12850,
},
headers=_auth(token),
)
assert r.status_code == 200, r.text
# 店名虽同,但订单没 trace_id → 该记录不该被标「已下单」,也不进 ordered=true(无店名回退)
flags = {
it["trace_id"]: it["ordered"]
for it in client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
}
assert flags["no-trace-order"] is False
assert client.get(
"/api/v1/compare/records?ordered=true", headers=_auth(token)
).json()["items"] == []
+34 -80
View File
@@ -1,4 +1,4 @@
"""比价记录页后端概览聚合admin 口径:外部缺失记为成功)"""
"""比价记录页后端分页与概览聚合。"""
from __future__ import annotations
from datetime import UTC, date, datetime
@@ -7,14 +7,13 @@ 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(
[], admin_success_sql(), (0.05, 0.5, 0.95, 0.99)
[], "success", (0.05, 0.5, 0.95, 0.99)
)
sql = str(
stmt.compile(
@@ -22,106 +21,61 @@ def test_postgresql_duration_summary_uses_ordered_set_aggregates() -> None:
compile_kwargs={"literal_binds": True},
)
)
assert sql.count("percentile_cont") == 4
# 口径按原始结局 coalesce,而非直接读 status 列
assert "coalesce" in sql.lower()
assert "comparison_record.status = 'success'" in sql
def test_summary_counts_external_gaps_as_success() -> None:
def test_summary_uses_only_success_durations_and_filters_beijing_date() -> None:
db = SessionLocal()
try:
# (trace_id, status 列[已 normalize], record_status[原始结局], total_ms, cost, saved)
rows = [
("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),
("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),
]
for trace_id, status, record_status, total_ms, cost, saved in rows:
for trace_id, 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)
)
# 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["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
assert summary["cancelled"] == 1
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_rate"] == 0.25
assert summary["cancelled_p50_ms"] == 5000
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
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()
@@ -1,95 +0,0 @@
"""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", "商品未找到"),
]
+3 -111
View File
@@ -10,7 +10,6 @@ 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
@@ -39,7 +38,6 @@ 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"
@@ -50,8 +48,8 @@ def test_point_scores_by_trace() -> None:
db.close()
def test_skipped_detail_is_distinguished_from_no_events() -> None:
"""仅有 skipped 时分数仍为0/0,但保留事件数供前端开放明细"""
def test_skipped_detail_does_not_create_a_score() -> None:
"""仅有 skipped 时按需明细仍可查到,但列表没有虚假的 0/0 分数"""
db = SessionLocal()
trace = "point-score-skipped"
try:
@@ -65,7 +63,7 @@ def test_skipped_detail_is_distinguished_from_no_events() -> None:
db.flush()
scores = _point_scores_by_trace(db, [trace, "missing-trace"])
assert scores[trace] == {"succeeded": 0, "tried": 0, "events": 1}
assert trace not in scores
assert "missing-trace" not in scores
assert coupon_point_details(db, trace_id=trace)[0]["status"] == "skipped"
finally:
@@ -116,112 +114,6 @@ 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()
-113
View File
@@ -1,113 +0,0 @@
"""0 点自动兑金币 worker:持久化「当天已兑」标记,防同日重启重复补扫。
背景(路径 B bug):worker 原用内存变量 last_run 今天跑过没,进程重启即归零 每次
0 点部署/重启都会全量补扫一遍, 0 点后才达标的用户在** 0 **兑成现金(用户反馈的
0 点也出现金币转现金记录)修复:标记持久化到 app_config,跨重启不丢
"""
from __future__ import annotations
from datetime import date
import pytest
from sqlalchemy import delete, select, update
from app.core import daily_exchange_worker as w
from app.db.session import SessionLocal
from app.models.app_config import AppConfig
from app.models.user import User
from app.models.wallet import CashTransaction, CoinAccount
from app.repositories import wallet as wallet_repo
_PHONE_SEQ = [0]
@pytest.fixture(autouse=True)
def _isolate_exchange_state():
"""daily_auto_exchange 全表扫描 + app_config 标记会跨用例泄漏(SQLite 测试库 session 级共享,
commit rollback no-op),故每个用例先清零所有余额 + 清掉自动兑标记,保证干净起步"""
db = SessionLocal()
try:
db.execute(update(CoinAccount).values(
coin_balance=0, cash_balance_cents=0, invite_cash_balance_cents=0))
db.execute(delete(AppConfig).where(AppConfig.key == w.LAST_RUN_KEY))
db.commit()
finally:
db.close()
yield
def _new_user(db, *, coin: int) -> int:
"""建一个 User + 指定金币余额的 CoinAccount,返回 user_id。"""
_PHONE_SEQ[0] += 1
# 198 段避免撞其他测试文件的固定手机号 / username UNIQUE
u = User(phone=f"198{_PHONE_SEQ[0]:08d}", username=f"ax{_PHONE_SEQ[0]}", status="active")
db.add(u)
db.flush()
acc = wallet_repo.get_or_create_account(db, u.id, commit=False)
acc.coin_balance = coin
acc.total_coin_earned = coin
db.flush()
return u.id
def test_first_run_converts_and_records_marker() -> None:
"""首轮(标记为空)→ 到分全额兑 + 落库当天标记。"""
db = SessionLocal()
try:
uid = _new_user(db, coin=300)
db.commit()
today = date(2026, 8, 6)
result = w._exchange_if_due(db, today)
assert result is not None and result["converted"] >= 1
acc = db.get(CoinAccount, uid)
assert acc.coin_balance == 0 and acc.cash_balance_cents > 0 # 300 为整分,无零头
assert w._read_last_run(db) == today # 标记落库
finally:
db.rollback()
db.close()
def test_same_day_restart_does_not_resweep() -> None:
"""路径 B 回归:0 点兑过后,同日进程重启(下午部署)不得再补扫当天新达标用户。"""
db = SessionLocal()
try:
today = date(2026, 8, 6)
first = _new_user(db, coin=200)
db.commit()
assert w._exchange_if_due(db, today) is not None
assert db.get(CoinAccount, first).coin_balance == 0 # 首轮兑掉
# 0 点后才达标的用户(白天攒够;或 0 点是零头、白天赚够)
late = _new_user(db, coin=500)
db.commit()
# 模拟同一北京日内进程重启 → 持久化标记 == today → 整轮跳过,不碰 late
assert w._exchange_if_due(db, today) is None
acc = db.get(CoinAccount, late)
assert acc.coin_balance == 500 and acc.cash_balance_cents == 0
assert db.execute(select(CashTransaction).where(
CashTransaction.user_id == late,
CashTransaction.biz_type == "exchange_in",
)).first() is None # late 无任何兑现金流水
finally:
db.rollback()
db.close()
def test_new_day_runs_again() -> None:
"""跨到北京新的一天(或真漏了 0 点,标记 < today)→ 照常补跑。"""
db = SessionLocal()
try:
w._write_last_run(db, date(2026, 8, 6)) # 昨天已兑
late = _new_user(db, coin=500)
db.commit()
result = w._exchange_if_due(db, date(2026, 8, 7))
assert result is not None and result["converted"] >= 1
assert db.get(CoinAccount, late).coin_balance == 0
assert w._read_last_run(db) == date(2026, 8, 7)
finally:
db.rollback()
db.close()
-184
View File
@@ -1,184 +0,0 @@
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
-169
View File
@@ -1,169 +0,0 @@
"""飞书群机器人发送:text/post 消息体格式 / 关键词失败 / 网络失败,均 monkeypatch httpx 不真发。"""
from __future__ import annotations
import httpx
import pytest
from app.integrations import feishu_notifier
# ---- send_feishu_text ----
def test_send_posts_text_payload(monkeypatch):
captured = {}
def fake_post(url, json, timeout):
captured["url"] = url
captured["json"] = json
return httpx.Response(200, json={"code": 0, "msg": "success"})
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
feishu_notifier.send_feishu_text("https://open.feishu.cn/hook/xxx", "比价失败报警 · test", timeout=5.0)
assert captured["url"] == "https://open.feishu.cn/hook/xxx"
assert captured["json"] == {"msg_type": "text", "content": {"text": "比价失败报警 · test"}}
def test_send_raises_on_keyword_rejection(monkeypatch):
def fake_post(url, json, timeout):
return httpx.Response(200, json={"code": 19024, "msg": "Key Words Not Found"})
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_text("https://hook", "无关键词文本", timeout=5.0)
def test_send_raises_on_http_error(monkeypatch):
def fake_post(url, json, timeout):
return httpx.Response(500, text="boom")
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_text("https://hook", "比价失败报警", timeout=5.0)
# ---- send_feishu_post ----
def test_send_post_payload_structure(monkeypatch):
"""send_feishu_post 发出 msg_type=post 的 payload,结构符合飞书 post 格式。"""
captured = {}
def fake_post(url, json, timeout):
captured["url"] = url
captured["json"] = json
return httpx.Response(200, json={"code": 0, "msg": "success"})
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
content = [[{"tag": "text", "text": "合计 3 条"}]]
feishu_notifier.send_feishu_post(
"https://open.feishu.cn/hook/yyy",
"🚨 比价失败报警 · 2026-08-04",
content,
timeout=5.0,
)
assert captured["url"] == "https://open.feishu.cn/hook/yyy"
payload = captured["json"]
assert payload["msg_type"] == "post"
zh_cn = payload["content"]["post"]["zh_cn"]
assert zh_cn["title"] == "🚨 比价失败报警 · 2026-08-04"
assert zh_cn["content"] == content
def test_send_post_raises_on_code_nonzero(monkeypatch):
"""send_feishu_post code!=0 时抛 FeishuNotifyError。"""
def fake_post(url, json, timeout):
return httpx.Response(200, json={"code": 19024, "msg": "Key Words Not Found"})
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_post(
"https://hook", "🚨 比价失败报警", [[{"tag": "text", "text": "test"}]], timeout=5.0
)
def test_send_post_raises_on_http_error(monkeypatch):
"""send_feishu_post 非 2xx 抛 FeishuNotifyError。"""
def fake_post(url, json, timeout):
return httpx.Response(500, text="internal server error")
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_post(
"https://hook", "🚨 比价失败报警", [[{"tag": "text", "text": "test"}]], timeout=5.0
)
def test_send_post_raises_on_network_error(monkeypatch):
"""send_feishu_post 网络异常抛 FeishuNotifyError。"""
def fake_post(url, json, timeout):
raise httpx.ConnectError("connection refused")
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_post(
"https://hook", "🚨 比价失败报警", [[{"tag": "text", "text": "test"}]], timeout=5.0
)
# ---- send_feishu_card ----
def test_send_card_payload_structure(monkeypatch):
"""send_feishu_card 发出 msg_type=interactive + card 字段的 payload。"""
captured = {}
def fake_post(url, json, timeout):
captured["url"] = url
captured["json"] = json
return httpx.Response(200, json={"code": 0, "msg": "success"})
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
card = {
"schema": "2.0",
"header": {"title": {"tag": "plain_text", "content": "🚨 比价失败报警 · test"}, "template": "red"},
"body": {"elements": [{"tag": "markdown", "content": "近 15 分钟"}]},
}
feishu_notifier.send_feishu_card("https://open.feishu.cn/hook/zzz", card, timeout=5.0)
assert captured["url"] == "https://open.feishu.cn/hook/zzz"
assert captured["json"]["msg_type"] == "interactive"
assert captured["json"]["card"] is card
def test_send_card_uses_default_timeout(monkeypatch):
"""send_feishu_card 默认 timeout=10.0。"""
captured = {}
def fake_post(url, json, timeout):
captured["timeout"] = timeout
return httpx.Response(200, json={"code": 0, "msg": "success"})
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
feishu_notifier.send_feishu_card("https://hook", {"schema": "2.0"})
assert captured["timeout"] == 10.0
def test_send_card_raises_on_code_nonzero(monkeypatch):
"""send_feishu_card code!=0 时抛 FeishuNotifyError。"""
def fake_post(url, json, timeout):
return httpx.Response(200, json={"code": 19024, "msg": "Key Words Not Found"})
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_card("https://hook", {"schema": "2.0"})
def test_send_card_raises_on_http_error(monkeypatch):
"""send_feishu_card 非 2xx 抛 FeishuNotifyError。"""
def fake_post(url, json, timeout):
return httpx.Response(500, text="boom")
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_card("https://hook", {"schema": "2.0"})
def test_send_card_raises_on_network_error(monkeypatch):
"""send_feishu_card 网络异常抛 FeishuNotifyError。"""
def fake_post(url, json, timeout):
raise httpx.ConnectError("connection refused")
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_card("https://hook", {"schema": "2.0"})
-4
View File
@@ -108,10 +108,6 @@ 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",
-242
View File
@@ -1,242 +0,0 @@
"""trace_stuck 单测:用 tmp 造 step_*.json(只含头部字段)验证卡死判据。"""
import json
from pathlib import Path
from app.services.trace_stuck import (
StuckPoint,
dir_name_from_trace_url,
last_step,
read_stuck_points,
)
def _frame(pdir: Path, idx: int, step: str, page: str) -> None:
"""造一帧 step json:头部放 pipeline_step/detected_page,尾部塞大 windows 模拟真实。"""
pdir.mkdir(parents=True, exist_ok=True)
body = {
"trace_id": "t", "step": idx, "platform": pdir.name,
"pipeline_step": step, "detected_page": page,
"windows": [{"nodes": ["x" * 200]}],
}
(pdir / f"step_{idx:03d}.json").write_text(
json.dumps(body, ensure_ascii=False), encoding="utf-8"
)
def test_stuck_when_tail_repeats_same_step(tmp_path):
pdir = tmp_path / "meituan"
for i in range(20):
_frame(pdir, i, "add_one_dish", "meal_detail_popup")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is True
assert res.points == [StuckPoint("meituan", "add_one_dish", 20, detected_page="meal_detail_popup")]
def test_not_stuck_when_progressing(tmp_path):
pdir = tmp_path / "eleme"
_frame(pdir, 0, "set_address", "home")
for i in range(1, 8):
_frame(pdir, i, "enter_store", "store")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is True
assert res.points == []
def test_adding_many_dishes_not_stuck_when_page_changes(tmp_path):
# add_one_dish 重复但 detected_page 在跳(换菜)=推进,不判卡死
pdir = tmp_path / "meituan"
for i in range(20):
_frame(pdir, i, "add_one_dish", "menu" if i % 2 == 0 else "dish_popup")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.points == []
def test_below_threshold_not_stuck(tmp_path):
pdir = tmp_path / "meituan"
for i in range(10): # < 15
_frame(pdir, i, "add_one_dish", "meal_detail_popup")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.points == []
def test_missing_dir_not_readable(tmp_path):
res = read_stuck_points(tmp_path / "nope", threshold=15, max_tail=40)
assert res.readable is False
assert res.points == []
def test_empty_dir_no_platform_frames_not_readable(tmp_path):
(tmp_path / "emptysub").mkdir()
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is False
def test_per_platform_one_stuck_one_normal(tmp_path):
m = tmp_path / "meituan"
for i in range(18):
_frame(m, i, "add_one_dish", "meal_detail_popup")
e = tmp_path / "eleme"
_frame(e, 0, "set_address", "home")
for i in range(1, 6):
_frame(e, i, "enter_store", "store")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is True
assert res.points == [StuckPoint("meituan", "add_one_dish", 18, detected_page="meal_detail_popup")]
def test_last_step_returns_busiest_platform_last_env(tmp_path):
m = tmp_path / "meituan"
for i in range(20):
_frame(m, i, "add_one_dish", "meal_detail_popup")
e = tmp_path / "eleme"
for i in range(3):
_frame(e, i, "enter_store", "store")
sp = last_step(tmp_path, max_tail=40)
assert sp == StuckPoint("meituan", "add_one_dish", 20, detected_page="meal_detail_popup")
def test_stuck_point_label_includes_page():
# label 带页面(平台/环节中文 + 页面原始英文);无页面时只到环节
assert StuckPoint("meituan", "add_one_dish", 20, detected_page="meal_detail_popup").label() \
== "美团·加菜·meal_detail_popup"
assert StuckPoint("meituan", "add_one_dish", 20).label() == "美团·加菜"
def test_read_stuck_points_returns_last_frame(tmp_path):
# res.last = 帧数最多平台的末帧(带 detected_page),供"退出前在哪屏"用
m = tmp_path / "meituan"
for i in range(6):
_frame(m, i, "enter_store", "store")
_frame(m, 6, "add_one_dish", "menu") # 末帧换到 menu
e = tmp_path / "eleme"
_frame(e, 0, "set_address", "home")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.last == StuckPoint("meituan", "add_one_dish", 7, detected_page="menu")
assert res.points == [] # 没卡死,但末帧照样有
def test_dir_name_from_trace_url():
assert dir_name_from_trace_url("https://x/traces/20260804_1_abc/") == "20260804_1_abc"
assert dir_name_from_trace_url("https://x/traces/20260804_1_abc") == "20260804_1_abc"
assert dir_name_from_trace_url("") is None
assert dir_name_from_trace_url(None) is None
def test_corrupt_frame_does_not_crash(tmp_path):
# 截断的 UTF-8(pricebot 被 SIGTERM 打断的末帧)不应抛 UnicodeDecodeError
pdir = tmp_path / "meituan"
pdir.mkdir()
(pdir / "step_000.json").write_bytes(b'{"pipeline_step": "add\xff')
res = read_stuck_points(tmp_path, threshold=1, max_tail=40)
assert res.points == [] # 抠不出字段 → 不判卡死;关键是没崩溃
def _frame_ts(pdir: Path, idx: int, step: str, page: str, ts: str) -> None:
pdir.mkdir(parents=True, exist_ok=True)
body = {"pipeline_step": step, "detected_page": page, "timestamp": ts,
"windows": [{"n": ["x" * 200]}]}
(pdir / f"step_{idx:03d}.json").write_text(
json.dumps(body, ensure_ascii=False), encoding="utf-8"
)
def test_stuck_ms_computed_from_timestamps(tmp_path):
# 末段 16 帧都卡在 add_one_dish,timestamp 从 :00 到 :30(每帧+2s) → 卡住 30s
pdir = tmp_path / "meituan"
for i in range(16):
_frame_ts(pdir, i, "add_one_dish", "meal_detail_popup",
f"2026-08-04T12:00:{i * 2:02d}.000000")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert len(res.points) == 1
sp = res.points[0]
assert sp.frames == 16
assert sp.stuck_ms == 30000 # (第15帧:30 - 第0帧:00) = 30s
def test_stuck_ms_none_when_timestamp_missing(tmp_path):
# 帧无 timestamp 字段 → stuck_ms 为 None(不报错)
pdir = tmp_path / "meituan"
for i in range(16):
_frame(pdir, i, "add_one_dish", "meal_detail_popup") # 现有 _frame,无 timestamp
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert len(res.points) == 1
assert res.points[0].stuck_ms is None
def test_stuck_ms_none_when_clock_goes_backwards(tmp_path):
# 帧 timestamp 非单调(时钟回退):step_000=:30 ... step_015=:00 → 末帧早于段首 → 负时长 → 降级 None
pdir = tmp_path / "meituan"
for i in range(16):
_frame_ts(pdir, i, "add_one_dish", "meal_detail_popup",
f"2026-08-04T12:00:{(30 - i * 2):02d}.000000")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert len(res.points) == 1
assert res.points[0].stuck_ms is None # 负时长降级为 None
def test_last_step_computes_dwell_ms(tmp_path):
# 帧数最多平台末段 5 帧都在 checkout,ts :00→:08(每帧+2s) → 停留 8s
m = tmp_path / "meituan"
_frame_ts(m, 0, "enter_store", "store", "2026-08-07T12:00:00.000000")
_frame_ts(m, 1, "enter_store", "store", "2026-08-07T12:00:02.000000")
_frame_ts(m, 2, "enter_store", "store", "2026-08-07T12:00:04.000000")
for i in range(3, 8): # step3..7 checkout,末段 5 帧
_frame_ts(m, i, "checkout", "checkout_page",
f"2026-08-07T12:00:{(i - 3) * 2:02d}.000000")
sp = last_step(tmp_path, max_tail=40)
assert sp.platform == "meituan"
assert sp.pipeline_step == "checkout"
assert sp.frames == 8 # 总帧数(非末段)
assert sp.dwell_ms == 8000 # 末段 checkout :00→:08 = 8s
def test_last_step_dwell_none_without_ts(tmp_path):
m = tmp_path / "meituan"
for i in range(5):
_frame(m, i, "checkout", "checkout_page") # 无 timestamp
sp = last_step(tmp_path, max_tail=40)
assert sp.dwell_ms is None
def test_last_step_dwell_zero_single_frame_segment(tmp_path):
# 末帧与前一帧不同屏 → 末段只有末帧 1 帧 → 停留 0(一到就是末屏)
m = tmp_path / "meituan"
for i in range(4):
_frame_ts(m, i, "enter_store", "store", f"2026-08-07T12:00:{i:02d}.000000")
_frame_ts(m, 4, "checkout", "checkout_page", "2026-08-07T12:00:10.000000")
sp = last_step(tmp_path, max_tail=40)
assert sp.pipeline_step == "checkout"
assert sp.dwell_ms == 0
def test_read_stuck_points_last_has_dwell(tmp_path):
# 没卡死(末段<threshold),但末帧末段带 ts → last.dwell_ms 有值
m = tmp_path / "meituan"
_frame_ts(m, 0, "enter_store", "store", "2026-08-07T12:00:00.000000")
_frame_ts(m, 1, "enter_store", "store", "2026-08-07T12:00:02.000000")
for i in range(2, 5): # 末段 checkout 3 帧 :00→:04
_frame_ts(m, i, "checkout", "checkout_page",
f"2026-08-07T12:00:{(i - 2) * 2:02d}.000000")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.points == [] # 末段 3<15,没卡死
assert res.last.pipeline_step == "checkout"
assert res.last.frames == 5 # 总帧数
assert res.last.dwell_ms == 4000 # 末段 :00→:04 = 4s
def test_read_stuck_points_last_falls_through_when_busiest_last_frame_corrupt(tmp_path):
# 最忙平台末帧损坏(抠不出环节)→ _last_segment=None → 整段跳过(重构 continue 分支)
# → last 落到次忙的干净平台。锁定 read_stuck_points 重构的最险等价分支。
m = tmp_path / "meituan"
m.mkdir()
for i in range(7):
_frame(m, i, "add_one_dish", "menu")
(m / "step_007.json").write_bytes(b'{"pipeline_step": "add\xff') # 末帧截断 UTF-8
e = tmp_path / "eleme"
for i in range(3):
_frame(e, i, "enter_store", "store")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.points == [] # 谁都没卡死
assert res.last is not None
assert res.last.platform == "eleme" # 最忙的 meituan(8帧)末帧损坏被跳过,last 落到 eleme
assert res.last.pipeline_step == "enter_store"
+1 -209
View File
@@ -4,7 +4,7 @@
"""
from __future__ import annotations
from sqlalchemy import select, text
import json
from app.core.config import settings
from app.core.rewards import (
@@ -17,9 +17,6 @@ from app.core.rewards import (
)
from app.db.session import SessionLocal
from app.integrations import pangle
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.wallet import CoinTransaction
from app.repositories import ad_feed_reward as crud_feed
from app.repositories import wallet as crud_wallet
from app.repositories.user import get_user_by_phone
@@ -387,208 +384,3 @@ def test_endpoints_require_auth(client) -> None:
assert client.get("/api/v1/savings/summary").status_code == 401
assert client.get("/api/v1/savings/battle").status_code == 401
assert client.get("/api/v1/savings/records").status_code == 401
def test_grant_coins_persists_trace_id(client) -> None:
"""grant_coins 传 trace_id 落库;不传则为 None。"""
phone = "13800002001"
_login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_, txn1 = crud_wallet.grant_coins(
db, user.id, 5, biz_type="feed_ad_reward_comparison",
ref_id="evt1", remark="比价奖励", trace_id="trace-A",
)
_, txn2 = crud_wallet.grant_coins(
db, user.id, 30, biz_type="signin", remark="每日签到奖励",
)
db.commit()
assert txn1.trace_id == "trace-A"
assert txn2.trace_id is None
def test_backfill_coin_trace_id_from_ad_record(client) -> None:
"""回填:coin_transaction(trace_id 空)按 ref_id==client_event_id 从 ad_feed_reward_record 补 trace_id;
只补比价/领券两类,无关类型与无匹配的不动SQL 与迁移 coin_transaction_trace_id 保持同步"""
phone = "13800002002"
_login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
db.add(AdFeedRewardRecord(
client_event_id="evt-cmp", user_id=user.id, reward_date="2026-08-07",
duration_seconds=10, unit_count=1, ecpm_raw="1000",
feed_scene="comparison", trace_id="trace-CMP", coin=5, status="granted",
))
db.add(AdFeedRewardRecord(
client_event_id="evt-cpn", user_id=user.id, reward_date="2026-08-07",
duration_seconds=10, unit_count=1, ecpm_raw="1000",
feed_scene="coupon", trace_id="trace-CPN", coin=7, status="granted",
))
# 广告行存在但 trace_id 为空(2026-07-15 前的老比价广告):回填必须跳过、保持 NULL(EXISTS 守护)。
db.add(AdFeedRewardRecord(
client_event_id="evt-null", user_id=user.id, reward_date="2026-08-07",
duration_seconds=10, unit_count=1, ecpm_raw="1000",
feed_scene="comparison", trace_id=None, coin=5, status="granted",
))
db.commit()
_, c1 = crud_wallet.grant_coins(db, user.id, 5, biz_type="feed_ad_reward_comparison", ref_id="evt-cmp", remark="比价奖励")
_, c2 = crud_wallet.grant_coins(db, user.id, 7, biz_type="feed_ad_reward_coupon", ref_id="evt-cpn", remark="领券奖励")
_, c3 = crud_wallet.grant_coins(db, user.id, 30, biz_type="signin", remark="每日签到奖励")
_, c4 = crud_wallet.grant_coins(db, user.id, 5, biz_type="feed_ad_reward_comparison", ref_id="evt-null", remark="比价奖励")
db.commit()
assert c1.trace_id is None and c2.trace_id is None
db.execute(text(
"""
UPDATE coin_transaction SET trace_id = (
SELECT r.trace_id FROM ad_feed_reward_record r
WHERE r.client_event_id = coin_transaction.ref_id)
WHERE biz_type IN ('feed_ad_reward_comparison', 'feed_ad_reward_coupon')
AND trace_id IS NULL
AND EXISTS (SELECT 1 FROM ad_feed_reward_record r2
WHERE r2.client_event_id = coin_transaction.ref_id
AND r2.trace_id IS NOT NULL)
"""
))
db.commit()
db.refresh(c1)
db.refresh(c2)
db.refresh(c3)
db.refresh(c4)
assert c1.trace_id == "trace-CMP"
assert c2.trace_id == "trace-CPN"
assert c3.trace_id is None # 无关类型不动
assert c4.trace_id is None # 广告行 trace_id 为空 → EXISTS 守护跳过、保持 NULL
def test_grant_feed_reward_sets_coin_trace_id(client) -> None:
"""grant_feed_reward(comparison) 把 trace_id 透传给 grant_coins,coin_transaction 带上本场 trace_id。"""
phone = "13800002003"
_login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
rec = crud_feed.grant_feed_reward(
db, user.id,
client_event_id="evt-fr-1", ecpm="1000", duration_seconds=10,
feed_scene="comparison", trace_id="trace-FR", display_coin=5,
)
assert rec.status == "granted", rec.status
txn = db.execute(
select(CoinTransaction).where(
CoinTransaction.user_id == user.id,
CoinTransaction.ref_id == "evt-fr-1",
)
).scalar_one()
assert txn.biz_type == "feed_ad_reward_comparison"
assert txn.trace_id == "trace-FR"
def _seed_coin(db, user_id, amount, biz_type, *, trace_id=None, ref_id=None, remark=None):
crud_wallet.grant_coins(
db, user_id, amount, biz_type=biz_type, ref_id=ref_id, remark=remark, trace_id=trace_id
)
def test_coin_transactions_aggregate_by_trace(client) -> None:
"""一次比价的多条广告金币聚合成一条:金额合计、merged_count=条数、balance_after 取最后一条。"""
phone = "13800002004"
token = _login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="t1", ref_id="e1", remark="比价奖励")
_seed_coin(db, user.id, 3, "feed_ad_reward_comparison", trace_id="t1", ref_id="e2", remark="比价奖励")
_seed_coin(db, user.id, 4, "feed_ad_reward_comparison", trace_id="t1", ref_id="e3", remark="比价奖励")
db.commit()
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
row = items[0]
assert row["biz_type"] == "feed_ad_reward_comparison"
assert row["amount"] == 12
assert row["merged_count"] == 3
assert row["balance_after"] == 12
def test_coin_transactions_distinct_traces_stay_separate(client) -> None:
"""不同 trace(两次比价 / 一次领券)各成一条;不同会话不合并。"""
phone = "13800002005"
token = _login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="cmpA", ref_id="a1", remark="比价奖励")
_seed_coin(db, user.id, 6, "feed_ad_reward_comparison", trace_id="cmpB", ref_id="b1", remark="比价奖励")
_seed_coin(db, user.id, 6, "feed_ad_reward_comparison", trace_id="cmpB", ref_id="b2", remark="比价奖励")
_seed_coin(db, user.id, 7, "feed_ad_reward_coupon", trace_id="cpnC", ref_id="c1", remark="领券奖励")
db.commit()
items = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)).json()["items"]
assert len(items) == 3
assert sorted(i["amount"] for i in items) == [5, 7, 12]
cpn = next(i for i in items if i["biz_type"] == "feed_ad_reward_coupon")
assert cpn["amount"] == 7 and cpn["merged_count"] == 1
def test_coin_transactions_non_session_rows_stay_per_row(client) -> None:
"""签到 / 无 trace 的通用信息流各自一行,不被聚合;夹在比价广告中间的签到不影响比价聚合。"""
phone = "13800002006"
token = _login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="t1", ref_id="e1", remark="比价奖励")
_seed_coin(db, user.id, 30, "signin", remark="每日签到奖励")
_seed_coin(db, user.id, 4, "feed_ad_reward_comparison", trace_id="t1", ref_id="e2", remark="比价奖励")
_seed_coin(db, user.id, 8, "feed_ad_reward", trace_id=None, ref_id="w1", remark="信息流广告奖励")
db.commit()
items = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)).json()["items"]
assert len(items) == 3
cmp_row = next(i for i in items if i["biz_type"] == "feed_ad_reward_comparison")
assert cmp_row["amount"] == 9 and cmp_row["merged_count"] == 2
signin_row = next(i for i in items if i["biz_type"] == "signin")
assert signin_row["amount"] == 30 and signin_row["merged_count"] == 1
feed_row = next(i for i in items if i["biz_type"] == "feed_ad_reward")
assert feed_row["amount"] == 8 and feed_row["merged_count"] == 1
def test_coin_transactions_pagination_no_phantom_regroup(client) -> None:
"""交错跨游标不产生残组:会话广告成员被其它记录隔开、rep_id 在游标上、成员在游标下时,
翻到下一页该会话不得以残组重复出现(锁死 spec §11 反面优化警示)"""
phone = "13800002007"
token = _login(client, phone)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="t1", ref_id="e1", remark="比价奖励") # id=n+1
_seed_coin(db, user.id, 30, "signin", remark="每日签到奖励") # id=n+2
_seed_coin(db, user.id, 40, "signin", remark="每日签到奖励") # id=n+3
_seed_coin(db, user.id, 4, "feed_ad_reward_comparison", trace_id="t1", ref_id="e2", remark="比价奖励") # id=n+4 = t1 的 rep
db.commit()
t1_ids = db.execute(
select(CoinTransaction.id)
.where(CoinTransaction.user_id == user.id, CoinTransaction.trace_id == "t1")
.order_by(CoinTransaction.id)
).scalars().all()
# 第 1 页(limit=2):按 rep 降序 = [t1(rep=n+4, 合计 9), signin(n+3, 40)]
p1 = client.get("/api/v1/wallet/coin-transactions?limit=2", headers=_auth(token)).json()
assert len(p1["items"]) == 2
assert p1["items"][0]["biz_type"] == "feed_ad_reward_comparison"
assert p1["items"][0]["amount"] == 9 and p1["items"][0]["merged_count"] == 2
assert p1["items"][1]["biz_type"] == "signin" and p1["items"][1]["amount"] == 40
assert p1["next_cursor"] is not None
# 前置条件:游标(第1页末条 rep)必须严格落在 t1 两成员 id 之间,才是真正的「跨游标」场景;
# 否则用例会退化成非跨游标、悄悄测错形状仍通过,失去回归守护意义。
assert t1_ids[0] < p1["next_cursor"] < t1_ids[-1]
# 第 2 页:只剩另一条 signin(30);t1 的 rep 在游标上,成员虽在游标下也不得成残组重复
p2 = client.get(
f"/api/v1/wallet/coin-transactions?limit=2&cursor={p1['next_cursor']}",
headers=_auth(token),
).json()
assert len(p2["items"]) == 1
assert p2["items"][0]["biz_type"] == "signin" and p2["items"][0]["amount"] == 30
assert all(i["biz_type"] != "feed_ad_reward_comparison" for i in p2["items"])
assert p2["next_cursor"] is None
+8 -12
View File
@@ -1,9 +1,9 @@
"""福利页(coin_cash)提现档位规则测试(7-9提现ui对齐)。
规则(2026-07-09 拍板):
- 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20/100(日1次)
- 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20(日1次)
- 计次口径"发起就算":当天创建的单不论最终状态(含被拒)都占名额;新人档任何状态都算用过
- 常规档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次
- 常规档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次
- invite_cash 无档位概念:tiers 为空下单不走档位闸(邀请页行为不变)
wxpay 调用全部 monkeypatch;现金余额 DB 直灌( test_withdraw.py 套路)
"""
@@ -74,18 +74,15 @@ 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 下发 6 档(新人角标齐);invite_cash tiers 为空。"""
"""新用户 coin_cash 下发 5 档(新人角标齐);invite_cash tiers 为空。"""
token = _login(client, "13800006001")
tiers = _tiers(client, token)
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 [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 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") == []
@@ -104,9 +101,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, 10000))
assert all(regular[a]["available"] for a in (50, 1000, 2000))
r = _withdraw(client, token, 30) # 同天 0.3 照提
assert r.status_code == 200, r.text
@@ -118,7 +115,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/100 置灰 other_tier_selected,下单 409;0.5 还能继续提(3 次内)。"""
"""当天提过 0.5 → 10/20 置灰 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)
@@ -132,7 +129,6 @@ 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