Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 084c6e1894 | |||
| c1cc6bb43f | |||
| 91f9981885 |
+78
-40
@@ -27,44 +27,66 @@ 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
|
||||
|
||||
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
|
||||
# ===== 厂商直推(无障碍保护存活告警 + 消息中心 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 后置兜底)=====
|
||||
HEARTBEAT_MONITOR_ENABLED=true
|
||||
HEARTBEAT_TIMEOUT_MINUTES=60
|
||||
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
||||
|
||||
# ===== 短信 (mock 模式) =====
|
||||
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。生产改 false。
|
||||
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。
|
||||
# 后续接阿里云/腾讯云短信时,改成 false 并填供应商相关 key。
|
||||
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 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
|
||||
@@ -91,6 +113,13 @@ 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)。
|
||||
@@ -110,6 +139,11 @@ 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 之类
|
||||
@@ -164,14 +198,18 @@ PANGLE_REPORT_SECURITY_KEY=
|
||||
PANGLE_REPORT_SITE_ID_PROD=5830519
|
||||
PANGLE_REPORT_SITE_ID_TEST=5832303
|
||||
|
||||
# ===== 客户端运行日志上报(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 超此字节数截断
|
||||
# ===== 可观测(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
|
||||
|
||||
@@ -49,9 +49,6 @@ secrets/*
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# 本地 admin server(端口 8771)Windows 启动脚本,个人调试用,不入库
|
||||
/run8771.bat
|
||||
|
||||
# Claude Code 自动持久化的权限 allowlist / 个人本地设置(会话专属,不入库)。
|
||||
# 需要团队共享的 Claude 配置(commands/ 等)可单独 git add -f,不受此忽略影响。
|
||||
.claude/settings.json
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""comparison_record.fail_reason (失败卡展示原因)
|
||||
|
||||
Revision ID: comparison_record_fail_reason
|
||||
Revises: user_manual_risk_fields
|
||||
Create Date: 2026-07-28 12:00:00.000000
|
||||
|
||||
失败记录的展示原因:information 具体则=它;笼统则由写路径从 platform_results 捞出的
|
||||
业务原因;纯系统失败为 None(端侧品牌兜底)。见 repositories.comparison._derive_fail_display。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'comparison_record_fail_reason'
|
||||
down_revision: Union[str, Sequence[str], None] = 'user_manual_risk_fields'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('fail_reason', sa.String(length=256), nullable=True))
|
||||
# 回填老失败记录:information 具体的直接搬过来(笼统/系统失败留 None → 端侧品牌兜底)。
|
||||
# 新记录由写路径 _derive_fail_display 落库(含 platform_results 救援/补判),不走这条。
|
||||
# platform_results 只在 raw_payload 里,SQL 里不易解析,故老记录不做救援/补判(可接受:
|
||||
# 老 mixed/打烊记录回退品牌兜底);具体 information 的老记录本次即可显示真实原因。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE comparison_record
|
||||
SET fail_reason = information
|
||||
WHERE status = 'failed'
|
||||
AND information IS NOT NULL
|
||||
AND information <> ''
|
||||
AND information NOT IN (
|
||||
'比价过程出错,请稍后重试',
|
||||
'比价出错',
|
||||
'比价未完成',
|
||||
'done 参数缺少可验证的目标平台结果'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
|
||||
batch_op.drop_column('fail_reason')
|
||||
@@ -43,7 +43,7 @@ _KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"})
|
||||
_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"})
|
||||
|
||||
|
||||
def business_code_ids(db: Session, app_env: str | None) -> set[str]:
|
||||
def _business_code_ids(db: Session, app_env: str | None) -> set[str]:
|
||||
"""返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。"""
|
||||
prod_config = app_config.get_ad_config(db)
|
||||
prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | {
|
||||
@@ -320,10 +320,10 @@ def ad_revenue_report(
|
||||
|
||||
# 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试
|
||||
# demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。
|
||||
business_ids: set[str] | None = None
|
||||
business_code_ids: set[str] | None = None
|
||||
if revenue_scope == "business":
|
||||
business_ids = business_code_ids(db, app_env)
|
||||
events = [e for e in events if e.get("our_code_id") in business_ids]
|
||||
business_code_ids = _business_code_ids(db, app_env)
|
||||
events = [e for e in events if e.get("our_code_id") in business_code_ids]
|
||||
|
||||
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
|
||||
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
|
||||
@@ -381,7 +381,7 @@ def ad_revenue_report(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
app_env=app_env,
|
||||
our_code_ids=business_ids,
|
||||
our_code_ids=business_code_ids,
|
||||
)
|
||||
if pangle_aggs:
|
||||
by_date = {a["date"]: a for a in pangle_aggs}
|
||||
|
||||
@@ -14,7 +14,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.config import settings
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.admin import AdminAuditLog
|
||||
@@ -64,40 +63,6 @@ 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:
|
||||
item.device_model_name = _device_marketing_name(item.device_model)
|
||||
|
||||
|
||||
def _attach_feedback_device_details(db: Session, feedbacks: list[Feedback]) -> None:
|
||||
"""按同一用户、同一设备编码及提交时间补齐厂商和 ROM 大版本。"""
|
||||
candidates = [
|
||||
@@ -379,8 +344,6 @@ 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)读出。
|
||||
rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items])
|
||||
@@ -527,8 +490,6 @@ 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
|
||||
|
||||
|
||||
@@ -1258,15 +1219,11 @@ def user_reward_stats(
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
withdraw_source: str | None = None,
|
||||
app_env: str | None = None,
|
||||
revenue_scope: str = "all",
|
||||
feed_scene: str | None = None,
|
||||
) -> dict:
|
||||
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
|
||||
|
||||
口径:激励视频/信息流奖励数量只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加)。
|
||||
平均 Draw eCPM 与广告收益报表一致:基于 ad_ecpm_record 的全部 draw/feed 展示记录计算,
|
||||
不以是否发奖为筛选条件。各「提现」= 该来源累计金币折现。
|
||||
口径:激励视频/信息流只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加);
|
||||
平均 eCPM 用原始分值(分/千次)按记录取算术平均;各「提现」= 该来源累计金币折现。
|
||||
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。
|
||||
"""
|
||||
withdraw_source_conds = (
|
||||
@@ -1301,68 +1258,30 @@ def user_reward_stats(
|
||||
|
||||
# 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时,
|
||||
# SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。
|
||||
business_ids: set[str] | None = None
|
||||
if revenue_scope == "business":
|
||||
# 与广告收益报表共用正式/测试业务代码位集合,避免两个页面随配置切换后再次漂移。
|
||||
from app.admin.repositories.ad_revenue import business_code_ids
|
||||
|
||||
business_ids = business_code_ids(db, app_env)
|
||||
|
||||
rv_conds = [
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
AdRewardRecord.status == "granted",
|
||||
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
||||
]
|
||||
if app_env is not None:
|
||||
rv_conds.append(AdRewardRecord.app_env == app_env)
|
||||
if business_ids is not None:
|
||||
rv_conds.append(AdRewardRecord.our_code_id.in_(business_ids))
|
||||
rv = db.execute(
|
||||
select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where(
|
||||
*rv_conds,
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
AdRewardRecord.status == "granted",
|
||||
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
||||
)
|
||||
).all()
|
||||
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
|
||||
rv_coins = sum(r.coin for r in rv)
|
||||
|
||||
feed_reward_conds = [
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
||||
]
|
||||
if app_env is not None:
|
||||
feed_reward_conds.append(AdFeedRewardRecord.app_env == app_env)
|
||||
if feed_scene is not None:
|
||||
feed_reward_conds.append(AdFeedRewardRecord.feed_scene == feed_scene)
|
||||
if business_ids is not None:
|
||||
feed_reward_conds.append(AdFeedRewardRecord.our_code_id.in_(business_ids))
|
||||
feed_rewards = db.execute(
|
||||
feed = db.execute(
|
||||
select(
|
||||
AdFeedRewardRecord.unit_count,
|
||||
AdFeedRewardRecord.ecpm_raw,
|
||||
AdFeedRewardRecord.coin,
|
||||
).where(
|
||||
*feed_reward_conds,
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
||||
)
|
||||
).all()
|
||||
feed_coins = sum(f.coin for f in feed_rewards)
|
||||
|
||||
feed_impression_conds = [
|
||||
AdEcpmRecord.user_id == user_id,
|
||||
AdEcpmRecord.ad_type.in_(("draw", "feed")),
|
||||
*_window_conds(AdEcpmRecord.created_at, date_from, date_to),
|
||||
]
|
||||
if app_env is not None:
|
||||
feed_impression_conds.append(AdEcpmRecord.app_env == app_env)
|
||||
if feed_scene is not None:
|
||||
feed_impression_conds.append(AdEcpmRecord.feed_scene == feed_scene)
|
||||
if business_ids is not None:
|
||||
feed_impression_conds.append(AdEcpmRecord.our_code_id.in_(business_ids))
|
||||
feed_impressions = db.execute(
|
||||
select(AdEcpmRecord.ecpm_raw).where(*feed_impression_conds)
|
||||
).all()
|
||||
# 与 ad_revenue.category_stats 相同:每次展示权重相同,非法原值按 parse_ecpm_fen 记 0。
|
||||
feed_ecpms = [rewards.parse_ecpm_fen(row.ecpm_raw) for row in feed_impressions]
|
||||
feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw]
|
||||
feed_coins = sum(f.coin for f in feed)
|
||||
|
||||
trad_coins = db.execute(
|
||||
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
|
||||
@@ -1381,7 +1300,7 @@ def user_reward_stats(
|
||||
"reward_video_count": len(rv),
|
||||
"reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0,
|
||||
"reward_video_cash_cents": _coins_to_cents(rv_coins),
|
||||
"feed_count": int(sum(f.unit_count for f in feed_rewards)),
|
||||
"feed_count": int(sum(f.unit_count for f in feed)),
|
||||
"feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0,
|
||||
"feed_cash_cents": _coins_to_cents(feed_coins),
|
||||
}
|
||||
|
||||
@@ -88,11 +88,6 @@ def get_user_reward_stats(
|
||||
withdraw_source: Annotated[
|
||||
str | None, Query(pattern="^(coin_cash|invite_cash)$")
|
||||
] = None,
|
||||
app_env: Annotated[str | None, Query(pattern="^(prod|test)$")] = None,
|
||||
revenue_scope: Annotated[str, Query(pattern="^(business|all)$")] = "all",
|
||||
feed_scene: Annotated[
|
||||
str | None, Query(pattern="^(comparison|coupon|welfare)$")
|
||||
] = None,
|
||||
) -> UserRewardStats:
|
||||
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
|
||||
if not user_repo.user_exists(db, user_id):
|
||||
@@ -104,9 +99,6 @@ def get_user_reward_stats(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
withdraw_source=withdraw_source,
|
||||
app_env=app_env,
|
||||
revenue_scope=revenue_scope,
|
||||
feed_scene=feed_scene,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ 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
|
||||
@@ -40,10 +39,8 @@ class AdminComparisonListItem(BaseModel):
|
||||
# 本次比价 LLM 总成本(元,按当时价冻结);旧记录/未回填为 None → 前端「成本」列回退估算。见 services/llm_cost.py。
|
||||
llm_cost_yuan: float | None = None
|
||||
device_model: str | None = None
|
||||
device_model_name: str | None = None
|
||||
rom_vendor: str | None = None
|
||||
rom_name: str | None = None
|
||||
rom_version: int | None = None
|
||||
android_version: str | None = None
|
||||
app_version: str | None = None
|
||||
ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上
|
||||
@@ -87,6 +84,7 @@ class AdminComparisonDetail(AdminComparisonListItem):
|
||||
skipped_dish_names: list = []
|
||||
# 全量环境
|
||||
device_manufacturer: str | None = None
|
||||
rom_version: int | None = None
|
||||
android_sdk: int | None = None
|
||||
app_version_code: int | None = None
|
||||
source_app_version: str | None = None
|
||||
|
||||
@@ -60,7 +60,7 @@ class UserRewardStats(BaseModel):
|
||||
reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次)
|
||||
reward_video_cash_cents: int # 激励视频提现(金币折现)
|
||||
feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加)
|
||||
feed_avg_ecpm: float # 全部 Draw/feed 实际展示的平均 eCPM(分/千次,含未发奖展示)
|
||||
feed_avg_ecpm: float # 平均信息流广告 eCPM(分/千次)
|
||||
feed_cash_cents: int # 信息流广告提现(金币折现)
|
||||
|
||||
|
||||
|
||||
+1
-13
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -77,18 +77,6 @@ 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)]
|
||||
|
||||
+10
-2
@@ -10,7 +10,7 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.api.deps import DbSession, get_client_ip
|
||||
from app.api.deps import DbSession
|
||||
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,11 +20,19 @@ 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=get_client_ip(request))
|
||||
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
|
||||
return AnalyticsIngestOut(received=n)
|
||||
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"""客户端运行日志批量上报接口。
|
||||
|
||||
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)
|
||||
+2
-11
@@ -292,12 +292,7 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
|
||||
detail="登录尝试过于频繁,请稍后再试",
|
||||
)
|
||||
|
||||
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,不算「验证失败」)
|
||||
if not verify_code(req.phone, req.code):
|
||||
risk_repo.record_behavior_event(
|
||||
db,
|
||||
event_type=risk_repo.EVENT_SMS_LOGIN,
|
||||
@@ -461,11 +456,7 @@ def wechat_bind_phone_sms(
|
||||
detail="登录尝试过于频繁,请稍后再试",
|
||||
)
|
||||
|
||||
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:
|
||||
if not verify_code(req.phone, req.code):
|
||||
raise HTTPException(status_code=400, detail="invalid sms code")
|
||||
|
||||
return _finish_wechat_bind(
|
||||
|
||||
+3
-11
@@ -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,
|
||||
) -> int | None:
|
||||
) -> 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,7 +118,6 @@ 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(
|
||||
@@ -292,10 +291,7 @@ async def trace_epilogue(
|
||||
|
||||
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
|
||||
async def trace_finalize(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: OptionalUser,
|
||||
db: DbSession,
|
||||
request: Request, user: OptionalUser, db: DbSession
|
||||
) -> dict[str, Any]:
|
||||
_ensure_compare_allowed(user, db)
|
||||
# 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url};
|
||||
@@ -306,16 +302,12 @@ async def trace_finalize(
|
||||
request, "/api/trace/finalize", user, harvest_first_frame=False,
|
||||
)
|
||||
try:
|
||||
record_id = await run_in_threadpool(
|
||||
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
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
"""客户端运行日志专用落盘 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
|
||||
@@ -104,7 +104,6 @@ class Settings(BaseSettings):
|
||||
|
||||
XIAOMI_PUSH_APP_SECRET: str = ""
|
||||
XIAOMI_PUSH_SEND_ENDPOINT: str = "https://api.xmpush.xiaomi.com/v3/message/regid"
|
||||
XIAOMI_PUSH_MODE: int = 1 # 0=正式推送,1=测试推送
|
||||
XIAOMI_PUSH_CHANNEL_ID: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_ID: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_TITLE: str = ""
|
||||
@@ -142,49 +141,6 @@ 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 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""极光短信 provider(自管码 Mode B)。
|
||||
"""短信验证码服务。
|
||||
|
||||
两种运行模式由 `SMS_MOCK` 切换:
|
||||
- **mock**(开发/测试,默认):不真发短信,验证码打到日志;校验**放行任意 N 位数字**
|
||||
(测试/开发便利)。真实校验逻辑(比对存码 / 一次性 / 防爆破)由 real 分支 + 单测覆盖。
|
||||
- **real**(生产 `SMS_MOCK=false` 且 `SMS_PROVIDER=jiguang`):本服务生成 N 位验证码 → 调极光
|
||||
短信 REST `/v1/messages` 发送(自定义验证码模式,极光只负责发,code 由本服务生成/保管/
|
||||
- **real**(生产 `SMS_MOCK=false`):本服务生成 N 位验证码 → 调极光短信 REST
|
||||
`/v1/messages` 发送(自定义验证码模式,极光只负责发,code 由本服务生成/保管/
|
||||
校验)→ 鉴权复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`(同一极光应用)。
|
||||
|
||||
验证码存储:**进程内存**(单 worker uvicorn 够用)。重启丢失(用户重发即可)。多
|
||||
worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移到 DB/Redis(或改用 aliyun provider,
|
||||
其验证码由阿里云托管、无本地存码)。见 docs/待办与技术债.md。
|
||||
worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移到 DB/Redis。
|
||||
见 docs/待办与技术债.md。
|
||||
|
||||
防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
|
||||
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
|
||||
@@ -34,9 +34,17 @@ import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
from .base import SmsError, mock_verify
|
||||
logger = logging.getLogger("shagua.sms")
|
||||
|
||||
logger = logging.getLogger("shagua.sms.jiguang")
|
||||
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -118,7 +126,7 @@ def verify_code(phone: str, code: str) -> bool:
|
||||
- **real 模式**:比对本服务存的码,匹配即作废(一次性);失败累计到上限也作废(防爆破)。
|
||||
"""
|
||||
if settings.SMS_MOCK:
|
||||
ok = mock_verify(code)
|
||||
ok = len(code) == settings.SMS_CODE_LENGTH and code.isdigit()
|
||||
logger.info("[SMS-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
|
||||
return ok
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
"""短信验证码服务 —— 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)
|
||||
@@ -1,193 +0,0 @@
|
||||
"""阿里云号码认证(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,
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"""短信 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()
|
||||
@@ -1,224 +0,0 @@
|
||||
"""创蓝云智(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)
|
||||
@@ -424,7 +424,6 @@ def _send_xiaomi(token: str, title: str, body: str, extras: dict[str, str]) -> d
|
||||
"payload": json.dumps(extras, ensure_ascii=False),
|
||||
"pass_through": "0",
|
||||
"notify_type": "-1",
|
||||
"pushMode": settings.XIAOMI_PUSH_MODE,
|
||||
"time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000),
|
||||
}
|
||||
# 点击落地:带 notificationId 的消息中心推送 → notify_effect=2 + intent_uri,MiPush 直接打开
|
||||
|
||||
@@ -21,7 +21,6 @@ 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
|
||||
@@ -154,7 +153,6 @@ 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)
|
||||
|
||||
@@ -102,11 +102,6 @@ class ComparisonRecord(Base):
|
||||
# done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…";
|
||||
# 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。
|
||||
information: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
# 失败卡「原因」行的展示文案(仅 status=failed 时非空):information 具体则=它;笼统则从
|
||||
# platform_results 捞出的业务原因(打烊/未起送/找不到店或菜/单点不配送);纯系统失败为 None
|
||||
# → 端侧显示品牌兜底「网络开小差…」。写路径(harvest_done / upsert_record)落库时派生。
|
||||
# 见 repositories.comparison._derive_fail_display。
|
||||
fail_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
|
||||
# ===== 明细(JSON,越详细越好)=====
|
||||
# 下单菜品 [{name, qty, specs?}]
|
||||
|
||||
@@ -52,81 +52,6 @@ def _product_names_from_items(items: list | None) -> str | None:
|
||||
return joined[:500] or None
|
||||
|
||||
|
||||
# ---- 失败记录的展示文案(记录页失败卡「原因」行)------------------------------
|
||||
# information 具体就直出;笼统(_GENERIC_INFO)则从 platform_results 捞一条用户可读的业务
|
||||
# 原因;捞不到 → None(端侧显示品牌兜底「网络开小差…」)。pricebot 把 store_closed /
|
||||
# no_delivery 漏成了 status=failed,这里按 reason 关键字补判;打烊类 reason 常带一坨脏店名
|
||||
# (店名+月售+起送+配送…),统一成简短模板。自动化黑话(搜索失败/读价失败/购物车残留/裸
|
||||
# FAILED…)不给用户看 → 归入品牌兜底。
|
||||
|
||||
# pricebot 组不出具体原因时的笼统 information(线上统计的大头),一律走品牌兜底。
|
||||
_GENERIC_INFO = {
|
||||
"比价过程出错,请稍后重试",
|
||||
"比价出错",
|
||||
"比价未完成",
|
||||
"done 参数缺少可验证的目标平台结果",
|
||||
}
|
||||
# 干净业务结局 status(直接可信),按展示优先级(越靠前越先选)。
|
||||
_BIZ_STATUS_PRIORITY = (
|
||||
"below_minimum",
|
||||
"no_delivery",
|
||||
"store_closed",
|
||||
"items_not_found",
|
||||
"store_not_found",
|
||||
)
|
||||
|
||||
|
||||
def _store_closed_text(reason: str | None) -> str:
|
||||
"""打烊/暂停营业/休息类 reason 常带脏店名元数据 → 只留结论,套简短模板。"""
|
||||
r = reason or ""
|
||||
if "暂停营业" in r:
|
||||
state = "暂停营业"
|
||||
elif "休息" in r:
|
||||
state = "休息中"
|
||||
else:
|
||||
state = "已打烊"
|
||||
return f"门店{state},无法比价"
|
||||
|
||||
|
||||
def _target_display_reason(platform_results: dict | None) -> str | None:
|
||||
"""从逐平台结果里挑一条"可展示给用户"的失败原因;挑不到返回 None。
|
||||
① status 命中干净业务结局集 → 直接采信(打烊套模板,其余用 reason);
|
||||
② 补判 pricebot 漏成 status=failed 的两类:打烊(套模板)、单点不配送(reason 本身干净);
|
||||
自动化黑话(搜索失败/读价失败/购物车残留/裸 FAILED…)一律不展示 → None。"""
|
||||
pr = platform_results or {}
|
||||
targets = [
|
||||
v for v in pr.values() if isinstance(v, dict) and not v.get("is_source")
|
||||
]
|
||||
for want in _BIZ_STATUS_PRIORITY: # ① 干净 status 优先
|
||||
for v in targets:
|
||||
if v.get("status") == want:
|
||||
if want == "store_closed":
|
||||
return _store_closed_text(v.get("reason"))
|
||||
if v.get("reason"):
|
||||
return v["reason"]
|
||||
for v in targets: # ② 漏成 failed 的业务结局补判
|
||||
if v.get("status") != "failed":
|
||||
continue
|
||||
reason = (v.get("reason") or "").strip()
|
||||
if any(k in reason for k in ("打烊", "暂停营业", "休息")):
|
||||
return _store_closed_text(reason)
|
||||
if "单点不配送" in reason:
|
||||
return reason
|
||||
return None
|
||||
|
||||
|
||||
def _derive_fail_display(
|
||||
information: str | None, platform_results: dict | None
|
||||
) -> str | None:
|
||||
"""失败记录展示文案:information 具体则直出;笼统则从 platform_results 捞/补判;
|
||||
都拿不到 → None(端侧品牌兜底)。仅在 status=failed 时调用。"""
|
||||
info = (information or "").strip()
|
||||
text = info if (info and info not in _GENERIC_INFO) else _target_display_reason(
|
||||
platform_results
|
||||
)
|
||||
return text[:256] if text else None
|
||||
|
||||
|
||||
def _derive(payload: ComparisonRecordIn) -> dict:
|
||||
"""从上报 payload 派生结构化列(best/saved/is_source_best/status)。"""
|
||||
results = payload.comparison_results
|
||||
@@ -180,11 +105,6 @@ def _derive(payload: ComparisonRecordIn) -> dict:
|
||||
"saved_amount_cents": saved_amount_cents,
|
||||
"is_source_best": is_source_best,
|
||||
"status": status,
|
||||
"fail_reason": (
|
||||
_derive_fail_display(payload.information, _pr)
|
||||
if status == "failed"
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -494,19 +414,11 @@ def harvest_done(
|
||||
行不存在(理论上帧0已建;防御)则新建。"""
|
||||
results = done_params.get("comparison_results") or []
|
||||
derived = _derive_from_results(results, done_params.get("platform_results"))
|
||||
fail_reason = (
|
||||
_derive_fail_display(
|
||||
done_params.get("information"), done_params.get("platform_results")
|
||||
)
|
||||
if derived["status"] == "failed"
|
||||
else None
|
||||
)
|
||||
# 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
|
||||
items = next((r.get("items") or [] for r in results if r.get("is_source")), [])
|
||||
fields = dict(
|
||||
business_type=business_type or "food",
|
||||
information=done_params.get("information") or None,
|
||||
fail_reason=fail_reason,
|
||||
# best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补;
|
||||
# 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。
|
||||
trace_url=trace_url or done_params.get("trace_url"),
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"""客户端运行日志批量上报 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
|
||||
@@ -173,8 +173,6 @@ class ComparisonRecordOut(BaseModel):
|
||||
skipped_dish_count: int | None = None
|
||||
status: str
|
||||
information: str | None = None
|
||||
# 失败卡「原因」文案:具体失败给具体原因,纯系统失败为 None(端侧品牌兜底)。见模型 fail_reason。
|
||||
fail_reason: str | None = None
|
||||
items: list = []
|
||||
comparison_results: list = []
|
||||
skipped_dish_names: list = []
|
||||
|
||||
@@ -135,7 +135,7 @@ def repair_missing_comparison_llm_costs(
|
||||
select(ComparisonRecord.id, ComparisonRecord.trace_id)
|
||||
.where(
|
||||
*date_conditions,
|
||||
ComparisonRecord.status.in_(("success", "failed", "cancelled")),
|
||||
ComparisonRecord.status.in_(("success", "failed")),
|
||||
ComparisonRecord.llm_cost_yuan.is_(None),
|
||||
)
|
||||
.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc())
|
||||
|
||||
+23
-4
@@ -1,9 +1,13 @@
|
||||
# 傻瓜比价 App 后端 — API 接口文档(索引)
|
||||
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**(⚠️ 例外:消息通知中心 `notifications` 族与厂商推送 `push` 族按 PRD 前端契约用 **camelCase**,见各自文档)
|
||||
> 鉴权:需鉴权的接口在请求头带 `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)。
|
||||
|
||||
---
|
||||
@@ -78,7 +82,6 @@
|
||||
| **签到**(前缀 `/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) |
|
||||
@@ -89,6 +92,7 @@
|
||||
| **看广告发奖**(前缀 `/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) |
|
||||
@@ -103,19 +107,33 @@
|
||||
| 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、applog/batch 不强制登录) |||
|
||||
| **埋点 & 订单上报**(前缀分散;全部 Bearer 除 analytics/events 不强制登录) |||
|
||||
| E1 | `POST /api/v1/analytics/events` | 无 | [详情](./other/analytics-events.md)(批量上报埋点事件,不强制登录,每批最多200条) |
|
||||
| E2 | `POST /api/v1/order/report` | Bearer | [详情](./other/order-report.md)(上报归因订单,比价后5分钟内点链接+支付金额与比价价相差≤1元) |
|
||||
| E3 | `POST /api/v1/applog/batch` | 无 | [详情](./other/applog-batch.md)(批量上报客户端运行日志,逐条落独立文件供 Logtail 采进 SLS,不强制登录,每批≤500条) |
|
||||
>>>>>>> origin/main
|
||||
| **首页门面数据 / 客户端配置**(前缀 `/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 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
|
||||
@@ -154,6 +172,7 @@
|
||||
| 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) |
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
# 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:未登录态也要采日志;带上便于按用户排查。
|
||||
@@ -1,224 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
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. 没有开通融合认证功能
|
||||
@@ -1,117 +0,0 @@
|
||||
# 创蓝云智(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 字段,必须与签名里用的一致。
|
||||
|
||||
## 请求参数(body,JSON)
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `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` | 消息 ID(32 位) |
|
||||
| `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)= 请求鉴权。二者含义完全不同,勿混。
|
||||
@@ -1,26 +1,9 @@
|
||||
# 短信验证码(sms)
|
||||
|
||||
> 文件:`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)
|
||||
> 文件:`app/integrations/sms.py` | 关联接口:[auth-sms-send](../api/auth-sms-send.md) · [auth-sms-login](../api/auth-sms-login.md) | [← 集成索引](./README.md)
|
||||
|
||||
## 作用
|
||||
手机号 + 验证码登录的验证码发送 / 校验。支持**可切换 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` 的存码/冷却/校验语义与之相同)。
|
||||
手机号 + 验证码登录的验证码发送 / 校验。**已接极光短信 REST**,由 `SMS_MOCK` 切 mock / real。
|
||||
|
||||
| | mock(`SMS_MOCK=true`,默认 / 开发测试) | real(`SMS_MOCK=false`,生产) |
|
||||
|---|---|---|
|
||||
|
||||
@@ -1,561 +0,0 @@
|
||||
# 客户端运行日志批量上报 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 新增 `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: PASS(5 个 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` 返回 404(happy-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: PASS(writer 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 间一致引用。
|
||||
@@ -1,200 +0,0 @@
|
||||
# 客户端运行日志批量上报 → 落专用文件 → 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 不进 APK;Logtail 天然提供落盘缓冲+断点续传 |
|
||||
| B | 客户端直连 SLS(Producer 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 | 422(Pydantic) | `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`→单写入者 或外部 logrotate(copytruncate)或写 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 表关联。
|
||||
@@ -1,182 +0,0 @@
|
||||
# 阿里云短信验证服务 — 设计方案
|
||||
|
||||
- 日期: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. 数据流 — 阿里云 provider(Mode 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` 全绿。
|
||||
@@ -1,143 +0,0 @@
|
||||
# 创蓝云智(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 provider(Mode 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` 全绿。
|
||||
@@ -29,9 +29,6 @@ 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",
|
||||
|
||||
|
||||
@@ -219,115 +219,6 @@ def test_user_reward_stats_can_scope_withdrawals_by_account(
|
||||
assert invite.json()["cash_balance_cents"] == 456
|
||||
|
||||
|
||||
def test_user_reward_stats_draw_ecpm_uses_all_filtered_impressions(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
"""Draw 平均 eCPM 应与收益报表一致,不能只平均成功发奖记录。"""
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
|
||||
uid = _seed_user_with_data("13800000024")
|
||||
created_at = datetime(2038, 1, 15, 4, tzinfo=UTC)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add_all(
|
||||
[
|
||||
AdFeedRewardRecord(
|
||||
client_event_id="reward-stats-granted-high",
|
||||
ad_session_id="reward-stats-granted-high",
|
||||
user_id=uid,
|
||||
reward_date="2038-01-15",
|
||||
duration_seconds=10,
|
||||
unit_count=1,
|
||||
ecpm_raw="9000",
|
||||
ad_type="draw",
|
||||
feed_scene="coupon",
|
||||
app_env="prod",
|
||||
our_code_id="104098712",
|
||||
coin=9,
|
||||
status="granted",
|
||||
created_at=created_at,
|
||||
),
|
||||
AdEcpmRecord(
|
||||
user_id=uid,
|
||||
ad_type="draw",
|
||||
feed_scene="coupon",
|
||||
ad_session_id="reward-stats-impression-low",
|
||||
app_env="prod",
|
||||
our_code_id="104098712",
|
||||
ecpm_raw="1000",
|
||||
report_date="2038-01-15",
|
||||
created_at=created_at,
|
||||
),
|
||||
AdEcpmRecord(
|
||||
user_id=uid,
|
||||
ad_type="feed",
|
||||
feed_scene="coupon",
|
||||
ad_session_id="reward-stats-impression-mid",
|
||||
app_env="prod",
|
||||
our_code_id="104098712",
|
||||
ecpm_raw="3000",
|
||||
report_date="2038-01-15",
|
||||
created_at=created_at,
|
||||
),
|
||||
# 同用户但不同场景/环境/非业务代码位,均不应进入本次详情筛选。
|
||||
AdEcpmRecord(
|
||||
user_id=uid,
|
||||
ad_type="draw",
|
||||
feed_scene="comparison",
|
||||
ad_session_id="reward-stats-other-scene",
|
||||
app_env="prod",
|
||||
our_code_id="104098712",
|
||||
ecpm_raw="7000",
|
||||
report_date="2038-01-15",
|
||||
created_at=created_at,
|
||||
),
|
||||
AdEcpmRecord(
|
||||
user_id=uid,
|
||||
ad_type="draw",
|
||||
feed_scene="coupon",
|
||||
ad_session_id="reward-stats-test-env",
|
||||
app_env="test",
|
||||
our_code_id="104127529",
|
||||
ecpm_raw="8000",
|
||||
report_date="2038-01-15",
|
||||
created_at=created_at,
|
||||
),
|
||||
AdEcpmRecord(
|
||||
user_id=uid,
|
||||
ad_type="draw",
|
||||
feed_scene="coupon",
|
||||
ad_session_id="reward-stats-non-business",
|
||||
app_env="prod",
|
||||
our_code_id="demo-slot",
|
||||
ecpm_raw="9000",
|
||||
report_date="2038-01-15",
|
||||
created_at=created_at,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
response = admin_client.get(
|
||||
f"/admin/api/users/{uid}/reward-stats",
|
||||
params={
|
||||
"date_from": "2038-01-15T00:00:00Z",
|
||||
"date_to": "2038-01-15T23:59:59Z",
|
||||
"app_env": "prod",
|
||||
"revenue_scope": "business",
|
||||
"feed_scene": "coupon",
|
||||
},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["feed_count"] == 1
|
||||
# 全部真实展示 (1000 + 3000) / 2;不能返回成功发奖记录的 9000。
|
||||
assert data["feed_avg_ecpm"] == 2000.0
|
||||
|
||||
|
||||
def test_user_coin_record_sort_accepts_mixed_timezone_datetimes() -> None:
|
||||
"""线上 PostgreSQL 返回 aware,SQLite/历史转换可能返回 naive,二者必须可混排。"""
|
||||
naive = datetime(2038, 1, 1, 8, 0)
|
||||
@@ -702,118 +593,6 @@ def test_withdraw_summary_counts_match_status_lists_by_source(
|
||||
).status_code == 422
|
||||
|
||||
|
||||
def test_comparison_records_show_readable_device_and_rom_version(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = user_repo.upsert_user_for_login(
|
||||
db, phone="13800009041", register_channel="sms"
|
||||
)
|
||||
record = ComparisonRecord(
|
||||
user_id=user.id,
|
||||
trace_id="comparison-readable-device",
|
||||
status="success",
|
||||
device_model="V2166BA",
|
||||
device_manufacturer="vivo",
|
||||
rom_vendor="vivo",
|
||||
rom_name="OriginOS",
|
||||
rom_version=4,
|
||||
android_version="14",
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
record_id = 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
|
||||
item = response.json()["items"][0]
|
||||
assert item["device_model"] == "V2166BA"
|
||||
assert item["device_model_name"] == "vivo Y77e"
|
||||
assert item["rom_name"] == "OriginOS"
|
||||
assert item["rom_version"] == 4
|
||||
|
||||
detail = admin_client.get(
|
||||
f"/admin/api/comparison-records/{record_id}",
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert detail.json()["device_model_name"] == "vivo Y77e"
|
||||
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:
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
"""客户端运行日志上报: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 必填
|
||||
+39
-40
@@ -11,13 +11,12 @@ import time
|
||||
import pytest
|
||||
|
||||
from app.integrations import sms
|
||||
from app.integrations.sms import jiguang
|
||||
|
||||
|
||||
def _reset(phone: str) -> None:
|
||||
"""清该号的进程内存状态,隔离 real 模式用例。"""
|
||||
jiguang._codes.pop(phone, None)
|
||||
jiguang._last_sent.pop(phone, None)
|
||||
sms._codes.pop(phone, None)
|
||||
sms._last_sent.pop(phone, None)
|
||||
|
||||
|
||||
class _OkResp:
|
||||
@@ -228,16 +227,16 @@ def test_sms_real_send_calls_jiguang(monkeypatch) -> None:
|
||||
captured.update(url=url, body=json, auth=headers.get("Authorization", ""))
|
||||
return _OkResp()
|
||||
|
||||
monkeypatch.setattr(jiguang.settings, "SMS_MOCK", False)
|
||||
monkeypatch.setattr(jiguang.httpx, "post", _fake_post)
|
||||
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
|
||||
monkeypatch.setattr(sms.httpx, "post", _fake_post)
|
||||
|
||||
jiguang.send_code(phone)
|
||||
sms.send_code(phone)
|
||||
|
||||
assert captured["url"] == jiguang.settings.SMS_SEND_ENDPOINT
|
||||
assert captured["url"] == sms.settings.SMS_SEND_ENDPOINT
|
||||
assert captured["body"]["mobile"] == phone
|
||||
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["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["auth"].startswith("Basic ")
|
||||
|
||||
|
||||
@@ -245,33 +244,33 @@ def test_sms_real_verify_one_time_and_wrong(monkeypatch) -> None:
|
||||
"""real 校验:错误码拒(不消费)→ 正确码成功 → 验过即作废。"""
|
||||
phone = "13455134000"
|
||||
_reset(phone)
|
||||
monkeypatch.setattr(jiguang.settings, "SMS_MOCK", False)
|
||||
monkeypatch.setattr(jiguang.httpx, "post", lambda *a, **k: _OkResp())
|
||||
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
|
||||
monkeypatch.setattr(sms.httpx, "post", lambda *a, **k: _OkResp())
|
||||
|
||||
jiguang.send_code(phone)
|
||||
code = jiguang._codes[phone].code
|
||||
sms.send_code(phone)
|
||||
code = sms._codes[phone].code
|
||||
wrong = "000000" if code != "000000" else "111111"
|
||||
|
||||
assert jiguang.verify_code(phone, wrong) is False
|
||||
assert jiguang.verify_code(phone, code) is True
|
||||
assert jiguang.verify_code(phone, code) is False # 已作废
|
||||
assert sms.verify_code(phone, wrong) is False
|
||||
assert sms.verify_code(phone, code) is True
|
||||
assert sms.verify_code(phone, code) is False # 已作废
|
||||
|
||||
|
||||
def test_sms_real_verify_attempts_exhausted(monkeypatch) -> None:
|
||||
"""real 校验:错误次数到上限即作废,正确码也不再通过(防爆破)。"""
|
||||
phone = "13466134000"
|
||||
_reset(phone)
|
||||
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())
|
||||
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())
|
||||
|
||||
jiguang.send_code(phone)
|
||||
code = jiguang._codes[phone].code
|
||||
sms.send_code(phone)
|
||||
code = sms._codes[phone].code
|
||||
wrong = "000000" if code != "000000" else "111111"
|
||||
|
||||
for _ in range(3):
|
||||
assert jiguang.verify_code(phone, wrong) is False
|
||||
assert jiguang.verify_code(phone, code) is False # 超限作废
|
||||
assert sms.verify_code(phone, wrong) is False
|
||||
assert sms.verify_code(phone, code) is False # 超限作废
|
||||
|
||||
|
||||
def test_sms_real_balance_error_keeps_cooldown(monkeypatch) -> None:
|
||||
@@ -285,36 +284,36 @@ def test_sms_real_balance_error_keeps_cooldown(monkeypatch) -> None:
|
||||
def json(self):
|
||||
return {"error": {"code": 50014, "message": "no money"}}
|
||||
|
||||
monkeypatch.setattr(jiguang.settings, "SMS_MOCK", False)
|
||||
monkeypatch.setattr(jiguang.httpx, "post", lambda *a, **k: _ErrResp())
|
||||
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
|
||||
monkeypatch.setattr(sms.httpx, "post", lambda *a, **k: _ErrResp())
|
||||
|
||||
with pytest.raises(sms.SmsError) as ei:
|
||||
jiguang.send_code(phone)
|
||||
sms.send_code(phone)
|
||||
assert ei.value.status_code == 503
|
||||
assert phone not in jiguang._codes # 没发出去的码已清
|
||||
assert phone in jiguang._last_sent # 冷却保留:失败也限速
|
||||
assert phone not in sms._codes # 没发出去的码已清
|
||||
assert phone in sms._last_sent # 冷却保留:失败也限速
|
||||
|
||||
# 立即重试 → 被冷却挡下(429),不会再打极光
|
||||
with pytest.raises(sms.SmsError) as ei2:
|
||||
jiguang.send_code(phone)
|
||||
sms.send_code(phone)
|
||||
assert ei2.value.status_code == 429
|
||||
|
||||
|
||||
def test_sms_gc_purges_stale_only(monkeypatch) -> None:
|
||||
"""GC 清过期码 / 旧冷却,但不动今天有效的(阈值设 0 强制每次扫)。"""
|
||||
monkeypatch.setattr(jiguang, "_GC_THRESHOLD", 0)
|
||||
jiguang._codes.clear()
|
||||
jiguang._last_sent.clear()
|
||||
monkeypatch.setattr(sms, "_GC_THRESHOLD", 0)
|
||||
sms._codes.clear()
|
||||
sms._last_sent.clear()
|
||||
now = time.time()
|
||||
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._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._gc(now)
|
||||
sms._gc(now)
|
||||
|
||||
assert "stale" not in jiguang._codes and "fresh" in jiguang._codes
|
||||
assert "old" not in jiguang._last_sent and "recent" in jiguang._last_sent
|
||||
assert "stale" not in sms._codes and "fresh" in sms._codes
|
||||
assert "old" not in sms._last_sent and "recent" in sms._last_sent
|
||||
|
||||
|
||||
# ============================ 用户名 / 默认昵称 ============================
|
||||
|
||||
@@ -102,7 +102,6 @@ def test_harvest_done_derives_and_newly_success_once(client) -> None:
|
||||
assert rec.is_source_best is False
|
||||
assert rec.store_name == "测试店"
|
||||
assert rec.information == "美团更便宜"
|
||||
assert rec.fail_reason is None # 成功记录不派生失败原因
|
||||
assert rec.items == [{"name": "肥牛饭", "qty": 1}]
|
||||
assert rec.trace_url.endswith("/done/")
|
||||
# 再来一次(重试 done)→ 已 success,newly_success=False(发奖不重复触发)
|
||||
@@ -111,35 +110,6 @@ def test_harvest_done_derives_and_newly_success_once(client) -> None:
|
||||
assert newly2 is False
|
||||
|
||||
|
||||
def test_harvest_done_failed_derives_fail_reason(client) -> None:
|
||||
"""failed 记录:记录级 information 笼统,但 fail_reason 从 platform_results 救出具体原因
|
||||
(id 3030 型:美团系统失败 + 京东 items_not_found → 展示京东那条)。"""
|
||||
tid = _tid()
|
||||
done_failed = {
|
||||
"comparison_results": [
|
||||
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购",
|
||||
"package": "com.taobao.taobao", "price": 23.04, "is_source": True, "rank": 1,
|
||||
"items": [{"name": "肥牛饭", "qty": 1}]},
|
||||
],
|
||||
"platform_results": {
|
||||
"taobao_flash": {"is_source": True, "status": "source", "price": 23.04},
|
||||
"meituan_waimai": {"is_source": False, "status": "failed",
|
||||
"reason": "搜索店铺失败, 无法跳转到搜索页"},
|
||||
"jd_waimai_standalone": {"is_source": False, "status": "items_not_found",
|
||||
"reason": "京东外卖此店内未找到这些菜品"},
|
||||
},
|
||||
"information": "比价过程出错,请稍后重试",
|
||||
}
|
||||
with SessionLocal() as db:
|
||||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||||
rec, newly = crud.harvest_done(db, trace_id=tid, user_id=None,
|
||||
done_params=done_failed)
|
||||
assert newly is False # 没落成 success
|
||||
assert rec.status == "failed"
|
||||
assert rec.fail_reason == "京东外卖此店内未找到这些菜品"
|
||||
assert rec.information == "比价过程出错,请稍后重试" # 原文案仍留存
|
||||
|
||||
|
||||
def test_harvest_abort_cancels_running(client) -> None:
|
||||
tid = _tid()
|
||||
with SessionLocal() as db:
|
||||
@@ -263,9 +233,7 @@ 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, patch(
|
||||
"app.api.v1.compare.backfill_comparison_llm_cost"
|
||||
) as backfill:
|
||||
with p:
|
||||
r = client.post("/api/v1/trace/finalize",
|
||||
json={"trace_id": tid, "status": "cancelled", "reason": "用户终止"})
|
||||
assert r.status_code == 200
|
||||
@@ -273,7 +241,6 @@ 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:
|
||||
|
||||
@@ -72,7 +72,6 @@ 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 = [
|
||||
{
|
||||
@@ -97,15 +96,12 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ def test_xiaomi_accessibility_payload(monkeypatch) -> None:
|
||||
body = captured["data"]
|
||||
assert body["registration_id"] == "xm-regid"
|
||||
assert body["restricted_package_name"] == "com.jishisongfu.shaguabijia"
|
||||
assert body["pushMode"] == 1
|
||||
assert json.loads(body["payload"]) == {"type": "accessibility_disabled"}
|
||||
assert "extra.channel_id" not in body
|
||||
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
"""失败卡展示原因派生(repositories.comparison._derive_fail_display)单元测试。
|
||||
|
||||
用例取自线上真实 failed 记录(platform_results 形态),覆盖:
|
||||
- information 具体 → 直出
|
||||
- information 笼统 + platform_results 有干净业务结局 → 救援出该原因(id 3030/2964 型)
|
||||
- information 笼统 + 仅系统失败(搜索失败等黑话)→ None(端侧品牌兜底,id 3027 型)
|
||||
- store_closed / no_delivery 被 pricebot 漏成 status=failed → 按 reason 关键字补判
|
||||
- 打烊类脏店名 blob → 统一简短模板
|
||||
- platform_results 为空 / 非对象 → None
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.repositories import comparison as crud
|
||||
|
||||
|
||||
def test_specific_information_passthrough() -> None:
|
||||
# information 本身具体(未达起送/找不到菜等)→ 直出,不看 platform_results
|
||||
assert (
|
||||
crud._derive_fail_display("淘宝闪购未达起送门槛,可加菜凑单后下单", {})
|
||||
== "淘宝闪购未达起送门槛,可加菜凑单后下单"
|
||||
)
|
||||
assert crud._derive_fail_display("未识别到商品", {}) == "未识别到商品"
|
||||
|
||||
|
||||
def test_generic_info_rescued_from_items_not_found() -> None:
|
||||
# id 3030 型:美团系统失败 + 京东 items_not_found,记录级 information 笼统 → 救出京东那条
|
||||
pr = {
|
||||
"eleme": {"is_source": True, "status": "source", "price": 23.04},
|
||||
"meituan_waimai": {
|
||||
"is_source": False, "status": "failed",
|
||||
"reason": "搜索店铺失败, 无法跳转到搜索页",
|
||||
},
|
||||
"jd_waimai_standalone": {
|
||||
"is_source": False, "status": "items_not_found",
|
||||
"reason": "京东外卖此店内未找到这些菜品",
|
||||
},
|
||||
}
|
||||
assert (
|
||||
crud._derive_fail_display("比价过程出错,请稍后重试", pr)
|
||||
== "京东外卖此店内未找到这些菜品"
|
||||
)
|
||||
|
||||
|
||||
def test_generic_info_rescued_from_store_not_found() -> None:
|
||||
# id 2964 型:美团系统失败 + 京东 store_not_found → 救出京东相似店铺文案
|
||||
pr = {
|
||||
"taobao_flash": {"is_source": True, "status": "source", "price": 127.98},
|
||||
"meituan": {
|
||||
"is_source": False, "status": "failed",
|
||||
"reason": "比价过程出错,请稍后重试",
|
||||
},
|
||||
"jd_waimai": {
|
||||
"is_source": False, "status": "store_not_found",
|
||||
"reason": "未在京东找到「黔珍味·贵州牛肉蘸水健康菜 (望京店)」相似店铺",
|
||||
},
|
||||
}
|
||||
assert (
|
||||
crud._derive_fail_display("比价过程出错,请稍后重试", pr)
|
||||
== "未在京东找到「黔珍味·贵州牛肉蘸水健康菜 (望京店)」相似店铺"
|
||||
)
|
||||
|
||||
|
||||
def test_generic_info_pure_system_failure_returns_none() -> None:
|
||||
# id 3027 型:唯一目标平台是自动化黑话失败 → 不给用户看 → None(端侧品牌兜底)
|
||||
pr = {
|
||||
"eleme": {"is_source": True, "status": "source", "price": 18.83},
|
||||
"meituan_waimai": {
|
||||
"is_source": False, "status": "failed",
|
||||
"reason": "搜索店铺失败, 无法跳转到搜索页",
|
||||
},
|
||||
}
|
||||
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) is None
|
||||
|
||||
|
||||
def test_store_closed_leaked_to_failed_is_rescued_and_cleaned() -> None:
|
||||
# 打烊被漏成 status=failed;reason 常带脏店名 blob → 统一简短模板
|
||||
pr = {
|
||||
"jd_waimai": {"is_source": True, "status": "source", "price": 25},
|
||||
"taobao_flash": {
|
||||
"is_source": False, "status": "failed",
|
||||
"reason": "淘宝闪购「沙胆彪炭炉牛杂煲(...),蜂鸟准时达,月售300+,起送¥20」本店已休息,无法比价",
|
||||
},
|
||||
}
|
||||
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) == "门店休息中,无法比价"
|
||||
|
||||
pr2 = {
|
||||
"taobao_flash": {"is_source": True, "status": "source", "price": 31.83},
|
||||
"meituan": {
|
||||
"is_source": False, "status": "failed",
|
||||
"reason": "美团「奈雪的茶(北京王府井奥莱·香江」门店已打烊,无法比价",
|
||||
},
|
||||
}
|
||||
assert crud._derive_fail_display("比价出错", pr2) == "门店已打烊,无法比价"
|
||||
|
||||
|
||||
def test_no_delivery_leaked_to_failed_is_rescued() -> None:
|
||||
# 单点不配送被漏成 status=failed;reason 本身干净 → 直接用
|
||||
reason = "京东外卖该商家所选商品单点不配送,无法进入结算比价"
|
||||
pr = {
|
||||
"taobao_flash": {"is_source": True, "status": "source", "price": 20.1},
|
||||
"jd_waimai_standalone": {
|
||||
"is_source": False, "status": "failed", "reason": reason,
|
||||
},
|
||||
}
|
||||
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) == reason
|
||||
|
||||
|
||||
def test_empty_or_missing_platform_results_returns_none() -> None:
|
||||
# 「比价出错」+ 空 {} / None / 非对象:引擎早夭,无可展示原因 → None
|
||||
assert crud._derive_fail_display("比价出错", {}) is None
|
||||
assert crud._derive_fail_display("比价过程出错,请稍后重试", None) is None
|
||||
assert crud._derive_fail_display("比价出错", []) is None # 老 array 形态,防御
|
||||
|
||||
|
||||
def test_clean_status_wins_over_priority_order() -> None:
|
||||
# 多个业务结局同现时按 _BIZ_STATUS_PRIORITY 选(below_minimum 优先于 store_not_found)
|
||||
pr = {
|
||||
"src": {"is_source": True, "status": "source", "price": 30},
|
||||
"a": {"is_source": False, "status": "store_not_found", "reason": "未找到店铺A"},
|
||||
"b": {"is_source": False, "status": "below_minimum", "reason": "B未达起送门槛"},
|
||||
}
|
||||
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) == "B未达起送门槛"
|
||||
@@ -1,185 +0,0 @@
|
||||
"""阿里云短信 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
|
||||
@@ -1,286 +0,0 @@
|
||||
"""创蓝云智(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
|
||||
@@ -1,53 +0,0 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user