Compare commits

..

2 Commits

Author SHA1 Message Date
guke 7c241b4772 比价记录失败卡展示具体原因(新增 fail_reason)
失败记录不再一律「网络开小差」:新增记录级 fail_reason 派生列——information
具体则直出,笼统则从 platform_results 救出业务原因(找不到店/菜、未起送、打烊、
单点不配送等),纯系统失败为 None → 端侧品牌兜底。store_closed/no_delivery 被
pricebot 漏成 status=failed 的按 reason 补判,打烊脏店名统一简短模板。接入
harvest_done 与灰度期 upsert_record 两条写路径。

- models: comparison_record.fail_reason 列
- repositories: _derive_fail_display + 补判/清洗 helper,两条写路径接入
- schemas: ComparisonRecordOut 暴露 fail_reason
- alembic: 加列 + 回填老 specific 失败记录
- tests: _derive_fail_display 单测(8 例)+ harvest 失败落库集成测试

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 13:13:14 +08:00
guke ed76820e97 feat(sms): 短信验证码可切换多 provider(极光/阿里云/创蓝),默认极光零改动 (#188)
读完了两份 spec + 分派器,这分支的全貌清晰了。下面是可直接粘进 Gitea PR 的 MR 说明(标题 + 正文):

建议标题

feat(sms): 短信验证码可切换多 provider(极光/阿里云/创蓝),默认极光零改动
MR 正文(复制以下 markdown):

## 概述
把原单文件 `app/integrations/sms.py` 重构为**可切换 provider 包** `app/integrations/sms/`,在保留极光(默认、行为零改动)的基础上,新增两家验证码短信 provider:
- **阿里云号码认证 dypns**(Mode A:阿里云生成/存储/校验验证码,核验免费)
- **创蓝云智 253**(Mode B:本服务自管码,httpx 直连 + HMAC 签名)

Provider 由 `SMS_PROVIDER` 按调用实时选择,默认 `jiguang`。短信=花钱 + 登录关键路径,故新 provider **opt-in、可灰度、秒级回退**,默认路径零变更。

## 为什么
现有极光路径本地内存存码(多 worker 不共享,已是技术债),且单一供应商无法灰度/切换。引入 provider 抽象后:阿里云托管码可消除存码债,创蓝作为备选降低单点依赖,三家随配置切换与回退。

## 改动内容

**架构(`app/integrations/sms/`)**
| 文件 | 说明 |
|---|---|
| `__init__.py` | 对外仍暴露 `send_code/verify_code/SmsError`(auth 导入不变);按 `SMS_PROVIDER` **每次调用**分派;未知值回退 `jiguang` |
| `base.py` | `SmsError`(status_code→HTTP) + provider 无关的 `mock_verify` |
| `jiguang.py` | 原 `sms.py` 逻辑**原样迁入**,行为零改动(git 识别为 rename) |
| `aliyun.py` | 新增,Mode A:`SendSmsVerifyCode` + `CheckSmsVerifyCode`,惰性加载 SDK |
| `chuanglan.py` | 新增,Mode B:自管码 + `tpl/send` + HMAC 签名 |

**两种验证码模式**
- Mode A(阿里云):不本地存码,阿里云 `##code##` 托管生成+校验;本地仅留 per-phone 失败计数防爆破。
- Mode B(极光/创蓝):`secrets` 生成 N 位 → 进程内存 → 供应商只下发;本地一次性校验 + 失败 N 次作废。创蓝**复制**极光存码机器(不重构极光,零回归风险)。

**配置(`config.py` + `.env.example`)**
- `SMS_PROVIDER = jiguang | aliyun | chuanglan`(默认 jiguang)
- `ALIYUN_SMS_*`(AK/签名/模板/方案名/时长…) + `aliyun_sms_configured` 门控
- `CHUANGLAN_SMS_*`(账号/密码/模板/签名/endpoint…) + `chuanglan_sms_configured` 门控
- 复用现有 `SMS_MOCK / SMS_CODE_LENGTH / SMS_CODE_TTL_SEC / SMS_SEND_INTERVAL_SEC / SMS_MAX_VERIFY_ATTEMPTS`
- 切到某 provider 却未配齐 → `send_code` 抛 `SmsError(503)`,不静默

**auth.py(最小改动)**
- `verify_code` 现在可能抛 `SmsError`(阿里云降级 503)→ `sms_login`、`wechat_bind_phone_sms` 两处各包 `try/except SmsError → HTTPException`,与 `send_code` 现有写法一致。

**依赖**
- `pyproject.toml` 增 `alibabacloud_dypnsapi20170525`(仅阿里云 provider 惰性 import;jiguang/chuanglan 不加载)。创蓝零新依赖(httpx + 标准库)。

**测试**
- 新增 `test_sms_aliyun.py` / `test_sms_chuanglan.py`(均 monkeypatch 网络接缝,不发真短信) + `test_sms_dispatch.py`(分派/回退)。
- `test_auth.py` 相应更新。
- 现有测试走 `SMS_MOCK=true` 在分派层短路,不受影响。

**文档**
- 设计 spec:`docs/superpowers/specs/2026-07-25-aliyun-sms-verify-design.md`、`2026-07-26-chuanglan-sms-verify-design.md`
- 接口调研:`docs/integrations/aliyun/*`、`docs/integrations/chuanglan/tpl-send.md`、`docs/integrations/sms.md`

## 兼容性 & 回退
- **默认 `SMS_PROVIDER=jiguang`,线上行为与现状完全一致**;不改极光逻辑、不动 API 层频控与测试账号短路。
- 切阿里云/创蓝仅改环境变量,出问题秒切回极光;未知 `SMS_PROVIDER` 一律回退极光,防误配打挂登录。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #188
2026-07-28 09:20:57 +08:00
6 changed files with 296 additions and 0 deletions
@@ -0,0 +1,49 @@
"""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')
+5
View File
@@ -102,6 +102,11 @@ 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,6 +52,81 @@ 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
@@ -105,6 +180,11 @@ 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
),
}
@@ -414,11 +494,19 @@ 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,6 +173,8 @@ 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 = []
+30
View File
@@ -102,6 +102,7 @@ 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(发奖不重复触发)
@@ -110,6 +111,35 @@ 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:
+122
View File
@@ -0,0 +1,122 @@
"""失败卡展示原因派生(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未达起送门槛"