Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1a6095670 | |||
| e8c2ebda13 | |||
| 3663b5b11c | |||
| a6b660486f | |||
| 51c08a8de7 | |||
| 43376abae6 | |||
| b2cca9551a | |||
| e40e078691 | |||
| b39d918cda |
@@ -0,0 +1,60 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,50 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""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')
|
||||
@@ -76,30 +76,30 @@ def _device_marketing_name(model: str | None) -> str | None:
|
||||
|
||||
|
||||
def _attach_comparison_order_status(db: Session, items: list[ComparisonRecord]) -> None:
|
||||
"""按 C 端既有口径给比价记录批量补充是否真实下单。"""
|
||||
"""按 C 端既有口径给比价记录批量补充是否真实下单(按 trace_id 精确对齐真实下单上报)。"""
|
||||
user_ids = {item.user_id for item in items if item.user_id is not None}
|
||||
shop_names = {item.store_name for item in items if item.store_name}
|
||||
trace_ids = {item.trace_id for item in items if item.trace_id}
|
||||
ordered_pairs: set[tuple[int, str]] = set()
|
||||
if user_ids and shop_names:
|
||||
if user_ids and trace_ids:
|
||||
rows = db.execute(
|
||||
select(SavingsRecord.user_id, SavingsRecord.shop_name)
|
||||
select(SavingsRecord.user_id, SavingsRecord.trace_id)
|
||||
.where(
|
||||
SavingsRecord.user_id.in_(user_ids),
|
||||
SavingsRecord.source == "compare",
|
||||
SavingsRecord.shop_name.in_(shop_names),
|
||||
SavingsRecord.trace_id.in_(trace_ids),
|
||||
)
|
||||
.distinct()
|
||||
).all()
|
||||
ordered_pairs = {
|
||||
(row.user_id, row.shop_name)
|
||||
(row.user_id, row.trace_id)
|
||||
for row in rows
|
||||
if row.shop_name is not None
|
||||
if row.trace_id is not None
|
||||
}
|
||||
for item in items:
|
||||
item.ordered = bool(
|
||||
item.user_id is not None
|
||||
and item.store_name
|
||||
and (item.user_id, item.store_name) in ordered_pairs
|
||||
and item.trace_id
|
||||
and (item.user_id, item.trace_id) in ordered_pairs
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -376,15 +376,14 @@ def dashboard_overview(
|
||||
.where(
|
||||
SavingsRecord.user_id == ComparisonRecord.user_id,
|
||||
SavingsRecord.source == "compare",
|
||||
SavingsRecord.shop_name.is_not(None),
|
||||
SavingsRecord.shop_name == ComparisonRecord.store_name,
|
||||
SavingsRecord.trace_id.is_not(None),
|
||||
SavingsRecord.trace_id == ComparisonRecord.trace_id,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
period_ordered_count = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.store_name.is_not(None),
|
||||
ordered_exists,
|
||||
)
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ logger = logging.getLogger("shagua.compare_record")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
|
||||
|
||||
COMPARE_DAILY_LIMIT_MESSAGE = "今日比价额度用完啦,明天再来吧~"
|
||||
|
||||
|
||||
@router.post(
|
||||
"/start",
|
||||
@@ -78,11 +80,7 @@ def reserve_compare_start(
|
||||
except crud_compare.DailyCompareStartLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=(
|
||||
f"今日已比价超过{policy.limit}次,请明天再试"
|
||||
if policy.limit is not None
|
||||
else "今日比价次数已达上限,请明天再试"
|
||||
),
|
||||
detail=COMPARE_DAILY_LIMIT_MESSAGE,
|
||||
) from None
|
||||
except crud_compare.ComparisonTraceOwnershipError:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""比价失败报警后台任务:周期扫 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
|
||||
@@ -132,6 +132,26 @@ 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
|
||||
@@ -217,6 +237,22 @@ 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 = ""
|
||||
|
||||
@@ -5,9 +5,14 @@
|
||||
`wallet.daily_auto_exchange`。
|
||||
|
||||
健壮性:
|
||||
- **逐用户幂等**:当天已有 exchange_in 流水的用户跳过(见 wallet._has_exchange_in_on),
|
||||
故启动补跑 / 多次唤醒 / 进程重启都安全,不会重复兑。
|
||||
- **当天首跑即补**:进程起来时若当天还没兑过,立即兑一轮(等价原 timer 的 Persistent 补跑)。
|
||||
- **持久化「当天已兑」标记**(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),做兜底
|
||||
防同机多进程 / 标记写入失败的竞态,不会重复兑。
|
||||
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑(防跨进程并发导致 TOCTOU 双兑)。
|
||||
- **开关**:settings.AUTO_EXCHANGE_ENABLED=false 时不启动(与脚本/原 timer 同一开关)。
|
||||
"""
|
||||
@@ -27,10 +32,14 @@ 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:
|
||||
@@ -72,9 +81,44 @@ def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
|
||||
_LOCK_PATH.unlink()
|
||||
|
||||
|
||||
def _exchange_once() -> dict:
|
||||
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:
|
||||
with SessionLocal() as db:
|
||||
return wallet_repo.daily_auto_exchange(db)
|
||||
return _exchange_if_due(db, today)
|
||||
|
||||
|
||||
async def _run_loop() -> None:
|
||||
@@ -89,16 +133,15 @@ 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()
|
||||
if last_run != today:
|
||||
result = await asyncio.to_thread(_exchange_once)
|
||||
last_run = today
|
||||
# 「今天是否已兑」以持久化标记为准(见 _exchange_if_due),不再用内存变量 →
|
||||
# 进程重启不会把当天当「没跑过」重复补扫;已兑当天返回 None(整轮跳过)。
|
||||
result = await asyncio.to_thread(_exchange_once, today)
|
||||
if result is not None:
|
||||
logger.info("daily auto-exchange done date=%s result=%s", today, result)
|
||||
except SQLAlchemyError:
|
||||
logger.exception("daily auto-exchange db error")
|
||||
|
||||
+12
-1
@@ -26,6 +26,16 @@ 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]
|
||||
@@ -58,7 +68,7 @@ WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
|
||||
# 规则(2026-07-09 拍板):
|
||||
# - 新人档(is_newbie):账号历史一次性,"发起就算用过"(任意状态含被拒),用过即不再下发;
|
||||
# 0.1 与 0.3 各自独立同天可各提一次,且不参与常规档"每日选一个额度"互斥。
|
||||
# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1),三档每天只能选一个。
|
||||
# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1 / 100×1),四档每天只能选一个。
|
||||
# invite_cash(邀请页)本轮无档位概念,不在此表。改档位=改这里发版。
|
||||
class WithdrawTier(NamedTuple):
|
||||
amount_cents: int
|
||||
@@ -74,6 +84,7 @@ WITHDRAW_TIERS_COIN_CASH: tuple[WithdrawTier, ...] = (
|
||||
WithdrawTier(50, "0.5", None, 3, False),
|
||||
WithdrawTier(1000, "10", None, 1, False),
|
||||
WithdrawTier(2000, "20", None, 1, False),
|
||||
WithdrawTier(10000, "100", None, 1, False),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""飞书群自定义机器人发送(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)
|
||||
@@ -45,6 +45,10 @@ 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,
|
||||
@@ -123,6 +127,7 @@ 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:
|
||||
@@ -133,6 +138,7 @@ 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")
|
||||
|
||||
@@ -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(仅 comparison 场景由客户端带上):把这场广告金币归属到对应比价记录,
|
||||
# 比价记录页按 trace_id 聚合本场实发金币显示「比价赚 N 金币」。领券/福利/旧客户端 = NULL。
|
||||
# 本次会话 trace_id:比价一直带;领券自 2026-07-15(客户端 a98cab8)起也带;福利/旧客户端 = NULL。
|
||||
# 用途:比价记录页按 trace_id 聚合「比价赚 N 金币」;金币记录列表把一次比价/领券的多条广告金币聚合成一条。
|
||||
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)
|
||||
|
||||
@@ -49,6 +49,9 @@ 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)
|
||||
@@ -160,6 +163,15 @@ 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 (
|
||||
|
||||
@@ -56,6 +56,9 @@ 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(
|
||||
|
||||
@@ -84,6 +84,9 @@ 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
|
||||
|
||||
@@ -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,
|
||||
remark=reward_remark, trace_id=trace_id,
|
||||
)
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
|
||||
@@ -737,18 +737,18 @@ def harvest_abort(
|
||||
return rec
|
||||
|
||||
|
||||
def _ordered_shop_name_select(user_id: int):
|
||||
"""该用户「真实下单」(source='compare')覆盖到的店名 select,给「已下单」筛选当子查询。
|
||||
def _ordered_trace_id_select(user_id: int):
|
||||
"""该用户「真实下单」(source='compare')覆盖到的比价 trace_id 的 select,给「已下单」筛选当子查询。
|
||||
|
||||
口径与 [_ordered_shop_names] 完全一致,只是时机不同:那边是**拿到本页之后**按 candidates
|
||||
口径与 [_ordered_trace_ids] 完全一致,只是时机不同:那边是**拿到本页之后**按 candidates
|
||||
反查打标;这边是**分页之前**就要过滤,拿不到 candidates,只能整段下推成子查询。
|
||||
没有先捞成集合再展开 IN (...) 字面量 —— 重度用户下单过的店名可能上千,展开会撞 SQLite
|
||||
没有先捞成集合再展开 IN (...) 字面量 —— 重度用户下单过的 trace_id 可能上千,展开会撞 SQLite
|
||||
的绑定变量上限,而且又变回了那个「随下单量线性变慢」的老写法。
|
||||
"""
|
||||
return select(SavingsRecord.shop_name).where(
|
||||
return select(SavingsRecord.trace_id).where(
|
||||
SavingsRecord.user_id == user_id,
|
||||
SavingsRecord.source == "compare",
|
||||
SavingsRecord.shop_name.is_not(None),
|
||||
SavingsRecord.trace_id.is_not(None),
|
||||
)
|
||||
|
||||
|
||||
@@ -760,27 +760,27 @@ def _like_escape(kw: str) -> str:
|
||||
return kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _ordered_shop_names(db: Session, user_id: int, candidates: set[str]) -> set[str]:
|
||||
"""[candidates] 里哪些店名被该用户「真实下单」(source='compare')覆盖过,用来打「已下单」。
|
||||
def _ordered_trace_ids(db: Session, user_id: int, candidates: set[str]) -> set[str]:
|
||||
"""[candidates] 里哪些 trace_id 被该用户「真实下单」(source='compare')覆盖过,用来打「已下单」。
|
||||
|
||||
只认 compare(归因命中后真实上报),demo 演示数据不算。下单上报不带 trace_id,
|
||||
只能按店名对齐——两边店名同源(都来自比价意图识别阶段的门店名 query),精确相等即视为同店。
|
||||
语义=店级:同一家店比价过多次,这些记录会一并标「已下单」。
|
||||
只认 compare(归因命中后真实上报),demo 演示数据不算。下单上报带上本次比价的 trace_id →
|
||||
按 trace_id 精确对齐 comparison_record.trace_id:同一家店比价多次,只有真正下单的那一条会被
|
||||
标「已下单」。没带 trace_id 的下单(历史 / 老客户端)对齐不上任何记录 → 不进「已下单」。
|
||||
|
||||
⚠️ 只查**本页出现过的店名**(candidates ≤ limit 条),不再把该用户全部下单店名捞回内存:
|
||||
老写法随下单量线性增长,重度用户几千行全读一遍只为跟 50 条记录取交集。空集合直接返回
|
||||
⚠️ 只查**本页出现过的 trace_id**(candidates ≤ limit 条),不再把该用户全部下单 trace_id 捞回
|
||||
内存:老写法随下单量线性增长,重度用户几千行全读一遍只为跟 50 条记录取交集。空集合直接返回
|
||||
(避免 IN () 非法)。
|
||||
"""
|
||||
if not candidates:
|
||||
return set()
|
||||
rows = db.execute(
|
||||
select(SavingsRecord.shop_name).where(
|
||||
select(SavingsRecord.trace_id).where(
|
||||
SavingsRecord.user_id == user_id,
|
||||
SavingsRecord.source == "compare",
|
||||
SavingsRecord.shop_name.in_(candidates),
|
||||
SavingsRecord.trace_id.in_(candidates),
|
||||
).distinct()
|
||||
).scalars().all()
|
||||
return {s for s in rows if s}
|
||||
return {t for t in rows if t}
|
||||
|
||||
|
||||
def _ad_coins_by_trace(db: Session, user_id: int, trace_ids: list[str]) -> dict[str, int]:
|
||||
@@ -832,7 +832,7 @@ def list_records(
|
||||
ordered: bool | None = None,
|
||||
keyword: str | None = None,
|
||||
) -> tuple[list[ComparisonRecord], int | None]:
|
||||
"""比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记 + 「看广告赚的金币」(瞬态,不写库)。"""
|
||||
"""比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」标记(按 trace_id 精确对齐真实下单)+ 「看广告赚的金币」(瞬态,不写库)。"""
|
||||
stmt = (
|
||||
select(ComparisonRecord)
|
||||
.where(ComparisonRecord.user_id == user_id)
|
||||
@@ -844,7 +844,7 @@ def list_records(
|
||||
# 分页之后一页里可能一条都不命中,列表看着就是空的/卡住的,得翻很多页才蹦出一条。
|
||||
if ordered:
|
||||
stmt = stmt.where(
|
||||
ComparisonRecord.store_name.in_(_ordered_shop_name_select(user_id))
|
||||
ComparisonRecord.trace_id.in_(_ordered_trace_id_select(user_id))
|
||||
)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
@@ -865,16 +865,16 @@ def list_records(
|
||||
items = list(db.execute(stmt).scalars().all())
|
||||
next_cursor = items[-1].id if len(items) == limit else None
|
||||
|
||||
# 「已下单」标记:本页记录的 store_name 若落在该用户真实下单的店名集合里即 True。
|
||||
# 「已下单」标记:本页记录的 trace_id 若落在该用户真实下单(带 trace_id)的集合里即 True。
|
||||
# ordered / ad_coins_earned 均非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。
|
||||
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)
|
||||
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)
|
||||
# 「本次比价看广告赚的金币」:按本页 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 = bool(it.store_name and it.store_name in ordered_shops)
|
||||
it.ordered = it.trace_id in ordered_traces
|
||||
it.ad_coins_earned = ad_coins.get(it.trace_id, 0)
|
||||
|
||||
return items, next_cursor
|
||||
|
||||
@@ -177,6 +177,7 @@ 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 分支一致)。
|
||||
|
||||
+89
-14
@@ -10,9 +10,10 @@ import logging
|
||||
import re
|
||||
import unicodedata
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy import String, and_, case, cast, func, literal, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -170,6 +171,7 @@ def grant_coins(
|
||||
biz_type: str,
|
||||
ref_id: str | None = None,
|
||||
remark: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
) -> tuple[CoinAccount, CoinTransaction]:
|
||||
"""金币变动入口(正数入账 / 负数出账)。更新余额 + 写流水,不 commit。
|
||||
|
||||
@@ -189,6 +191,7 @@ 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)
|
||||
@@ -257,24 +260,94 @@ 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[CoinTransaction], int | None]:
|
||||
"""金币流水分页(按 id 倒序,游标式)。
|
||||
) -> tuple[list[CoinLedgerRow], int | None]:
|
||||
"""金币流水分页(游标式,按 id 倒序)。
|
||||
|
||||
cursor 为上一页最后一条的 id;返回 (本页列表, next_cursor)。
|
||||
next_cursor 为 None 表示没有下一页。
|
||||
比价/领券两类信息流广告(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)。
|
||||
"""
|
||||
stmt = select(CoinTransaction).where(CoinTransaction.user_id == user_id)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(CoinTransaction.id < cursor)
|
||||
stmt = stmt.order_by(CoinTransaction.id.desc()).limit(limit)
|
||||
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")
|
||||
|
||||
items = list(db.execute(stmt).scalars().all())
|
||||
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
|
||||
|
||||
@@ -735,8 +808,8 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -
|
||||
- 新人档(0.1/0.3):账号历史一次性——进行中(reviewing/pending)或成功打款(success)即视为
|
||||
已用,直接**从返回列表消失**;被拒/转账失败/解绑退回(均已退款、钱没到手)则恢复可提,不永久
|
||||
占用资格。两档各自独立互不影响,不参与"每日选一个额度"互斥。
|
||||
- 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
|
||||
都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。
|
||||
- 常规档(0.5×3 / 10×1 / 20×1 / 100×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
|
||||
都计入,被拒/失败不退当天名额);四档每天只能选一个,选定后其余三档当天 other_tier_selected。
|
||||
- invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。
|
||||
余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)。
|
||||
"""
|
||||
@@ -785,7 +858,7 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -
|
||||
out.append({
|
||||
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
|
||||
"is_newbie": True, "available": True, "disabled_reason": None,
|
||||
"remaining_today": 1,
|
||||
"remaining_today": 1, "daily_limit": t.daily_limit,
|
||||
})
|
||||
continue
|
||||
used = today_counts.get(t.amount_cents, 0)
|
||||
@@ -798,7 +871,9 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -
|
||||
out.append({
|
||||
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
|
||||
"is_newbie": False, "available": available, "disabled_reason": reason,
|
||||
"remaining_today": remaining,
|
||||
# daily_limit 一起下发:客户端要靠它区分「本档能提 3 次」和「本档只能提 1 次」——
|
||||
# 只看 remaining_today 分不出「0.5 已提两次剩 1」与「10 元一次没提剩 1」。
|
||||
"remaining_today": remaining, "daily_limit": t.daily_limit,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ 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="源平台原价(分),省额=原价−实付")
|
||||
|
||||
@@ -30,6 +30,7 @@ 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):
|
||||
@@ -88,6 +89,14 @@ class WithdrawTierOut(BaseModel):
|
||||
description="不可提原因:quota_exhausted(今日次数满) / other_tier_selected(今日已选其他额度)",
|
||||
)
|
||||
remaining_today: int = Field(0, description="今日剩余可提次数")
|
||||
daily_limit: int = Field(
|
||||
1,
|
||||
description=(
|
||||
"该档每日可提次数上限(rewards.WITHDRAW_TIERS_COIN_CASH.daily_limit)。"
|
||||
"客户端据此判「本档可提多次」——只有 daily_limit>1 且 remaining_today<daily_limit"
|
||||
"(= 今天已提过至少一次)才展示「今日还可提N次」角标。"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class WithdrawInfoOut(BaseModel):
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""比价失败报警规则:一条记录 → 命中的 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
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""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 带当前配置阈值(耗时 ms→s、步数);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=red,title 含 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])
|
||||
@@ -0,0 +1,182 @@
|
||||
"""比价卡死定位:读 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
|
||||
@@ -5,9 +5,13 @@
|
||||
|
||||
## 现状(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 并存**(虽幂等不会双兑,纯属多余)。
|
||||
|
||||
@@ -0,0 +1,735 @@
|
||||
# 比价卡死定位报警增强 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: PASS(9 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: PASS(6 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` 全枚举补全(映射不到原样英文,可随线上观察增量补)。
|
||||
@@ -0,0 +1,761 @@
|
||||
# 金币记录按会话汇总比价/领券看广告金币 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 · pytest(SQLite)。
|
||||
|
||||
**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: PASS(Task 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)。✓
|
||||
@@ -0,0 +1,538 @@
|
||||
# 比价报警「末帧停留时长」实现计划
|
||||
|
||||
> **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` 改动文件通过。
|
||||
- 无新增列、无落库、无迁移。
|
||||
@@ -0,0 +1,197 @@
|
||||
# 比价失败报警机制 · 设计文档
|
||||
|
||||
- **日期**: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 602s,max 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:00–08: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` | 扫描间隔,可配 900(15min) |
|
||||
| `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 的 cancelled,SQL 比较 `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 SQL,187MB)。
|
||||
- **本地环境**: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 条)。
|
||||
@@ -0,0 +1,168 @@
|
||||
# 比价「卡死定位」报警增强设计
|
||||
|
||||
- 日期: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 cancelled(trace 优先 → 保底)
|
||||
|
||||
```
|
||||
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 failed(T1/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` 缺卡死平台,且报警用不上逐帧耗时)。
|
||||
- 不做环节黑白名单 / 语义分类(结构判据已够,且需大数据)。
|
||||
@@ -0,0 +1,190 @@
|
||||
# 金币记录:比价/领券看广告金币按会话汇总成一条 设计
|
||||
|
||||
- 日期: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`。
|
||||
|
||||
等价 SQL(SQLite / 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` 补齐(相关子查询 UPDATE,SQLite/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、不做「点开看明细」。
|
||||
@@ -0,0 +1,121 @@
|
||||
# 比价报警「末帧停留时长」增强设计
|
||||
|
||||
- 日期: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()`) | ❌ |
|
||||
| failed(T1/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 UTC)vs 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` 算的段时长是两回事,数据现成、报警用得上)。
|
||||
- 不加列、不落库、不迁移。
|
||||
@@ -31,6 +31,7 @@ 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")
|
||||
|
||||
@@ -906,6 +906,7 @@ 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",
|
||||
),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""比价报警配置默认值与关键词解析。"""
|
||||
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
|
||||
@@ -0,0 +1,62 @@
|
||||
"""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
|
||||
@@ -0,0 +1,598 @@
|
||||
"""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:00–08: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
|
||||
@@ -0,0 +1,46 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""比价报警规则分类:记录 → 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
|
||||
@@ -0,0 +1,213 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""扫描 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 # 未推进,下轮补发
|
||||
@@ -140,7 +140,7 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.json()["detail"] == "今日已比价超过100次,请明天再试"
|
||||
assert response.json()["detail"] == "今日比价额度用完啦,明天再来吧~"
|
||||
with SessionLocal() as db:
|
||||
assert db.scalar(
|
||||
select(func.count(ComparisonRecord.id)).where(
|
||||
|
||||
+122
-31
@@ -223,10 +223,10 @@ def test_stats_compare_count_and_saved(client) -> None:
|
||||
|
||||
|
||||
def test_records_ordered_flag(client) -> None:
|
||||
"""「已下单」店级标记:店名命中该用户 source='compare' 的下单记录才 True。
|
||||
"""「已下单」按 trace_id 标记:下单上报带上本次比价的 trace_id,精确命中该条记录才 True。
|
||||
|
||||
覆盖 list_records 只按**本页店名**反查 savings 的写法(原来是把该用户全部下单店名捞回内存
|
||||
再取交集,随下单量线性变慢)——两种写法结果必须一致,故这里按店名逐条断言。
|
||||
覆盖 list_records 只按**本页 trace_id**反查 savings 的写法(不把该用户全部下单 trace_id 捞回
|
||||
内存再取交集)——两种写法结果必须一致,故这里逐条断言。
|
||||
"""
|
||||
token = _login(client, "13800002010")
|
||||
|
||||
@@ -236,14 +236,15 @@ def test_records_ordered_flag(client) -> None:
|
||||
other["store_name"] = "没下过单的店"
|
||||
client.post("/api/v1/compare/record", json=other, headers=_auth(token))
|
||||
|
||||
# 下单前:两条都不该带「已下单」
|
||||
items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
|
||||
assert {it["store_name"]: it["ordered"] for it in items} == {
|
||||
"海底捞(朝阳店)": False,
|
||||
"没下过单的店": False,
|
||||
# 下单前:两条都不该带「已下单」。按 trace_id 断言本测试自己的两条(不受同库其它用例数据干扰)。
|
||||
flags = {
|
||||
it["trace_id"]: it["ordered"]
|
||||
for it in client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]
|
||||
}
|
||||
assert flags["ord-1"] is False
|
||||
assert flags["ord-2"] is False
|
||||
|
||||
# 对海底捞真实下单一笔(order/report 写 source='compare' 的 savings_record)
|
||||
# 对海底捞那次比价真实下单一笔:上报带上该次比价的 trace_id(order/report 写 source='compare')
|
||||
r = client.post(
|
||||
"/api/v1/order/report",
|
||||
json={
|
||||
@@ -255,23 +256,28 @@ 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
|
||||
|
||||
# 下单后:只有同店名那条翻成 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,
|
||||
# 下单后:只有命中 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"]
|
||||
}
|
||||
assert flags["ord-1"] is True
|
||||
assert flags["ord-2"] is False
|
||||
|
||||
# 别人的下单不该影响本人标记(_ordered_shop_names 按 user_id 过滤)
|
||||
# 别人的下单不该影响本人标记(_ordered_trace_ids 按 user_id 过滤)
|
||||
token_b = _login(client, "13800002011")
|
||||
client.post("/api/v1/compare/record", json=_food_payload("ord-b"), headers=_auth(token_b))
|
||||
items_b = client.get("/api/v1/compare/records", headers=_auth(token_b)).json()["items"]
|
||||
assert [it["ordered"] for it in items_b] == [False]
|
||||
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
|
||||
|
||||
|
||||
def test_records_list_omits_raw_payload(client) -> None:
|
||||
@@ -316,20 +322,24 @@ def test_records_ordered_filter(client) -> None:
|
||||
q["store_name"] = "没下过单的店"
|
||||
client.post("/api/v1/compare/record", json=q, headers=_auth(token))
|
||||
|
||||
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),
|
||||
)
|
||||
# 对这 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
|
||||
|
||||
# 不传 ordered:5 条全出(「全部记录」tab 口径不变)
|
||||
assert len(client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]) == 5
|
||||
@@ -439,3 +449,84 @@ 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"] == []
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,169 @@
|
||||
"""飞书群机器人发送: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"})
|
||||
@@ -0,0 +1,242 @@
|
||||
"""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"
|
||||
+209
-1
@@ -4,7 +4,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.rewards import (
|
||||
@@ -17,6 +17,9 @@ 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
|
||||
|
||||
@@ -384,3 +387,208 @@ 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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""福利页(coin_cash)提现档位规则测试(7-9提现ui对齐)。
|
||||
|
||||
规则(2026-07-09 拍板):
|
||||
- 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20(日1次)。
|
||||
- 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20/100(日1次)。
|
||||
- 计次口径"发起就算":当天创建的单不论最终状态(含被拒)都占名额;新人档任何状态都算用过。
|
||||
- 常规三档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次。
|
||||
- 常规四档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次。
|
||||
- invite_cash 无档位概念:tiers 为空、下单不走档位闸(邀请页行为不变)。
|
||||
wxpay 调用全部 monkeypatch;现金余额 DB 直灌(同 test_withdraw.py 套路)。
|
||||
"""
|
||||
@@ -74,15 +74,18 @@ def _tiers(client, token: str, source: str = "coin_cash") -> list[dict]:
|
||||
|
||||
|
||||
def test_withdraw_info_tiers_full_and_invite_empty(client, monkeypatch) -> None:
|
||||
"""新用户 coin_cash 下发 5 档(新人角标齐);invite_cash tiers 为空。"""
|
||||
"""新用户 coin_cash 下发 6 档(新人角标齐);invite_cash tiers 为空。"""
|
||||
token = _login(client, "13800006001")
|
||||
tiers = _tiers(client, token)
|
||||
assert [t["amount_cents"] for t in tiers] == [10, 30, 50, 1000, 2000]
|
||||
assert [t["label"] for t in tiers] == ["0.1", "0.3", "0.5", "10", "20"]
|
||||
assert [t["amount_cents"] for t in tiers] == [10, 30, 50, 1000, 2000, 10000]
|
||||
assert [t["label"] for t in tiers] == ["0.1", "0.3", "0.5", "10", "20", "100"]
|
||||
assert tiers[0]["badge"] == "新人福利" and tiers[0]["is_newbie"] is True
|
||||
assert tiers[1]["badge"] == "新人福利" and tiers[1]["is_newbie"] is True
|
||||
assert all(t["available"] for t in tiers)
|
||||
assert tiers[2]["remaining_today"] == 3 # 0.5 日 3 次
|
||||
# daily_limit 一起下发:客户端只有在 daily_limit>1 且 remaining_today<daily_limit
|
||||
# (= 今天已提过)时才画「今日还可提N次」角标,光看 remaining_today 分不出单次档没提的情况。
|
||||
assert [t["daily_limit"] for t in tiers] == [1, 1, 3, 1, 1, 1]
|
||||
assert _tiers(client, token, source="invite_cash") == []
|
||||
|
||||
|
||||
@@ -101,9 +104,9 @@ def test_newbie_tiers_independent_and_once_forever(client, monkeypatch) -> None:
|
||||
amounts = [t["amount_cents"] for t in tiers]
|
||||
assert 10 not in amounts # 0.1 消失
|
||||
assert 30 in amounts # 0.3 还在,同天仍可提
|
||||
# 新人档不参与"选一个额度":常规三档全部仍可提
|
||||
# 新人档不参与"选一个额度":常规四档全部仍可提
|
||||
regular = {t["amount_cents"]: t for t in tiers if not t["is_newbie"]}
|
||||
assert all(regular[a]["available"] for a in (50, 1000, 2000))
|
||||
assert all(regular[a]["available"] for a in (50, 1000, 2000, 10000))
|
||||
|
||||
r = _withdraw(client, token, 30) # 同天 0.3 照提
|
||||
assert r.status_code == 200, r.text
|
||||
@@ -115,7 +118,7 @@ def test_newbie_tiers_independent_and_once_forever(client, monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_regular_daily_select_one_tier(client, monkeypatch) -> None:
|
||||
"""当天提过 0.5 → 10/20 置灰 other_tier_selected,下单 409;0.5 还能继续提(3 次内)。"""
|
||||
"""当天提过 0.5 → 10/20/100 置灰 other_tier_selected,下单 409;0.5 还能继续提(3 次内)。"""
|
||||
_patch_userinfo(monkeypatch, "openid_tier_3")
|
||||
token = _login(client, "13800006003")
|
||||
_seed_balances(client, token, "13800006003", cash=10_000)
|
||||
@@ -129,6 +132,7 @@ def test_regular_daily_select_one_tier(client, monkeypatch) -> None:
|
||||
assert tiers[50]["available"] and tiers[50]["remaining_today"] == 2
|
||||
assert not tiers[1000]["available"] and tiers[1000]["disabled_reason"] == "other_tier_selected"
|
||||
assert not tiers[2000]["available"] and tiers[2000]["disabled_reason"] == "other_tier_selected"
|
||||
assert not tiers[10000]["available"] and tiers[10000]["disabled_reason"] == "other_tier_selected"
|
||||
|
||||
r = _withdraw(client, token, 1000) # 选一额度互斥 → 409
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
Reference in New Issue
Block a user