Compare commits

..

7 Commits

Author SHA1 Message Date
guke 67a3c43d04 Merge branch 'main' into feat/aliyun-sms-verify 2026-07-27 21:30:49 +08:00
guke 4efb765c46 杂务:.gitignore 忽略本地 admin 启动脚本 run8771.bat
个人本地调试用(admin server 端口 8771,run.sh 的 Windows 变体),不入库;
精确锚定 /run8771.bat,不影响 run.bat 及 scripts 下的 .bat。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 16:17:23 +08:00
guke 95380d58ae 配置:.env.example 补充创蓝云短信模板ID示例值
账号/密码仍留空,真实凭证只放本地 .env(不入库)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 15:42:43 +08:00
guke dfa3d4f07e 功能:创蓝云智(253)短信 provider(Mode B 自管码,可切换)
新增第三个可切换短信 provider chuanglan,与极光/阿里云并列(SMS_PROVIDER 切换,默认仍
jiguang,未知值回退 jiguang)。创蓝 tpl/send v2 是纯发送网关:本服务生成码、创蓝只下发、
本地内存校验(与极光同 Mode B)。存码/冷却/一次性/防爆破/GC 从极光隔离复制(极光文件不动,
零回归风险),唯一新逻辑是 HMAC-SHA256 签名(password 不上行)+ httpx POST + 错误码映射。
复用现有 httpx + 标准库 hashlib/hmac,无新依赖。

- app/integrations/sms/chuanglan.py: 新 provider
- app/integrations/sms/__init__.py: 分派器改 dict + chuanglan 路由
- app/core/config.py: SMS_PROVIDER 加 chuanglan + CHUANGLAN_SMS_* + chuanglan_sms_configured
- .env.example / docs/integrations/sms.md / docs/integrations/chuanglan/tpl-send.md: 配置与接口调研文档
- docs/superpowers/specs/2026-07-26-chuanglan-sms-verify-design.md: 设计 spec
- tests/test_sms_chuanglan.py(28 例)+ test_sms_dispatch.py 扩 chuanglan 路由

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 22:13:05 +08:00
guke 1e7f6024c7 Merge main into feat/aliyun-sms-verify
冲突仅 app/api/v1/auth.py::sms_login,合并两侧改动:
  - 保留(本分支)阿里云 provider 的 verify_code try/except —— 校验降级抛 SmsError 时透出其状态码(503),不误报 400。
  - 保留(main)风控失败事件 record_behavior_event —— 但仅在「验证码错误」(ok 为 False)时记;
    provider 降级 503 已在 try/except 提前 raise,不计入「验证失败」风控。
wechat_bind_phone_sms 的 try/except 自动合并、无冲突(main 未在该处加风控)。

验证:全量 pytest 552 passed / 8 failed(8 个均为合并前已存在、与本次无关)。
2026-07-26 00:42:31 +08:00
guke 7a2b7cb8ed 功能:阿里云号码认证短信 provider + sms.py 拆为可切换 provider 包
现有 sms.py(极光自管码)升级为 app/integrations/sms/ 包:
  - base   : SmsError + provider 无关的 mock_verify
  - jiguang: 原极光自管码逻辑逐字迁入,行为零改动(git 识别为 sms.py 的 rename)
  - aliyun : 新增阿里云 dypns 号码认证(Mode A:阿里云生成+下发+校验,核验免费)
  - __init__: 按 SMS_PROVIDER 每次调用路由的分派器(默认 jiguang,可秒切回退)

