Compare commits

...

5 Commits

Author SHA1 Message Date
unknown e85abe07eb 修复:补齐中途退出比价的LLM成本回填 2026-07-28 14:47:28 +08:00
guke 50da718e35 比价记录失败卡展示具体原因(新增 fail_reason) (#189)
失败记录不再一律「网络开小差」:新增记录级 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>

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #189
2026-07-28 14:04:36 +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
guke f05dd1cf74 docs(applog): 客户端运行日志批量上报→落文件→SLS 采集 设计(spec) (#187)
Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #187
2026-07-27 18:00:23 +08:00
linkeyu b5962464e8 为比价记录补充是否下单状态 (#184)
## 改动说明
- 后台比价记录列表与详情增加 ordered 字段
- 按当前页批量查询真实下单记录,避免逐行查询
- 判定口径与 C 端一致:同一用户、同一店铺且 source=compare
- demo 数据不计为真实下单
- 增加列表和详情接口回归测试

## 验证
- tests/test_admin_read.py:19 项通过

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #184
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-27 17:33:09 +08:00
43 changed files with 3931 additions and 176 deletions
+40 -78
View File
@@ -27,66 +27,44 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem
JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
JG_REQUEST_TIMEOUT_SEC=15
# ===== 厂商直推(无障碍保护存活告警 + 消息中心 13 类通知)=====
# 敏感密钥只放 .env / 服务器环境变量,不要提交到 git。
# 各厂商配置状态可随时 GET /api/v1/push/vendors 查看(缺哪些键一目了然)。
ANDROID_PACKAGE_NAME=com.jishisongfu.shaguabijia
PUSH_REQUEST_TIMEOUT_SEC=15
PUSH_TIME_TO_LIVE_SEC=86400
HONOR_PUSH_APP_ID=
HONOR_PUSH_CLIENT_ID=
HONOR_PUSH_CLIENT_SECRET=
HONOR_PUSH_TOKEN_ENDPOINT=https://iam.developer.honor.com/auth/token
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage
# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,AppId + AppSecret
HUAWEI_PUSH_APP_ID=
HUAWEI_PUSH_APP_SECRET=
HUAWEI_PUSH_TOKEN_ENDPOINT=https://oauth-login.cloud.huawei.com/oauth2/v3/token
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.huawei.com/v1/{app_id}/messages:send
VIVO_PUSH_APP_ID=
VIVO_PUSH_APP_KEY=
VIVO_PUSH_APP_SECRET=
VIVO_PUSH_AUTH_ENDPOINT=https://api-push.vivo.com.cn/message/auth
VIVO_PUSH_SEND_ENDPOINT=https://api-push.vivo.com.cn/message/send
# vivo 未上架测试时可用 push_mode=1; 上架正式推送改为 0。
VIVO_PUSH_MODE=1
VIVO_PUSH_NOTIFY_TYPE=4
VIVO_PUSH_CATEGORY=DEVICE_REMINDER
XIAOMI_PUSH_APP_SECRET=
XIAOMI_PUSH_SEND_ENDPOINT=https://api.xmpush.xiaomi.com/v3/message/regid
XIAOMI_PUSH_CHANNEL_ID=
XIAOMI_PUSH_TEMPLATE_ID=
XIAOMI_PUSH_TEMPLATE_TITLE=
XIAOMI_PUSH_TEMPLATE_DESCRIPTION=
# 可选: JSON 字符串,支持 {title}/{alert} 占位符,例如 {"title":"{title}","content":"{alert}"}
XIAOMI_PUSH_TEMPLATE_PARAM_JSON=
OPPO_PUSH_APP_KEY=
OPPO_PUSH_MASTER_SECRET=
OPPO_PUSH_AUTH_ENDPOINT=https://api.push.oppomobile.com/server/v1/auth
OPPO_PUSH_SEND_ENDPOINT=https://api.push.oppomobile.com/server/v1/message/notification/unicast
# OPPO 新消息分类(2024-11-20 后创建的应用必须携带 category;channel_id 为后台「通道ID」;
# notify_level 0=不传走默认,内容营销类仅支持 1/2)
OPPO_PUSH_CHANNEL_ID=
OPPO_PUSH_CATEGORY=
OPPO_PUSH_NOTIFY_LEVEL=0
# ===== 无障碍保护存活监控(推送 + pull 后置兜底)=====
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
HEARTBEAT_MONITOR_ENABLED=true
HEARTBEAT_TIMEOUT_MINUTES=60
HEARTBEAT_SCAN_INTERVAL_SEC=60
# ===== 短信 (mock 模式) =====
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。
# 后续接阿里云/腾讯云短信时,改成 false 并填供应商相关 key。
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。生产改 false。
SMS_MOCK=true
SMS_CODE_TTL_SEC=300
SMS_SEND_INTERVAL_SEC=60
# ===== 短信提供商(可切换:jiguang 默认 / aliyun 阿里云号码认证 / chuanglan 创蓝云智)=====
# jiguang :本服务生成验证码,极光 REST 只负责下发,本地内存校验(复用上面极光 JG_* 凭证)。
# aliyun :阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A,核验免费);缺凭证时 /sms/* 返 503。
# 需在阿里云号码认证控制台开通「融合认证」,并使用系统赠送签名 + 赠送模板。
# chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。
# 用 YZM 前缀验证码账号;服务器出网 IP 需在创蓝控制台加白名单(否则 117)。见 docs/integrations/chuanglan/tpl-send.md。
SMS_PROVIDER=jiguang
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
# 方案名:留空=默认方案;若填,发码与校验须一致(本服务已共用同一配置项,不会不匹配)。
ALIYUN_SMS_SCHEME_NAME=
ALIYUN_SMS_ENDPOINT=dypnsapi.aliyuncs.com
ALIYUN_SMS_CODE_LENGTH=6
ALIYUN_SMS_VALID_TIME_SEC=300
ALIYUN_SMS_INTERVAL_SEC=60
ALIYUN_SMS_TIMEOUT_SEC=15
# --- 创蓝云智(253)---
CHUANGLAN_SMS_ACCOUNT=
CHUANGLAN_SMS_PASSWORD=
CHUANGLAN_SMS_TEMPLATE_ID=1022457679
# 短信签名文案【品牌】;模板已关联签名则留空。
CHUANGLAN_SMS_SIGNATURE=
CHUANGLAN_SMS_ENDPOINT=https://smssh.253.com/msg/sms/v2/tpl/send
CHUANGLAN_SMS_TIMEOUT_SEC=10
# ===== 测试账号(release 包全流程联调用)=====
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
@@ -113,13 +91,6 @@ JD_UNION_APP_SECRET=
JD_UNION_SITE_ID=
JD_UNION_AUTH_KEY=
# 美团 + 京东订单每天北京时间 05:00 自动对账;按更新时间回拉近 3 天,重叠防漏单并刷新状态。
# 手动对账按钮不受该开关影响。通常保持开启;临时停自动任务时设为 false。
CPS_AUTO_RECONCILE_ENABLED=true
CPS_AUTO_RECONCILE_RUN_HOUR=5
CPS_AUTO_RECONCILE_LOOKBACK_DAYS=3
CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC=60
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
@@ -139,11 +110,6 @@ PRICEBOT_COMPARE_TIMEOUT_SEC=60
# 必须与 pricebot 侧的 INTERNAL_API_SECRET **同值**;留空 = 内部写端点关闭(返 503)。
# 启用前两边都填同一高熵串:python -c "import secrets; print(secrets.token_urlsafe(48))"
INTERNAL_API_SECRET=
# 新版比价 harvest 完成后即时回填;以下 worker 再补偿短暂故障期间漏掉的记录。
LLM_COST_BACKFILL_ENABLED=true
LLM_COST_BACKFILL_INTERVAL_SEC=300
LLM_COST_BACKFILL_BATCH_SIZE=100
LLM_COST_BACKFILL_LOOKBACK_DAYS=30
# ===== CORS =====
# 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类
@@ -198,18 +164,14 @@ PANGLE_REPORT_SECURITY_KEY=
PANGLE_REPORT_SITE_ID_PROD=5830519
PANGLE_REPORT_SITE_ID_TEST=5832303
# ===== 可观测(OpenObserve 接口指标)=====
# 采集每个接口 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker,见 deploy/openobserve/)。
# 默认关;开启需 ENABLED=true 且填 USER/PASSWORD(与 docker-compose 里 root 账号一致)。
# 未开/缺凭证 → 中间件透传、worker 不启动,整套 no-op,不影响业务
OBSERVE_ENABLED=false
OBSERVE_ENDPOINT=http://localhost:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=admin@shaguabijia.local
OBSERVE_PASSWORD=Complexpass#123
# 进阶(一般不用改):攒批间隔秒 / 单批最大条数 / 有界队列上限(满则丢) / 上报超时秒
OBSERVE_FLUSH_INTERVAL_SEC=5
OBSERVE_BATCH_MAX=200
OBSERVE_QUEUE_MAX=10000
OBSERVE_TIMEOUT_SEC=5
# ===== 客户端运行日志上报(POST /api/v1/applog/batch)=====
# 客户端批量上报的 App 运行日志逐条落到独立滚动文件 logs/app-client.log,供阿里云 Logtail
# 采进【独立 SLS logstore】(与服务日志 app-server.log 分开;滚动机制相同,trace_id 可跨层检索)。
# 全部有默认值,不填即用默认(定义见 app/core/client_log.py 与 app/api/v1/applog.py)
# CLIENT_LOG_FILE=logs/app-client.log # 落盘路径
# CLIENT_LOG_MAX_BYTES=20971520 # 单文件 20MB 滚动
# CLIENT_LOG_BACKUP_COUNT=10 # 保留 10 个 → ~200MB 缓冲(给 Logtail 断线留余量)
# CLIENT_LOG_SERVICE_NAME=app-client # 输出行 service 字段
# APPLOG_MAX_BATCH=500 # 单批最大条数(超 → 422;导入期常量,改需重启)
# APPLOG_MAX_BODY_BYTES=2097152 # 请求体上限 2MB(超 → 413;运行期可调)
# APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超此字节数截断
+3
View File
@@ -49,6 +49,9 @@ secrets/*
*.log
logs/
# 本地 admin server(端口 8771)Windows 启动脚本,个人调试用,不入库
/run8771.bat
# Claude Code 自动持久化的权限 allowlist / 个人本地设置(会话专属,不入库)。
# 需要团队共享的 Claude 配置(commands/ 等)可单独 git add -f,不受此忽略影响。
.claude/settings.json
@@ -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')
+30
View File
@@ -63,6 +63,34 @@ def _device_marketing_name(model: str | None) -> str | None:
return _DEVICE_MARKETING_NAMES.get(model.strip().upper())
def _attach_comparison_order_status(db: Session, items: list[ComparisonRecord]) -> None:
"""按 C 端既有口径给比价记录批量补充是否真实下单。"""
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}
ordered_pairs: set[tuple[int, str]] = set()
if user_ids and shop_names:
rows = db.execute(
select(SavingsRecord.user_id, SavingsRecord.shop_name)
.where(
SavingsRecord.user_id.in_(user_ids),
SavingsRecord.source == "compare",
SavingsRecord.shop_name.in_(shop_names),
)
.distinct()
).all()
ordered_pairs = {
(row.user_id, row.shop_name)
for row in rows
if row.shop_name 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
)
def _attach_comparison_device_details(items: list[ComparisonRecord]) -> None:
"""给比价记录补充可读机型名,同时保留原始设备编码。"""
for item in items:
@@ -350,6 +378,7 @@ def list_comparison_records(
limit=limit, cursor=cursor,
)
_attach_user_info(db, items)
_attach_comparison_order_status(db, items)
_attach_comparison_device_details(items)
# 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。
# ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。
@@ -497,6 +526,7 @@ def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | Non
rec = db.get(ComparisonRecord, record_id)
if rec is not None:
_attach_user_info(db, [rec])
_attach_comparison_order_status(db, [rec])
_attach_comparison_device_details([rec])
return rec
+1
View File
@@ -29,6 +29,7 @@ class AdminComparisonListItem(BaseModel):
source_price_cents: int | None = None
best_price_cents: int | None = None
saved_amount_cents: int | None = None
ordered: bool = False
# debug 概览
total_ms: int | None = None
step_count: int | None = None
+13 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
@@ -77,6 +77,18 @@ def get_current_user_optional(
return user
def get_client_ip(request: Request) -> str:
"""客户端真实 IP:生产经 nginx 反代优先 X-Forwarded-For 首段,否则直连 IP。
路由层取 IP 的规范实现(analytics / applog 等共用);core 层(ratelimit)因不能
反向依赖 app.api,自留一份私有副本。
"""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else ""
CurrentUser = Annotated[User, Depends(get_current_user)]
OptionalUser = Annotated[User | None, Depends(get_current_user_optional)]
DbSession = Annotated[Session, Depends(get_db)]
+2 -10
View File
@@ -10,7 +10,7 @@ import logging
from fastapi import APIRouter, HTTPException, Request
from app.api.deps import DbSession
from app.api.deps import DbSession, get_client_ip
from app.repositories import analytics as analytics_repo
from app.repositories import analytics_selfstat as selfstat_repo
from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut
@@ -20,19 +20,11 @@ router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
logger = logging.getLogger("shagua.analytics")
def _client_ip(request: Request) -> str:
"""取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 第一段,否则直连 IP(同 admin get_client_ip)。"""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else ""
@router.post("/events", response_model=AnalyticsIngestOut, summary="批量上报埋点事件")
def ingest_events(
batch: AnalyticsBatchIn, request: Request, db: DbSession
) -> AnalyticsIngestOut:
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
n = analytics_repo.record_batch(db, batch, client_ip=get_client_ip(request))
return AnalyticsIngestOut(received=n)
+50
View File
@@ -0,0 +1,50 @@
"""客户端运行日志批量上报接口。
POST /api/v1/applog/batch — 批量接收客户端运行日志,逐条写专用滚动文件 logs/app-client.log
(供 Logtail 采进独立 SLS logstore)。鉴权同 analytics(不强制登录,user_id 可选在 body)。
fire-and-forget:写失败也不 500(避免客户端重试风暴);超批 422、超体积 413、msg 超限截断。
"""
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, HTTPException, Request
from app.api.deps import get_client_ip
from app.core.client_log import write_records
from app.core.ratelimit import rate_limit
from app.schemas.applog import AppLogBatchIn, AppLogIngestOut
router = APIRouter(prefix="/api/v1/applog", tags=["applog"])
def _enforce_body_limit(request: Request) -> None:
"""依赖:body 声明过大直接 413(在 body 校验前拦截)。缺 Content-Length 由 nginx 兜底。"""
max_bytes = int(os.getenv("APPLOG_MAX_BODY_BYTES", str(1024 * 1024 * 2)))
cl = request.headers.get("content-length")
if cl is not None and cl.isdigit() and int(cl) > max_bytes:
raise HTTPException(status_code=413, detail="日志批量过大")
@router.post(
"/batch",
response_model=AppLogIngestOut,
summary="批量上报客户端运行日志",
dependencies=[
Depends(rate_limit(120, 60, "applog-batch")),
Depends(_enforce_body_limit),
],
)
def ingest_logs(batch: AppLogBatchIn, request: Request) -> AppLogIngestOut:
received, dropped = write_records(
batch.logs,
meta={
"device_id": batch.device_id,
"user_id": batch.user_id,
"app_ver": batch.app_ver,
"platform": batch.platform,
"sent_at": batch.sent_at,
},
client_ip=get_client_ip(request),
)
return AppLogIngestOut(received=received, dropped=dropped)
+11 -2
View File
@@ -292,7 +292,12 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
detail="登录尝试过于频繁,请稍后再试",
)
if not verify_code(req.phone, req.code):
try:
ok = verify_code(req.phone, req.code)
except SmsError as e: # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
if not ok:
# 校验码错误才记风控失败事件(provider 降级 503 已在上面提前 raise,不算「验证失败」)
risk_repo.record_behavior_event(
db,
event_type=risk_repo.EVENT_SMS_LOGIN,
@@ -456,7 +461,11 @@ def wechat_bind_phone_sms(
detail="登录尝试过于频繁,请稍后再试",
)
if not verify_code(req.phone, req.code):
try:
ok = verify_code(req.phone, req.code)
except SmsError as e: # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
if not ok:
raise HTTPException(status_code=400, detail="invalid sms code")
return _finish_wechat_bind(
+11 -3
View File
@@ -108,7 +108,7 @@ def _harvest_done_blocking(
def _harvest_abort_blocking(
trace_id: str, status_hint: str, reason: str | None, trace_url: str | None,
) -> None:
) -> int | None:
with SessionLocal() as db:
rec = crud_compare.harvest_abort(
db, trace_id=trace_id, status=status_hint, reason=reason, trace_url=trace_url,
@@ -118,6 +118,7 @@ def _harvest_abort_blocking(
extra={"phase": "harvest_abort",
"status": (rec.status if rec else None), "reason": reason},
)
return rec.id if rec is not None else None
async def _forward(
@@ -291,7 +292,10 @@ async def trace_epilogue(
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
async def trace_finalize(
request: Request, user: OptionalUser, db: DbSession
request: Request,
background_tasks: BackgroundTasks,
user: OptionalUser,
db: DbSession,
) -> dict[str, Any]:
_ensure_compare_allowed(user, db)
# 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url};
@@ -302,12 +306,16 @@ async def trace_finalize(
request, "/api/trace/finalize", user, harvest_first_frame=False,
)
try:
await run_in_threadpool(
record_id = await run_in_threadpool(
_harvest_abort_blocking, trace_id,
(meta.get("status") or "cancelled"),
(meta.get("reason") or meta.get("information")),
(resp.get("trace_url") if isinstance(resp, dict) else None),
)
if record_id is not None:
background_tasks.add_task(
backfill_comparison_llm_cost, record_id, trace_id
)
except Exception as e: # noqa: BLE001
logger.warning("harvest_abort failed trace=%s: %s", trace_id, e)
return resp
+157
View File
@@ -0,0 +1,157 @@
"""客户端运行日志专用落盘 writer(独立于服务端 app-server.log)。
- 独占 logger "shagua.client_log" + 自己的 RotatingFileHandler,propagate=False → 不污染 app-server.log。
- 每条按「白名单键(client_ts/level/trace_id/tag/msg)提顶层 + 其余并入 data」封装,再
json.dumps 成一行写出(钉死 SLS 索引列;见 spec §5)。formatter 用 %(message)s——行本身
已是 JSON,不能再过 JsonFormatter 二次编码。
- 滚动 20MB×10(env 可调),与服务日志同机制。
⚠️ 依赖 --workers 1:RotatingFileHandler 多进程并发 doRollover 会损坏/丢日志;扩 worker
前换 QueueHandler→单写入者 / 外部 logrotate(copytruncate) / 写 stdout 交 journald。
服务端补的字段(time/source/service/client_ip/device_id/...)是「事实」,与客户端自述分开。
`time` 用服务端接收时间作 SLS 主时间(客户端时钟不可信),client_ts 另存为可查字段。
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
# 仅这些客户端键提到输出行顶层;其余(含客户端自带 data)一律并入 data,防 SLS 索引列爆炸
_TOP_LEVEL_KEYS = ("client_ts", "level", "trace_id", "tag", "msg")
# trace_id/tag/level 是 SLS 索引字段(spec §8):给长度上限,防客户端塞超大值撑爆索引/抬升成本。
# (msg 另有字节截断;data 内的值不限,留待后续「服务端脱敏」knob。)
_MAX_LEVEL_LEN = 16
_MAX_TRACE_ID_LEN = 256
_MAX_TAG_LEN = 128
_logger: logging.Logger | None = None
def _max_msg_bytes() -> int:
# 每次调用现读 env(不设模块级常量):便于运行期调整 / 测试 monkeypatch,开销可忽略。
return int(os.getenv("APPLOG_MAX_MSG_BYTES", "8192"))
def _build_logger() -> logging.Logger:
lg = logging.getLogger("shagua.client_log") # 与仓库 shagua.* 业务 logger 命名一致
lg.setLevel(logging.INFO)
lg.propagate = False # 不冒泡到 root → 不写进 app-server.log
log_file = os.getenv("CLIENT_LOG_FILE") or str(
Path(os.getenv("LOG_DIR", "logs")) / "app-client.log"
)
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
log_file,
maxBytes=int(os.getenv("CLIENT_LOG_MAX_BYTES", str(20 * 1024 * 1024))),
backupCount=int(os.getenv("CLIENT_LOG_BACKUP_COUNT", "10")),
encoding="utf-8",
)
handler.setFormatter(logging.Formatter("%(message)s")) # 行已是 JSON,不再包装
lg.handlers = [handler]
return lg
def get_logger() -> logging.Logger:
global _logger
if _logger is None:
_logger = _build_logger()
return _logger
def reset_client_logger() -> None:
"""测试用:关闭并丢弃当前 logger,使下次 get_logger 按当时 env 重建(切临时文件)。"""
global _logger
if _logger is not None:
for h in list(_logger.handlers):
h.close()
_logger.handlers = []
_logger = None
def _truncate_msg(msg: str) -> tuple[str, bool]:
raw = msg.encode("utf-8")
limit = _max_msg_bytes()
if len(raw) <= limit:
return msg, False
# 按字节截断后解码,忽略截断处半个多字节字符
return raw[:limit].decode("utf-8", "ignore") + "…[truncated]", True
def _build_line(
record: dict, *, meta: dict, client_ip: str, service: str, now_iso: str
) -> str:
out: dict = {
"time": now_iso,
"source": "client",
"service": service,
"client_ip": client_ip,
}
# 批级公共字段(非空才带)
for k in ("device_id", "user_id", "app_ver", "platform", "sent_at"):
v = meta.get(k)
if v is not None:
out[k] = v
# 白名单键提顶层(索引字段做长度上限 + 统一转 str,保证 SLS 里类型/大小可控)
if record.get("level") is not None:
out["level"] = str(record["level"])[:_MAX_LEVEL_LEN].upper()
if record.get("trace_id"):
out["trace_id"] = str(record["trace_id"])[:_MAX_TRACE_ID_LEN]
if record.get("tag"):
out["tag"] = str(record["tag"])[:_MAX_TAG_LEN]
if record.get("client_ts") is not None:
out["client_ts"] = record["client_ts"]
if record.get("msg") is not None:
msg, truncated = _truncate_msg(str(record["msg"]))
out["msg"] = msg
if truncated:
out["msg_truncated"] = True
# 其余键并入 data:先收白名单外的散键(兜底),再让客户端显式的 data 覆盖同名散键
# —— 显式 data 为准,不静默丢客户端明确给的值(保真)。
data: dict = {}
for k, v in record.items():
if k in _TOP_LEVEL_KEYS or k == "data":
continue
data[k] = v
client_data = record.get("data")
if isinstance(client_data, dict):
data.update(client_data)
if data:
out["data"] = data
return json.dumps(out, ensure_ascii=False, default=str)
def write_records(
records: list[dict], *, meta: dict, client_ip: str
) -> tuple[int, int]:
"""把一批客户端日志逐条写入专用文件。返回 (received, dropped)。
尽力而为(fire-and-forget):logger 初始化或单条写入失败只跳过并计 dropped,
不抛给上层——端点因此永不因写日志而 500。
"""
try:
lg = get_logger()
except Exception: # noqa: BLE001 — 初始化失败也不能让端点 500
logging.getLogger("shagua.applog").exception("client log writer init failed")
return 0, len(records)
service = os.getenv("CLIENT_LOG_SERVICE_NAME", "app-client")
# 一批共用同一「服务端接收时间」:降开销,且语义上是服务端「收到」而非逐条「处理」时间。
now_iso = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
received = dropped = 0
for rec in records:
try:
line = _build_line(
rec, meta=meta, client_ip=client_ip, service=service, now_iso=now_iso
)
lg.info(line)
received += 1
except Exception: # noqa: BLE001 — 坏条跳过,不影响其余
logging.getLogger("shagua.applog").exception(
"client log record dropped client_ip=%s", client_ip
)
dropped += 1
return received, dropped
+43
View File
@@ -141,6 +141,49 @@ class Settings(BaseSettings):
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
# ===== 短信提供商(可切换:极光 / 阿里云号码认证 / 创蓝云智)=====
# jiguang(默认):本服务生成验证码,极光只负责下发,本地内存校验(自管码,现状不变)。
# aliyun:阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A);缺凭证时 /sms/* 返 503(优雅降级)。
# chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。
SMS_PROVIDER: Literal["jiguang", "aliyun", "chuanglan"] = "jiguang"
ALIYUN_SMS_ACCESS_KEY_ID: str = ""
ALIYUN_SMS_ACCESS_KEY_SECRET: str = ""
ALIYUN_SMS_SIGN_NAME: str = "" # 系统赠送签名(自定义签名下发易失败)
ALIYUN_SMS_TEMPLATE_CODE: str = "" # 赠送模板 CODE(须与赠送签名搭配)
ALIYUN_SMS_SCHEME_NAME: str = "" # 方案名(可空=默认方案);send/check 共用避免不匹配
ALIYUN_SMS_ENDPOINT: str = "dypnsapi.aliyuncs.com"
ALIYUN_SMS_CODE_LENGTH: int = 6 # 验证码位数(CodeLength 4~8)
ALIYUN_SMS_VALID_TIME_SEC: int = 300 # 验证码有效期秒(ValidTime);短信内 min 文案 = //60
ALIYUN_SMS_INTERVAL_SEC: int = 60 # 单号发送频控秒(Interval);核验免费
ALIYUN_SMS_TIMEOUT_SEC: int = 15 # 阿里云 API 读/连超时秒
# --- 创蓝云智(253)模板短信,Mode B 自管码,httpx 直连 + HMAC 签名(见 docs/integrations/chuanglan/tpl-send.md)---
CHUANGLAN_SMS_ACCOUNT: str = "" # YZM 前缀验证码账号
CHUANGLAN_SMS_PASSWORD: str = "" # API 密码(仅用于本地算 HMAC 签名,不随请求上行)
CHUANGLAN_SMS_TEMPLATE_ID: str = "" # 模板 ID(控制台创建)
CHUANGLAN_SMS_SIGNATURE: str = "" # 短信签名文案【品牌】;模板已关联签名则留空
CHUANGLAN_SMS_ENDPOINT: str = "https://smssh.253.com/msg/sms/v2/tpl/send"
CHUANGLAN_SMS_TIMEOUT_SEC: int = 10 # httpx 读/连超时秒
@property
def aliyun_sms_configured(self) -> bool:
"""阿里云短信凭证齐全(缺则 SMS_PROVIDER=aliyun 时 /sms/* 返 503,而非启动崩)。"""
return bool(
self.ALIYUN_SMS_ACCESS_KEY_ID
and self.ALIYUN_SMS_ACCESS_KEY_SECRET
and self.ALIYUN_SMS_SIGN_NAME
and self.ALIYUN_SMS_TEMPLATE_CODE
)
@property
def chuanglan_sms_configured(self) -> bool:
"""创蓝短信凭证齐全(缺则 SMS_PROVIDER=chuanglan 时 /sms/send 返 503,而非启动崩)。"""
return bool(
self.CHUANGLAN_SMS_ACCOUNT
and self.CHUANGLAN_SMS_PASSWORD
and self.CHUANGLAN_SMS_TEMPLATE_ID
)
# ===== 测试账号(release 包全流程联调用)=====
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
+37
View File
@@ -0,0 +1,37 @@
"""短信验证码服务 —— provider 分派入口。
对外只暴露 `send_code` / `verify_code` / `SmsError`,api 层无需关心用哪个 provider。
provider 由 `settings.SMS_PROVIDER` 选择(**每次调用读取**,支持运行时切换 + 灰度回退):
- `jiguang`(默认):自管码(本服务生成、内存存/校验,极光只发)。见 [jiguang.py](jiguang.py)。
- `aliyun`:阿里云号码认证(阿里云生成+下发+校验,Mode A)。见 [aliyun.py](aliyun.py)。
- `chuanglan`:创蓝云智(253)模板短信,自管码 Mode B(本服务生成、内存存/校验,创蓝只发)。见 [chuanglan.py](chuanglan.py)。
mock(`SMS_MOCK=true`)与各 provider 的行为差异都封在 provider 内部;本层只做路由。
拆包前本模块是单文件 `sms.py`;拆包后极光逻辑迁入 `jiguang` 子模块,行为零改动。
"""
from __future__ import annotations
from app.core.config import settings
from . import aliyun, chuanglan, jiguang
from .base import SmsError
__all__ = ["SmsError", "send_code", "verify_code"]
# provider 名 -> 模块;未知/缺省值回退 jiguang(默认兜底,防误配把登录打挂)。
_PROVIDERS = {"aliyun": aliyun, "chuanglan": chuanglan}
def _provider():
"""按配置选 provider 模块(每次调用读 settings,支持运行时切换 / 测试注入)。"""
return _PROVIDERS.get(settings.SMS_PROVIDER, jiguang)
def send_code(phone: str) -> int:
"""发送验证码,返回距下次可发的冷却秒数;失败抛 SmsError。委托给当前 provider。"""
return _provider().send_code(phone)
def verify_code(phone: str, code: str) -> bool:
"""校验验证码,返回是否通过;provider 异常降级抛 SmsError。委托给当前 provider。"""
return _provider().verify_code(phone, code)
+193
View File
@@ -0,0 +1,193 @@
"""阿里云号码认证(dypns)短信 provider —— Mode A(阿里云托管验证码)。
与极光(自管码)最大不同:**本服务不生成/不存储验证码**,验证码由阿里云生成+存储+下发+校验。
- 发码:调 SendSmsVerifyCode,TemplateParam 用 `{"code":"##code##","min":...}` 占位,阿里云生成。
- 校验:调 CheckSmsVerifyCode,阿里云返回 PASS / UNKNOWN。核验免费。
→ 天然消除极光路径「内存存码、多 worker 不共享」的技术债(发码/校验可落不同 worker,阿里云统一裁决)。
**唯一本地态**:per-phone 连续失败计数(`_verify_attempts`),用于复刻极光「单码失败
`SMS_MAX_VERIFY_ATTEMPTS` 次即作废」的防爆破语义 —— 刻意与极光一致,避免两 provider 行为不同
导致排查困惑。其多 worker 降级特性与极光现状同级;另有 API 层登录频控(设备+IP)做硬兜底。
单号发送频控(冷却)交给阿里云 `Interval` 参数(命中→FREQUENCY_FAIL→429),本地不再维护冷却。
SDK 交互隔离在 `_call_send` / `_call_check` 两个薄封装(惰性 import + 惰性建 client,仿 wxpay
惰性加载),单测 monkeypatch 这两个即可,不触真 SDK / 网络。
"""
from __future__ import annotations
import json
import logging
import time
from threading import Lock
from app.core.config import settings
from .base import SmsError, mock_verify
logger = logging.getLogger("shagua.sms.aliyun")
# 阿里云路径唯一本地态:per-phone 连续失败次数(与极光同语义,防爆破)。
_verify_attempts: dict[str, int] = {} # phone -> 连续失败次数
_verify_seen: dict[str, float] = {} # phone -> 最近触碰 epoch(仅供 GC 老化)
_lock = Lock()
_GC_THRESHOLD = 10000 # 超此阈值,send 时顺手清老于验证码有效期的计数(仿极光 _gc)
# 发码错误码 → (HTTP 码, 用户提示)。未列出的一律 503(供应商不可用)。
_SEND_ERRORS: dict[str, tuple[int, str]] = {
"MOBILE_NUMBER_ILLEGAL": (400, "手机号无效"),
"BUSINESS_LIMIT_CONTROL": (429, "今日发送次数过多,请明天再试"),
"FREQUENCY_FAIL": (429, "发送过于频繁,请稍后再试"),
}
# 需运维介入的配置/开通类错误:打 critical 日志(融合认证未开通 / 参数非法)。
_SEND_CRITICAL_CODES = frozenset({"FUNCTION_NOT_OPENED", "INVALID_PARAMETERS"})
_client = None # 惰性构建的 SDK client(模块级缓存)
# ============================ 对外:发码 / 校验 ============================
def send_code(phone: str) -> int:
"""发送验证码(阿里云生成+下发)。
Returns: 距下次可发的秒数(= ALIYUN_SMS_INTERVAL_SEC,冷却由阿里云 Interval 侧执行)。
Raises: SmsError(手机号无效 400 / 过频·天级流控 429 / 未配置·未开通·其他 503)。
"""
if settings.SMS_MOCK:
logger.info("[SMS-aliyun-MOCK] to %s**** (不真发)", phone[:3])
return settings.ALIYUN_SMS_INTERVAL_SEC
if not settings.aliyun_sms_configured:
raise SmsError("短信服务未配置(缺阿里云凭证)", status_code=503)
result = _call_send(phone) # 传输/SDK 异常在内部抛 SmsError(503)
if result["success"] and result["code"] == "OK":
now = time.time()
with _lock:
_gc(now) # 顺手清老计数(超阈值才扫)
_verify_attempts.pop(phone, None) # 新码 = 新失败预算
_verify_seen.pop(phone, None)
logger.info("[SMS-aliyun] sent to %s****", phone[:3])
return settings.ALIYUN_SMS_INTERVAL_SEC
code = result["code"]
logger.error("[SMS-aliyun] send failed code=%s msg=%s", code, result["message"])
if code in _SEND_CRITICAL_CODES:
logger.critical("[SMS-aliyun] %s —— 需运维处理(融合认证未开通 / 参数非法)", code)
status, msg = _SEND_ERRORS.get(code, (503, "短信服务暂不可用,请稍后重试"))
raise SmsError(msg, status_code=status)
def verify_code(phone: str, code: str) -> bool:
"""校验验证码(阿里云裁决)。
- **mock**:放行任意 N 位数字(provider 无关,同极光)。
- **real**:先查本地失败计数(达上限即本地作废,不调阿里云,与极光一致)→ 调 CheckSmsVerifyCode:
PASS 清计数返 True(一次性);UNKNOWN 计数 +1 返 False;接口异常抛 SmsError(503)。
"""
if settings.SMS_MOCK:
ok = mock_verify(code)
logger.info("[SMS-aliyun-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
return ok
# 失败计数是 best-effort:网络调用不持锁(不能锁跨 IO),故并发下同号可能多放行个位数次。
# 无碍——API 层登录频控(设备+IP 5/时)是硬上限,阿里云码有效期 + DuplicatePolicy 亦兜底。
with _lock:
if _verify_attempts.get(phone, 0) >= settings.SMS_MAX_VERIFY_ATTEMPTS:
return False # 已作废:保持计数(直到 send_code 重置),与极光「达上限即作废」一致
result = _call_check(phone, code) # 传输/SDK 异常在内部抛 SmsError(503)
if not (result["success"] and result["code"] == "OK"):
# 接口层失败(非码错):降级 503,别误报「验证码错误」(400),便于区分排查。
logger.error("[SMS-aliyun] check failed code=%s msg=%s", result["code"], result["message"])
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503)
if result["verify_result"] == "PASS":
with _lock:
_verify_attempts.pop(phone, None) # 验过即清(一次性)
_verify_seen.pop(phone, None)
return True
# UNKNOWN:码错 / 过期 → 失败计数 +1(累计到上限即作废)
with _lock:
_verify_attempts[phone] = _verify_attempts.get(phone, 0) + 1
_verify_seen[phone] = time.time()
return False
def _gc(now: float) -> None:
"""超阈值时清理老于验证码有效期的失败计数(码早已在阿里云侧失效,计数无意义)。仅持锁调用。"""
if len(_verify_attempts) <= _GC_THRESHOLD:
return
cutoff = now - settings.ALIYUN_SMS_VALID_TIME_SEC
for p in [p for p, ts in _verify_seen.items() if ts < cutoff]:
_verify_attempts.pop(p, None)
_verify_seen.pop(p, None)
# ============================ SDK 接缝(单测 monkeypatch 这两个)============================
def _get_client():
"""惰性构建 dypns SDK client(仿 wxpay 惰性加载:jiguang-only 部署不加载 alibabacloud)。"""
global _client
if _client is None:
from alibabacloud_dypnsapi20170525.client import Client
from alibabacloud_tea_openapi import models as open_api_models
cfg = open_api_models.Config(
access_key_id=settings.ALIYUN_SMS_ACCESS_KEY_ID,
access_key_secret=settings.ALIYUN_SMS_ACCESS_KEY_SECRET,
read_timeout=settings.ALIYUN_SMS_TIMEOUT_SEC * 1000, # SDK 单位 ms
connect_timeout=settings.ALIYUN_SMS_TIMEOUT_SEC * 1000,
)
cfg.endpoint = settings.ALIYUN_SMS_ENDPOINT
_client = Client(cfg)
return _client
def _call_send(phone: str) -> dict:
"""调 SendSmsVerifyCode。返回归一化 {success, code, message};import/建 client/调用 任一失败抛 SmsError(503)。"""
valid_min = max(1, settings.ALIYUN_SMS_VALID_TIME_SEC // 60)
template_param = json.dumps({"code": "##code##", "min": str(valid_min)}, ensure_ascii=False)
try:
# import + 建 req + 调用 全在 try 内:任一 provider 侧失败都归一成 503(保「provider 出问题→503」不变式)
from alibabacloud_dypnsapi20170525 import models as dypns_models
req = dypns_models.SendSmsVerifyCodeRequest(
phone_number=phone,
sign_name=settings.ALIYUN_SMS_SIGN_NAME,
template_code=settings.ALIYUN_SMS_TEMPLATE_CODE,
template_param=template_param,
code_length=settings.ALIYUN_SMS_CODE_LENGTH,
valid_time=settings.ALIYUN_SMS_VALID_TIME_SEC,
interval=settings.ALIYUN_SMS_INTERVAL_SEC,
scheme_name=settings.ALIYUN_SMS_SCHEME_NAME or None,
)
body = _get_client().send_sms_verify_code(req).body
except Exception as e:
logger.exception("[SMS-aliyun] send_sms_verify_code 调用异常 phone=%s****", phone[:3])
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e
return {"success": bool(body.success), "code": body.code, "message": body.message}
def _call_check(phone: str, code: str) -> dict:
"""调 CheckSmsVerifyCode。返回归一化 {success, code, message, verify_result};import/建 client/调用 任一失败抛 SmsError(503)。"""
try:
# import + 建 req + 调用 全在 try 内:任一 provider 侧失败都归一成 503(保「provider 出问题→503」不变式)
from alibabacloud_dypnsapi20170525 import models as dypns_models
req = dypns_models.CheckSmsVerifyCodeRequest(
phone_number=phone,
verify_code=code,
scheme_name=settings.ALIYUN_SMS_SCHEME_NAME or None,
)
body = _get_client().check_sms_verify_code(req).body
except Exception as e:
logger.exception("[SMS-aliyun] check_sms_verify_code 调用异常 phone=%s****", phone[:3])
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e
verify_result = getattr(body.model, "verify_result", None) if body.model else None
return {
"success": bool(body.success),
"code": body.code,
"message": body.message,
"verify_result": verify_result,
}
+23
View File
@@ -0,0 +1,23 @@
"""短信 provider 共享基座:业务异常 + provider 无关的 mock 校验。
各 provider(jiguang / aliyun)都 `from .base import SmsError`,api 层也从包入口拿到同一个
`SmsError` —— 保证无论用哪个 provider,异常类型与 HTTP 码映射语义一致。
"""
from __future__ import annotations
from app.core.config import settings
class SmsError(Exception):
"""业务异常。`status_code` 决定 api 层翻成哪个 HTTP 码:
过频/每日超限 = 429(客户端等会再来),供应商不可用 = 503,手机号无效 = 400。
"""
def __init__(self, message: str, status_code: int = 429) -> None:
super().__init__(message)
self.status_code = status_code
def mock_verify(code: str) -> bool:
"""mock 模式校验:放行任意 SMS_CODE_LENGTH 位数字(provider 无关,测试/开发便利,不真校验)。"""
return len(code) == settings.SMS_CODE_LENGTH and code.isdigit()
+224
View File
@@ -0,0 +1,224 @@
"""创蓝云智(253)短信 provider(自管码 Mode B)。
创蓝 `tpl/send` v2 是**纯发送网关**(本服务生成码 → 放入 templateParamJson → 创蓝只下发,
无校验接口),故与极光同为 **Mode B**:本服务生成/存储/校验验证码,创蓝只负责发。
**本模块的存码/冷却/一次性/防爆破/GC 机器与 [jiguang.py](jiguang.py) 是刻意的隔离复制**
(设计见 docs/superpowers/specs/2026-07-26-chuanglan-sms-verify-design.md):极光文件一行不动、
零回归风险于登录关键路径的默认 provider;代价是两处 Mode B 并发逻辑重复,改动需同步。唯一新逻辑
是 `_send_via_chuanglan`(HMAC-SHA256 签名 + httpx POST + 错误码映射)。
两种运行模式由 `SMS_MOCK` 切换:
- **mock**(开发/测试,默认):不真发,验证码打日志;校验放行任意 N 位数字。
- **real**(`SMS_MOCK=false` 且 `SMS_PROVIDER=chuanglan`):`secrets` 生成码 → 调创蓝 `tpl/send`
下发(HMAC 签名,password 仅本地算签不上行)→ 校验比对本地存码(一次性 / 过期 / 防爆破)。
验证码存储:**进程内存**(单 worker 够用,多 worker 不共享,与极光同级技术债)。防刷同极光:
单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)+ 单设备/IP 频控(api 层)+ 单码失败 `SMS_MAX_VERIFY_ATTEMPTS`
次即作废。运维侧另需在创蓝控制台配 **IP 白名单**(否则 117)。接口调研见 docs/integrations/chuanglan/tpl-send.md。
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import secrets
import time
from dataclasses import dataclass
from threading import Lock
import httpx
from app.core.config import settings
from .base import SmsError, mock_verify
logger = logging.getLogger("shagua.sms.chuanglan")
@dataclass
class _CodeRecord:
code: str
expires_at: float
attempts: int = 0
# 进程内存(单 worker 有效;多 worker 不共享,见模块 docstring)。与极光同结构。
_codes: dict[str, _CodeRecord] = {} # phone -> 当前有效验证码
_last_sent: dict[str, float] = {} # phone -> 上次发送 epoch(冷却)
_lock = Lock()
_GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期项(防无限增长)
# 发码错误码(创蓝 `code`)→ (HTTP 码, 用户提示)。未列出的一律 503(供应商不可用)。
_SEND_ERRORS: dict[str, tuple[int, str]] = {
"103": (429, "发送过于频繁,请稍后再试"), # 提交速度过快
"107": (400, "手机号无效"), # 手机号码错误
}
# 需运维介入的配置/开通/余额类错误:打 critical 日志(仍归 503)。
_SEND_CRITICAL_CODES = frozenset({
"109", # 无发送量/余额不足
"117", # IP 未加白名单
"102", # 密码错误
"116", # 签名不合法
"124", # 模板内容不匹配
"152", # 模板不存在
"101", # 账号不存在
"118", # 无发送权限
})
def _gen_code() -> str:
"""生成 N 位数字验证码(用 secrets 而非 random;允许前导 0)。"""
return "".join(secrets.choice("0123456789") for _ in range(settings.SMS_CODE_LENGTH))
def _gc(now: float) -> None:
"""顺手清理过期内存项,防两个 dict 无限增长。仅在持锁时调用,且某 dict 超阈值才扫它。"""
if len(_codes) > _GC_THRESHOLD:
for p in [p for p, r in _codes.items() if now > r.expires_at]:
_codes.pop(p, None)
if len(_last_sent) > _GC_THRESHOLD:
cutoff = now - settings.SMS_SEND_INTERVAL_SEC
for p in [p for p, ts in _last_sent.items() if ts < cutoff]:
_last_sent.pop(p, None)
def send_code(phone: str) -> int:
"""发送验证码。
Returns: 距下次可发的秒数(= SMS_SEND_INTERVAL_SEC)
Raises: SmsError(过频 429 / 手机号无效 400 / 供应商失败 503)
"""
now = time.time()
# --- lock 内:防刷检查 + 预占(防并发重复发烧钱)---
with _lock:
_gc(now) # 顺手清过期内存(超阈值才扫)
elapsed = now - _last_sent.get(phone, 0.0)
if elapsed < settings.SMS_SEND_INTERVAL_SEC:
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
code = _gen_code()
# 预占:先记冷却/存码,释放锁后再发网络(发失败保留冷却,见下)
_last_sent[phone] = now
_codes[phone] = _CodeRecord(code=code, expires_at=now + settings.SMS_CODE_TTL_SEC)
# --- lock 外:真正发送(网络 IO 不持锁)---
try:
if settings.SMS_MOCK:
logger.info("[SMS-chuanglan-MOCK] to %s**** code=%s (不真发)", phone[:3], code)
else:
_send_via_chuanglan(phone, code)
logger.info("[SMS-chuanglan] sent to %s****", phone[:3])
except Exception as e:
# 发送失败:**保留冷却**(失败也限速,挡住余额不足/签名失效时前端重试狂打),
# 只清掉没发出去的码(用户收不到,留着无意义且占内存)。
with _lock:
_codes.pop(phone, None)
if isinstance(e, SmsError):
raise
logger.exception("[SMS-chuanglan] send failed phone=%s****", phone[:3])
raise SmsError("验证码发送失败,请稍后重试", status_code=503) from e
return settings.SMS_SEND_INTERVAL_SEC
def verify_code(phone: str, code: str) -> bool:
"""校验验证码。
- **mock 模式**:放行任意 N 位数字(测试/开发便利,不真校验)。
- **real 模式**:比对本服务存的码,匹配即作废(一次性);失败累计到上限也作废(防爆破)。
"""
if settings.SMS_MOCK:
ok = mock_verify(code)
logger.info("[SMS-chuanglan-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
return ok
with _lock:
rec = _codes.get(phone)
if rec is None:
return False
if time.time() > rec.expires_at:
_codes.pop(phone, None)
return False
if rec.attempts >= settings.SMS_MAX_VERIFY_ATTEMPTS:
_codes.pop(phone, None) # 试错过多,作废
return False
if secrets.compare_digest(code.encode("utf-8"), rec.code.encode("utf-8")):
_codes.pop(phone, None) # 验过即作废
return True
rec.attempts += 1
return False
# ============================ 发送接缝(单测 monkeypatch 这两个 / httpx.post)============================
def _sign(password: str, timestamp: str, nonce: str) -> str:
"""创蓝 HMAC-SHA256 签名:key=md5(password),msg=sorted([md5pwd,ts,nonce]) 拼接去空白,输出小写 hex。"""
md5pwd = hashlib.md5(password.encode()).hexdigest() # 32 位小写 hex
raw = "".join(sorted([md5pwd, timestamp, nonce])) # 字典序升序,无分隔符拼接
raw = "".join(raw.split()) # 去所有空白(faithful;三段本无空白)
return hmac.new(md5pwd.encode(), raw.encode(), hashlib.sha256).hexdigest()
def _call_chuanglan(phone: str, code: str) -> dict:
"""组装 + 签名 + POST 创蓝 tpl/send,返回解析后的响应 dict。
传输错误 / HTTP≠200 / 响应非 JSON 一律抛 SmsError(503)(保「provider 出问题→503」不变式);
业务码(含 000000)由调用方 `_send_via_chuanglan` 判读。password 只用于算签,不入 body。
"""
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16) # 32 位 hex
body = {
"account": settings.CHUANGLAN_SMS_ACCOUNT,
"timestamp": timestamp,
"nonce": nonce,
"phoneNumbers": phone,
"templateId": settings.CHUANGLAN_SMS_TEMPLATE_ID,
"templateParamJson": json.dumps([{"param1": code}]),
}
if settings.CHUANGLAN_SMS_SIGNATURE:
body["signature"] = settings.CHUANGLAN_SMS_SIGNATURE
headers = {
"Content-Type": "application/json",
"X-QA-Hmac-Signature": _sign(settings.CHUANGLAN_SMS_PASSWORD, timestamp, nonce),
}
try:
resp = httpx.post(
settings.CHUANGLAN_SMS_ENDPOINT,
json=body,
headers=headers,
timeout=settings.CHUANGLAN_SMS_TIMEOUT_SEC,
)
except httpx.HTTPError as e:
logger.exception("[SMS-chuanglan] 网络错误 phone=%s****", phone[:3])
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e
if resp.status_code != 200:
logger.error("[SMS-chuanglan] http=%s body=%s", resp.status_code, resp.text[:200])
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503)
try:
return resp.json()
except Exception as e:
logger.error("[SMS-chuanglan] 响应非 JSON: %s", resp.text[:200])
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e
def _send_via_chuanglan(phone: str, code: str) -> None:
"""调创蓝 tpl/send 发送。成功静默返回;失败按错误码映射抛 SmsError。"""
if not settings.chuanglan_sms_configured:
raise SmsError("短信服务未配置(缺创蓝 account/password/templateId)", status_code=503)
result = _call_chuanglan(phone, code) # 传输/非200/解析异常在内部抛 SmsError(503)
rcode = str(result.get("code"))
if rcode == "000000":
return
emsg = result.get("errorMsg") or ""
logger.error("[SMS-chuanglan] send failed code=%s msg=%s", rcode, emsg)
if rcode in _SEND_CRITICAL_CODES:
logger.critical("[SMS-chuanglan] %s —— 需运维处理(余额/IP白名单/密码/签名/模板/账号)", rcode)
status, msg = _SEND_ERRORS.get(rcode, (503, "短信服务暂不可用,请稍后重试"))
raise SmsError(msg, status_code=status)
@@ -1,15 +1,15 @@
"""短信验证码服务
"""极光短信 provider(自管码 Mode B)
两种运行模式由 `SMS_MOCK` 切换:
- **mock**(开发/测试,默认):不真发短信,验证码打到日志;校验**放行任意 N 位数字**
(测试/开发便利)真实校验逻辑(比对存码 / 一次性 / 防爆破) real 分支 + 单测覆盖
- **real**(生产 `SMS_MOCK=false`):本服务生成 N 位验证码 调极光短信 REST
`/v1/messages` 发送(自定义验证码模式,极光只负责发,code 由本服务生成/保管/
- **real**(生产 `SMS_MOCK=false` `SMS_PROVIDER=jiguang`):本服务生成 N 位验证码 调极光
短信 REST `/v1/messages` 发送(自定义验证码模式,极光只负责发,code 由本服务生成/保管/
校验) 鉴权复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`(同一极光应用)
验证码存储:**进程内存**( worker uvicorn 够用)重启丢失(用户重发即可)
worker / 多机时内存不共享 冷却校验都会失效,届时迁移到 DB/Redis
docs/待办与技术债.md
worker / 多机时内存不共享 冷却校验都会失效,届时迁移到 DB/Redis(或改用 aliyun provider,
其验证码由阿里云托管无本地存码) docs/待办与技术债.md
防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
@@ -34,17 +34,9 @@ import httpx
from app.core.config import settings
logger = logging.getLogger("shagua.sms")
from .base import SmsError, mock_verify
class SmsError(Exception):
"""业务异常。`status_code` 决定 api 层翻成哪个 HTTP 码:
过频/每日超限 = 429(客户端等会再来),供应商不可用 = 503,手机号无效 = 400
"""
def __init__(self, message: str, status_code: int = 429) -> None:
super().__init__(message)
self.status_code = status_code
logger = logging.getLogger("shagua.sms.jiguang")
@dataclass
@@ -126,7 +118,7 @@ def verify_code(phone: str, code: str) -> bool:
- **real 模式**:比对本服务存的码,匹配即作废(一次性);失败累计到上限也作废(防爆破)
"""
if settings.SMS_MOCK:
ok = len(code) == settings.SMS_CODE_LENGTH and code.isdigit()
ok = mock_verify(code)
logger.info("[SMS-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
return ok
+2
View File
@@ -21,6 +21,7 @@ from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.ad import router as ad_router
from app.api.v1.analytics import router as analytics_router
from app.api.v1.applog import router as applog_router
from app.api.v1.auth import router as auth_router
from app.api.v1.compare import router as compare_router
from app.api.v1.compare_milestone import router as compare_milestone_router
@@ -153,6 +154,7 @@ app.include_router(auth_router)
app.include_router(user_router)
app.include_router(feedback_router)
app.include_router(analytics_router)
app.include_router(applog_router)
app.include_router(invite_router)
app.include_router(coupon_router)
app.include_router(device_router)
+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"),
+31
View File
@@ -0,0 +1,31 @@
"""客户端运行日志批量上报 schema。
批级公共字段(device_id/user_id/app_ver/platform/sent_at)发一次;logs 为原始 dict 列表,
**不强类型**——尽力而为的日志链路,单条内容异常不该让整批 422。每条的「白名单键 + data
兜底」拆分在写入层 [app.core.client_log] 做(见 spec §4/§5)。
"""
from __future__ import annotations
import os
from typing import Any
from pydantic import BaseModel, Field
# 单批条数上限。注意:这是**导入期**常量(Pydantic Field(max_length=) 在类定义时求值),
# 改它需重启进程;要运行期可调的上限用 APPLOG_MAX_BODY_BYTES(端点里 call-time 读)。
_MAX_BATCH = int(os.getenv("APPLOG_MAX_BATCH", "500"))
class AppLogBatchIn(BaseModel):
device_id: str = Field(max_length=64)
user_id: int | None = None
app_ver: str | None = Field(default=None, max_length=32)
platform: str | None = Field(default=None, max_length=16)
sent_at: int | None = None
logs: list[dict[str, Any]] = Field(min_length=1, max_length=_MAX_BATCH)
class AppLogIngestOut(BaseModel):
ok: bool = True
received: int
dropped: int = 0
+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 = []
+1 -1
View File
@@ -135,7 +135,7 @@ def repair_missing_comparison_llm_costs(
select(ComparisonRecord.id, ComparisonRecord.trace_id)
.where(
*date_conditions,
ComparisonRecord.status.in_(("success", "failed")),
ComparisonRecord.status.in_(("success", "failed", "cancelled")),
ComparisonRecord.llm_cost_yuan.is_(None),
)
.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc())
+4 -23
View File
@@ -1,13 +1,9 @@
# 傻瓜比价 App 后端 — API 接口文档(索引)
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**(⚠️ 例外:消息通知中心 `notifications` 族与厂商推送 `push` 族按 PRD 前端契约用 **camelCase**,见各自文档)
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
<<<<<<< HEAD
> 最后更新:2026-07-14(新增 **消息通知中心** 3 端点(M1-M3,虚拟数据阶段)与 **厂商推送测试** 3 端点(P1-P3,荣耀/华为/小米/OPPO/vivo);上一次 2026-06-23 补全 device/internal/CPS 短链等整族端点)
=======
> 最后更新:2026-07-09(① 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」(#112 尾声帧 `trace/epilogue` 一并补录);② 新端点:`user/onboarding/reset`(#114)、`GET /internal/launch-confirm-samples`(#91);③ 参数更新:提现族 `source` 分账(#82/#121)、`wallet/account` 邀请奖励金余额、美团 feed/top-sales 按城市过滤(#116)、admin 调现金 `account` 目标账户(#95);④ **Admin 索引补全到当前全量**:新家族 roles(#117/#126)/coupon-data(#99)/device-liveness(#80)/event-logs(#83)/price-reports(#94)/CPS 运营台/提现审核族,及 feedbacks 采纳拒绝(#94/#105)、marquee 模式与真实条浏览(#122/#123)等。上一次 2026-07-03
>>>>>>> origin/main
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
---
@@ -82,6 +78,7 @@
| **签到**(前缀 `/api/v1/signin` |||
| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin/signin-status.md) |
| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin/signin-do.md) |
| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin/signin-boost.md) |
| **任务**(前缀 `/api/v1/tasks` |||
| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks/tasks-list.md) |
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks/tasks-claim.md) |
@@ -92,7 +89,6 @@
| **看广告发奖**(前缀 `/api/v1/ad` |||
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad/ad-pangle-callback.md) |
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad/ad-reward-status.md) |
| 33a | `GET /api/v1/ad/reward-result/{ad_session_id}` | Bearer | [详情](./ad/ad-reward-result.md)(本次实发金币 + 本轮膨胀累计 `round_coin`,弹窗数字用它) |
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad/ad-test-grant.md) |
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad/ad-ecpm-report.md) |
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad/ad-feed-reward.md) |
@@ -107,33 +103,19 @@
| 36c | `POST /api/v1/user/onboarding/reset` | Bearer | [详情](./user/user-onboarding.md)(重置本设备引导标记,下次登录重走,#114 |
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) |
| **帮助与反馈**(前缀 `/api/v1/feedback` |||
<<<<<<< HEAD
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
| 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) |
| 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected(无单独文档) |
| **消息通知中心**(前缀 `/api/v1/notifications`;⚠️ 本族对外 **camelCase**;虚拟数据阶段:内存 mock,重启复位) |||
| M1 | `GET /api/v1/notifications` | Bearer | [详情](./notifications.md)(消息列表,分页;13 类型卡片字段 + sentAt/isRead;服务端已按时间倒序排好,不分组) |
| M2 | `GET /api/v1/notifications/unread-count` | Bearer | [详情](./notifications.md)(未读总数,首页铃铛角标;>99 → "99+",0 → null 隐藏) |
| M3 | `POST /api/v1/notifications/read` | Bearer | [详情](./notifications.md)(标记已读:`{ids:[...]}` 单条/多条 或 `{all:true}` 进通知中心全量清零;幂等) |
| **厂商推送测试**(前缀 `/api/v1/push`;荣耀/华为/小米/OPPO/vivo 五通道联调三件套,同为 camelCase |||
| P1 | `GET /api/v1/push/vendors` | Bearer | [详情](./push-vendor-test.md)(5 厂商服务端凭据配置状态,缺哪些 .env 键一目了然) |
| P2 | `GET /api/v1/push/templates` | Bearer | [详情](./push-vendor-test.md)(13 类通知的 push 标题/正文模板 + PRD 示例渲染效果) |
| P3 | `POST /api/v1/push/test` | Bearer | [详情](./push-vendor-test.md)(测试发送:默认 mock 不真发;mock=false 真发;可联动插一条站内 mock 通知闭环验证已读) |
=======
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) |
| 38a | `GET /api/v1/feedback/config` | Bearer | [详情](./other/feedback-config.md)(反馈页「加群二维码」卡配置:开关+二维码图+三行文案) |
| 38b | `GET /api/v1/feedback/records` | Bearer | [详情](./other/feedback-records.md)(我的反馈历史,pending/adopted/rejected |
| **埋点 & 订单上报**(前缀分散;全部 Bearer 除 analytics/events 不强制登录) |||
| **埋点 & 订单上报 / 客户端日志**(前缀分散;全部 Bearer 除 analytics/events、applog/batch 不强制登录) |||
| E1 | `POST /api/v1/analytics/events` | 无 | [详情](./other/analytics-events.md)(批量上报埋点事件,不强制登录,每批最多200条) |
| E2 | `POST /api/v1/order/report` | Bearer | [详情](./other/order-report.md)(上报归因订单,比价后5分钟内点链接+支付金额与比价价相差≤1元) |
>>>>>>> origin/main
| E3 | `POST /api/v1/applog/batch` | 无 | [详情](./other/applog-batch.md)(批量上报客户端运行日志,逐条落独立文件供 Logtail 采进 SLS,不强制登录,每批≤500条) |
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform/platform-stats.md) |
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./savings/platform-savings-feed.md) |
| 40a | `GET /api/v1/platform/flags` | 无 | [详情](./platform/platform-flags.md)(客户端运营 feature flag,比价/领券期广告开关等,拉取后缓存) |
| 40b | `GET /api/v1/platform/ad-config` | 无 | [详情](./platform/platform-ad-config.md)(客户端拉广告配置:穿山甲 app_id+各位ID+各场景开关;不含验签密钥) |
| 40c | `GET /api/v1/platform/app-version` | 无 | [详情](./platform/platform-app-version.md)(最新 App 版本,OTA 检查更新;与本机 versionCode 比) |
| 40d | `GET /api/v1/platform/huawei-review` | 无 | [详情](./platform/platform-huawei-review.md)(华为审核开关:快速设置权限步能否被用户关闭;仅华为 ROM 客户端拉) |
| **微信支付回调**(前缀 `/api/v1/wxpay` |||
| W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) |
| **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
@@ -172,7 +154,6 @@
| A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) |
| A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) |
| A14 | `GET /admin/api/config``PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) |
| A16 | `GET / PATCH /admin/api/huawei-review` | operator/tech | 华为审核开关(快速设置权限步能否被用户关闭,落 `app_config.huawei_review`;C 端只读版见 40d)(无单独文档,见 `app/admin/routers/huawei_review.py`) |
| **A·管理员与角色**(super_admin):`GET`/`POST` `/admins``PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles``GET /roles/catalog``PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) |
| A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
| **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links``POST /orders/reconcile`(美团+京东 #90)、`GET /orders``/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) |
+87
View File
@@ -0,0 +1,87 @@
# POST /api/v1/applog/batch — 批量上报客户端运行日志
> 所属:客户端日志组(前缀 `/api/v1/applog` | 鉴权:无(不强制登录,`user_id` 可选带上) | [← 返回 API 索引](../README.md)
批量接收客户端 App 运行日志(自动化步骤 / 网络 / 崩溃 / 调试等),**逐条**封装成单行 JSON 写入独立滚动文件 `logs/app-client.log`(与服务日志 `app-server.log` 分开),由阿里云 Logtail 采进**独立 SLS logstore****不落库**。服务端补 `client_ip`X-Forwarded-For)与 `time`(接收时间,SLS 主时间)。`trace_id` 提到输出行顶层,便于在 SLS 里跨「客户端 / 服务端」两个 logstore 按 trace 拼出端到端链路。**fire-and-forget**:写失败也返回 2xx,不 500。
## 入参
批级公共字段发一次;`logs` 里每条只带日志本身。**每条只有 `client_ts/level/trace_id/tag/msg` 会提到输出顶层,其余自定义字段一律并入输出的 `data`**(防 SLS 索引列爆炸)。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `device_id` | string | ✅(≤64 | 设备 ID(限流 / 分组键) |
| `user_id` | int \| null | ❌ | 登录用户 ID(未登录可空) |
| `app_ver` | string \| null | ❌(≤32 | App 版本 |
| `platform` | string \| null | ❌(≤16 | 平台(`android` / `ios` / `harmony` |
| `sent_at` | int \| null | ❌ | 批次发送时间(epoch ms) |
| `logs` | list[object] | ✅(1-500 条) | 日志数组(每条为对象,内部字段**不强类型**) |
| `logs[].client_ts` | int | ❌ | 端侧日志时间(epoch ms) |
| `logs[].level` | string | ❌ | 级别(服务端归一化大写,截断 ≤16) |
| `logs[].trace_id` | string \| null | ❌ | 关联服务端比价链路的 trace(有服务端交互的日志带上,截断 ≤256) |
| `logs[].tag` | string \| null | ❌ | 模块 / 分类(截断 ≤128) |
| `logs[].msg` | string | ❌ | 消息主体(超 8KB **字节**截断,加 `msg_truncated` 标记) |
| `logs[].*` | any | ❌ | 其余任意自定义字段 → 一律并入输出的 `data`(客户端自带的 `data` 对象会被合并进来) |
Mock 入参:
```json
{
"device_id": "android_abc123def456",
"user_id": 42,
"app_ver": "0.1.5",
"platform": "android",
"sent_at": 1719993700000,
"logs": [
{
"client_ts": 1719993600000,
"level": "info",
"tag": "automation",
"msg": "compare flow start",
"trace_id": "t_ab12cd34"
},
{
"client_ts": 1719993615000,
"level": "error",
"tag": "network",
"msg": "timeout calling /price/step",
"trace_id": "t_ab12cd34",
"http_status": 504,
"retry": 2
}
]
}
```
> 上例第二条的 `http_status` / `retry` 不在白名单 → 会被并入落盘行的 `data`:`{"http_status":504,"retry":2}`
## 出参
响应 `200`:`AppLogIngestOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `ok` | bool | 固定 `true` |
| `received` | int | 成功写入文件的条数 |
| `dropped` | int | 跳过的条数(服务端处理 / 序列化失败;正常为 `0` |
Mock 出参:
```json
{
"ok": true,
"received": 2,
"dropped": 0
}
```
## 错误码
- `413` 请求体超过上限(默认 2MB;服务端查 `Content-Length`,在 body 校验前拦截。缺该头时由 nginx `client_max_body_size` 兜底)
- `422` `logs` 为空或超过 500 条 / `device_id` 缺失 / 字段类型不符
- `429` 触发限流(同 IP 每分钟 > 120 次)
## 说明
- **落盘 → SLS**:逐条写独立文件 `logs/app-client.log`(单行 JSON`propagate=False` 不污染 `app-server.log`),由 Logtail JSON 模式采进**独立 logstore**;不进数据库。落盘行除白名单字段外,服务端另补 `time`(接收时间)、`source="client"``service``client_ip` 及批级 `device_id/user_id/app_ver/platform/sent_at`
- **`trace_id` 跨层检索**:字段名与服务端日志一致。客户端应给**有服务端交互**的日志带上当初 API 返回的 `trace_id`(如 `/api/v1/price/step` 等透传族返回的 trace),即可在 SLS 里 `trace_id: "xxx"` 一查拼出「客户端视角 + 服务端比价链路」;纯客户端日志不带即可。
- **白名单 + `data` 兜底**:只有 `client_ts/level/trace_id/tag/msg` 上顶层,其余键并入 `data`,把 SLS 索引列钉死在固定集合,防客户端任意 key 撑爆索引。索引字段另有长度上限(`level`≤16 / `trace_id`≤256 / `tag`≤128 / `msg`≤8KB)。
- **fire-and-forget**:写文件失败也返回 2xx(避免客户端重试风暴);网络重试可能在 SLS 造成重复条目,可接受。
- **量级建议**:客户端做等级过滤 / 采样,攒到一定量再批量上报;单批 ≤500 条、body ≤2MB。
- `client_ts` 是端侧时间(客户端时钟不可信),`time` 由服务端补(可靠时间轴)。
- `user_id` 不靠 JWT:未登录态也要采日志;带上便于按用户排查。
@@ -0,0 +1,224 @@
CheckSmsVerifyCode - 核验验证码
更新时间:2026年3月19日 20:02:53
核验短信验证码并返回核验是否成功的结果。
调试
您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。
调试
授权信息
下表是API对应的授权信息,可以在RAM权限策略语句的Action元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
操作:是指具体的权限点。
访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
资源类型:是指操作中支持授权的资源类型。具体说明如下:
对于必选的资源类型,用前面加 * 表示。
对于不支持资源级授权的操作,用全部资源表示。
条件关键字:是指云产品自身定义的条件关键字。
关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
放大查看
操作
访问级别
资源类型
条件关键字
关联操作
dypns:CheckSmsVerifyCode
none
*全部资源
*
无 无
请求参数
放大查看
名称
类型
必填
描述
示例值
SchemeName
string
方案名称,如果不填则为“默认方案”。最多不超过 20 个字符。
重要 如果发送接口的方案名称不为空,请确保该参数不为空且与发送接口的方案名称参数一致
测试方案
CountryCode
string
号码国家编码,默认为 86。
86
PhoneNumber
string
手机号。
186****0000
OutId
string
外部流水号。
12123231
VerifyCode
string
验证码。
说明
SendSmsVerifyCode 接口的字段 TemplateParam,配置方式有 2 种:
{"code":"##code##","min":"5"}
{"code":"123456","min":"5"}
{"code":"##code##","min":"5"}验证码是 api 动态生成的,阿里云接口可以完成校验。
{"code":"123456","min":"5"}验证码是用户配置的不是 api 动态生成,阿里云接口无法校验。
请您按照实际情况传入对应的验证码。
1231
CaseAuthPolicy
integer
验证码大小写字母核验策略。取值:
1:不区分大小写。
2:区分大小写。
1
返回参数
放大查看
名称
类型
描述
示例值
object
AccessDeniedDetail
string
访问被拒绝详细信息。
Message
string
状态码的描述。
成功
Model
object
请求结果数据。
OutId
string
外部流水号。
1212312
VerifyResult
string
短信验证码核验结果。取值:
PASS:短信验证码核验成功。
UNKNOWN:短信验证码核验失败。
PASS
Code
string
接口请求状态码。
返回 OK 代表请求成功。
其他错误码,请参见返回码。
重要 接口请求成功不代表短信验证码核验成功,短信验证码核验结果仅以Model.VerifyResult参数返回值为准。
OK
Success
boolean
接口调用是否成功。取值:
true:接口调用成功。
false:接口调用失败。
重要 接口调用成功不代表短信验证码核验成功,短信验证码核验结果仅以Model.VerifyResult参数返回值为准。
true
RequestId
string
CF8854E5-DB21-3E5D-A9B1-DDC752FD7384
示例
正常返回示例
JSON格式
放大查看复制代码
{
"AccessDeniedDetail": "无",
"Message": "成功",
"Model": {
"OutId": "1212312",
"VerifyResult": "PASS"
},
"Code": "OK",
"Success": true,
"RequestId": "CF8854E5-DB21-3E5D-A9B1-DDC752FD7384"
}
@@ -0,0 +1,396 @@
SendSmsVerifyCode - 发送短信验证码
更新时间:2026年7月3日 09:54:53
发送短信验证码。
接口说明
由于运营商近期加强对短信签名的管控。您自定义的签名面临下发失败问题,推荐您使用号码认证控制台赠送的短信签名和模板进行短信认证。系统赠送签名必须搭配系统赠送模板使用。
请确保在使用该接口前,已充分了解号码认证服务产品的收费方式和价格,短信认证服务仅收取短信发送费用(按运营商回执状态计费,短信提交成功但运营商回执失败时不计费),核验服务免费。
调试
您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。
调试
授权信息
下表是API对应的授权信息,可以在RAM权限策略语句的Action元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
操作:是指具体的权限点。
访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
资源类型:是指操作中支持授权的资源类型。具体说明如下:
对于必选的资源类型,用前面加 * 表示。
对于不支持资源级授权的操作,用全部资源表示。
条件关键字:是指云产品自身定义的条件关键字。
关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
放大查看
操作
访问级别
资源类型
条件关键字
关联操作
dypns:SendSmsVerifyCode
create
*全部资源
*
无 无
请求参数
放大查看
名称
类型
必填
描述
示例值
SchemeName
string
方案名称,如果不填则为“默认方案”。最多不超过 20 个字符。
测试方案
CountryCode
string
号码国家编码。默认为 86,目前也仅支持国内号码发送。
86
PhoneNumber
string
短信接收方手机号。
130****0000
SignName
string
签名名称。暂不支持使用自定义签名,请使用系统赠送的签名,您可在赠送签名配置页面选择需要下发的签名。
恒创联众
TemplateCode
string
短信模板 CODE。参数SignName选择赠送签名时,必须搭配赠送模板下发短信。您可在赠送模板配置页面选择适用您业务场景的模板。
100001
TemplateParam
string
短信模板参数。验证码位置有两种传值方式:
可使用"##code##"替代,由参数 CodeType 指定验证码生成规则;
也可直接传入具体的验证码值,直接下发至接收方。
示例:如模板内容为:“您的验证码是${code},有效期${min}分钟,请勿告诉他人。”。
重要 上文中的 code 请替换成您实际申请的验证码模板中的参数名称
该字段可传入{"code":"##code##","min":"5"}由系统根据规则生成验证码;
或直接传入指定的验证码值{"code":"123456","min":"5"}。
说明
{"code":"##code##","min":"5"}验证码是 api 动态生成的,阿里云接口可以完成校验。
{"code":"123456","min":"5"}验证码是用户配置的不是 api 动态生成,阿里云接口无法校验。
说明
如果 JSON 中需要带换行符,请参照标准的 JSON 协议处理。
模板变量规范,请参见短信模板规范。
{"code":"##code##","min":"5"}
SmsUpExtendCode
string
上行短信扩展码。上行短信指发送给通信服务提供商的短信,用于定制某种服务、完成查询,或是办理某种业务等,需要收费,按运营商普通短信资费进行扣费。
说明
扩展码是生成签名时系统自动默认生成的,不支持自行传入。无特殊需要此字段的用户请忽略此字段。如需使用,请联系您的商务经理。
1213123
OutId
string
外部流水号。
外部流水号(透传)
CodeLength
integer
验证码长度支持 4~8 位长度,默认是 4 位。
4
ValidTime
integer
验证码有效时长,单位秒,默认为 300 秒。
300
DuplicatePolicy
integer
核验规则,当有效时间内对同场景内的同号码重复发送验证码时,旧验证码如何处理。
1:覆盖处理(默认),即旧验证码会失效掉。
2:保留,即多个验证码都是在有效期内都可以校验通过。
枚举值:
1 :
覆盖
2 :
保留
1
Interval
integer
时间间隔,单位:秒。即多久间隔可以发送一次验证码,用于频控,默认 60 秒。
60
CodeType
integer
生成的验证码类型。当参数 TemplateParam 传入占位符时,此参数必填,将由系统根据指定的规则生成验证码。取值:
1:纯数字(默认)。
2:纯大写字母。
3:纯小写字母。
4:大小字母混合。
5:数字+大写字母混合。
6:数字+小写字母混合。
7:数字+大小写字母混合。
枚举值:
1 :
纯数字
2 :
纯大写字母
3 :
纯小写字母
4 :
大小字母混合
5 :
数字+大写字母混合
6 :
数字+小写字母混合
7 :
数字+大小写字母混合
1
ReturnVerifyCode
boolean
是否返回验证码。取值:
true:返回。
false:不返回。
true
AutoRetry
integer
是否自动替换签名重试(默认开启),可取值:
1 开启自动重试功能,开启后,在验证码有效期内,当运营商返回明确的失败状态时,允许阿里云尽可能的尝试使用其他方式发送验证码,以提升发送成功率。其他方式包括且不限于:通过其他运营商重试、更换签名重试等
0 不开启自动重试
是否自动重试
返回参数
放大查看
名称
类型
描述
示例值
object
AccessDeniedDetail
string
访问被拒绝详细信息。
Message
string
状态码的描述。
成功
RequestId
string
请求 ID。
CC3BB6D2-2FDF-4321-9DCE-B38165CE4C47
Model
object
请求结果数据。
VerifyCode
string
验证码。
4232
RequestId
string
请求 ID。
a3671ccf-0102-4c8e-8797-a3678e091d09
OutId
string
外部流水号。
1231231313
BizId
string
业务 ID。
112231421412414124123^4
Code
string
请求状态码。返回 OK 代表请求成功。其他错误码,请参见返回码列表。
OK
Success
boolean
请求是否成功。
true:请求成功。
false:请求失败。
true
示例
正常返回示例
JSON格式
放大查看复制代码
{
"AccessDeniedDetail": "无",
"Message": "成功 ",
"RequestId": "CC3BB6D2-2FDF-4321-9DCE-B38165CE4C47",
"Model": {
"VerifyCode": "4232",
"RequestId": "a3671ccf-0102-4c8e-8797-a3678e091d09",
"OutId": "1231231313",
"BizId": "112231421412414124123^4"
},
"Code": "OK",
"Success": true
}
错误码
放大查看
HTTP status code
错误码
错误信息
描述
400 MOBILE_NUMBER_ILLEGAL The mobile number is illegal. 手机号码格式错误
400 BUSINESS_LIMIT_CONTROL The number has exceeded the limit for the day. 触发号码天级流控
400 FREQUENCY_FAIL Check frequency fail. 频控校验未通过
400 INVALID_PARAMETERS parameter is not valid. 非法参数
400 FUNCTION_NOT_OPENED You have not opened this function. 没有开通融合认证功能
+117
View File
@@ -0,0 +1,117 @@
# 创蓝云智(253/蓝创云智)模板短信 v2 发送接口
> 官方文档:<https://doc.chuanglan.com/document/HAQYSZKH9HT5Z50L>
> 用途:手机号 + 验证码登录的**验证码短信下发**(本服务生成码 → 创蓝只负责发送,属自管码 Mode B,与极光同模式)。
> 本文件为**接口调研摘要**,供 `app/integrations/sms/chuanglan.py` 实现对照。以线上文档为准。
## 接口概览
| 项 | 值 |
|---|---|
| 请求地址 | `POST https://smssh.253.com/msg/sms/v2/tpl/send` |
| Content-Type | `application/json`UTF-8 |
| 协议 | HTTPS |
| 鉴权 | HMAC-SHA256 签名头 `X-QA-Hmac-Signature`(推荐)**或** body 明文 `password`(二选一) |
## 鉴权:两种方式(二选一,不可并用)
1. **HMAC 签名头(推荐,密码不上行)**:请求头带 `X-QA-Hmac-Signature`body **不放** `password`
2. **明文密码**body 放 `password`,不带签名头。
### HMAC-SHA256 签名算法
1. `md5Password = MD5(password)` —— 32 位**小写十六进制**。
2. 取三个值 `[md5Password, timestamp, nonce]`,**按字典序升序排序**,**无分隔符拼接**,再 `replaceAll("\\s+", "")` 去除所有空白。
3. `signature = HmacSHA256(key = md5Password, message = 上一步拼接串)` —— 输出**小写十六进制**。
4. 放入请求头:`X-QA-Hmac-Signature: <signature>`
> 注意 `key` 就是 `md5Password` 本身(32 位 hex 字符串),不是原始 password。`timestamp` / `nonce` 同时也是 body 字段,必须与签名里用的一致。
## 请求参数(bodyJSON
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `account` | String | 是 | API 账号;验证码短信用 **`YZM` 前缀**账号(如 `YZM0000001` |
| `timestamp` | String | 是 | Unix 秒级时间戳;**60 秒**内有效,过期报 139 |
| `nonce` | String | 是 | 32 位随机串(防重放) |
| `phoneNumbers` | String | 是 | 手机号,逗号分隔最多 1000 个;**YZM 验证码账号不支持批量,只能单号** |
| `templateId` | String | 是 | 模板 ID(控制台创建 / 模板接口查询) |
| `templateParamJson` | String | 条件 | 模板变量,JSON 字符串;模板有 `{s}` 占位符时必填(见下) |
| `password` | String | 条件 | 仅在**不使用**签名头时放 body |
| `signature` | String | 条件 | **短信签名文案**(如 `【创蓝云智】`);模板未关联签名时必填。**注意与鉴权头 `X-QA-Hmac-Signature` 是两回事** |
| `report` | String | 否 | `"true"` 时接收状态回执 |
| `callbackUrl` | String | 否 | 回执回调完整 URL |
| `uid` | String | 否 | 自定义标识(≤256 字符),回执原样返回 |
| `extend` | String | 否 | 数字扩展码(≤5 位),用于上行匹配 |
### `templateParamJson` 格式与 `{s}` 占位
- 模板内容用 `{s}` 作占位符,例:`您正在申请手机注册,验证码为:{s},5分钟内有效!`
- `templateParamJson`**JSON 数组,元素为对象**,键按 `param1``param2`…递增;第 1 个 `{s}``param1`,第 2 个 ← `param2`
- 单条验证码(一个 `{s}` = 验证码)示例:`"[{\"param1\":\"123456\"}]"`
## 响应格式
```json
{
"code": "000000",
"msgId": "25071018345400902898000000000001",
"time": "20250710183454",
"successNum": "1",
"failNum": "0",
"errorMsg": ""
}
```
| 字段 | 说明 |
|---|---|
| `code` | 状态码,`"000000"` = 成功 |
| `msgId` | 消息 ID32 位) |
| `time` | 响应时间戳 |
| `successNum` / `failNum` | 提交成功 / 失败条数 |
| `errorMsg` | 错误描述(成功为空) |
## 响应 / 错误码(节选)
| code | 含义 | 归属 |
|---|---|---|
| `000000` | 成功 | — |
| `101` | 账号不存在 | 客服 |
| `102` | 密码错误 | 客服 |
| `103` | 提交速度过快(超频) | 客服 |
| `107` | 手机号码错误 | 客服 |
| `109` | 无发送量(余额/套餐不足) | 销售 |
| `110` | 不在发送时段 | 销售 |
| `116` | 签名不合法 / 未带签名 | 服务 |
| `117` | IP 未加白名单 | 服务 |
| `118` | 账号无发送权限 | 服务 |
| `124` | 模板内容不匹配 | 服务 |
| `129` | JSON 格式错误 | 客服 |
| `135` | 相同手机号内容重复 | 客服 |
| `139` | 时间戳过期 | 客服 |
| `152` | 模板不存在 | 服务 |
| `158` | 退订文案不合规 | 客服 |
## 完整请求示例(单条验证码,明文密码方式省略 password 用签名头)
```json
{
"account": "YZM0000001",
"timestamp": "1752143733",
"nonce": "x4zfk0y5foqwx6cbnw3bfmimy98abqs1",
"phoneNumbers": "17601337176",
"templateId": "1021143438",
"templateParamJson": "[{\"param1\":\"123456\"}]",
"report": "true"
}
```
HMAC 方式:另加请求头 `X-QA-Hmac-Signature: <算法输出>`body 不含 `password`。)
## 接入要点
- **IP 白名单**:服务器出网 IP 必须在控制台加白,否则 117。
- **验证码账号(YZM**:无发送时段限制;单号发送、不支持批量。
- **时间戳 60s**`timestamp` 与本地时钟偏差过大会 139,注意服务器时间同步。
- **退订文案**:仅支持 `拒收请回复R`,且必须在短信末尾(验证码短信一般无需)。
- **签名 vs 鉴权头**`signature`body= 短信开头的 `【品牌】` 文案;`X-QA-Hmac-Signature`(header)= 请求鉴权。二者含义完全不同,勿混。
+19 -2
View File
@@ -1,9 +1,26 @@
# 短信验证码(sms
> 文件:`app/integrations/sms.py` | 关联接口:[auth-sms-send](../api/auth-sms-send.md) · [auth-sms-login](../api/auth-sms-login.md) | [← 集成索引](./README.md)
> 文件:`app/integrations/sms/`(分派器 `__init__` + `jiguang` / `aliyun` provider + `base`) | 关联接口:[auth-sms-send](../api/auth-sms-send.md) · [auth-sms-login](../api/auth-sms-login.md) | [← 集成索引](./README.md)
## 作用
手机号 + 验证码登录的验证码发送 / 校验。**已接极光短信 REST**,由 `SMS_MOCK` 切 mock / real。
手机号 + 验证码登录的验证码发送 / 校验。支持**可切换 provider**(`SMS_PROVIDER`):`jiguang`(默认,极光自管码)/ `aliyun`(阿里云号码认证托管码)/ `chuanglan`(创蓝云智模板短信,自管码)。`SMS_MOCK` 切 mock / real。
## 短信提供商(`SMS_PROVIDER`,可切换 + 灰度回退)
| | `jiguang`(默认) | `aliyun` | `chuanglan` |
|---|---|---|---|
| 验证码模式 | 自管码 Mode B | 托管码 Mode A | 自管码 Mode B(与极光同) |
| 验证码 | **本服务生成**、极光只下发、**本地内存校验** | **阿里云生成 + 下发 + 校验**(dypns 号码认证,核验免费) | **本服务生成**、创蓝只下发、**本地内存校验** |
| 发码 | 极光 `/v1/messages` | `SendSmsVerifyCode`(`##code##` 占位) | 创蓝 `tpl/send` v2(HMAC 签名头,password 不上行) |
| 校验 | 比对本地存码 | `CheckSmsVerifyCode``PASS` / `UNKNOWN` | 比对本地存码 |
| 多 worker | ⚠️ 内存存码不共享(见已知局限) | ✅ 阿里云托管,天然共享 | ⚠️ 内存存码不共享(与极光同级债) |
| 防爆破 | 单码失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 | **同语义**(本地 per-phone 失败计数) | **同语义**(复制自极光) |
| 单号冷却 | 本地 `SMS_SEND_INTERVAL_SEC` | 交给阿里云 `Interval` | 本地 `SMS_SEND_INTERVAL_SEC` |
| 依赖 | `httpx`(复用) | SDK `alibabacloud_dypnsapi20170525` | `httpx` + 标准库 `hashlib`/`hmac`(**无新依赖**) |
- `aliyun` 需在**号码认证控制台开通「融合认证」**,用系统赠送签名 + 赠送模板;配置见 `.env.example``ALIYUN_SMS_*`,SDK 为 `alibabacloud_dypnsapi20170525`
- `chuanglan`**YZM 前缀验证码账号**,服务器出网 IP 须在创蓝控制台**加白名单**(否则 117);Mode B 存码/冷却/校验逻辑是**从极光隔离复制**(极光文件不动),仅发送走 HMAC 签名。配置见 `.env.example``CHUANGLAN_SMS_*`,接口调研见 [chuanglan/tpl-send.md](chuanglan/tpl-send.md),设计见 [spec](../superpowers/specs/2026-07-26-chuanglan-sms-verify-design.md)。
**以下章节描述 `jiguang` provider(自管码)细节**(`chuanglan` 的存码/冷却/校验语义与之相同)。
| | mock(`SMS_MOCK=true`,默认 / 开发测试) | real(`SMS_MOCK=false`,生产) |
|---|---|---|
@@ -0,0 +1,561 @@
# 客户端运行日志批量上报 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:** 新增 `POST /api/v1/applog/batch`,把客户端批量上报的运行日志逐条写进独立滚动文件 `logs/app-client.log`,供 Aliyun Logtail 采进独立 SLS logstore。
**Architecture:** 复用两个现成范式——analytics 的「批量入口 + 服务端补 IP」+ `app-server.log` 的「单行 JSON + RotatingFileHandler + Logtail」。核心逻辑在写入层 `app/core/client_log.py`:专用 `client` logger`propagate=False`,不污染服务日志),每条按「白名单键提顶层 + 其余并入 `data`」封装成一行 JSON`trace_id` 提到顶层以便跨层检索。端点瘦、fire-and-forget(写失败不 500)。
**Tech Stack:** FastAPI、Pydantic v2、Python `logging.handlers.RotatingFileHandler`、pytest + `TestClient`
**Spec:** [docs/superpowers/specs/2026-07-19-client-applog-ingest-design.md](../specs/2026-07-19-client-applog-ingest-design.md)
---
## File Structure
| 文件 | 职责 | 动作 |
|---|---|---|
| `app/core/client_log.py` | 专用 logger/handler、滚动配置、白名单+`data` 兜底与信封拼装、`write_records()` | Create |
| `app/schemas/applog.py` | `AppLogBatchIn``logs: list[dict]`/ `AppLogIngestOut` | Create |
| `app/api/v1/applog.py` | 瘦路由:body 上限依赖 → 调 writer → 返回计数 | Create |
| `app/main.py` | 注册 `applog_router` | Modify |
| `tests/test_applog.py` | writer 单测 + 端点集成测试 | Create |
前置约束(写进代码注释):`RotatingFileHandler` 多进程并发 `doRollover()` 会损坏/丢日志;本方案依赖生产 `--workers 1`(与限流器/SMS 码/SQLite 同一既有假设)。扩 worker 前必须换 `QueueHandler`→单写入者 / 外部 logrotate / 写 stdout 交 journald。
---
## Task 1: 专用落盘 writer `app/core/client_log.py`
**Files:**
- Create: `app/core/client_log.py`
- Test: `tests/test_applog.py`
- [ ] **Step 1: 写失败测试(writer 层)**
创建 `tests/test_applog.py`
```python
"""客户端运行日志上报:writer 单测 + 端点集成测试。"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from app.core import client_log
@pytest.fixture()
def client_log_file(tmp_path, monkeypatch):
"""把客户端日志切到临时文件,并重置 writer 单例使其按当时 env 重建。"""
p = tmp_path / "app-client.log"
monkeypatch.setenv("CLIENT_LOG_FILE", str(p))
client_log.reset_client_logger()
yield p
client_log.reset_client_logger()
def _read_lines(p: Path) -> list[dict]:
text = p.read_text(encoding="utf-8").strip()
return [json.loads(ln) for ln in text.splitlines() if ln]
# ---------------- writer 层 ----------------
def _meta(**kw) -> dict:
base = {"device_id": "d-1", "user_id": None, "app_ver": None,
"platform": None, "sent_at": None}
base.update(kw)
return base
def test_writer_writes_one_line_per_record(client_log_file):
recs = [
{"client_ts": 1737000000000, "level": "info", "msg": "hello"},
{"client_ts": 1737000000001, "level": "error", "msg": "boom", "tag": "net"},
]
received, dropped = client_log.write_records(
recs, meta=_meta(user_id=42, app_ver="1.2.3", platform="android"),
client_ip="1.2.3.4",
)
assert (received, dropped) == (2, 0)
lines = _read_lines(client_log_file)
assert len(lines) == 2
assert lines[0]["source"] == "client"
assert lines[0]["service"] == "app-client"
assert lines[0]["device_id"] == "d-1"
assert lines[0]["user_id"] == 42
assert lines[0]["app_ver"] == "1.2.3"
assert lines[0]["client_ip"] == "1.2.3.4"
assert lines[0]["level"] == "INFO" # 归一化大写
assert lines[0]["msg"] == "hello"
assert lines[0]["client_ts"] == 1737000000000
assert lines[1]["tag"] == "net"
def test_writer_hoists_trace_id_to_top_level(client_log_file):
client_log.write_records(
[{"client_ts": 1, "level": "info", "msg": "x", "trace_id": "abc123"}],
meta=_meta(), client_ip="",
)
assert _read_lines(client_log_file)[0]["trace_id"] == "abc123"
def test_writer_sweeps_unknown_keys_into_data(client_log_file):
client_log.write_records(
[{"client_ts": 1, "level": "info", "msg": "x",
"foo": 123, "data": {"bar": "baz"}}],
meta=_meta(), client_ip="",
)
line = _read_lines(client_log_file)[0]
assert "foo" not in line # 白名单外不进顶层
assert line["data"]["foo"] == 123 # 兜底进 data
assert line["data"]["bar"] == "baz" # 客户端自带 data 合并进来
def test_writer_truncates_oversize_msg(client_log_file, monkeypatch):
monkeypatch.setenv("APPLOG_MAX_MSG_BYTES", "10")
received, dropped = client_log.write_records(
[{"client_ts": 1, "level": "info", "msg": "x" * 100}],
meta=_meta(), client_ip="",
)
assert (received, dropped) == (1, 0) # 截断而非丢弃
line = _read_lines(client_log_file)[0]
assert line["msg_truncated"] is True
assert line["msg"].endswith("…[truncated]")
def test_writer_logger_does_not_propagate(client_log_file):
lg = client_log.get_logger()
assert lg.name == "client"
assert lg.propagate is False # 不冒泡到 root → 不写 app-server.log
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_applog.py -q`
Expected: FAIL —— `AttributeError: module 'app.core.client_log' has no attribute 'reset_client_logger'`(模块尚不存在)。
- [ ] **Step 3: 实现 `app/core/client_log.py`**
```python
"""客户端运行日志专用落盘 writer(独立于服务端 app-server.log)。
- 独占 logger "client" + 自己的 RotatingFileHandler,propagate=False → 不污染 app-server.log。
- 每条按「白名单键(client_ts/level/trace_id/tag/msg)提顶层 + 其余并入 data」封装,再
json.dumps 成一行写出(钉死 SLS 索引列;见 spec §5)。formatter 用 %(message)s——行本身
已是 JSON,不能再过 JsonFormatter 二次编码。
- 滚动 20MB×10(env 可调),与服务日志同机制。
⚠️ 依赖 --workers 1:RotatingFileHandler 多进程并发 doRollover 会损坏/丢日志;扩 worker
前换 QueueHandler→单写入者 / 外部 logrotate(copytruncate) / 写 stdout 交 journald。
服务端补的字段(time/source/service/client_ip/device_id/...)是「事实」,与客户端自述分开。
`time` 用服务端接收时间作 SLS 主时间(客户端时钟不可信),client_ts 另存为可查字段。
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
# 仅这些客户端键提到输出行顶层;其余(含客户端自带 data)一律并入 data,防 SLS 索引列爆炸
_TOP_LEVEL_KEYS = ("client_ts", "level", "trace_id", "tag", "msg")
_logger: logging.Logger | None = None
def _max_msg_bytes() -> int:
return int(os.getenv("APPLOG_MAX_MSG_BYTES", "8192"))
def _build_logger() -> logging.Logger:
lg = logging.getLogger("client")
lg.setLevel(logging.INFO)
lg.propagate = False # 不冒泡到 root → 不写进 app-server.log
log_file = os.getenv("CLIENT_LOG_FILE") or str(
Path(os.getenv("LOG_DIR", "logs")) / "app-client.log"
)
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
log_file,
maxBytes=int(os.getenv("CLIENT_LOG_MAX_BYTES", str(20 * 1024 * 1024))),
backupCount=int(os.getenv("CLIENT_LOG_BACKUP_COUNT", "10")),
encoding="utf-8",
)
handler.setFormatter(logging.Formatter("%(message)s")) # 行已是 JSON,不再包装
lg.handlers = [handler]
return lg
def get_logger() -> logging.Logger:
global _logger
if _logger is None:
_logger = _build_logger()
return _logger
def reset_client_logger() -> None:
"""测试用:关闭并丢弃当前 logger,使下次 get_logger 按当时 env 重建(切临时文件)。"""
global _logger
if _logger is not None:
for h in list(_logger.handlers):
h.close()
_logger.handlers = []
_logger = None
def _truncate_msg(msg: str) -> tuple[str, bool]:
raw = msg.encode("utf-8")
limit = _max_msg_bytes()
if len(raw) <= limit:
return msg, False
# 按字节截断后解码,忽略截断处半个多字节字符
return raw[:limit].decode("utf-8", "ignore") + "…[truncated]", True
def _build_line(
record: dict, *, meta: dict, client_ip: str, service: str, now_iso: str
) -> str:
out: dict = {
"time": now_iso,
"source": "client",
"service": service,
"client_ip": client_ip,
}
# 批级公共字段(非空才带)
for k in ("device_id", "user_id", "app_ver", "platform", "sent_at"):
v = meta.get(k)
if v is not None:
out[k] = v
# 白名单键提顶层
if record.get("level") is not None:
out["level"] = str(record["level"]).upper()
if record.get("trace_id"):
out["trace_id"] = record["trace_id"]
if record.get("tag"):
out["tag"] = record["tag"]
if record.get("client_ts") is not None:
out["client_ts"] = record["client_ts"]
if record.get("msg") is not None:
msg, truncated = _truncate_msg(str(record["msg"]))
out["msg"] = msg
if truncated:
out["msg_truncated"] = True
# 其余键(含客户端自带 data)并入 data
data: dict = {}
client_data = record.get("data")
if isinstance(client_data, dict):
data.update(client_data)
for k, v in record.items():
if k in _TOP_LEVEL_KEYS or k == "data":
continue
data[k] = v
if data:
out["data"] = data
return json.dumps(out, ensure_ascii=False, default=str)
def write_records(
records: list[dict], *, meta: dict, client_ip: str
) -> tuple[int, int]:
"""把一批客户端日志逐条写入专用文件。返回 (received, dropped)。
尽力而为(fire-and-forget):logger 初始化或单条写入失败只跳过并计 dropped,
不抛给上层——端点因此永不因写日志而 500。
"""
try:
lg = get_logger()
except Exception: # noqa: BLE001 — 初始化失败也不能让端点 500
logging.getLogger("shagua.applog").exception("client log writer init failed")
return 0, len(records)
service = os.getenv("CLIENT_LOG_SERVICE_NAME", "app-client")
now_iso = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
received = dropped = 0
for rec in records:
try:
line = _build_line(
rec, meta=meta, client_ip=client_ip, service=service, now_iso=now_iso
)
lg.info(line)
received += 1
except Exception: # noqa: BLE001 — 坏条跳过,不影响其余
dropped += 1
return received, dropped
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pytest tests/test_applog.py -q`
Expected: PASS5 个 writer 测试全绿)。
- [ ] **Step 5: ruff**
Run: `ruff check app/core/client_log.py tests/test_applog.py`
Expected: 无错误(如有 import 排序等自动可修问题:`ruff check --fix`)。
- [ ] **Step 6: 提交**
```bash
git add app/core/client_log.py tests/test_applog.py
git commit -m "feat(applog): 客户端日志专用落盘 writer(白名单+data 兜底, propagate=False)"
```
---
## Task 2: 端点 + schema + 注册
**Files:**
- Create: `app/schemas/applog.py`
- Create: `app/api/v1/applog.py`
- Modify: `app/main.py`import + `include_router`
- Test: `tests/test_applog.py`(追加端点用例)
- [ ] **Step 1: 追加失败测试(端点层)**
`tests/test_applog.py` 末尾追加:
```python
# ---------------- 端点层 ----------------
def _post(client, body):
return client.post("/api/v1/applog/batch", json=body)
def test_endpoint_happy_path(client, client_log_file):
body = {
"device_id": "d-1", "user_id": 42, "app_ver": "1.2.3", "platform": "android",
"logs": [
{"client_ts": 1, "level": "info", "msg": "a", "trace_id": "t1"},
{"client_ts": 2, "level": "warn", "msg": "b"},
],
}
resp = _post(client, body)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "received": 2, "dropped": 0}
lines = _read_lines(client_log_file)
assert len(lines) == 2
assert lines[0]["trace_id"] == "t1" # 端到端:trace_id 落到文件顶层
assert lines[0]["client_ip"] # 服务端补了 IP
def test_endpoint_rejects_batch_over_max(client, client_log_file):
body = {"device_id": "d-1",
"logs": [{"client_ts": i, "level": "info", "msg": str(i)} for i in range(501)]}
resp = _post(client, body)
assert resp.status_code == 422 # Pydantic max_length=500
def test_endpoint_rejects_body_over_limit(client, client_log_file, monkeypatch):
monkeypatch.setenv("APPLOG_MAX_BODY_BYTES", "50")
body = {"device_id": "d-1", "logs": [{"client_ts": 1, "level": "info", "msg": "x" * 500}]}
resp = _post(client, body)
assert resp.status_code == 413 # 依赖查 Content-Length,body 校验前拦截
def test_endpoint_returns_200_on_write_failure(client, client_log_file, monkeypatch):
class _BoomLogger:
name = "client"
propagate = False
handlers: list = []
def info(self, *a, **k):
raise RuntimeError("disk full")
monkeypatch.setattr(client_log, "get_logger", lambda: _BoomLogger())
body = {"device_id": "d-1", "logs": [
{"client_ts": 1, "level": "info", "msg": "a"},
{"client_ts": 2, "level": "info", "msg": "b"}]}
resp = _post(client, body)
assert resp.status_code == 200 # fire-and-forget:写失败不 500
assert resp.json() == {"ok": True, "received": 0, "dropped": 2}
def test_endpoint_missing_device_id_is_422(client, client_log_file):
resp = _post(client, {"logs": [{"client_ts": 1, "level": "info", "msg": "a"}]})
assert resp.status_code == 422 # device_id 必填
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_applog.py -q`
Expected: FAIL —— 端点未注册,`POST /api/v1/applog/batch` 返回 404happy-path 断言 200 失败)。
- [ ] **Step 3: 实现 schema `app/schemas/applog.py`**
```python
"""客户端运行日志批量上报 schema。
批级公共字段(device_id/user_id/app_ver/platform/sent_at)发一次;logs 为原始 dict 列表,
**不强类型**——尽力而为的日志链路,单条内容异常不该让整批 422。每条的「白名单键 + data
兜底」拆分在写入层 [app.core.client_log] 做(见 spec §4/§5)。
"""
from __future__ import annotations
import os
from typing import Any
from pydantic import BaseModel, Field
_MAX_BATCH = int(os.getenv("APPLOG_MAX_BATCH", "500"))
class AppLogBatchIn(BaseModel):
device_id: str = Field(max_length=64)
user_id: int | None = None
app_ver: str | None = Field(default=None, max_length=32)
platform: str | None = Field(default=None, max_length=16)
sent_at: int | None = None
logs: list[dict[str, Any]] = Field(min_length=1, max_length=_MAX_BATCH)
class AppLogIngestOut(BaseModel):
ok: bool = True
received: int
dropped: int = 0
```
- [ ] **Step 4: 实现端点 `app/api/v1/applog.py`**
```python
"""客户端运行日志批量上报接口。
POST /api/v1/applog/batch — 批量接收客户端运行日志,逐条写专用滚动文件 logs/app-client.log
(供 Logtail 采进独立 SLS logstore)。鉴权同 analytics(不强制登录,user_id 可选在 body)。
fire-and-forget:写失败也不 500(避免客户端重试风暴);超批 422、超体积 413、msg 超限截断。
"""
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, HTTPException, Request
from app.core.client_log import write_records
from app.core.ratelimit import rate_limit
from app.schemas.applog import AppLogBatchIn, AppLogIngestOut
router = APIRouter(prefix="/api/v1/applog", tags=["applog"])
def _client_ip(request: Request) -> str:
"""取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 首段,否则直连 IP(同 analytics)。"""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else ""
def _enforce_body_limit(request: Request) -> None:
"""依赖:body 声明过大直接 413(在 body 校验前拦截)。缺 Content-Length 由 nginx 兜底。"""
max_bytes = int(os.getenv("APPLOG_MAX_BODY_BYTES", str(1024 * 1024 * 2)))
cl = request.headers.get("content-length")
if cl is not None and cl.isdigit() and int(cl) > max_bytes:
raise HTTPException(status_code=413, detail="日志批量过大")
@router.post(
"/batch",
response_model=AppLogIngestOut,
summary="批量上报客户端运行日志",
dependencies=[
Depends(rate_limit(120, 60, "applog-batch")),
Depends(_enforce_body_limit),
],
)
def ingest_logs(batch: AppLogBatchIn, request: Request) -> AppLogIngestOut:
received, dropped = write_records(
batch.logs,
meta={
"device_id": batch.device_id,
"user_id": batch.user_id,
"app_ver": batch.app_ver,
"platform": batch.platform,
"sent_at": batch.sent_at,
},
client_ip=_client_ip(request),
)
return AppLogIngestOut(received=received, dropped=dropped)
```
- [ ] **Step 5: 注册路由 `app/main.py`**
在 import 区(analytics_router 之后,约 [app/main.py:23](../../../app/main.py#L23))加:
```python
from app.api.v1.applog import router as applog_router
```
`include_router` 区(`app.include_router(analytics_router)` 之后,约 [app/main.py:125](../../../app/main.py#L125))加:
```python
app.include_router(applog_router)
```
- [ ] **Step 6: 跑测试确认通过**
Run: `pytest tests/test_applog.py -q`
Expected: PASSwriter 5 + 端点 5,共 10 个)。
- [ ] **Step 7: 全量测试 + ruff**
Run: `pytest -q && ruff check app/api/v1/applog.py app/schemas/applog.py app/main.py`
Expected: 全绿、无 lint 错误。
- [ ] **Step 8: 提交**
```bash
git add app/api/v1/applog.py app/schemas/applog.py app/main.py tests/test_applog.py
git commit -m "feat(applog): POST /api/v1/applog/batch 批量上报端点(限流+体积上限+fire-and-forget)"
```
---
## Task 3: `.env.example` 文档化新 env(可选但推荐)
**Files:**
- Modify: `.env.example`
- [ ] **Step 1: 追加 env 说明**
`.env.example` 末尾(或日志相关区块)追加,让运维知道可调项:
```bash
# 客户端运行日志上报(POST /api/v1/applog/batch)→ 落 logs/app-client.log 供 Logtail 采集
# 独立于服务日志 app-server.log;滚动机制同服务日志。默认值见 app/core/client_log.py。
# CLIENT_LOG_FILE=logs/app-client.log
# CLIENT_LOG_MAX_BYTES=20971520 # 单文件 20MB 滚动
# CLIENT_LOG_BACKUP_COUNT=10 # 保留 10 个 → ~200MB 缓冲
# CLIENT_LOG_SERVICE_NAME=app-client # 输出行 service 字段
# APPLOG_MAX_BATCH=500 # 单批最大条数(超 → 422)
# APPLOG_MAX_BODY_BYTES=2097152 # 请求体上限 2MB(超 → 413)
# APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超限截断
```
- [ ] **Step 2: 提交**
```bash
git add .env.example
git commit -m "docs(applog): .env.example 补充客户端日志上报可调 env"
```
> 若仓库无 `.env.example`(以 `git ls-files .env.example` 确认),跳过本任务。
---
## Definition of Done
- [ ] `pytest -q` 全绿(含新增 `tests/test_applog.py` 10 用例)。
- [ ] `ruff check .` 无错误。
- [ ] `POST /api/v1/applog/batch` 手动冒烟:发一批含 `trace_id` 的日志,确认 `logs/app-client.log` 出现对应单行 JSON、`trace_id` 在顶层、未知键落在 `data`,且 `logs/app-server.log` **未**被写入客户端记录。
- [ ] Spec 的运维项(独立 logstore、Logtail JSON 模式采 `app-client.log`、给 `trace_id` 建索引、nginx `client_max_body_size` 对齐)已同步给运维(不在本仓代码内,见 spec §8)。
## Self-Review 结论(作者已核对)
- **Spec 覆盖**:§4 端点/限额 → Task 2;§5 白名单+data 兜底 → Task 1 `_build_line`;§6 落盘/滚动/单 worker 注释 → Task 1;§7 trace_id 顶层 → Task 1 + 端点测试;§9 fire-and-forget → Task 1 `write_records` + 端点测试;§13 env → Task 1/2 读取 + Task 3 文档化。§8 为纯运维配置,列入 Definition of Done。
- **无占位符**:所有步骤含完整代码/命令/预期。
- **类型/命名一致**`write_records(records, *, meta, client_ip) -> (received, dropped)``get_logger()``reset_client_logger()``_TOP_LEVEL_KEYS``AppLogBatchIn/AppLogIngestOut` 在 Task 1/2 间一致引用。
@@ -0,0 +1,200 @@
# 客户端运行日志批量上报 → 落专用文件 → SLS 采集 设计
- 日期:2026-07-19
- 状态:设计已评审,待写实现计划
- 相关代码:[app/core/logging.py](../../../app/core/logging.py)(服务端日志落盘范式)、[app/api/v1/analytics.py](../../../app/api/v1/analytics.py)(批量上报入口范式)、[deploy/shaguabijia-app-server.service](../../../deploy/shaguabijia-app-server.service)`--workers 1`
## 1. 背景与目标
Android 客户端会在本地攒一批 App 运行日志(自动化步骤 / 网络 / 崩溃 / 调试等),批量以 JSON 数组发给后端。后端把这些日志**逐条**写进一个**专用滚动日志文件**,文件滚动规则与服务日志一致;再由阿里云 SLS 的 Logtail 采集该文件、进独立 logstore。
目标:
1. 新增 `POST /api/v1/applog/batch` 接收批量客户端日志。
2. 逐条落进 `logs/app-client.log`**独立于** `app-server.log`),单行 JSON、大小滚动、供 Logtail JSON 模式零正则采集。
3. 客户端日志进**独立 logstore**,但 **可按 `trace_id` 检索**,以便和服务端比价链路跨层对齐。
4. 入口安全:鉴权同 analytics(不强制 Bearer),加体积/条数上限、限流,防撑盘与日志注入。
本方案是两个**已验证范式的组合**:analytics 的「批量入口 + 服务端补 IP/接收时间」+ `app-server.log` 的「单行 JSON + RotatingFileHandler + Logtail」。不是新架构。
## 2. 非目标(本期不做,YAGNI)
- **异步队列 / 后台 writer**`<10 万条/天` ≈ 均值 1.2 条/秒,10× 突发 ~12/秒,请求路径同步写文件足矣。
- **去重**:网络重试会在 SLS 产生重复条目。本期**接受重复**并在文档写明;后续如需,加客户端 `batch_id` 短窗口去重。
- **服务端脱敏**:本期靠**客户端侧**控制等级/采样与不打 PII;服务端只做体积截断。脱敏留作后续 knob。
- **多 worker 支持**:见 §5 的单 worker 约束。
- **入库 / admin 查询界面**:日志的归宿是 SLS,不落 DB。
## 3. 方案总览与备选取舍
**采用 A**:客户端 → 后端接口 → 专用滚动文件 → Logtail → SLS。
| 方案 | 说明 | 为何不选 |
|---|---|---|
| **A(选定)** | 后端中转落文件,Logtail 采集 | — 代码最少、完全复用现成文件→Logtail 管线;AK/SK 不进 APKLogtail 天然提供落盘缓冲+断点续传 |
| B | 客户端直连 SLSProducer SDK / Web-Tracking | 要么把凭证埋进 APK,要么额外跑 STS 换 token 服务;服务端难做鉴权/富化/脱敏 |
| C | 后端调 SLS PutLogs API(不落文件) | 请求路径硬依赖 SLS 可用性,需自建缓冲/背压/重试——等于重造 Logtail |
## 4. 端点契约
`POST /api/v1/applog/batch`
- 新路由 `app/api/v1/applog.py`,在 [app/main.py](../../../app/main.py) `import ... as applog_router``app.include_router(applog_router)`(紧挨 analytics)。
- 鉴权同 analytics**不强制 Bearer**`user_id` 可选放 body;服务端补 `client_ip`(复用 analytics 里的 `_client_ip``X-Forwarded-For` 首段逻辑)与接收时间。
### 请求体 `AppLogBatchIn`(批级公共字段发一次,省带宽)
| 字段 | 类型 | 必填 | 约束/说明 |
|---|---|---|---|
| `device_id` | str | 是 | `max_length=64`;限流/分组键 |
| `user_id` | int? | 否 | 可选,未登录态也采集 |
| `app_ver` | str? | 否 | `max_length=32` |
| `platform` | str? | 否 | `max_length=16`android/ios/harmony |
| `sent_at` | int? | 否 | 本批上报时刻 epoch ms |
| `logs` | list[dict] | 是 | `min_length=1, max_length=500`;**每条为对象**,逐条按 §5 契约处理 |
`logs``list[dict[str, Any]]` 而非强类型列表:这是**尽力而为**的日志链路,单条内容异常不应让整批 422 失败。超过 500 条由 Pydantic `max_length` 触发 422(客户端应更小批)。
### 响应 `AppLogIngestOut`
```jsonc
{ "ok": true, "received": 128, "dropped": 2 }
```
- `received`:成功写入文件的条数。
- `dropped`:服务端处理/序列化失败被跳过的条数(正常为 0,属异常兜底计数)。**oversize 的 `msg` 是截断而非丢弃**,不计入 dropped。
### 限额(防撑盘 / 注入 / DoS)
| 限额 | 默认 | 超限行为 | env |
|---|---|---|---|
| 单批条数 | 500 | 422Pydantic | `APPLOG_MAX_BATCH` |
| body 字节 | 2 MB | 413(依赖查 `Content-Length`,在 body 校验前拦截;缺该头由 nginx `client_max_body_size` 兜底) | `APPLOG_MAX_BODY_BYTES` |
| 单条 `msg` 字节 | 8192 | 截断 + 标记,不丢 | `APPLOG_MAX_MSG_BYTES` |
同时**对齐 nginx `client_max_body_size`**(见 [deploy/nginx](../../../deploy/nginx/))避免反代先于应用截断。限流复用 `app/core/ratelimit.py`(项目现有 IP 固定窗口);具体挂法参照现有已限流写端点,测试环境 `RATE_LIMIT_ENABLED=false` 关闭。
## 5. 每条记录契约 + 白名单键 + `data` 兜底
**客户端每条日志的识别键(仅这些提到输出行顶层):**
| 键 | 类型 | 说明 |
|---|---|---|
| `client_ts` | int | 端事件时间 epoch ms(与 analytics 命名一致) |
| `level` | str | 服务端归一化为大写;不做硬枚举拒绝(fire-and-forget |
| `trace_id` | str? | **§6 的核心**:有服务端交互的日志带上当初 API 返回的 trace |
| `tag` | str? | 模块/分类,便于 SLS 过滤 |
| `msg` | str | 消息主体,超 `APPLOG_MAX_MSG_BYTES` 截断并加标记 |
**其余任意自定义字段 → 服务端一律收进单个 `data` 对象。**
**为什么这么设计(防 SLS 索引列爆炸):** Logtail JSON 模式下每个**顶层 key** 都会成为 logstore 一个可索引字段/列。若放任客户端往顶层写任意 key(甚至把动态 id 拼进 key),logstore 会长出成千上万个不同顶层列 → 索引成本膨胀、可能撞字段数上限、schema 混乱到无法建稳定 dashboard/告警,一个客户端 bug 就能把 logstore 搞脏。
**「兜底」= 服务端强制、不信任客户端。** `logs``list[dict]` 原样收下(不强类型,单条异常不该让整批 422),在**写入层 `client_log.py`** 逐条拆分:白名单键提顶层,**其余键(含客户端自带的 `data`)统一并进 `data`**(而非透传到顶层,也非静默丢弃——日志要保真)。于是**输出行顶层列恒定**,无论客户端怎么发。
这与现有 `JsonFormatter` 平铺 `phase/step/command/cost_ms` 同思路,区别:那些 extra 是**服务端可信有限**的键;客户端不可信无界,故只给一个 `data` 沙盒。
## 6. 落盘 writer
新模块 `app/core/client_log.py`
- 惰性单例 `logging.getLogger("client")`**`propagate=False`**(否则冒泡进 root 被 `app-server.log` 二次写入并污染)。
- 独占一个 `RotatingFileHandler`formatter 为 `%(message)s`——**不复用 root 的 `JsonFormatter`**(那会把已是 JSON 的行二次编码成字符串)。writer 自己拼信封 dict 后 `json.dumps(..., ensure_ascii=False, default=str)` 得到**一行**`logger.info(line)` 写出。
- 复用 `logging` 模块的 handler 锁保证多线程(uvicorn threadpool)并发写安全。
- 幂等 setup(仿 `setup_logging``_CONFIGURED` 守卫)。
**滚动规则(与服务日志同机制 `RotatingFileHandler`,尺寸给客户端量级):**
| 参数 | 默认 | env |
|---|---|---|
| 文件路径 | `logs/app-client.log` | `CLIENT_LOG_FILE` / `LOG_DIR` |
| `maxBytes` | 20 MB | `CLIENT_LOG_MAX_BYTES` |
| `backupCount` | 10 | `CLIENT_LOG_BACKUP_COUNT` |
| `service` 字段 | `app-client` | `CLIENT_LOG_SERVICE_NAME` |
≈ 200 MB / ~2 天缓冲,给 Logtail 断线留余量(按 <10 万条/天、条均值估算)。**滚动文件不 gzip**——Logtail 读不了压缩包会丢数据。
> ⚠️ **单 worker 约束**`RotatingFileHandler` 多进程并发 `doRollover()` 会损坏/丢日志。当前生产 `--workers 1`(与限流器/SMS 码/SQLite 写锁同一既有假设,见 [deploy/shaguabijia-app-server.service](../../../deploy/shaguabijia-app-server.service))故安全。**代码注释显式标注**:扩 worker 前必须换 `QueueHandler`→单写入者 或外部 logrotatecopytruncate)或写 stdout 交 journald 采集。
**每条输出行(写入 `app-client.log`):**
```jsonc
{ "time": "2026-07-19T12:00:00.123", // 服务端接收时间 = SLS 主时间(客户端时钟不可信)
"source": "client", "service": "app-client",
"level": "ERROR", "trace_id": "abc123", "tag": "automation", "msg": "...",
"device_id": "d-xxx", "user_id": 123, "app_ver": "1.2.3", "platform": "android",
"client_ip": "1.2.3.4", "client_ts": 1737000000123, "sent_at": 1737000005000,
"data": { /* 客户端其余任意字段 */ } }
```
- `time` 用服务端接收时间作 SLS 主时间;`client_ts` 保留为可查字段(时钟漂移不影响检索基准)。
- 值为空的可选字段省略,保持行精简(仿服务端 formatter 省略空 `trace_id`)。
## 7. trace_id 检索(满足目标 #3
关键:**per-record 把 `trace_id` 提到输出行顶层,字段名与服务端日志完全一致(`trace_id`)**。来源是客户端在**它发起过服务端调用的那些日志**里带上当初 API 返回的 trace(如 `/api/v1/compare/*` 由 [pricebot_router](../../../app/core/pricebot_router.py) 透传的 trace)。没有服务端交互的纯客户端日志不带 `trace_id`,正常。
于是 SLS 里对客户端 logstore 与服务端 logstore 各查 `trace_id: "xxx"`,即可拼出「客户端自动化视角 + 服务端比价链路」的端到端故事。
> **需客户端配合**:给有服务端 trace 的日志记录打上 `trace_id`(Android 侧改动,见 §10)。
## 8. Logtail / SLS 侧配置(运维,非本仓代码)
- 新建**独立 logstore**(独立保留期,客户端日志建议**比服务端短**以控成本)。
- 新 Logtail 配置采集 `logs/app-client.log`**JSON 模式**(每行一条 JSON,零正则,与 `app-server.log` 同套路)。
- **把 `trace_id` 配成索引字段**(否则 #3 查不了);`level` / `tag` / `device_id` 亦建议建索引。
- 时间字段用输出行 `time`(服务端接收时间)。
## 9. 失败语义 / 安全
- **fire-and-forget**:写文件失败 → 服务端记一笔(`shagua.applog` logger+ 仍返回 2xx**绝不 500**(对比 selfstat 的 503 是 DB 关键链路,日志不是;500 会招致客户端重试风暴+重复上报)。
- **日志行注入防护**:一律 `json.dumps` 重新序列化,内嵌 `\n` 被转义,客户端伪造不出假日志行;绝不把客户端原始字符串直接写文件。
- **撑盘/DoS**:§4 的条数/体积/msg 上限 + IP 限流;`device_id` 便于后续拉黑滥用设备。
## 10. 客户端契约(Android 侧需配合,属另一仓)
1. 每条日志结构:顶层放 `client_ts / level / trace_id? / tag? / msg`,其余自定义字段放 `data`(否则会被服务端兜底挪进 `data`)。
2. 有服务端交互的日志带上对应 `trace_id`
3. 等级/采样与 PII 控制在客户端侧做(省流量、免服务端脱敏)。
4. 单批 ≤500 条、body ≤2MB;失败可重试(服务端接受重复)。
## 11. 代码落点
| 文件 | 职责 |
|---|---|
| `app/api/v1/applog.py` | 瘦路由:body 上限依赖 → 解析 `AppLogBatchIn` → 调 writer → 返回 `received/dropped``_client_ip` 复用 analytics 逻辑、IP 限流 |
| `app/schemas/applog.py` | `AppLogBatchIn``logs: list[dict]` 不强类型)/ `AppLogIngestOut` |
| `app/core/client_log.py` | 专用 logger/handler、滚动配置、白名单+`data` 兜底与信封拼装 `write_records(...)` |
| `app/main.py` | 注册 `applog_router` |
## 12. 测试计划(`tests/test_applog.py`
用临时目录做 `CLIENT_LOG_FILE`(测试前置 env + 重置 writer 单例)。
1. **落盘逐行**POST N 条 → 200`received=N`,文件恰 N 行、每行合法 JSON、关键字段齐。
2. **trace_id 顶层**:带 `trace_id` 的记录 → 输出行顶层出现 `trace_id`
3. **未知键兜底**:记录带 `foo` → 输出行顶层无 `foo``data.foo` 存在。
4. **msg 截断**`msg` > 8KB → 截断+标记,仍 `received`(不进 dropped)。
5. **超批拒绝**>500 条 → 422。
6. **超体积拒绝**`Content-Length` > 2MB → 413。
7. **写失败不 500**monkeypatch writer 抛错 → 仍 2xx。
8. **不污染服务日志**`client` logger `propagate=False`,写客户端日志不落 `app-server.log`
## 13. env 变量汇总
| env | 默认 | 用途 |
|---|---|---|
| `CLIENT_LOG_FILE` | `logs/app-client.log` | 客户端日志文件路径 |
| `CLIENT_LOG_MAX_BYTES` | `20971520`(20MB) | 单文件滚动阈值 |
| `CLIENT_LOG_BACKUP_COUNT` | `10` | 保留滚动文件数 |
| `CLIENT_LOG_SERVICE_NAME` | `app-client` | 输出行 `service` 字段 |
| `APPLOG_MAX_BATCH` | `500` | 单批最大条数 |
| `APPLOG_MAX_BODY_BYTES` | `2097152`(2MB) | 请求体上限 |
| `APPLOG_MAX_MSG_BYTES` | `8192` | 单条 msg 截断阈值 |
滚动/文件类 env 在 `client_log.py``os.getenv` 读取(与 [logging.py](../../../app/core/logging.py) 风格一致);请求限额类同样以 `os.getenv` 兜默认。
## 14. 已知取舍 / 未来工作
- **重复**:本期接受 SLS 重复条目;需要时加 `batch_id` 去重。
- **多 worker**:见 §6 约束;扩容前迁移写入模型。
- **服务端脱敏**:留作后续 knob。
- **更多设备维度**os/model/rom):需要时加到批级字段或让客户端放 `data`;可经 `device_id` 与 analytics/device 表关联。
@@ -0,0 +1,182 @@
# 阿里云短信验证服务 — 设计方案
- 日期:2026-07-25
- 状态:已定稿,待实现
- 范围:新增阿里云 dypns(号码认证服务)短信验证码 provider,与现有极光短信可切换
## 1. 背景与目标
现有短信验证码服务 `app/integrations/sms.py`:本服务**本地生成**验证码、存**进程内存**、极光 REST 仅负责下发;`verify_code()` 比对本地内存(一次性 + 单码失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废)。docstring 已标注"内存存码、多 worker 不共享"为技术债。
阿里云文档(`docs/integrations/aliyun/`)为 **号码认证服务 dypns**`SendSmsVerifyCode` + `CheckSmsVerifyCode`:该产品由**阿里云生成并校验**验证码(`{"code":"##code##"}` 模式),核验免费。
目标:接入阿里云该套接口作为一个新的短信 provider,可与极光切换。
## 2. 关键决策(已确认)
1. **验证码模式 = Mode A(阿里云托管码)**:发码用 `SendSmsVerifyCode` + `##code##` 占位符,阿里云生成/存储/下发;校验用 `CheckSmsVerifyCode`,阿里云返回 `PASS/UNKNOWN`。本服务不再本地生成/存储验证码。
2. **可切换 Provider**:新增 `SMS_PROVIDER=jiguang|aliyun` 开关,`send_code/verify_code` 按 provider 分派;保留极光作回退(短信=花钱+登录关键路径,灰度上线/融合认证未开通时可秒切回极光)。
3. **官方 SDK**:调阿里云 dypns 用 `alibabacloud_dypnsapi20170525`,签名/加签由 SDK 处理。
4. **防爆破与极光一致**(排查一致性):阿里云路径**保留**与极光相同的"单码失败 N 次即作废"本地计数,而非改用 API 层频控,避免两 provider 行为不一致导致排查困惑。
## 3. 模块结构(`sms.py` 单文件升级为 provider 包)
```
app/integrations/sms/
__init__.py # 公开 API + 分派器:send_code / verify_code / SmsError
# - SMS_MOCK=true 短路(不碰任何 provider)
# - 按 settings.SMS_PROVIDER 选 jiguang / aliyun
base.py # SmsError(沿用现定义)+ Provider 协议(send_code/verify_code 签名约定)
jiguang.py # 现有自管码逻辑原样迁入(内存存码/冷却/一次性/防爆破 全保留,行为零改动)
aliyun.py # 新增:SendSmsVerifyCode 发码 + CheckSmsVerifyCode 校验 + 本地失败计数
```
- `__init__.py` 继续 re-export `SmsError / send_code / verify_code`,故 `app/api/v1/auth.py:37`
`from app.integrations.sms import SmsError, send_code, verify_code` **导入不变**
- 纯增量重构:极光逻辑整体迁入 `jiguang.py`,对外行为零变化。
## 4. 数据流 — 阿里云 providerMode A
### 4.1 发码 `aliyun.send_code(phone) -> int`
1. 校验 `settings.aliyun_sms_configured`(缺 AK/SignName/TemplateCode → `SmsError(503)`)。
2. 调 `SendSmsVerifyCode`
- `PhoneNumber=phone`
- `SignName=ALIYUN_SMS_SIGN_NAME``TemplateCode=ALIYUN_SMS_TEMPLATE_CODE`
- `TemplateParam = json({"code":"##code##","min": str(ALIYUN_SMS_VALID_TIME_SEC//60)})`
- `CodeLength=ALIYUN_SMS_CODE_LENGTH``ValidTime=ALIYUN_SMS_VALID_TIME_SEC``Interval=ALIYUN_SMS_INTERVAL_SEC`
- `SchemeName=ALIYUN_SMS_SCHEME_NAME`(可空)
3. 成功(`body.Success and body.Code=="OK"`)→ **清本地失败计数**(新码=新预算)→ 返回 `ALIYUN_SMS_INTERVAL_SEC` 作客户端冷却秒数。
4. 失败 → 按 §6 错误码映射抛 `SmsError`
### 4.2 校验 `aliyun.verify_code(phone, code) -> bool`
1. **本地失败计数**`attempts >= SMS_MAX_VERIFY_ATTEMPTS` → 直接 `False`(码已作废,不调阿里云)。
2. 调 `CheckSmsVerifyCode(PhoneNumber, VerifyCode=code, SchemeName)`
3. `body.Model.VerifyResult`
- `"PASS"` → 清计数,返回 `True`(一次性)。
- `"UNKNOWN"``attempts += 1`,返回 `False`(码错/过期)。
4. 网络错误 / 接口非 `OK` → 抛 `SmsError(503)`**不静默返回 False**,区分"阿里云挂了"与"码错了";网络错误不计入 attempts)。
本服务**不存验证码**,仅存一个 per-phone 失败计数(见 §7)。
## 5. 分派器 & mock`__init__.py`
```
send_code(phone):
if settings.SMS_MOCK: # 短路:不碰 provider(测试/开发)
log placeholder code; return cooldown
return _provider().send_code(phone)
verify_code(phone, code):
if settings.SMS_MOCK: # 放行任意 N 位数字(沿用现 mock 语义)
return len(code)==SMS_CODE_LENGTH and code.isdigit()
return _provider().verify_code(phone, code)
_provider(): jiguang if settings.SMS_PROVIDER=="jiguang" else aliyun
```
- mock 语义提到分派层、provider 无关 → 现有 28 个测试文件(conftest 设 `SMS_MOCK=true`)全部零改动通过。
## 6. 错误映射
### 发码(阿里云错误码 → SmsError.status_code
| 阿里云码 | HTTP | 说明 |
|---|---|---|
| `MOBILE_NUMBER_ILLEGAL` | 400 | 手机号格式错误 |
| `BUSINESS_LIMIT_CONTROL` | 429 | 号码天级流控 |
| `FREQUENCY_FAIL` | 429 | 频控(`Interval` 命中) |
| `FUNCTION_NOT_OPENED` | 503 | 融合认证未开通(**critical 日志**,需运维开通) |
| `INVALID_PARAMETERS` | 503 | 参数错误(配置/模板问题,**critical 日志** |
| 其他非 OK / `Success=false` / 网络错误 | 503 | 供应商不可用 |
### 校验
- `PASS` → True`UNKNOWN` → False;接口异常/网络错误 → `SmsError(503)`
## 7. 防爆破 / 频控分工
| 机制 | 极光(Mode B | 阿里云(Mode A |
|---|---|---|
| 验证码存储 | 本地内存 | **阿里云托管**(消除多 worker 存码债) |
| 单号发送冷却 | 本地 `_last_sent` 60s | **交给阿里云 `Interval`**(无本地状态),命中→429 |
| 单设备+IP 频控 | API 层 5/时、20/天 | 同左,**不变** |
| **防爆破(单码失败 N 次即作废)** | 本地 `_CodeRecord.attempts` | **本地 per-phone 计数**,与极光同语义(§4.2 |
- 阿里云路径的**唯一本地状态** = per-phone 失败计数 `dict[phone,int]` + `Lock` + GC(仿极光 `_gc`)。
- 多 worker 降级:失败计数按 worker 各计,effective 上限 = N×workers;与极光现状**同级**(属刻意保留的一致性),且 API 层登录频控(`sms-login-device` 设备+IP 5/时)提供硬兜底。
- 计数复位:`send_code` 成功清计数、`verify` PASS 清计数(新码/验过即新预算)。
- API 层设备频控与测试账号短路(`app/core/test_account.py`**完全不动**。
## 8. 配置项(`app/core/config.py` 新增)
```python
SMS_PROVIDER: str = "jiguang" # jiguang | aliyun;默认极光(保持现状,上线后切 aliyun)
# --- 阿里云 dypns 号码认证 ---
ALIYUN_SMS_ACCESS_KEY_ID: str = ""
ALIYUN_SMS_ACCESS_KEY_SECRET: str = ""
ALIYUN_SMS_SIGN_NAME: str = "" # 系统赠送签名(自定义签名下发易失败)
ALIYUN_SMS_TEMPLATE_CODE: str = "" # 赠送模板 CODE(须与赠送签名搭配)
ALIYUN_SMS_SCHEME_NAME: str = "" # 方案名(可空=默认方案);send/check 必须一致 → 单一来源
ALIYUN_SMS_ENDPOINT: str = "dypnsapi.aliyuncs.com"
ALIYUN_SMS_CODE_LENGTH: int = 6 # CodeLength 4~8
ALIYUN_SMS_VALID_TIME_SEC: int = 300 # ValidTime;短信内 min 文案 = //60
ALIYUN_SMS_INTERVAL_SEC: int = 60 # Interval 单号发送频控
```
- 新增属性 `aliyun_sms_configured`(仿 `mt_cps_configured`):AK_ID/AK_SECRET/SignName/TemplateCode 齐全才为真;`SMS_PROVIDER=aliyun` 但未配 → `send_code``SmsError(503)`
- 复用现有 `SMS_MOCK``SMS_CODE_LENGTH`mock 校验位数)、`SMS_MAX_VERIFY_ATTEMPTS`(防爆破上限,两 provider 共用)。
### 配置敏感点
- `TemplateParam` 变量名(`code`/`min`)须与控制台所选**赠送模板**一致。融合认证验证码模板通常即 `code`+`min`,按此硬编码并加注释;若模板变量名不同,改 `aliyun.py` 该处即可。
- `SchemeName` 在 send 与 check 必须一致,故用**单一** `ALIYUN_SMS_SCHEME_NAME` 供两处,避免不匹配(CheckSmsVerifyCode 文档明确警告)。
## 9. auth.py 改动(最小)
`verify_code` 现在可能抛 `SmsError`(阿里云降级 503)。两处调用点各包一层 `try/except SmsError → HTTPException(e.status_code)`,与 `send_code` 现有写法一致:
- `app/api/v1/auth.py` `sms_login`(约 L185
- `app/api/v1/auth.py` `wechat_bind_phone_sms`(约 L325
`send_code` 调用点已 try/except `SmsError`,无需改。
## 10. 依赖 & SDK
- `pyproject.toml``alibabacloud_dypnsapi20170525`(连带 `alibabacloud-tea-openapi` 等)。
- SDK 同步阻塞调用 → 与现有 sync 端点 + sync httpx 风格一致(FastAPI 跑 threadpool,无碍)。
- `aliyun.py` 内**惰性 import SDK + 惰性建 client**(仿 `wxpay` 惰性加载证书):`SMS_PROVIDER=jiguang` 时不加载 alibabacloud,启动保持精简。
- SDK 调用形态(实现时按实际包名/字段核对):
```python
from alibabacloud_dypnsapi20170525.client import Client
from alibabacloud_dypnsapi20170525 import models as dypns_models
from alibabacloud_tea_openapi import models as open_api_models
cfg = open_api_models.Config(access_key_id=..., access_key_secret=...)
cfg.endpoint = settings.ALIYUN_SMS_ENDPOINT
client = Client(cfg)
resp = client.send_sms_verify_code(dypns_models.SendSmsVerifyCodeRequest(...))
# resp.body.code / resp.body.success / resp.body.model.verify_code
resp = client.check_sms_verify_code(dypns_models.CheckSmsVerifyCodeRequest(...))
# resp.body.model.verify_result == "PASS"
```
## 11. 测试
- 现有测试:`SMS_MOCK=true` → 分派器短路,全绿不变。
- 新增 `tests/test_sms_aliyun.py`monkeypatch SDK client,不发真网络):
1. 发码成功 → 返回 cooldown、清计数。
2. 各错误码 → 对应 `SmsError.status_code`400/429/503)。
3. 校验 `PASS`→True(清计数)/ `UNKNOWN`→False(计数 +1)/ 接口异常→`SmsError(503)`
4. 失败计数达 `SMS_MAX_VERIFY_ATTEMPTS` → 直接 False,不再调阿里云。
5. `send_code` 成功复位计数。
- 新增分派测试:`SMS_PROVIDER` 切换选中正确 provider`SMS_MOCK` 优先于 provider。
## 12. YAGNI(明确不做)
- ❌ 不做 Redis/DB 存码(Mode A 无需;极光路径内存债维持现状,非本次范围)。
- ❌ 不改极光任何行为、不动 API 层频控/测试账号逻辑。
- ❌ 不做多签名/多模板轮换(单签名单模板足够)。
- ❌ 不把失败计数持久化/跨进程(刻意保留与极光同级的本地态)。
## 13. 验收标准
- `SMS_PROVIDER=aliyun` 且配置齐全时:`/sms/send``SendSmsVerifyCode``/sms/login``CheckSmsVerifyCode`,真机可收码并登录。
- `SMS_PROVIDER=jiguang`(默认):行为与当前完全一致。
- `SMS_MOCK=true`:任意 N 位数字通过,不发真短信。
- 阿里云接口异常时:`/sms/login` 返回 503(非 400),日志可区分。
- `ruff check .` 通过;新增/现有 `pytest` 全绿。
@@ -0,0 +1,143 @@
# 创蓝云智(253)短信验证服务 — 设计方案
- 日期:2026-07-26
- 状态:已定稿,待实现
- 范围:新增创蓝云智(253/蓝创云智)模板短信 provider,与现有极光 / 阿里云可切换
## 1. 背景与目标
短信验证码服务已是**可切换 provider** 架构(`app/integrations/sms/``__init__` 分派 + `jiguang` / `aliyun` + `base`)。本次接入第三家 **创蓝云智** 作为新 provider。
创蓝 `tpl/send` v2 接口(调研见 `docs/integrations/chuanglan/tpl-send.md`)是**纯发送网关**:本服务生成验证码、放入 `templateParamJson`,创蓝只负责下发,**无校验接口**。故属 **Mode B(自管码)**,与极光同模式(本地生成/存储/校验),仅"发送调用"不同。
目标:接入创蓝作为可切换 provider;默认仍极光,opt-in 切换,灰度可秒回退。
## 2. 关键决策(已确认)
1. **Mode B 自管码**:本服务 `secrets` 生成 N 位码 → 存进程内存 → 创蓝 REST 只下发;`verify_code` 比对本地存码(一次性 + 失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废)。与极光同语义。
2. **代码组织 = 隔离复制(不重构极光)**`chuanglan.py` **自带一份**存码/冷却/校验机器(从 `jiguang.py` 复制适配),**极光文件一行不动**。契合阿里云先例的 provider 隔离哲学,零回归风险于登录关键路径的默认 provider。代价:Mode B 并发逻辑在 jiguang / chuanglan 两处重复,日后改动需同步(YAGNI 权衡,已接受)。
3. **鉴权 = HMAC 签名头**`X-QA-Hmac-Signature`):password 仅用于本地算签、**不上行**。不做明文密码 body 模式。
4. **无新依赖**:创蓝是普通 HTTPS POST,复用现有 `httpx` + 标准库 `hashlib`/`hmac`(对比阿里云需 SDK)。
5. **可切换 + 回退**`SMS_PROVIDER``chuanglan`;默认仍 `jiguang`;误配/未知值一律回退 `jiguang`(保持 `test_unknown_provider_falls_back_to_jiguang` 语义)。
## 3. 模块结构
```
app/integrations/sms/
__init__.py # 分派器: {"aliyun":aliyun,"chuanglan":chuanglan}.get(SMS_PROVIDER, jiguang)
base.py # 不动(SmsError / mock_verify 复用)
jiguang.py # 不动
aliyun.py # 不动
chuanglan.py # 新增(本设计)
```
- `__init__.py` 继续 re-export `SmsError / send_code / verify_code``app/api/v1/auth.py` 导入不变。
- 纯增量:只新增 `chuanglan.py` + 扩分派 dict + 加配置;不改极光/阿里云行为。
## 4. 数据流 — chuanglan providerMode B,复制自 jiguang
### 4.1 发码 `chuanglan.send_code(phone) -> int`
结构与 `jiguang.send_code` 一致:
1. `_lock` 内:`_gc` → 单号冷却检查(`_last_sent``SMS_SEND_INTERVAL_SEC`,命中→`SmsError(429)`)→ `_gen_code()` 生成 N 位 → **预占**(写 `_last_sent` + `_codes[phone]=_CodeRecord(code, expires_at=now+SMS_CODE_TTL_SEC)`)。
2. `_lock` 外:`SMS_MOCK` → 打日志不真发;否则 `_send_via_chuanglan(phone, code)`
3. 失败:**保留冷却**(失败也限速)、`_codes.pop(phone)`(没发出去的码删掉);`SmsError` 原样抛,其他异常 → `SmsError(503)`
4. 返回 `SMS_SEND_INTERVAL_SEC` 作客户端冷却秒数。
### 4.2 校验 `chuanglan.verify_code(phone, code) -> bool`
`jiguang.verify_code` 一致:
- `SMS_MOCK``mock_verify`(放行任意 N 位数字)。
- real`_lock` 内查 `_codes[phone]`;不存在/过期→False(并清);`attempts >= SMS_MAX_VERIFY_ATTEMPTS`→清+False(防爆破);`secrets.compare_digest` 匹配→清+True(一次性);否则 `attempts += 1` 返 False。
### 4.3 发送 `_send_via_chuanglan(phone, code)`(唯一新逻辑)
1. 配置校验 `settings.chuanglan_sms_configured`(缺 account/password/templateId → `SmsError(503)`)。
2. 组装:
- `timestamp = str(int(time.time()))``nonce = secrets.token_hex(16)`32 hex
- `body = {account, timestamp, nonce, phoneNumbers=phone, templateId, templateParamJson=json.dumps([{"param1": code}])}``CHUANGLAN_SMS_SIGNATURE` 非空则加 `signature` 字段。**HMAC 方式 body 不含 password。**
3. 签名 `_sign(password, timestamp, nonce)`
```
md5pwd = md5(password).hexdigest() # 32 位小写 hex
s = "".join(sorted([md5pwd, timestamp, nonce])) # 字典序升序拼接
s = "".join(s.split()) # 去空白(faithful,本例无空白)
sig = hmac_sha256(key=md5pwd.encode(), msg=s.encode()).hexdigest() # 小写 hex
```
置请求头 `X-QA-Hmac-Signature: sig``Content-Type: application/json`
4. `httpx.post(CHUANGLAN_SMS_ENDPOINT, json=body, headers=..., timeout=CHUANGLAN_SMS_TIMEOUT_SEC)`;网络异常 → `SmsError(503)`
5. 解析 `resp.json()``code == "000000"` → 成功返回;否则按 §5 映射抛 `SmsError`。HTTP≠200 或 JSON 解析失败 → `SmsError(503)`
## 5. 错误码映射(创蓝 `code` → SmsError.status_code
| 创蓝 code | HTTP | 处理 |
|---|---|---|
| `000000` | — | 成功 return |
| `103` | 429 | 超频,"发送过于频繁,请稍后再试" |
| `107` | 400 | 手机号错误,"手机号无效" |
| `109` | 503 | 无发送量/余额 → **critical 日志**(需充值) |
| `117` | 503 | IP 未白名单 → **critical 日志**(需运维加白) |
| `102` / `116` / `124` / `152` / `101` / `118` | 503 | 密码/签名/模板/账号/权限配置错 → **critical 日志** |
| 其他 / `Success` 非 000000 / HTTP≠200 / 网络错 | 503 | "短信服务暂不可用,请稍后重试" |
- 映射用 `_SEND_ERRORS: dict[str,(int,str)]` + `_SEND_CRITICAL_CODES: frozenset`(仿 aliyun 写法)。
## 6. 防爆破 / 频控分工(与极光同级)
| 机制 | 实现 |
|---|---|
| 验证码存储 | 本地进程内存 `_codes`(与极光同,多 worker 不共享的技术债同级) |
| 单号发送冷却 | 本地 `_last_sent``SMS_SEND_INTERVAL_SEC` |
| 单设备+IP 频控 | API 层(`app/api/v1/auth.py`),**不变** |
| 防爆破(单码失败 N 次作废)| 本地 `_CodeRecord.attempts``SMS_MAX_VERIFY_ATTEMPTS` |
- 创蓝控制台侧另建议叠加:**IP 白名单**(否则 117)+ 发送频控。
## 7. 配置项(`app/core/config.py` 新增)
```python
SMS_PROVIDER: Literal["jiguang", "aliyun", "chuanglan"] = "jiguang"
# --- 创蓝云智(253)模板短信,Mode B 自管码,httpx 直连 + HMAC 签名 ---
CHUANGLAN_SMS_ACCOUNT: str = "" # YZM 前缀验证码账号
CHUANGLAN_SMS_PASSWORD: str = "" # API 密码(仅本地算签,不上行)
CHUANGLAN_SMS_TEMPLATE_ID: str = "" # 模板 ID
CHUANGLAN_SMS_SIGNATURE: str = "" # 短信签名文案【品牌】;模板已带签名则留空
CHUANGLAN_SMS_ENDPOINT: str = "https://smssh.253.com/msg/sms/v2/tpl/send"
CHUANGLAN_SMS_TIMEOUT_SEC: int = 10 # httpx 读/连超时
```
- 新增属性 `chuanglan_sms_configured`(仿 `aliyun_sms_configured`):account/password/templateId 齐全才为真。
- 复用 `SMS_MOCK` / `SMS_CODE_LENGTH` / `SMS_CODE_TTL_SEC` / `SMS_SEND_INTERVAL_SEC` / `SMS_MAX_VERIFY_ATTEMPTS`provider 无关的 Mode B 旋钮)。
- `.env.example``CHUANGLAN_SMS_*` 块 + 注释。
### 模板变量敏感点
- 默认按**单占位** `templateParamJson=[{"param1": code}]`(模板形如「您的验证码 {s},5分钟内有效」)。
- 若控制台模板把「有效分钟」也做成第二个 `{s}`,实现时在此加 `param2`(改 `chuanglan.py` 一处)。
## 8. auth.py 改动
无。`send_code` / `verify_code` 签名与返回不变,分派层内部路由;两调用点现有 `try/except SmsError` 已覆盖 chuanglan 的 429/400/503。
## 9. 测试
- 现有测试:`SMS_MOCK=true` → 分派器短路,全绿不变。
- 新增 `tests/test_sms_chuanglan.py`monkeypatch `_send_via_chuanglan` 内 httpx 接缝,不发真网络):
1. 发码成功(`code=000000`)→ 返回 cooldown、码入内存。
2. 各错误码 → 对应 `SmsError.status_code`103→429 / 107→400 / 109/117/其他→503)。
3. HTTP≠200 / 网络异常 → `SmsError(503)`
4. 校验:匹配→True 且作废(一次性);过期→False;失败累计达上限→作废 False;不匹配→attempts+1 False。
5. 冷却:`SMS_SEND_INTERVAL_SEC` 内二次发 → `SmsError(429)`
6. `_sign` 签名算法:对固定 (password, ts, nonce) 断言 HMAC 输出(独立复算比对)。
- 扩 `tests/test_sms_dispatch.py``SMS_PROVIDER=chuanglan` 路由命中 chuanglan;未知值回退 jiguang。
## 10. YAGNI(明确不做)
- ❌ 不重构极光 / 不动阿里云。
- ❌ 不做明文密码 body 模式(只 HMAC)。
- ❌ 不做状态回执 `report` / `callbackUrl`
- ❌ 不做批量发送(验证码单号)。
- ❌ 不做 DB/Redis 存码(与极光同级内存态,多 worker 债维持现状)。
## 11. 验收标准
- `SMS_PROVIDER=chuanglan` 且配置齐全时:`/sms/send` 走创蓝 `tpl/send``/sms/login` 本地校验,真机可收码并登录。
- `SMS_PROVIDER=jiguang`(默认)/ `aliyun`:行为与当前完全一致。
- `SMS_MOCK=true`:任意 N 位数字通过,不真发。
- 创蓝接口异常时:`/sms/send` 返回对应码(429/400/503),日志可区分(余额/白名单打 critical)。
- `ruff check .` 通过;新增/现有 `pytest` 全绿。
+3
View File
@@ -29,6 +29,9 @@ dependencies = [
# HTTP 客户端 (调极光 REST)
"httpx>=0.27.0",
# 阿里云号码认证(dypns)短信验证码 provider(SMS_PROVIDER=aliyun 时用;签名由 SDK 处理)
"alibabacloud_dypnsapi20170525>=2.0.0",
# multipart form (FastAPI 表单上传依赖)
"python-multipart>=0.0.9",
+65
View File
@@ -640,6 +640,71 @@ def test_comparison_records_show_readable_device_and_rom_version(
assert detail.json()["rom_version"] == 4
def test_comparison_records_show_real_order_status(
admin_client: TestClient, admin_token: str
) -> None:
db = SessionLocal()
try:
user = user_repo.upsert_user_for_login(
db, phone="13800009042", register_channel="sms"
)
ordered_record = ComparisonRecord(
user_id=user.id,
trace_id="comparison-ordered-shop",
status="success",
store_name="真实下单店",
)
unordered_record = ComparisonRecord(
user_id=user.id,
trace_id="comparison-demo-order-shop",
status="success",
store_name="演示下单店",
)
db.add_all([ordered_record, unordered_record])
db.flush()
db.add_all(
[
SavingsRecord(
user_id=user.id,
order_amount_cents=1800,
saved_amount_cents=300,
shop_name="真实下单店",
source="compare",
client_event_id="admin-comparison-real-order",
),
SavingsRecord(
user_id=user.id,
order_amount_cents=1500,
saved_amount_cents=200,
shop_name="演示下单店",
source="demo",
),
]
)
db.commit()
ordered_record_id = ordered_record.id
user_id = user.id
finally:
db.close()
response = admin_client.get(
"/admin/api/comparison-records",
params={"user_id": user_id},
headers=_auth(admin_token),
)
assert response.status_code == 200, response.text
by_trace = {item["trace_id"]: item for item in response.json()["items"]}
assert by_trace["comparison-ordered-shop"]["ordered"] is True
assert by_trace["comparison-demo-order-shop"]["ordered"] is False
detail = admin_client.get(
f"/admin/api/comparison-records/{ordered_record_id}",
headers=_auth(admin_token),
)
assert detail.status_code == 200, detail.text
assert detail.json()["ordered"] is True
def test_ad_coin_audit_full_count_truncate_and_only_mismatch(
admin_client: TestClient, admin_token: str
) -> None:
+182
View File
@@ -0,0 +1,182 @@
"""客户端运行日志上报:writer 单测 + 端点集成测试。"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from app.core import client_log
@pytest.fixture()
def client_log_file(tmp_path, monkeypatch):
"""把客户端日志切到临时文件,并重置 writer 单例使其按当时 env 重建。"""
p = tmp_path / "app-client.log"
monkeypatch.setenv("CLIENT_LOG_FILE", str(p))
client_log.reset_client_logger()
yield p
client_log.reset_client_logger()
def _read_lines(p: Path) -> list[dict]:
text = p.read_text(encoding="utf-8").strip()
return [json.loads(ln) for ln in text.splitlines() if ln]
# ---------------- writer 层 ----------------
def _meta(**kw) -> dict:
base = {"device_id": "d-1", "user_id": None, "app_ver": None,
"platform": None, "sent_at": None}
base.update(kw)
return base
def test_writer_writes_one_line_per_record(client_log_file):
recs = [
{"client_ts": 1737000000000, "level": "info", "msg": "hello"},
{"client_ts": 1737000000001, "level": "error", "msg": "boom", "tag": "net"},
]
received, dropped = client_log.write_records(
recs, meta=_meta(user_id=42, app_ver="1.2.3", platform="android"),
client_ip="1.2.3.4",
)
assert (received, dropped) == (2, 0)
lines = _read_lines(client_log_file)
assert len(lines) == 2
assert lines[0]["source"] == "client"
assert lines[0]["service"] == "app-client"
assert lines[0]["device_id"] == "d-1"
assert lines[0]["user_id"] == 42
assert lines[0]["app_ver"] == "1.2.3"
assert lines[0]["client_ip"] == "1.2.3.4"
assert lines[0]["level"] == "INFO" # 归一化大写
assert lines[0]["msg"] == "hello"
assert lines[0]["client_ts"] == 1737000000000
assert lines[1]["tag"] == "net"
def test_writer_hoists_trace_id_to_top_level(client_log_file):
client_log.write_records(
[{"client_ts": 1, "level": "info", "msg": "x", "trace_id": "abc123"}],
meta=_meta(), client_ip="",
)
assert _read_lines(client_log_file)[0]["trace_id"] == "abc123"
def test_writer_sweeps_unknown_keys_into_data(client_log_file):
client_log.write_records(
[{"client_ts": 1, "level": "info", "msg": "x",
"foo": 123, "data": {"bar": "baz"}}],
meta=_meta(), client_ip="",
)
line = _read_lines(client_log_file)[0]
assert "foo" not in line # 白名单外不进顶层
assert line["data"]["foo"] == 123 # 兜底进 data
assert line["data"]["bar"] == "baz" # 客户端自带 data 合并进来
def test_writer_truncates_oversize_msg(client_log_file, monkeypatch):
monkeypatch.setenv("APPLOG_MAX_MSG_BYTES", "10")
received, dropped = client_log.write_records(
[{"client_ts": 1, "level": "info", "msg": "x" * 100}],
meta=_meta(), client_ip="",
)
assert (received, dropped) == (1, 0) # 截断而非丢弃
line = _read_lines(client_log_file)[0]
assert line["msg_truncated"] is True
assert line["msg"].endswith("…[truncated]")
body = line["msg"].removesuffix("…[truncated]")
assert len(body.encode("utf-8")) <= 10 # 截断体不超过字节上限
def test_writer_caps_indexed_field_lengths(client_log_file):
client_log.write_records(
[{"client_ts": 1, "level": "info" * 20, "msg": "x",
"trace_id": "t" * 1000, "tag": "g" * 1000}],
meta=_meta(), client_ip="",
)
line = _read_lines(client_log_file)[0]
assert len(line["level"]) <= 16 # 索引字段(spec §8)做长度上限
assert len(line["trace_id"]) <= 256
assert len(line["tag"]) <= 128
def test_writer_logger_does_not_propagate(client_log_file):
lg = client_log.get_logger()
assert lg.name == "shagua.client_log"
assert lg.propagate is False # 不冒泡到 root → 不写 app-server.log
def test_writer_drops_bad_record_without_raising(client_log_file, monkeypatch):
def _boom(*a, **kw):
raise RuntimeError("boom")
monkeypatch.setattr(client_log, "_build_line", _boom)
received, dropped = client_log.write_records(
[{"client_ts": 1, "level": "info", "msg": "x"}],
meta=_meta(), client_ip="",
)
assert (received, dropped) == (0, 1) # 坏条计 dropped,且不抛给上层
# ---------------- 端点层 ----------------
def _post(client, body):
return client.post("/api/v1/applog/batch", json=body)
def test_endpoint_happy_path(client, client_log_file):
body = {
"device_id": "d-1", "user_id": 42, "app_ver": "1.2.3", "platform": "android",
"logs": [
{"client_ts": 1, "level": "info", "msg": "a", "trace_id": "t1"},
{"client_ts": 2, "level": "warn", "msg": "b"},
],
}
resp = _post(client, body)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "received": 2, "dropped": 0}
lines = _read_lines(client_log_file)
assert len(lines) == 2
assert lines[0]["trace_id"] == "t1" # 端到端:trace_id 落到文件顶层
assert lines[0]["client_ip"] # 服务端补了 IP
def test_endpoint_rejects_batch_over_max(client, client_log_file):
body = {"device_id": "d-1",
"logs": [{"client_ts": i, "level": "info", "msg": str(i)} for i in range(501)]}
resp = _post(client, body)
assert resp.status_code == 422 # Pydantic max_length=500
def test_endpoint_rejects_body_over_limit(client, client_log_file, monkeypatch):
# 只测有 Content-Length 的常规情形(TestClient/requests 恒发该头)。缺该头(chunked)
# 时依赖 nginx client_max_body_size 兜底,不在应用层测。
monkeypatch.setenv("APPLOG_MAX_BODY_BYTES", "50")
body = {"device_id": "d-1", "logs": [{"client_ts": 1, "level": "info", "msg": "x" * 500}]}
resp = _post(client, body)
assert resp.status_code == 413 # 依赖查 Content-Length,body 校验前拦截
def test_endpoint_returns_200_on_write_failure(client, client_log_file, monkeypatch):
class _BoomLogger:
name = "shagua.client_log"
propagate = False
handlers: list = []
def info(self, *a, **k):
raise RuntimeError("disk full")
monkeypatch.setattr(client_log, "get_logger", lambda: _BoomLogger())
body = {"device_id": "d-1", "logs": [
{"client_ts": 1, "level": "info", "msg": "a"},
{"client_ts": 2, "level": "info", "msg": "b"}]}
resp = _post(client, body)
assert resp.status_code == 200 # fire-and-forget:写失败不 500
assert resp.json() == {"ok": True, "received": 0, "dropped": 2}
def test_endpoint_missing_device_id_is_422(client, client_log_file):
resp = _post(client, {"logs": [{"client_ts": 1, "level": "info", "msg": "a"}]})
assert resp.status_code == 422 # device_id 必填
+40 -39
View File
@@ -11,12 +11,13 @@ import time
import pytest
from app.integrations import sms
from app.integrations.sms import jiguang
def _reset(phone: str) -> None:
"""清该号的进程内存状态,隔离 real 模式用例。"""
sms._codes.pop(phone, None)
sms._last_sent.pop(phone, None)
jiguang._codes.pop(phone, None)
jiguang._last_sent.pop(phone, None)
class _OkResp:
@@ -227,16 +228,16 @@ def test_sms_real_send_calls_jiguang(monkeypatch) -> None:
captured.update(url=url, body=json, auth=headers.get("Authorization", ""))
return _OkResp()
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
monkeypatch.setattr(sms.httpx, "post", _fake_post)
monkeypatch.setattr(jiguang.settings, "SMS_MOCK", False)
monkeypatch.setattr(jiguang.httpx, "post", _fake_post)
sms.send_code(phone)
jiguang.send_code(phone)
assert captured["url"] == sms.settings.SMS_SEND_ENDPOINT
assert captured["url"] == jiguang.settings.SMS_SEND_ENDPOINT
assert captured["body"]["mobile"] == phone
assert captured["body"]["sign_id"] == sms.settings.SMS_SIGN_ID
assert captured["body"]["temp_id"] == sms.settings.SMS_TEMPLATE_ID
assert captured["body"]["temp_para"]["code"] == sms._codes[phone].code
assert captured["body"]["sign_id"] == jiguang.settings.SMS_SIGN_ID
assert captured["body"]["temp_id"] == jiguang.settings.SMS_TEMPLATE_ID
assert captured["body"]["temp_para"]["code"] == jiguang._codes[phone].code
assert captured["auth"].startswith("Basic ")
@@ -244,33 +245,33 @@ def test_sms_real_verify_one_time_and_wrong(monkeypatch) -> None:
"""real 校验:错误码拒(不消费)→ 正确码成功 → 验过即作废。"""
phone = "13455134000"
_reset(phone)
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
monkeypatch.setattr(sms.httpx, "post", lambda *a, **k: _OkResp())
monkeypatch.setattr(jiguang.settings, "SMS_MOCK", False)
monkeypatch.setattr(jiguang.httpx, "post", lambda *a, **k: _OkResp())
sms.send_code(phone)
code = sms._codes[phone].code
jiguang.send_code(phone)
code = jiguang._codes[phone].code
wrong = "000000" if code != "000000" else "111111"
assert sms.verify_code(phone, wrong) is False
assert sms.verify_code(phone, code) is True
assert sms.verify_code(phone, code) is False # 已作废
assert jiguang.verify_code(phone, wrong) is False
assert jiguang.verify_code(phone, code) is True
assert jiguang.verify_code(phone, code) is False # 已作废
def test_sms_real_verify_attempts_exhausted(monkeypatch) -> None:
"""real 校验:错误次数到上限即作废,正确码也不再通过(防爆破)。"""
phone = "13466134000"
_reset(phone)
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
monkeypatch.setattr(sms.settings, "SMS_MAX_VERIFY_ATTEMPTS", 3)
monkeypatch.setattr(sms.httpx, "post", lambda *a, **k: _OkResp())
monkeypatch.setattr(jiguang.settings, "SMS_MOCK", False)
monkeypatch.setattr(jiguang.settings, "SMS_MAX_VERIFY_ATTEMPTS", 3)
monkeypatch.setattr(jiguang.httpx, "post", lambda *a, **k: _OkResp())
sms.send_code(phone)
code = sms._codes[phone].code
jiguang.send_code(phone)
code = jiguang._codes[phone].code
wrong = "000000" if code != "000000" else "111111"
for _ in range(3):
assert sms.verify_code(phone, wrong) is False
assert sms.verify_code(phone, code) is False # 超限作废
assert jiguang.verify_code(phone, wrong) is False
assert jiguang.verify_code(phone, code) is False # 超限作废
def test_sms_real_balance_error_keeps_cooldown(monkeypatch) -> None:
@@ -284,36 +285,36 @@ def test_sms_real_balance_error_keeps_cooldown(monkeypatch) -> None:
def json(self):
return {"error": {"code": 50014, "message": "no money"}}
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
monkeypatch.setattr(sms.httpx, "post", lambda *a, **k: _ErrResp())
monkeypatch.setattr(jiguang.settings, "SMS_MOCK", False)
monkeypatch.setattr(jiguang.httpx, "post", lambda *a, **k: _ErrResp())
with pytest.raises(sms.SmsError) as ei:
sms.send_code(phone)
jiguang.send_code(phone)
assert ei.value.status_code == 503
assert phone not in sms._codes # 没发出去的码已清
assert phone in sms._last_sent # 冷却保留:失败也限速
assert phone not in jiguang._codes # 没发出去的码已清
assert phone in jiguang._last_sent # 冷却保留:失败也限速
# 立即重试 → 被冷却挡下(429),不会再打极光
with pytest.raises(sms.SmsError) as ei2:
sms.send_code(phone)
jiguang.send_code(phone)
assert ei2.value.status_code == 429
def test_sms_gc_purges_stale_only(monkeypatch) -> None:
"""GC 清过期码 / 旧冷却,但不动今天有效的(阈值设 0 强制每次扫)。"""
monkeypatch.setattr(sms, "_GC_THRESHOLD", 0)
sms._codes.clear()
sms._last_sent.clear()
monkeypatch.setattr(jiguang, "_GC_THRESHOLD", 0)
jiguang._codes.clear()
jiguang._last_sent.clear()
now = time.time()
sms._codes["stale"] = sms._CodeRecord(code="111111", expires_at=now - 1)
sms._codes["fresh"] = sms._CodeRecord(code="222222", expires_at=now + 999)
sms._last_sent["old"] = now - 99999
sms._last_sent["recent"] = now
jiguang._codes["stale"] = jiguang._CodeRecord(code="111111", expires_at=now - 1)
jiguang._codes["fresh"] = jiguang._CodeRecord(code="222222", expires_at=now + 999)
jiguang._last_sent["old"] = now - 99999
jiguang._last_sent["recent"] = now
sms._gc(now)
jiguang._gc(now)
assert "stale" not in sms._codes and "fresh" in sms._codes
assert "old" not in sms._last_sent and "recent" in sms._last_sent
assert "stale" not in jiguang._codes and "fresh" in jiguang._codes
assert "old" not in jiguang._last_sent and "recent" in jiguang._last_sent
# ============================ 用户名 / 默认昵称 ============================
+34 -1
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:
@@ -233,7 +263,9 @@ def test_trace_finalize_harvests_abort(client) -> None:
with SessionLocal() as db: # 先有 running 行(帧0建的)
crud.harvest_running(db, trace_id=tid, user_id=None)
p, _cap = _mock_pricebot({"trace_url": "https://price.shaguabijia.com/traces/fin/"})
with p:
with p, patch(
"app.api.v1.compare.backfill_comparison_llm_cost"
) as backfill:
r = client.post("/api/v1/trace/finalize",
json={"trace_id": tid, "status": "cancelled", "reason": "用户终止"})
assert r.status_code == 200
@@ -241,6 +273,7 @@ def test_trace_finalize_harvests_abort(client) -> None:
rec = _get(db, tid)
assert rec is not None and rec.status == "cancelled"
assert rec.trace_url.endswith("/fin/")
backfill.assert_called_once_with(rec.id, tid)
def test_price_step_binds_user_when_authed(client) -> None:
+4
View File
@@ -72,6 +72,7 @@ def test_backfill_retries_then_persists_cost(monkeypatch):
def test_repair_batch_only_targets_terminal_missing_rows(monkeypatch):
missing_id = _record("llm-repair-missing")
cancelled_id = _record("llm-repair-cancelled", status="cancelled")
running_id = _record("llm-repair-running", status="running")
calls = [
{
@@ -96,12 +97,15 @@ def test_repair_batch_only_targets_terminal_missing_rows(monkeypatch):
)
assert result["repaired"] >= 1
assert "llm-repair-missing" in seen
assert "llm-repair-cancelled" in seen
assert "llm-repair-running" not in seen
with SessionLocal() as db:
assert db.get(ComparisonRecord, missing_id).llm_cost_yuan is not None
assert db.get(ComparisonRecord, cancelled_id).llm_cost_yuan is not None
assert db.get(ComparisonRecord, running_id).llm_cost_yuan is None
finally:
_delete(missing_id)
_delete(cancelled_id)
_delete(running_id)
+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未达起送门槛"
+185
View File
@@ -0,0 +1,185 @@
"""阿里云短信 provider(Mode A)单元测试。
SDK 交互隔离在 aliyun._call_send / aliyun._call_check 两个薄封装,本文件全程 monkeypatch
它们(返回归一化结果 dict 或抛 SmsError) 不触真 SDK不发网络测的是 provider 的可映射逻辑:
错误码HTTP PASS/UNKNOWN 解释本地失败计数(与极光同语义)mock 短路
"""
from __future__ import annotations
import pytest
from app.core.config import settings
from app.integrations.sms import aliyun
from app.integrations.sms.base import SmsError
PHONE = "13800138000"
def _configure(monkeypatch, *, mock: bool = False) -> None:
"""配齐阿里云凭证 + 设 SMS_MOCK;清本地失败计数隔离用例。"""
monkeypatch.setattr(settings, "SMS_MOCK", mock)
monkeypatch.setattr(settings, "ALIYUN_SMS_ACCESS_KEY_ID", "ak")
monkeypatch.setattr(settings, "ALIYUN_SMS_ACCESS_KEY_SECRET", "sk")
monkeypatch.setattr(settings, "ALIYUN_SMS_SIGN_NAME", "恒创联众")
monkeypatch.setattr(settings, "ALIYUN_SMS_TEMPLATE_CODE", "SMS_100001")
aliyun._verify_attempts.clear()
def _send_ok(phone):
return {"success": True, "code": "OK", "message": "成功", "verify_code": "1234"}
def _check(result):
def _f(phone, code):
return {"success": True, "code": "OK", "message": "成功", "verify_result": result}
return _f
# ============================ 发码 ============================
def test_send_success_returns_interval_and_resets_attempts(monkeypatch) -> None:
_configure(monkeypatch)
aliyun._verify_attempts[PHONE] = 3 # 旧失败计数
monkeypatch.setattr(aliyun, "_call_send", _send_ok)
assert aliyun.send_code(PHONE) == settings.ALIYUN_SMS_INTERVAL_SEC
assert PHONE not in aliyun._verify_attempts # 新码 = 新预算
@pytest.mark.parametrize(
"code,expected",
[
("MOBILE_NUMBER_ILLEGAL", 400),
("BUSINESS_LIMIT_CONTROL", 429),
("FREQUENCY_FAIL", 429),
("FUNCTION_NOT_OPENED", 503),
("INVALID_PARAMETERS", 503),
("SOME_UNEXPECTED_CODE", 503),
],
)
def test_send_maps_error_codes(monkeypatch, code, expected) -> None:
_configure(monkeypatch)
monkeypatch.setattr(
aliyun, "_call_send",
lambda phone: {"success": False, "code": code, "message": code, "verify_code": None},
)
with pytest.raises(SmsError) as ei:
aliyun.send_code(PHONE)
assert ei.value.status_code == expected
def test_send_not_configured_raises_503_without_calling_aliyun(monkeypatch) -> None:
monkeypatch.setattr(settings, "SMS_MOCK", False)
monkeypatch.setattr(settings, "ALIYUN_SMS_ACCESS_KEY_ID", "") # 凭证缺
def _boom(phone):
raise AssertionError("未配置时不应调用阿里云")
monkeypatch.setattr(aliyun, "_call_send", _boom)
with pytest.raises(SmsError) as ei:
aliyun.send_code(PHONE)
assert ei.value.status_code == 503
def test_send_transport_error_propagates_503(monkeypatch) -> None:
_configure(monkeypatch)
def _boom(phone):
raise SmsError("network down", status_code=503)
monkeypatch.setattr(aliyun, "_call_send", _boom)
with pytest.raises(SmsError) as ei:
aliyun.send_code(PHONE)
assert ei.value.status_code == 503
def test_send_mock_returns_interval_no_network(monkeypatch) -> None:
_configure(monkeypatch, mock=True)
def _boom(phone):
raise AssertionError("mock 不应调用阿里云")
monkeypatch.setattr(aliyun, "_call_send", _boom)
assert aliyun.send_code(PHONE) == settings.ALIYUN_SMS_INTERVAL_SEC
# ============================ 校验 ============================
def test_verify_pass_true_and_clears_attempts(monkeypatch) -> None:
_configure(monkeypatch)
aliyun._verify_attempts[PHONE] = 2
monkeypatch.setattr(aliyun, "_call_check", _check("PASS"))
assert aliyun.verify_code(PHONE, "1234") is True
assert PHONE not in aliyun._verify_attempts # 验过即清
def test_verify_unknown_false_and_increments(monkeypatch) -> None:
_configure(monkeypatch)
monkeypatch.setattr(aliyun, "_call_check", _check("UNKNOWN"))
assert aliyun.verify_code(PHONE, "0000") is False
assert aliyun._verify_attempts[PHONE] == 1
assert aliyun.verify_code(PHONE, "0000") is False
assert aliyun._verify_attempts[PHONE] == 2
def test_verify_attempts_cap_short_circuits(monkeypatch) -> None:
_configure(monkeypatch)
aliyun._verify_attempts[PHONE] = settings.SMS_MAX_VERIFY_ATTEMPTS
def _boom(phone, code):
raise AssertionError("达失败上限后不应再调阿里云")
monkeypatch.setattr(aliyun, "_call_check", _boom)
assert aliyun.verify_code(PHONE, "1234") is False # 本地作废
def test_verify_api_error_raises_503(monkeypatch) -> None:
_configure(monkeypatch)
monkeypatch.setattr(
aliyun, "_call_check",
lambda phone, code: {"success": False, "code": "SYSTEM_ERROR",
"message": "err", "verify_result": None},
)
with pytest.raises(SmsError) as ei:
aliyun.verify_code(PHONE, "1234")
assert ei.value.status_code == 503
def test_verify_transport_error_raises_503(monkeypatch) -> None:
_configure(monkeypatch)
def _boom(phone, code):
raise SmsError("network down", status_code=503)
monkeypatch.setattr(aliyun, "_call_check", _boom)
with pytest.raises(SmsError) as ei:
aliyun.verify_code(PHONE, "1234")
assert ei.value.status_code == 503
def test_verify_mock_passes_any_ndigit(monkeypatch) -> None:
_configure(monkeypatch, mock=True)
def _boom(phone, code):
raise AssertionError("mock 不应调用阿里云")
monkeypatch.setattr(aliyun, "_call_check", _boom)
assert aliyun.verify_code(PHONE, "123456") is True # 6 位数字放行
assert aliyun.verify_code(PHONE, "12345") is False # 位数不对
# ============================ 端点:阿里云降级 → 503(auth.py 包 try/except)============================
def test_sms_login_aliyun_outage_returns_503(client, monkeypatch) -> None:
"""SMS_PROVIDER=aliyun 且校验时阿里云异常 → /sms/login 返 503(而非 400/500),便于区分排查。"""
_configure(monkeypatch) # 配齐凭证 + SMS_MOCK=False + 清计数
monkeypatch.setattr(settings, "SMS_PROVIDER", "aliyun")
def _boom(phone, code):
raise SmsError("aliyun down", status_code=503)
monkeypatch.setattr(aliyun, "_call_check", _boom)
r = client.post("/api/v1/auth/sms/login", json={"phone": "13812345678", "code": "1234"})
assert r.status_code == 503, r.text
+286
View File
@@ -0,0 +1,286 @@
"""创蓝云智(253)短信 provider(Mode B 自管码)单元测试。
HTTP 交互隔离在 chuanglan._call_chuanglan(薄封装:签名 + httpx POST + 解析),映射逻辑在
chuanglan._send_via_chuanglan本文件 monkeypatch 这两个接缝(或更底层 httpx.post) 不发真网络
测的是:HMAC 签名算法请求体不上行 password错误码HTTP 自管码存/校验(与极光同语义)mock 短路
"""
from __future__ import annotations
import hashlib
import hmac
import json
import time
import httpx
import pytest
from app.core.config import settings
from app.integrations.sms import chuanglan
from app.integrations.sms.base import SmsError
PHONE = "13800138000"
class _FakeResp:
"""极简 httpx.Response 替身:只暴露 status_code / json() / text。"""
def __init__(self, status_code: int = 200, payload: dict | None = None, text: str = "") -> None:
self.status_code = status_code
self._payload = payload if payload is not None else {}
self.text = text or json.dumps(self._payload)
def json(self) -> dict:
return self._payload
def _configure(monkeypatch, *, mock: bool = False) -> None:
"""配齐创蓝凭证 + 设 SMS_MOCK;清本地存码/冷却隔离用例。"""
monkeypatch.setattr(settings, "SMS_MOCK", mock)
monkeypatch.setattr(settings, "CHUANGLAN_SMS_ACCOUNT", "YZM0000001")
monkeypatch.setattr(settings, "CHUANGLAN_SMS_PASSWORD", "secret")
monkeypatch.setattr(settings, "CHUANGLAN_SMS_TEMPLATE_ID", "1021143438")
monkeypatch.setattr(settings, "CHUANGLAN_SMS_SIGNATURE", "【创蓝云智】")
chuanglan._codes.clear()
chuanglan._last_sent.clear()
def _ok_payload(**over) -> dict:
p = {
"code": "000000",
"msgId": "25071018345400902898000000000001",
"time": "20250710183454",
"successNum": "1",
"failNum": "0",
"errorMsg": "",
}
p.update(over)
return p
# ============================ 签名算法 ============================
def test_sign_implements_documented_hmac() -> None:
"""_sign = HmacSHA256(key=md5(password), msg=sorted([md5pwd,ts,nonce]) 拼接),小写 hex。"""
password, ts, nonce = "secret", "1752143733", "0123456789abcdef0123456789abcdef"
md5pwd = hashlib.md5(password.encode()).hexdigest()
expected = hmac.new(
md5pwd.encode(),
"".join(sorted([md5pwd, ts, nonce])).encode(),
hashlib.sha256,
).hexdigest()
sig = chuanglan._sign(password, ts, nonce)
assert sig == expected
assert len(sig) == 64 and sig == sig.lower()
def test_sign_changes_with_nonce() -> None:
assert chuanglan._sign("p", "1", "nonceA") != chuanglan._sign("p", "1", "nonceB")
# ============================ _call_chuanglan(HTTP 接缝)============================
def test_call_chuanglan_builds_signed_request_without_password(monkeypatch) -> None:
_configure(monkeypatch)
captured: dict = {}
def fake_post(url, **kw):
captured.update(url=url, body=kw.get("json"), headers=kw.get("headers"), timeout=kw.get("timeout"))
return _FakeResp(200, _ok_payload())
monkeypatch.setattr(httpx, "post", fake_post)
result = chuanglan._call_chuanglan(PHONE, "123456")
assert result["code"] == "000000"
assert captured["url"] == settings.CHUANGLAN_SMS_ENDPOINT
body = captured["body"]
assert body["account"] == "YZM0000001"
assert body["phoneNumbers"] == PHONE
assert body["templateId"] == "1021143438"
assert body["templateParamJson"] == json.dumps([{"param1": "123456"}])
assert body["signature"] == "【创蓝云智】"
assert "password" not in body # HMAC 方式:密码只用于算签,不上行
assert len(body["nonce"]) == 32
assert captured["headers"]["X-QA-Hmac-Signature"] == chuanglan._sign(
"secret", body["timestamp"], body["nonce"]
)
assert captured["timeout"] == settings.CHUANGLAN_SMS_TIMEOUT_SEC
def test_call_chuanglan_omits_signature_when_blank(monkeypatch) -> None:
_configure(monkeypatch)
monkeypatch.setattr(settings, "CHUANGLAN_SMS_SIGNATURE", "")
captured: dict = {}
monkeypatch.setattr(httpx, "post", lambda url, **kw: captured.update(body=kw.get("json")) or _FakeResp(200, _ok_payload()))
chuanglan._call_chuanglan(PHONE, "123456")
assert "signature" not in captured["body"] # 模板自带签名时不传
def test_call_chuanglan_http_non_200_raises_503(monkeypatch) -> None:
_configure(monkeypatch)
monkeypatch.setattr(httpx, "post", lambda url, **kw: _FakeResp(500, {}, "oops"))
with pytest.raises(SmsError) as ei:
chuanglan._call_chuanglan(PHONE, "123456")
assert ei.value.status_code == 503
def test_call_chuanglan_network_error_raises_503(monkeypatch) -> None:
_configure(monkeypatch)
def boom(url, **kw):
raise httpx.ConnectError("down")
monkeypatch.setattr(httpx, "post", boom)
with pytest.raises(SmsError) as ei:
chuanglan._call_chuanglan(PHONE, "123456")
assert ei.value.status_code == 503
# ============================ _send_via_chuanglan(错误码映射)============================
@pytest.mark.parametrize(
"code,expected",
[
("000000", None), # 成功不抛
("103", 429), # 超频
("107", 400), # 手机号错误
("109", 503), # 无发送量/余额
("117", 503), # IP 未白名单
("102", 503), # 密码错误
("116", 503), # 签名不合法
("124", 503), # 模板内容不匹配
("152", 503), # 模板不存在
("999999", 503), # 未知码兜底
],
)
def test_send_via_chuanglan_maps_codes(monkeypatch, code, expected) -> None:
_configure(monkeypatch)
monkeypatch.setattr(chuanglan, "_call_chuanglan", lambda phone, c: _ok_payload(code=code, errorMsg=code))
if expected is None:
assert chuanglan._send_via_chuanglan(PHONE, "123456") is None
else:
with pytest.raises(SmsError) as ei:
chuanglan._send_via_chuanglan(PHONE, "123456")
assert ei.value.status_code == expected
def test_send_via_chuanglan_not_configured_raises_503_without_calling(monkeypatch) -> None:
monkeypatch.setattr(settings, "SMS_MOCK", False)
monkeypatch.setattr(settings, "CHUANGLAN_SMS_ACCOUNT", "") # 凭证缺
def _boom(phone, c):
raise AssertionError("未配置时不应发起请求")
monkeypatch.setattr(chuanglan, "_call_chuanglan", _boom)
with pytest.raises(SmsError) as ei:
chuanglan._send_via_chuanglan(PHONE, "123456")
assert ei.value.status_code == 503
# ============================ send_code(自管码,复制自极光)============================
def test_send_code_success_stores_and_returns_cooldown(monkeypatch) -> None:
_configure(monkeypatch)
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", lambda p, c: None)
assert chuanglan.send_code(PHONE) == settings.SMS_SEND_INTERVAL_SEC
assert PHONE in chuanglan._codes
assert len(chuanglan._codes[PHONE].code) == settings.SMS_CODE_LENGTH
def test_send_code_cooldown_raises_429(monkeypatch) -> None:
_configure(monkeypatch)
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", lambda p, c: None)
chuanglan.send_code(PHONE)
with pytest.raises(SmsError) as ei:
chuanglan.send_code(PHONE)
assert ei.value.status_code == 429
def test_send_code_failure_keeps_cooldown_drops_code(monkeypatch) -> None:
_configure(monkeypatch)
def boom(p, c):
raise SmsError("no balance", status_code=503)
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", boom)
with pytest.raises(SmsError) as ei:
chuanglan.send_code(PHONE)
assert ei.value.status_code == 503
assert PHONE not in chuanglan._codes # 没发出去的码删掉
assert PHONE in chuanglan._last_sent # 冷却保留:失败也限速
def test_send_code_unexpected_error_wrapped_503(monkeypatch) -> None:
_configure(monkeypatch)
def boom(p, c):
raise RuntimeError("boom")
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", boom)
with pytest.raises(SmsError) as ei:
chuanglan.send_code(PHONE)
assert ei.value.status_code == 503
def test_send_code_mock_short_circuits_no_network(monkeypatch) -> None:
_configure(monkeypatch, mock=True)
def boom(p, c):
raise AssertionError("mock 不应发网络")
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", boom)
assert chuanglan.send_code(PHONE) == settings.SMS_SEND_INTERVAL_SEC
# ============================ verify_code(自管码,复制自极光)============================
def test_verify_code_success_is_one_time(monkeypatch) -> None:
_configure(monkeypatch)
chuanglan._codes[PHONE] = chuanglan._CodeRecord(code="123456", expires_at=time.time() + 300)
assert chuanglan.verify_code(PHONE, "123456") is True
assert chuanglan.verify_code(PHONE, "123456") is False # 验过即作废
def test_verify_code_wrong_caps_then_invalidates(monkeypatch) -> None:
_configure(monkeypatch)
chuanglan._codes[PHONE] = chuanglan._CodeRecord(code="123456", expires_at=time.time() + 300)
for _ in range(settings.SMS_MAX_VERIFY_ATTEMPTS):
assert chuanglan.verify_code(PHONE, "000000") is False
# 达失败上限即作废:即便随后给对的码也 False
assert chuanglan.verify_code(PHONE, "123456") is False
def test_verify_code_expired_false_and_cleared(monkeypatch) -> None:
_configure(monkeypatch)
chuanglan._codes[PHONE] = chuanglan._CodeRecord(code="123456", expires_at=time.time() - 1)
assert chuanglan.verify_code(PHONE, "123456") is False
assert PHONE not in chuanglan._codes
def test_verify_code_no_record_false(monkeypatch) -> None:
_configure(monkeypatch)
assert chuanglan.verify_code(PHONE, "123456") is False
def test_verify_code_mock_passes_any_ndigit(monkeypatch) -> None:
_configure(monkeypatch, mock=True)
assert chuanglan.verify_code(PHONE, "123456") is True # 6 位数字放行
assert chuanglan.verify_code(PHONE, "12345") is False # 位数不对
# ============================ 端点:SMS_PROVIDER=chuanglan 端到端路由 ============================
def test_sms_send_chuanglan_outage_returns_503(client, monkeypatch) -> None:
"""SMS_PROVIDER=chuanglan 且发送时创蓝异常 → /sms/send 返 503(auth.py 现有 try/except 覆盖)。"""
_configure(monkeypatch)
monkeypatch.setattr(settings, "SMS_PROVIDER", "chuanglan")
def boom(p, c):
raise SmsError("chuanglan down", status_code=503)
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", boom)
r = client.post("/api/v1/auth/sms/send", json={"phone": "13812345678"})
assert r.status_code == 503, r.text
+53
View File
@@ -0,0 +1,53 @@
"""SMS 分派器:按 settings.SMS_PROVIDER 路由到正确 provider。
契约:send_code / verify_code **每次调用** settings.SMS_PROVIDER provider(支持运行时切换 /
灰度回退);默认 jiguang此处 monkeypatch provider 的实现为标记函数,断言路由命中 + 可秒切
"""
from __future__ import annotations
from app.core.config import settings
from app.integrations import sms
from app.integrations.sms import aliyun, chuanglan, jiguang
def test_send_code_routes_by_provider_and_switches_per_call(monkeypatch) -> None:
calls: list[str] = []
monkeypatch.setattr(jiguang, "send_code", lambda phone: (calls.append("jiguang"), 60)[1])
monkeypatch.setattr(aliyun, "send_code", lambda phone: (calls.append("aliyun"), 60)[1])
monkeypatch.setattr(chuanglan, "send_code", lambda phone: (calls.append("chuanglan"), 60)[1])
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
assert sms.send_code("13800138000") == 60
monkeypatch.setattr(settings, "SMS_PROVIDER", "aliyun")
assert sms.send_code("13800138000") == 60
monkeypatch.setattr(settings, "SMS_PROVIDER", "chuanglan")
assert sms.send_code("13800138000") == 60
assert calls == ["jiguang", "aliyun", "chuanglan"] # 每次按当前 provider 路由,运行时可切
def test_verify_code_routes_by_provider(monkeypatch) -> None:
calls: list[str] = []
monkeypatch.setattr(jiguang, "verify_code", lambda phone, code: (calls.append("jiguang"), True)[1])
monkeypatch.setattr(aliyun, "verify_code", lambda phone, code: (calls.append("aliyun"), True)[1])
monkeypatch.setattr(chuanglan, "verify_code", lambda phone, code: (calls.append("chuanglan"), True)[1])
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
assert sms.verify_code("13800138000", "123456") is True
monkeypatch.setattr(settings, "SMS_PROVIDER", "aliyun")
assert sms.verify_code("13800138000", "123456") is True
monkeypatch.setattr(settings, "SMS_PROVIDER", "chuanglan")
assert sms.verify_code("13800138000", "123456") is True
assert calls == ["jiguang", "aliyun", "chuanglan"]
def test_unknown_provider_falls_back_to_jiguang(monkeypatch) -> None:
"""SMS_PROVIDER 非 aliyun 一律走 jiguang(默认兜底,防误配把登录打挂)。"""
calls: list[str] = []
monkeypatch.setattr(jiguang, "send_code", lambda phone: (calls.append("jiguang"), 60)[1])
monkeypatch.setattr(aliyun, "send_code", lambda phone: (calls.append("aliyun"), 60)[1])
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
sms.send_code("13800138000")
assert calls == ["jiguang"]