关键决策:
  - Mode A:发码 SendSmsVerifyCode(##code## 占位)、校验 CheckSmsVerifyCode(PASS/UNKNOWN);
    本服务不再存码 → 消除极光路径「内存存码、多 worker 不共享」技术债。
  - 防爆破与极光一致:aliyun 保留 per-phone 失败计数(SMS_MAX_VERIFY_ATTEMPTS),达上限本地作废,
    避免两 provider 行为不同致排查困惑(此为 aliyun 路径唯一本地态)。
  - 校验降级:阿里云接口异常 → verify_code 抛 SmsError(503),auth 两处 try/except 透出 503(非误报 400)。
  - 官方 SDK alibabacloud_dypnsapi20170525;SDK 交互隔离在 _call_send/_call_check(惰性 import + 惰性建
    client),单测 monkeypatch 不触真网络。

测试:test_sms_aliyun(17)+ test_sms_dispatch(3)全绿;test_auth 内部访问 retarget 到 jiguang.*。
配置:SMS_PROVIDER + ALIYUN_SMS_*(见 .env.example);文档 docs/integrations/sms.md + aliyun 接口参考。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 23:51:30 +08:00
guke deb7730773 docs: 阿里云短信验证服务设计spec
Mode A(阿里云托管码) + 可切换 provider(SMS_PROVIDER) + 官方 SDK;
防爆破保留与极光一致的单码失败计数,校验降级返回 503。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 17:58:44 +08:00
11 changed files with 7 additions and 802 deletions
@@ -1,49 +0,0 @@
"""comparison_record.fail_reason (失败卡展示原因)
Revision ID: comparison_record_fail_reason
Revises: user_manual_risk_fields
Create Date: 2026-07-28 12:00:00.000000
失败记录的展示原因:information 具体则=它;笼统则由写路径从 platform_results 捞出的
业务原因;纯系统失败为 None(端侧品牌兜底)。见 repositories.comparison._derive_fail_display。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'comparison_record_fail_reason'
down_revision: Union[str, Sequence[str], None] = 'user_manual_risk_fields'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.add_column(sa.Column('fail_reason', sa.String(length=256), nullable=True))
# 回填老失败记录:information 具体的直接搬过来(笼统/系统失败留 None → 端侧品牌兜底)。
# 新记录由写路径 _derive_fail_display 落库(含 platform_results 救援/补判),不走这条。
# platform_results 只在 raw_payload 里,SQL 里不易解析,故老记录不做救援/补判(可接受:
# 老 mixed/打烊记录回退品牌兜底);具体 information 的老记录本次即可显示真实原因。
op.execute(
"""
UPDATE comparison_record
SET fail_reason = information
WHERE status = 'failed'
AND information IS NOT NULL
AND information <> ''
AND information NOT IN (
'比价过程出错,请稍后重试',
'比价出错',
'比价未完成',
'done 参数缺少可验证的目标平台结果'
)
"""
)
def downgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.drop_column('fail_reason')
+7 -17
View File
@@ -298,31 +298,21 @@ def dashboard_overview(
if period_comparison_success_denominator > 0
else None
)
# “平均节省金额”表示一次可计算的成功比价平均能省多少钱:
# 原平台已经最便宜时 saved_amount_cents=0,也必须进入分母;否则只统计
# “确实省到钱”的记录会系统性抬高大盘指标。源价/最优价缺失导致的 NULL
# 无法判断实际节省额,仍排除在分母之外。
period_saved_calculable_count = _count(
period_saved_positive_count = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents.is_not(None),
ComparisonRecord.saved_amount_cents > 0,
)
period_saved_sum = _sum(
case(
(
ComparisonRecord.saved_amount_cents > 0,
ComparisonRecord.saved_amount_cents,
),
else_=0,
),
period_saved_positive_sum = _sum(
ComparisonRecord.saved_amount_cents,
*period_comparison_conds,
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents.is_not(None),
ComparisonRecord.saved_amount_cents > 0,
)
period_avg_saved_cents = (
round(period_saved_sum / period_saved_calculable_count)
if period_saved_calculable_count
round(period_saved_positive_sum / period_saved_positive_count)
if period_saved_positive_count
else None
)
period_avg_duration_ms = db.execute(
-5
View File
@@ -102,11 +102,6 @@ class ComparisonRecord(Base):
# done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…";
# 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。
information: Mapped[str | None] = mapped_column(String(256), nullable=True)
# 失败卡「原因」行的展示文案(仅 status=failed 时非空):information 具体则=它;笼统则从
# platform_results 捞出的业务原因(打烊/未起送/找不到店或菜/单点不配送);纯系统失败为 None
# → 端侧显示品牌兜底「网络开小差…」。写路径(harvest_done / upsert_record)落库时派生。
# 见 repositories.comparison._derive_fail_display。
fail_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
# ===== 明细(JSON,越详细越好)=====
# 下单菜品 [{name, qty, specs?}]
-88
View File
@@ -52,81 +52,6 @@ def _product_names_from_items(items: list | None) -> str | None:
return joined[:500] or None
# ---- 失败记录的展示文案(记录页失败卡「原因」行)------------------------------
# information 具体就直出;笼统(_GENERIC_INFO)则从 platform_results 捞一条用户可读的业务
# 原因;捞不到 → None(端侧显示品牌兜底「网络开小差…」)。pricebot 把 store_closed /
# no_delivery 漏成了 status=failed,这里按 reason 关键字补判;打烊类 reason 常带一坨脏店名
# (店名+月售+起送+配送…),统一成简短模板。自动化黑话(搜索失败/读价失败/购物车残留/裸
# FAILED…)不给用户看 → 归入品牌兜底。
# pricebot 组不出具体原因时的笼统 information(线上统计的大头),一律走品牌兜底。
_GENERIC_INFO = {
"比价过程出错,请稍后重试",
"比价出错",
"比价未完成",
"done 参数缺少可验证的目标平台结果",
}
# 干净业务结局 status(直接可信),按展示优先级(越靠前越先选)。
_BIZ_STATUS_PRIORITY = (
"below_minimum",
"no_delivery",
"store_closed",
"items_not_found",
"store_not_found",
)
def _store_closed_text(reason: str | None) -> str:
"""打烊/暂停营业/休息类 reason 常带脏店名元数据 → 只留结论,套简短模板。"""
r = reason or ""
if "暂停营业" in r:
state = "暂停营业"
elif "休息" in r:
state = "休息中"
else:
state = "已打烊"
return f"门店{state},无法比价"
def _target_display_reason(platform_results: dict | None) -> str | None:
"""从逐平台结果里挑一条"可展示给用户"的失败原因;挑不到返回 None。
① status 命中干净业务结局集 → 直接采信(打烊套模板,其余用 reason);
② 补判 pricebot 漏成 status=failed 的两类:打烊(套模板)、单点不配送(reason 本身干净);
自动化黑话(搜索失败/读价失败/购物车残留/裸 FAILED…)一律不展示 → None。"""
pr = platform_results or {}
targets = [
v for v in pr.values() if isinstance(v, dict) and not v.get("is_source")
]
for want in _BIZ_STATUS_PRIORITY: # ① 干净 status 优先
for v in targets:
if v.get("status") == want:
if want == "store_closed":
return _store_closed_text(v.get("reason"))
if v.get("reason"):
return v["reason"]
for v in targets: # ② 漏成 failed 的业务结局补判
if v.get("status") != "failed":
continue
reason = (v.get("reason") or "").strip()
if any(k in reason for k in ("打烊", "暂停营业", "休息")):
return _store_closed_text(reason)
if "单点不配送" in reason:
return reason
return None
def _derive_fail_display(
information: str | None, platform_results: dict | None
) -> str | None:
"""失败记录展示文案:information 具体则直出;笼统则从 platform_results 捞/补判;
都拿不到 → None(端侧品牌兜底)。仅在 status=failed 时调用。"""
info = (information or "").strip()
text = info if (info and info not in _GENERIC_INFO) else _target_display_reason(
platform_results
)
return text[:256] if text else None
def _derive(payload: ComparisonRecordIn) -> dict:
"""从上报 payload 派生结构化列(best/saved/is_source_best/status)。"""
results = payload.comparison_results
@@ -180,11 +105,6 @@ def _derive(payload: ComparisonRecordIn) -> dict:
"saved_amount_cents": saved_amount_cents,
"is_source_best": is_source_best,
"status": status,
"fail_reason": (
_derive_fail_display(payload.information, _pr)
if status == "failed"
else None
),
}
@@ -494,19 +414,11 @@ def harvest_done(
行不存在(理论上帧0已建;防御)则新建。"""
results = done_params.get("comparison_results") or []
derived = _derive_from_results(results, done_params.get("platform_results"))
fail_reason = (
_derive_fail_display(
done_params.get("information"), done_params.get("platform_results")
)
if derived["status"] == "failed"
else None
)
# 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
items = next((r.get("items") or [] for r in results if r.get("is_source")), [])
fields = dict(
business_type=business_type or "food",
information=done_params.get("information") or None,
fail_reason=fail_reason,
# best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补;
# 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。
trace_url=trace_url or done_params.get("trace_url"),
-2
View File
@@ -173,8 +173,6 @@ class ComparisonRecordOut(BaseModel):
skipped_dish_count: int | None = None
status: str
information: str | None = None
# 失败卡「原因」文案:具体失败给具体原因,纯系统失败为 None(端侧品牌兜底)。见模型 fail_reason。
fail_reason: str | None = None
items: list = []
comparison_results: list = []
skipped_dish_names: list = []
-201
View File
@@ -1,201 +0,0 @@
"""分批修正历史比价记录的最优平台与节省金额派生列。
该回填刻意不放进 Alembic:生产启动迁移不应一次性读取并更新全部历史记录。
调用方按主键游标分批执行,每批独立事务;默认 dry-run 时只计算差异、不写库。
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.comparison import ComparisonRecord
_DERIVED_FIELDS = (
"source_price_cents",
"best_platform_id",
"best_platform_name",
"best_price_cents",
"saved_amount_cents",
"is_source_best",
)
@dataclass(frozen=True)
class SavingsBackfillBatch:
"""一批回填结果;last_id 可直接作为下一批 after_id。"""
scanned: int
changed: int
last_id: int
done: bool
def _json_value(value: Any, fallback: Any) -> Any:
if isinstance(value, str):
try:
return json.loads(value)
except (TypeError, ValueError):
return fallback
return value
def _decimal(value: Any) -> Decimal | None:
if value is None or isinstance(value, bool):
return None
try:
return Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return None
def _yuan_to_cents(value: Any) -> int | None:
amount = _decimal(value)
if amount is None:
return None
return int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def _rank(value: Any) -> Decimal:
rank = _decimal(value)
return rank if rank is not None else Decimal("1e18")
def derive_historical_savings(
source_price_cents: int | None,
comparison_results: Any,
raw_payload: Any,
) -> dict[str, Any] | None:
"""按当前完整购物篮规则,从历史原始数据重新派生结构化金额字段。"""
results = _json_value(comparison_results, [])
raw = _json_value(raw_payload, {})
if not isinstance(results, list):
return None
platform_results = raw.get("platform_results", {}) if isinstance(raw, dict) else {}
if not isinstance(platform_results, dict):
platform_results = {}
clean: list[tuple[dict[str, Any], Decimal]] = []
for result in results:
if not isinstance(result, dict):
continue
price = _decimal(result.get("price"))
if price is None:
continue
platform_id = result.get("platform_id")
platform_info = platform_results.get(platform_id) if platform_id else None
skipped_count = (
_decimal(platform_info.get("skipped_dish_count"))
if isinstance(platform_info, dict)
else None
)
if skipped_count is not None and skipped_count > 0:
continue
clean.append((result, price))
if not clean:
return None
best, best_price = min(
clean,
key=lambda item: (_rank(item[0].get("rank")), item[1]),
)
if source_price_cents is None:
source = next(
(
result
for result in results
if isinstance(result, dict)
and result.get("is_source")
and _decimal(result.get("price")) is not None
),
None,
)
source_price_cents = _yuan_to_cents(source.get("price")) if source else None
best_price_cents = _yuan_to_cents(best_price)
saved_amount_cents = (
source_price_cents - best_price_cents
if source_price_cents is not None and best_price_cents is not None
else None
)
return {
"source_price_cents": source_price_cents,
"best_platform_id": best.get("platform_id"),
"best_platform_name": best.get("platform_name"),
"best_price_cents": best_price_cents,
"saved_amount_cents": saved_amount_cents,
"is_source_best": bool(best.get("is_source")),
}
def latest_success_id(db: Session) -> int:
"""固定本次任务的扫描上界,避免执行期间新增记录让任务无限延长。"""
return int(
db.execute(
select(func.coalesce(func.max(ComparisonRecord.id), 0)).where(
ComparisonRecord.status == "success"
)
).scalar_one()
)
def repair_comparison_savings_batch(
db: Session,
*,
after_id: int,
max_id: int,
batch_size: int = 500,
apply: bool = False,
) -> SavingsBackfillBatch:
"""按 id 游标处理一批;apply=True 时仅更新实际发生变化的记录并提交。"""
if after_id < 0 or max_id < 0:
raise ValueError("after_id and max_id must be non-negative")
if batch_size <= 0:
raise ValueError("batch_size must be positive")
records = list(
db.scalars(
select(ComparisonRecord)
.where(
ComparisonRecord.status == "success",
ComparisonRecord.id > after_id,
ComparisonRecord.id <= max_id,
)
.order_by(ComparisonRecord.id)
.limit(batch_size)
)
)
changed = 0
for record in records:
derived = derive_historical_savings(
record.source_price_cents,
record.comparison_results,
record.raw_payload,
)
if derived is None:
continue
differs = any(
getattr(record, field) != derived[field] for field in _DERIVED_FIELDS
)
if not differs:
continue
changed += 1
if apply:
for field in _DERIVED_FIELDS:
setattr(record, field, derived[field])
if apply:
db.commit()
last_id = records[-1].id if records else after_id
return SavingsBackfillBatch(
scanned=len(records),
changed=changed,
last_id=last_id,
done=not records or len(records) < batch_size or last_id >= max_id,
)
-103
View File
@@ -1,103 +0,0 @@
"""分批回填历史比价节省金额。
默认只预览,不写库:
python -m scripts.backfill_comparison_savings
确认预览后实际执行:
python -m scripts.backfill_comparison_savings --apply
中断后可从日志最后一个 last_id 继续:
python -m scripts.backfill_comparison_savings --apply --after-id 12345
"""
from __future__ import annotations
import argparse
from app.db.session import SessionLocal
from app.services.comparison_savings_backfill import (
latest_success_id,
repair_comparison_savings_batch,
)
def _positive_int(value: str) -> int:
parsed = int(value)
if parsed <= 0:
raise argparse.ArgumentTypeError("必须为正整数")
return parsed
def _non_negative_int(value: str) -> int:
parsed = int(value)
if parsed < 0:
raise argparse.ArgumentTypeError("不能为负数")
return parsed
def main() -> None:
parser = argparse.ArgumentParser(
description="按完整购物篮口径分批回填历史比价节省金额(默认 dry-run)"
)
parser.add_argument("--apply", action="store_true", help="实际写库;默认仅预览")
parser.add_argument("--batch-size", type=_positive_int, default=500)
parser.add_argument(
"--after-id",
type=_non_negative_int,
default=0,
help="只处理大于该主键的记录,用于断点续跑",
)
parser.add_argument(
"--max-id",
type=_non_negative_int,
help="固定扫描上界;不传则启动时取成功记录最大主键",
)
parser.add_argument(
"--max-batches",
type=_positive_int,
help="本次最多处理多少批,便于灰度限量执行",
)
args = parser.parse_args()
with SessionLocal() as db:
max_id = args.max_id if args.max_id is not None else latest_success_id(db)
mode = "APPLY" if args.apply else "DRY-RUN"
after_id = args.after_id
scanned = 0
changed = 0
batches = 0
print(
f"comparison savings backfill mode={mode} "
f"after_id={after_id} max_id={max_id} batch_size={args.batch_size}"
)
while after_id < max_id:
with SessionLocal() as db:
result = repair_comparison_savings_batch(
db,
after_id=after_id,
max_id=max_id,
batch_size=args.batch_size,
apply=args.apply,
)
batches += 1
scanned += result.scanned
changed += result.changed
after_id = result.last_id
print(
f"batch={batches} scanned={result.scanned} changed={result.changed} "
f"last_id={result.last_id} done={result.done}"
)
if result.done or (
args.max_batches is not None and batches >= args.max_batches
):
break
print(
f"completed mode={mode} batches={batches} scanned={scanned} "
f"changed={changed} last_id={after_id} max_id={max_id}"
)
if __name__ == "__main__":
main()
-49
View File
@@ -120,55 +120,6 @@ def test_dashboard_period_comparison_is_aggregated_by_backend(
assert comparison["token_cost_total_yuan"] == pytest.approx(1.0)
def test_dashboard_average_saved_includes_zero_and_excludes_uncalculable(
admin_client: TestClient, admin_token: str
) -> None:
created_at = datetime(2037, 2, 15, 12)
db = SessionLocal()
try:
db.add_all(
[
ComparisonRecord(
trace_id="dashboard-saved-positive",
status="success",
saved_amount_cents=100,
created_at=created_at,
),
ComparisonRecord(
trace_id="dashboard-saved-source-best",
status="success",
saved_amount_cents=0,
created_at=created_at,
),
ComparisonRecord(
trace_id="dashboard-saved-uncalculable",
status="success",
saved_amount_cents=None,
created_at=created_at,
),
ComparisonRecord(
trace_id="dashboard-saved-failed",
status="failed",
saved_amount_cents=900,
created_at=created_at,
),
]
)
db.commit()
finally:
db.close()
response = admin_client.get(
"/admin/api/stats/overview",
params={"date_from": "2037-02-15", "date_to": "2037-02-15"},
headers=_auth(admin_token),
)
assert response.status_code == 200, response.text
# (100 + 0) / 2NULL 无法计算,failed 不属于成功比价。
assert response.json()["period"]["comparison"]["average_saved_cents"] == 50
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
uid = _seed_user_with_data("13800000002")
r = admin_client.get("/admin/api/users", headers=_auth(admin_token))
-30
View File
@@ -102,7 +102,6 @@ def test_harvest_done_derives_and_newly_success_once(client) -> None:
assert rec.is_source_best is False
assert rec.store_name == "测试店"
assert rec.information == "美团更便宜"
assert rec.fail_reason is None # 成功记录不派生失败原因
assert rec.items == [{"name": "肥牛饭", "qty": 1}]
assert rec.trace_url.endswith("/done/")
# 再来一次(重试 done)→ 已 success,newly_success=False(发奖不重复触发)
@@ -111,35 +110,6 @@ def test_harvest_done_derives_and_newly_success_once(client) -> None:
assert newly2 is False
def test_harvest_done_failed_derives_fail_reason(client) -> None:
"""failed 记录:记录级 information 笼统,但 fail_reason 从 platform_results 救出具体原因
(id 3030 型:美团系统失败 + 京东 items_not_found → 展示京东那条)。"""
tid = _tid()
done_failed = {
"comparison_results": [
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购",
"package": "com.taobao.taobao", "price": 23.04, "is_source": True, "rank": 1,
"items": [{"name": "肥牛饭", "qty": 1}]},
],
"platform_results": {
"taobao_flash": {"is_source": True, "status": "source", "price": 23.04},
"meituan_waimai": {"is_source": False, "status": "failed",
"reason": "搜索店铺失败, 无法跳转到搜索页"},
"jd_waimai_standalone": {"is_source": False, "status": "items_not_found",
"reason": "京东外卖此店内未找到这些菜品"},
},
"information": "比价过程出错,请稍后重试",
}
with SessionLocal() as db:
crud.harvest_running(db, trace_id=tid, user_id=None)
rec, newly = crud.harvest_done(db, trace_id=tid, user_id=None,
done_params=done_failed)
assert newly is False # 没落成 success
assert rec.status == "failed"
assert rec.fail_reason == "京东外卖此店内未找到这些菜品"
assert rec.information == "比价过程出错,请稍后重试" # 原文案仍留存
def test_harvest_abort_cancels_running(client) -> None:
tid = _tid()
with SessionLocal() as db:
-136
View File
@@ -1,136 +0,0 @@
from __future__ import annotations
from datetime import datetime
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.services.comparison_savings_backfill import (
latest_success_id,
repair_comparison_savings_batch,
)
def _record(trace_id: str, *, status: str = "success") -> ComparisonRecord:
return ComparisonRecord(
trace_id=trace_id,
status=status,
source_price_cents=4200,
best_platform_id="missing-dishes",
best_platform_name="Incomplete",
best_price_cents=2500,
saved_amount_cents=1700,
is_source_best=False,
comparison_results=[
{
"platform_id": "source",
"platform_name": "Source",
"price": 42,
"rank": 3,
"is_source": True,
},
{
"platform_id": "missing-dishes",
"platform_name": "Incomplete",
"price": 25,
"rank": 1,
"is_source": False,
},
{
"platform_id": "complete",
"platform_name": "Complete",
"price": 38.5,
"rank": 2,
"is_source": False,
},
],
raw_payload={
"platform_results": {
"missing-dishes": {"skipped_dish_count": 1},
"complete": {"skipped_dish_count": 0},
}
},
created_at=datetime(2037, 3, 1, 12),
)
def test_comparison_savings_backfill_is_batched_dry_run_and_idempotent() -> None:
db = SessionLocal()
try:
affected = _record("savings-backfill-affected")
unchanged = _record("savings-backfill-unchanged")
unchanged.best_platform_id = "complete"
unchanged.best_platform_name = "Complete"
unchanged.best_price_cents = 3850
unchanged.saved_amount_cents = 350
failed = _record("savings-backfill-failed", status="failed")
db.add_all([affected, unchanged, failed])
db.commit()
affected_id = affected.id
unchanged_id = unchanged.id
failed_id = failed.id
finally:
db.close()
db = SessionLocal()
try:
assert latest_success_id(db) >= unchanged_id
max_id = unchanged_id
affected = db.get(ComparisonRecord, affected_id)
assert affected is not None
preview = repair_comparison_savings_batch(
db,
after_id=affected_id - 1,
max_id=max_id,
batch_size=1,
)
assert preview.scanned == 1
assert preview.changed == 1
assert preview.last_id == affected_id
assert not preview.done
db.refresh(affected)
assert affected.best_platform_id == "missing-dishes"
assert affected.saved_amount_cents == 1700
applied = repair_comparison_savings_batch(
db,
after_id=affected_id - 1,
max_id=max_id,
batch_size=1,
apply=True,
)
assert applied.changed == 1
db.refresh(affected)
assert affected.best_platform_id == "complete"
assert affected.best_platform_name == "Complete"
assert affected.best_price_cents == 3850
assert affected.saved_amount_cents == 350
second_batch = repair_comparison_savings_batch(
db,
after_id=applied.last_id,
max_id=max_id,
batch_size=1,
apply=True,
)
assert second_batch.scanned == 1
assert second_batch.changed == 0
assert second_batch.last_id == unchanged_id
assert second_batch.done
rerun = repair_comparison_savings_batch(
db,
after_id=affected_id - 1,
max_id=max_id,
batch_size=10,
apply=True,
)
assert rerun.scanned == 2
assert rerun.changed == 0
failed_row = db.get(ComparisonRecord, failed_id)
assert failed_row is not None
assert failed_row.best_platform_id == "missing-dishes"
assert failed_row.saved_amount_cents == 1700
finally:
db.close()
-122
View File
@@ -1,122 +0,0 @@
"""失败卡展示原因派生(repositories.comparison._derive_fail_display)单元测试。
用例取自线上真实 failed 记录(platform_results 形态),覆盖:
- information 具体 → 直出
- information 笼统 + platform_results 有干净业务结局 → 救援出该原因(id 3030/2964 型)
- information 笼统 + 仅系统失败(搜索失败等黑话)→ None(端侧品牌兜底,id 3027 型)
- store_closed / no_delivery 被 pricebot 漏成 status=failed → 按 reason 关键字补判
- 打烊类脏店名 blob → 统一简短模板
- platform_results 为空 / 非对象 → None
"""
from __future__ import annotations
from app.repositories import comparison as crud
def test_specific_information_passthrough() -> None:
# information 本身具体(未达起送/找不到菜等)→ 直出,不看 platform_results
assert (
crud._derive_fail_display("淘宝闪购未达起送门槛,可加菜凑单后下单", {})
== "淘宝闪购未达起送门槛,可加菜凑单后下单"
)
assert crud._derive_fail_display("未识别到商品", {}) == "未识别到商品"
def test_generic_info_rescued_from_items_not_found() -> None:
# id 3030 型:美团系统失败 + 京东 items_not_found,记录级 information 笼统 → 救出京东那条
pr = {
"eleme": {"is_source": True, "status": "source", "price": 23.04},
"meituan_waimai": {
"is_source": False, "status": "failed",
"reason": "搜索店铺失败, 无法跳转到搜索页",
},
"jd_waimai_standalone": {
"is_source": False, "status": "items_not_found",
"reason": "京东外卖此店内未找到这些菜品",
},
}
assert (
crud._derive_fail_display("比价过程出错,请稍后重试", pr)
== "京东外卖此店内未找到这些菜品"
)
def test_generic_info_rescued_from_store_not_found() -> None:
# id 2964 型:美团系统失败 + 京东 store_not_found → 救出京东相似店铺文案
pr = {
"taobao_flash": {"is_source": True, "status": "source", "price": 127.98},
"meituan": {
"is_source": False, "status": "failed",
"reason": "比价过程出错,请稍后重试",
},
"jd_waimai": {
"is_source": False, "status": "store_not_found",
"reason": "未在京东找到「黔珍味·贵州牛肉蘸水健康菜 (望京店)」相似店铺",
},
}
assert (
crud._derive_fail_display("比价过程出错,请稍后重试", pr)
== "未在京东找到「黔珍味·贵州牛肉蘸水健康菜 (望京店)」相似店铺"
)
def test_generic_info_pure_system_failure_returns_none() -> None:
# id 3027 型:唯一目标平台是自动化黑话失败 → 不给用户看 → None(端侧品牌兜底)
pr = {
"eleme": {"is_source": True, "status": "source", "price": 18.83},
"meituan_waimai": {
"is_source": False, "status": "failed",
"reason": "搜索店铺失败, 无法跳转到搜索页",
},
}
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) is None
def test_store_closed_leaked_to_failed_is_rescued_and_cleaned() -> None:
# 打烊被漏成 status=failed;reason 常带脏店名 blob → 统一简短模板
pr = {
"jd_waimai": {"is_source": True, "status": "source", "price": 25},
"taobao_flash": {
"is_source": False, "status": "failed",
"reason": "淘宝闪购「沙胆彪炭炉牛杂煲(...),蜂鸟准时达,月售300+,起送¥20」本店已休息,无法比价",
},
}
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) == "门店休息中,无法比价"
pr2 = {
"taobao_flash": {"is_source": True, "status": "source", "price": 31.83},
"meituan": {
"is_source": False, "status": "failed",
"reason": "美团「奈雪的茶(北京王府井奥莱·香江」门店已打烊,无法比价",
},
}
assert crud._derive_fail_display("比价出错", pr2) == "门店已打烊,无法比价"
def test_no_delivery_leaked_to_failed_is_rescued() -> None:
# 单点不配送被漏成 status=failed;reason 本身干净 → 直接用
reason = "京东外卖该商家所选商品单点不配送,无法进入结算比价"
pr = {
"taobao_flash": {"is_source": True, "status": "source", "price": 20.1},
"jd_waimai_standalone": {
"is_source": False, "status": "failed", "reason": reason,
},
}
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) == reason
def test_empty_or_missing_platform_results_returns_none() -> None:
# 「比价出错」+ 空 {} / None / 非对象:引擎早夭,无可展示原因 → None
assert crud._derive_fail_display("比价出错", {}) is None
assert crud._derive_fail_display("比价过程出错,请稍后重试", None) is None
assert crud._derive_fail_display("比价出错", []) is None # 老 array 形态,防御
def test_clean_status_wins_over_priority_order() -> None:
# 多个业务结局同现时按 _BIZ_STATUS_PRIORITY 选(below_minimum 优先于 store_not_found)
pr = {
"src": {"is_source": True, "status": "source", "price": 30},
"a": {"is_source": False, "status": "store_not_found", "reason": "未找到店铺A"},
"b": {"is_source": False, "status": "below_minimum", "reason": "B未达起送门槛"},
}
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) == "B未达起送门槛"