Compare commits

..

7 Commits

Author SHA1 Message Date
guke 3146224944 test(auth): 补 jverify 绑号 401/502 错误分支测试
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:31:46 +08:00
guke 3621d606ad feat(auth): POST /wechat/bind-phone/jverify(极光本机号绑号)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:27:00 +08:00
guke c1b00eca0c test(auth): 补 phone_occupied 的 avatar_url 断言(锁定 M2 冲突页契约)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:23:22 +08:00
guke a5fdcb53d8 feat(auth): POST /wechat/bind-phone/sms(短信绑号:建号/占用)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:14:42 +08:00
guke fe9b749154 style(auth): 微信登录 import 去前向未用 + isort 排序
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:10:22 +08:00
guke 88313cefdf feat(auth): POST /wechat-login(openid 命中即登入,否则发绑号令牌)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:57:36 +08:00
guke 2a82b20365 feat(auth): 微信登录 bind_ticket 令牌 + 配置
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:40:47 +08:00
19 changed files with 519 additions and 785 deletions
-33
View File
@@ -1,33 +0,0 @@
"""comparison_record: llm_cost_yuan + llm_price_snapshot(比价 LLM 调用成本 + 当时单价快照)
回填 llm_calls 时按「当时的价」逐模型算出本次比价 LLM 总成本(元),连同所用单价快照一起冻结到
记录上;admin 比价记录详情展示实际成本(旧记录 NULL → 前端回退估算)。见 services/llm_cost.py。
Revision ID: comparison_llm_cost
Revises: ad_ecpm_trace_id
Create Date: 2026-07-13
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "comparison_llm_cost"
down_revision: str | Sequence[str] | None = "ad_ecpm_trace_id"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
def upgrade() -> None:
# 均可空、无索引;SQLite 原生支持 ADD COLUMN,无需 batch_alter_table(同 comparison_debug_fields)。
op.add_column("comparison_record", sa.Column("llm_cost_yuan", sa.Float(), nullable=True))
op.add_column("comparison_record", sa.Column("llm_price_snapshot", _JSONB, nullable=True))
def downgrade() -> None:
op.drop_column("comparison_record", "llm_price_snapshot")
op.drop_column("comparison_record", "llm_cost_yuan")
-4
View File
@@ -36,8 +36,6 @@ class AdminComparisonListItem(BaseModel):
retry_count: int | None = None
input_tokens: int | None = None # Σ usage.prompt_tokens(server 派生)
output_tokens: int | None = None # Σ usage.completion_tokens(server 派生)
# 本次比价 LLM 总成本(元,按当时价冻结);旧记录/未回填为 None → 前端「成本」列回退估算。见 services/llm_cost.py。
llm_cost_yuan: float | None = None
device_model: str | None = None
rom_vendor: str | None = None
rom_name: str | None = None
@@ -74,5 +72,3 @@ class AdminComparisonDetail(AdminComparisonListItem):
# 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读
# (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。
raw_payload: dict | None = None
# 算成本所用单价快照 {mode, prices:{model:{...}}}(llm_cost_yuan 继承自列表项)。见 services/llm_cost.py。
llm_price_snapshot: dict | None = None
+179 -24
View File
@@ -12,17 +12,19 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import test_account
from app.core.ratelimit import (
RateLimitRule,
check_rate_limits,
enforce_rate_limit,
record_rate_limits,
from app.core.ratelimit import enforce_rate_limit
from app.core.security import (
TokenError,
create_bind_ticket,
decode_bind_ticket,
decode_token,
issue_token_pair,
)
from app.core.security import TokenError, decode_token, issue_token_pair
from app.integrations import wxpay
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code
from app.repositories import onboarding as onboarding_repo
@@ -30,6 +32,7 @@ from app.repositories import user as user_repo
from app.schemas.auth import (
JverifyLoginRequest,
LogoutResponse,
OccupiedAccountInfo,
RefreshRequest,
SmsLoginRequest,
SmsSendRequest,
@@ -37,6 +40,11 @@ from app.schemas.auth import (
TokenPair,
TokenWithUser,
UserOut,
WechatBindPhoneJverifyRequest,
WechatBindPhoneSmsRequest,
WechatBindResultResponse,
WechatLoginRequest,
WechatLoginResponse,
)
logger = logging.getLogger("shagua.auth")
@@ -45,10 +53,9 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
SMS_LOGIN_MAX_PER_HOUR = 5
# 发码防刷(同一设备 device_id + 同一 IP,**只按成功发码计数**;被单号 60s 冷却挡下的重发不占额度):
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限
SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸)
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
def _login_response(
@@ -105,26 +112,23 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
logger.info("test_account sms_send short-circuit (不真发)")
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
# 发码防刷:同一设备(device_id) + 同一 IP,每小时 / 每天两道闸,**均只按成功发码计数**
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。
# 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前
# (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。
send_rules = [
RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600,
"操作过于频繁,请稍后再试"),
RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400,
"今日验证码发送次数过多,请明天再试"),
]
check_rate_limits(request, subject=req.device_id, rules=send_rules)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。
enforce_rate_limit(
request,
scope="sms-send-device",
subject=req.device_id,
limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE,
window_sec=3600,
detail="操作过于频繁,请稍后再试",
)
try:
cooldown = send_code(req.phone)
except SmsError as e:
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
# 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度)
record_rate_limits(request, subject=req.device_id, rules=send_rules)
from app.core.config import settings # 局部 import 避免循环
return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown)
@@ -175,6 +179,157 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
return _login_response(user, onboarding_completed=completed)
# ===================== 微信登录 =====================
@router.post(
"/wechat-login",
response_model=WechatLoginResponse,
summary="微信登录(openid 命中即登入,否则发绑号令牌)",
)
def wechat_login(req: WechatLoginRequest, db: DbSession) -> WechatLoginResponse:
from app.core.config import settings # 局部 import,避免循环
# 微信登录只需 code→openid(sns/oauth2),不需要商户转账证书;故只校验 APP_ID/SECRET。
if not (settings.WECHAT_APP_ID and settings.WECHAT_APP_SECRET):
raise HTTPException(status_code=503, detail="wechat login not configured")
try:
info = wxpay.code_to_userinfo(req.code) # {openid, nickname, avatar_url, raw};失败抛 ValueError
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
openid = info["openid"]
user = user_repo.get_user_by_wechat_openid(db, openid)
if user is not None:
# openid 命中 → 直接登入(绝不套用提现 bind-wechat 的"撞号即 409"逻辑)
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
user_repo.touch_last_login(db, user)
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
logger.info("wechat_login hit user_id=%d openid=%s*** onboarded=%s", user.id, openid[:6], completed)
return WechatLoginResponse(
status="logged_in",
token=_login_response(user, onboarding_completed=completed),
)
# 未命中 → 签发短时 bind_ticket,进手机号绑定流程(账号此刻还不建)
ticket = create_bind_ticket(
openid=openid,
wechat_nickname=info["nickname"],
wechat_avatar_url=info["avatar_url"],
)
logger.info("wechat_login new openid=%s*** issue bind_ticket", openid[:6])
return WechatLoginResponse(
status="need_bind_phone",
bind_ticket=ticket,
wechat_nickname=info["nickname"],
wechat_avatar_url=info["avatar_url"],
)
def _finish_wechat_bind(
db,
*,
openid: str,
wechat_nickname: str | None,
wechat_avatar_url: str | None,
phone: str,
device_id: str,
) -> WechatBindResultResponse:
"""绑手机建号的公共尾段:手机号被占用 → 返回 phone_occupied(M2 处理 3 选 1);
未占用 → 新建微信账号(channel=wechat,昵称头像取微信)→ 签 token 登入。"""
existing = user_repo.get_user_by_phone(db, phone)
if existing is not None:
logger.info("wechat bind phone occupied phone=%s by user_id=%d", mask_phone(phone), existing.id)
return WechatBindResultResponse(
status="phone_occupied",
occupied_account=OccupiedAccountInfo(
nickname=existing.nickname,
avatar_url=existing.avatar_url,
created_at=existing.created_at,
),
)
user = user_repo.create_wechat_user(
db,
phone=phone,
openid=openid,
wechat_nickname=wechat_nickname,
wechat_avatar_url=wechat_avatar_url,
)
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=device_id)
logger.info("wechat bind ok user_id=%d phone=%s openid=%s*** onboarded=%s",
user.id, mask_phone(phone), openid[:6], completed)
return WechatBindResultResponse(
status="logged_in",
token=_login_response(user, onboarding_completed=completed),
)
@router.post(
"/wechat/bind-phone/sms",
response_model=WechatBindResultResponse,
summary="微信登录·其他手机号(短信)绑定",
)
def wechat_bind_phone_sms(
req: WechatBindPhoneSmsRequest, request: Request, db: DbSession
) -> WechatBindResultResponse:
try:
claims = decode_bind_ticket(req.bind_ticket)
except TokenError as e:
raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e
# 防刷:同 sms/login,按 设备+IP 每小时限流(放在验证码校验之前,失败也计数)
enforce_rate_limit(
request,
scope="wechat-bind-sms-device",
subject=req.device_id,
limit=SMS_LOGIN_MAX_PER_HOUR,
window_sec=3600,
detail="登录尝试过于频繁,请稍后再试",
)
if not verify_code(req.phone, req.code):
raise HTTPException(status_code=400, detail="invalid sms code")
return _finish_wechat_bind(
db,
openid=claims["openid"],
wechat_nickname=claims["wnk"],
wechat_avatar_url=claims["wav"],
phone=req.phone,
device_id=req.device_id,
)
@router.post(
"/wechat/bind-phone/jverify",
response_model=WechatBindResultResponse,
summary="微信登录·本机号(极光)绑定",
)
def wechat_bind_phone_jverify(
req: WechatBindPhoneJverifyRequest, db: DbSession
) -> WechatBindResultResponse:
try:
claims = decode_bind_ticket(req.bind_ticket)
except TokenError as e:
raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e
try:
phone = verify_and_get_phone(req.login_token)
except JiguangError as e:
logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True)
raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e
return _finish_wechat_bind(
db,
openid=claims["openid"],
wechat_nickname=claims["wnk"],
wechat_avatar_url=claims["wav"],
phone=phone,
device_id=req.device_id,
)
# ===================== Refresh =====================
@router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对")
-3
View File
@@ -27,7 +27,6 @@ from app.schemas.compare_record import (
ComparisonRecordOut,
ComparisonRecordPage,
)
from app.services.llm_cost import compute_llm_cost, get_llm_prices
from app.services.pricebot_llm_calls import fetch_llm_calls
logger = logging.getLogger("shagua.compare_record")
@@ -82,8 +81,6 @@ def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
# error 的调用 usage 可能为 None,or {} 兜底)
rec.input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls)
rec.output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls)
# 本次比价 LLM 成本(元)+ 当时单价快照:按 app_config 现价逐模型算好冻结(services/llm_cost.py)。
rec.llm_cost_yuan, rec.llm_price_snapshot = compute_llm_cost(calls, get_llm_prices(db))
db.commit()
logger.info(
"backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d",
+3
View File
@@ -44,6 +44,9 @@ class Settings(BaseSettings):
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 120
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
# 微信登录未命中 openid 时签发的"待绑手机"令牌有效期(分钟)。
# 见 auth.wechat_login / security.create_bind_ticket。
WECHAT_BIND_TICKET_EXPIRE_MINUTES: int = 10
# ===== Admin 后台 =====
# admin 用独立 JWT secret(≠ JWT_SECRET_KEY),App 用户 token 无法越权访问后台。
-15
View File
@@ -96,19 +96,4 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"group": "首页轮播", "type": "enum", "hidden": True,
"help": "mixed=真实优先+种子补位(默认);real=只用真实比价记录;seed=只用种子/合成(演示)。",
},
# 比价 LLM 调用成本计价。值是嵌套 JSON(非 str→int),借 dict_str_int 类型在配置页走原始 JSON
# 编辑框;set_value 不校验类型,嵌套 JSON 照存。
"llm_token_price": {
"default": {
"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
"currency": "CNY", "unit": "per_1m_tokens",
},
"label": "LLM 模型单价(元/百万 token)",
"group": "LLM 成本", "type": "dict_str_int",
"help": (
"比价 LLM 调用成本计价。JSON:per_model 按模型配 input/output 单价(元/1M token),"
"default 兜底未登记的模型。改价只影响之后回填的新记录,历史记录用当时价格快照。"
),
},
}
+8 -93
View File
@@ -9,41 +9,29 @@ from __future__ import annotations
import threading
import time
from typing import NamedTuple
from fastapi import HTTPException, Request, status
from app.core.config import settings
# key -> (window_start_ts, count, window_sec)
# 存每个 key 自己的 window_sec:_buckets 混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key,
# GC 必须按各 key 自己的窗口判过期(见 [_purge_expired]),否则短窗口调用触发的 GC 会误删长窗口 key。
_buckets: dict[str, tuple[float, int, float]] = {}
# key -> (window_start_ts, count)
_buckets: dict[str, tuple[float, int]] = {}
_lock = threading.Lock()
_GC_THRESHOLD = 10000 # _buckets 超此阈值才顺手清过期 key(仿 sms.py;测试可 monkeypatch 调小强制每次扫)
def _purge_expired(now: float) -> None:
"""清过期 key(**仅在持有 _lock 时调用**)。按每个 key 自己存的 window_sec 判过期,而非调用方的窗口
—— _buckets 是全局共享、混着 60s(广告)/3600s(登录)/86400s(日闸)不同窗口的 key;若用调用方窗口,
高频的 60s 广告端点触发 GC 时会把本该活 3600s/86400s 的登录/日闸计数一并删掉,使其在规模上(超阈值才
触发本清理)被反复清零而失效。仅在超阈值时扫,低频、开销可忽略。"""
if len(_buckets) <= _GC_THRESHOLD:
return
for k in [k for k, (s, _, w) in _buckets.items() if now - s >= w]:
_buckets.pop(k, None)
def _hit(key: str, limit: int, window_sec: float) -> bool:
"""记一次访问。返回 True=放行,False=超限。"""
now = time.monotonic()
with _lock:
start, count, _ = _buckets.get(key, (now, 0, window_sec))
start, count = _buckets.get(key, (now, 0))
if now - start >= window_sec: # 窗口过期,重置
start, count = now, 0
count += 1
_buckets[key] = (start, count, window_sec)
_purge_expired(now) # 顺手清过期 key(按各自窗口),防内存无限涨
_buckets[key] = (start, count)
# 顺手清过期 key,防内存无限涨(低频访问足够)
if len(_buckets) > 10000:
for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]:
_buckets.pop(k, None)
return count <= limit
@@ -95,76 +83,3 @@ def enforce_rate_limit(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=detail,
)
# ===================== 先判 / 后记(只按「成功」计数)=====================
# _hit 是原子「判+记」:一调用就 +1,适合登录爆破(失败尝试也要计)。但对「短信发码」这类
# **只想给成功动作计数**的场景不合适 —— 被单号冷却挡下的重发没真发、没烧钱,不该占额度。
# 故拆成 _peek(只判不记)+ _commit(只记):check_rate_limits 先判 → 动作 → 成功后 record。
class RateLimitRule(NamedTuple):
"""一条限流规则。scope 区分不同闸(不同 key 前缀);同一 (subject, IP) 在 window_sec
内最多 limit 次,超限抛 429 用 detail 文案。
(scope, window_sec) 成对绑在一条规则里 —— check(先判)与 record(计数)复用同一条,
避免两处把窗口/scope 写歪导致 key 对不上。
"""
scope: str
limit: int
window_sec: float
detail: str = "操作过于频繁,请稍后再试"
def _peek(key: str, limit: int, window_sec: float) -> bool:
"""只读:当前窗口内是否还没到上限(count < limit)。**不改计数**。
与 [_commit] 配对实现「先判后记」——只在动作成功后才 _commit。"""
now = time.monotonic()
with _lock:
start, count, _ = _buckets.get(key, (now, 0, window_sec))
if now - start >= window_sec: # 窗口已过期 → 视作已重置(count 归零)
count = 0
return count < limit
def _commit(key: str, window_sec: float) -> None:
"""记一次访问(+1)。窗口过期则以本次为起点重置。仅在动作成功后调用。"""
now = time.monotonic()
with _lock:
start, count, _ = _buckets.get(key, (now, 0, window_sec))
if now - start >= window_sec: # 窗口过期,重置
start, count = now, 0
_buckets[key] = (start, count + 1, window_sec)
_purge_expired(now) # 顺手清过期 key(按各自窗口,同 [_hit])
def check_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None:
"""【先判】一组限流:任一规则已达上限即抛 429,且**不改计数**。
配合 [record_rate_limits] 实现「只按成功计数」:先 check 所有闸(全未超才继续)→ 执行动作
→ 动作**成功后**再 record。动作被下游挡下(如短信单号冷却)、没真正发生时不 record → 不占额度。
key = `scope:subject:client_ip`(与 [enforce_rate_limit] 同款)。
"""
if not settings.RATE_LIMIT_ENABLED:
return
ip = _client_ip(request)
for rule in rules:
if not _peek(f"{rule.scope}:{subject}:{ip}", rule.limit, rule.window_sec):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=rule.detail,
)
def record_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None:
"""【记一次】一组限流(每条规则 +1)。仅在动作成功后调用,与 [check_rate_limits] 配对。
⚠️ check→动作→record 非原子:并发突发下计数可能略超 limit(每个在途请求各 +1)。对
「防脚本/防轰炸」的安全网定位可接受;要精确配额需迁 Redis(见模块 docstring)。
"""
if not settings.RATE_LIMIT_ENABLED:
return
ip = _client_ip(request)
for rule in rules:
_commit(f"{rule.scope}:{subject}:{ip}", rule.window_sec)
+39
View File
@@ -87,6 +87,45 @@ def issue_token_pair(user_id: int) -> dict[str, Any]:
}
def create_bind_ticket(
*, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None
) -> str:
"""微信登录未命中 openid 时,签发短时"待绑手机"令牌,承载 openid + 微信昵称头像。
typ='wechat_bind'、sub=openid;有效期 settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES 分钟。
与 access/refresh 用同一 JWT_SECRET_KEY 签名,靠 typ 区分,decode_bind_ticket 校验 typ。
"""
now = _now()
expire = now + timedelta(minutes=settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES)
payload: dict[str, Any] = {
"sub": openid,
"typ": "wechat_bind",
"wnk": wechat_nickname,
"wav": wechat_avatar_url,
"iat": int(now.timestamp()),
"exp": int(expire.timestamp()),
}
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def decode_bind_ticket(token: str) -> dict[str, Any]:
"""解析"待绑手机"令牌,校验签名/过期/类型。失败抛 TokenError。
返回 {'openid': str, 'wnk': str|None, 'wav': str|None}。
"""
try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
except jwt.ExpiredSignatureError as e:
raise TokenError("bind ticket expired") from e
except jwt.InvalidTokenError as e:
raise TokenError(f"invalid bind ticket: {e}") from e
if payload.get("typ") != "wechat_bind":
raise TokenError(f"wrong token type: want=wechat_bind got={payload.get('typ')}")
if "sub" not in payload:
raise TokenError("bind ticket missing sub")
return {"openid": payload["sub"], "wnk": payload.get("wnk"), "wav": payload.get("wav")}
# ===================== 密码 hash(admin 后台账号用)=====================
# 用户侧是手机号+验证码登录,不存密码;仅 admin 账号用 username+password 登录。
+1 -2
View File
@@ -13,8 +13,7 @@ worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移
防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
2. 单设备(device_id)+ IP 每小时 / 每天频控(api 层 auth.sms_send 的 check/record_rate_limits,
**只按成功发码计数** —— 被本文件单号冷却挡下的重发不占额度)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
2. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
⚠️ 原「单 IP 频控(rate_limit 依赖)」2026-06-26 按产品要求删除、改设备维度;但 device_id 客户端可伪造/轮换,
脚本轮换 id 能绕过本层 → 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)。
⚠️ 原「单号每日上限」2026-07-03 按精简要求删除(mentor 定:登录风控只留单号冷却 + 单设备频控);
-6
View File
@@ -137,12 +137,6 @@ class ComparisonRecord(Base):
# 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}];
# server 收上报后按 trace_id 同机拉 pricebot 落库(见 compare_record 端点)。旧记录/未采集为 None。
llm_calls: Mapped[list | None] = mapped_column(_JSON, nullable=True)
# 本次比价 LLM 总成本(元):回填时按「当时的价」逐模型算好冻结(见 services/llm_cost.py)。
# 单次亚分级 → float「元」(不用 *_cents)。旧记录/未回填为 None,前端回退「估算成本」。
llm_cost_yuan: Mapped[float | None] = mapped_column(Float, nullable=True)
# 算成本所用单价快照 {mode, prices:{model:{input_per_1m,output_per_1m,_source}}}:app_config 只存
# 当前价、不留历史,故把当时价冻结进来供审计/复算。
llm_price_snapshot: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
+44
View File
@@ -86,6 +86,50 @@ def get_user_by_phone(db: Session, phone: str) -> User | None:
return db.execute(stmt).scalar_one_or_none()
def get_user_by_wechat_openid(db: Session, openid: str) -> User | None:
stmt = select(User).where(User.wechat_openid == openid)
return db.execute(stmt).scalar_one_or_none()
def touch_last_login(db: Session, user: User) -> User:
"""openid 命中登录时更新 last_login_at(手机号登录在 upsert_user_for_login 里已更新)。"""
user.last_login_at = datetime.now(timezone.utc)
db.commit()
db.refresh(user)
return user
def create_wechat_user(
db: Session,
*,
phone: str,
openid: str,
wechat_nickname: str | None,
wechat_avatar_url: str | None,
) -> User:
"""微信登录新建账号:register_channel='wechat';展示昵称/头像取微信(§10 新注册),
昵称缺失(微信隐私脱敏返回 None)时退默认昵称。同时保存 wechat_* 源字段。
openid 唯一约束是并发/重复绑定的最终防线(极罕见,openid 在 wechat-login 刚查过为空)。
"""
now = datetime.now(timezone.utc)
user = User(
phone=phone,
username=_gen_unique_username(db),
nickname=wechat_nickname or _gen_nickname(),
avatar_url=wechat_avatar_url,
register_channel="wechat",
wechat_openid=openid,
wechat_nickname=wechat_nickname,
wechat_avatar_url=wechat_avatar_url,
last_login_at=now,
)
db.add(user)
db.commit()
db.refresh(user)
return user
def upsert_user_for_login(
db: Session,
*,
+47
View File
@@ -102,3 +102,50 @@ class RefreshRequest(BaseModel):
class LogoutResponse(BaseModel):
ok: bool = True
# ===== 微信登录 =====
class WechatLoginRequest(BaseModel):
code: str = Field(..., min_length=1, description="微信 App 授权拿到的 code(单次有效)")
device_id: str = Field(
"", max_length=64,
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
)
class WechatLoginResponse(BaseModel):
# status="logged_in" → openid 命中,token 有值;"need_bind_phone" → 未命中,bind_ticket 有值
status: str
token: TokenWithUser | None = None
bind_ticket: str | None = None
wechat_nickname: str | None = None
wechat_avatar_url: str | None = None
class OccupiedAccountInfo(BaseModel):
"""手机号被占用时返回的原账号脱敏展示信息(供 M2 冲突页;M1 客户端仅用于提示)。"""
nickname: str | None = None
avatar_url: str | None = None
created_at: datetime
class WechatBindResultResponse(BaseModel):
# status="logged_in" → 未占用,已建号登入,token 有值;
# "phone_occupied" → 手机号被占用,occupied_account 有值,token 为 None
status: str
token: TokenWithUser | None = None
occupied_account: OccupiedAccountInfo | None = None
class WechatBindPhoneSmsRequest(BaseModel):
bind_ticket: str = Field(..., min_length=1)
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
code: str = Field(..., min_length=4, max_length=8)
device_id: str = Field("", max_length=64)
class WechatBindPhoneJverifyRequest(BaseModel):
bind_ticket: str = Field(..., min_length=1)
login_token: str = Field(..., min_length=1, description="客户端 loginAuth 拿到的 loginToken")
device_id: str = Field("", max_length=64)
-53
View File
@@ -1,53 +0,0 @@
"""LLM 调用成本计算(纯逻辑,无 DB):按 model 分桶累加 token × 单价,返回总成本(元)+ 价格快照。
用量取自 comparison_record.llm_calls[].usage(pricebot 已归一为 prompt/completion_tokens);
error / usage 的调用跳过price_cfg = {per_model:{model:{input_per_1m,output_per_1m}}, default:{...}}
成本单位单次亚分级, float(不用 *_cents);snapshot 只含本次用到的模型的价(审计用,
不存整张价表)用到但没配价(既无 per_model 又无 default)的模型 快照标 unpriced,成本按 0
"""
from __future__ import annotations
_PRICE_KEY = "llm_token_price"
def get_llm_prices(db) -> dict:
"""读 LLM 单价配置(app_config;表内无则回退 CONFIG_DEFS 默认)。返回 compute_llm_cost 的 price_cfg。"""
from app.repositories import app_config # 延迟 import:compute_llm_cost 纯逻辑不牵连 DB 层
return app_config.get_value(db, _PRICE_KEY)
def compute_llm_cost(calls: list[dict], price_cfg: dict) -> tuple[float | None, dict | None]:
"""遍历 calls 按 model 分桶,cost = Σ(入/1e6*入价 + 出/1e6*出价);无有效调用 → (None, None)。"""
if not calls:
return None, None
per_model = price_cfg.get("per_model") or {}
default = price_cfg.get("default")
buckets: dict[str, list[int]] = {} # model -> [Σprompt_tokens, Σcompletion_tokens]
for c in calls:
if c.get("error"):
continue
usage = c.get("usage") or {}
model = c.get("model") or "unknown"
b = buckets.setdefault(model, [0, 0])
b[0] += usage.get("prompt_tokens") or 0
b[1] += usage.get("completion_tokens") or 0
if not buckets: # 全是 error / 无 usage
return None, None
total = 0.0
prices: dict[str, dict] = {}
for model, (tin, tout) in buckets.items():
price = per_model.get(model, default)
in_p = price.get("input_per_1m") if isinstance(price, dict) else None
out_p = price.get("output_per_1m") if isinstance(price, dict) else None
# 没配价 / 无 default / 单价残缺或非法(配置页手改 JSON 可能存出脏数据)→ 标记待补价、
# 不计入成本;绝不抛异常,以免连累同一回填里的 token/llm_calls 落库。
if not isinstance(in_p, (int, float)) or not isinstance(out_p, (int, float)):
prices[model] = {"input_per_1m": in_p, "output_per_1m": out_p, "unpriced": True}
continue
total += tin / 1e6 * in_p + tout / 1e6 * out_p
prices[model] = {
"input_per_1m": in_p,
"output_per_1m": out_p,
"_source": "per_model" if model in per_model else "default",
}
return round(total, 6), {"mode": "per_model", "prices": prices}
-2
View File
@@ -40,8 +40,6 @@
| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 |
| `input_tokens` | Integer | nullable | 本次 LLM 累计输入 token = Σ `llm_calls[].usage.prompt_tokens`(server 收上报后从 `llm_calls` 累加;旧记录/未采集为 null) |
| `output_tokens` | Integer | nullable | 本次 LLM 累计输出 token = Σ `llm_calls[].usage.completion_tokens`(同上) |
| `llm_cost_yuan` | Float | nullable | 本次比价 LLM 总成本(元),回填时按「当时价」逐模型算好冻结(见 `services/llm_cost.py`);旧记录/未回填为 null → 前端回退「估算成本」 |
| `llm_price_snapshot` | JSON(PG: JSONB) | nullable | 算成本所用单价快照 `{mode, prices:{model:{input_per_1m,output_per_1m,_source}}}`;`app_config` 只存当前价、不留历史,故冻结当时价供审计/复算 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
> `ordered`(已下单)是**瞬态字段**,不在表里:`list_records` 读取时按 `store_name ∈ 该用户 source='compare' 的 savings_record.shop_name 集合` 现挂到实例上供出参用。
-248
View File
@@ -1,248 +0,0 @@
"""一次性 mock:造带 LLM token 成本的比价记录 + 配好 app_config 模型单价,用于测「管理后端」LLM 成本展示。
覆盖 admin比价记录详情抽屉的LLM 成本展示分支:
app_config.llm_token_price 写一条多模型单价(= 配置页LLM 成本卡片已改,get_llm_prices 读它)
comparison_record 5 ,逐条**复用生产的 compute_llm_cost + _backfill_llm_calls 同款派生**
(llm_call_count/retry_count/input_tokens/output_tokens/llm_cost_yuan/llm_price_snapshot),
确保 mock = 真实回填产出5 条刻意覆盖:
单模型真实样本(qwen3.5-flash ×4) ¥0.006184(核对精确值)
多模型(flash + plus) 快照含两个模型各自 _source=per_model
未登记模型(deepseek-v3) default,快照 _source=default
旧记录( token cost) llm_cost_yuan=NULL 前端回退估算成本
error 调用 error 那次跳过计费retry_count+1
记录挂到库里第一个真实用户(admin 列表能显示手机号);无用户则 user_id=NULL(孤儿行,admin 照样全看)
created_at 用北京 naive最近几分钟内错开,详情列表倒序即 置顶
幂等:重跑先按 trace_id 前缀MOCKLLM-清旧再建app_config 单价是 upsert(不随 --clean-only ,
因该 key 本就是本需求新增无历史真实值;要改价直接去配置页或重跑本脚本)
python -m scripts.seed_mock_llm_cost # 造价格 + 5 条记录
python -m scripts.seed_mock_llm_cost --clean-only # 只清 MOCKLLM- 记录(保留单价)
验收:admin比价记录 traceMOCKLLM- 5 点开详情看LLM 成本:
显示实际·当时价+ 价格快照; 显示估算
"""
from __future__ import annotations
import argparse
import sys
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.models.user import User
from app.repositories import app_config
from app.services.llm_cost import compute_llm_cost
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥
_BJ = timezone(timedelta(hours=8))
ID_PREFIX = "MOCKLLM-"
# ── 写进 app_config 的模型单价(get_llm_prices 读它;配置页「LLM 成本」卡片可再改)──
PRICE_CFG = {
"per_model": {
"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0},
"qwen3.5-plus": {"input_per_1m": 4.0, "output_per_1m": 12.0},
},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
"currency": "CNY",
"unit": "per_1m_tokens",
}
def _c(scene: str, model: str, pin: int, cout: int, error: str | None = None) -> dict:
"""一条 llm_calls 明细,结构对齐真实 pricebot 归一后契约:
{scene, model, input_messages:[{role,content}], output, usage:{prompt/completion/total_tokens},
latency_ms, error}(详情抽屉会遍历 input_messages,缺了会崩)error 的调用无 usage/output"""
return {
"scene": scene,
"model": model,
"error": error,
"input_messages": [
{"role": "system", "content": f"你是比价助手,负责 {scene} 环节。"},
{"role": "user", "content": f"[mock] 请处理本次比价的 {scene} 任务。"},
],
"output": None if error else f"[mock] {scene} 环节完成。",
"usage": None if error else {
"prompt_tokens": pin, "completion_tokens": cout, "total_tokens": pin + cout,
},
"latency_ms": 780,
}
# ── 5 条记录蓝本:calls 决定成本;freeze=False 模拟旧记录(有 token 无 cost)──
RECORDS = [
{
"label": "①单模型·真实样本",
"source": ("美团外卖", 4280), "best": ("京东秒送", 3680),
"store": "肯德基(建国路店)", "product": "疯狂星期四全家桶",
"info": "在京东秒送找到同款,到手价 ¥36.80,省 ¥6.00",
"freeze": True,
"calls": [
_c("store_match", "qwen3.5-flash", 1512, 22),
_c("dish_match", "qwen3.5-flash", 2111, 160),
_c("dish_match", "qwen3.5-flash", 1940, 142),
_c("summary", "qwen3.5-flash", 1325, 13),
],
},
{
"label": "②多模型·flash+plus",
"source": ("淘宝闪购", 5900), "best": ("美团外卖", 5200),
"store": "瑞幸咖啡(国贸店)", "product": "生椰拿铁×2、丝绒拿铁",
"info": "在美团外卖找到同款,到手价 ¥52.00,省 ¥7.00",
"freeze": True,
"calls": [
_c("store_match", "qwen3.5-flash", 2000, 50),
_c("dish_match", "qwen3.5-flash", 1800, 40),
_c("reasoning", "qwen3.5-plus", 3000, 500),
],
},
{
"label": "③未登记模型走 default",
"source": ("京东秒送", 3100), "best": ("美团外卖", 2650),
"store": "麦当劳(soho店)", "product": "麦辣鸡腿堡套餐",
"info": "在美团外卖找到同款,到手价 ¥26.50,省 ¥4.50",
"freeze": True,
"calls": [
_c("store_match", "deepseek-v3", 5000, 800),
],
},
{
"label": "④旧记录·有token无成本(回退估算)",
"source": ("美团外卖", 3600), "best": ("淘宝闪购", 3200),
"store": "华莱士(双井店)", "product": "全鸡汉堡套餐",
"info": "在淘宝闪购找到同款,到手价 ¥32.00,省 ¥4.00",
"freeze": False, # 模拟本需求上线前的老记录:llm_cost_yuan=NULL → 前端回退估算
"calls": [
_c("store_match", "qwen3.5-flash", 2000, 100),
],
},
{
"label": "⑤含 error 调用(跳过计费)",
"source": ("淘宝闪购", 4100), "best": ("京东秒送", 3750),
"store": "海底捞(合生汇店)", "product": "番茄锅底、肥牛卷",
"info": "在京东秒送找到同款,到手价 ¥37.50,省 ¥3.50",
"freeze": True,
"calls": [
_c("store_match", "qwen3.5-flash", 0, 0, error="timeout"),
_c("store_match", "qwen3.5-flash", 1500, 30),
],
},
]
_PLATFORM_ID = { # 展示名 → 平台代号(comparison_results / source/best 列用)
"美团外卖": "meituan", "京东秒送": "jd", "淘宝闪购": "taobao",
}
def _naive_bj_now() -> datetime:
return datetime.now(_BJ).replace(tzinfo=None)
def clean(db) -> int:
n = db.execute(
delete(ComparisonRecord).where(ComparisonRecord.trace_id.like(f"{ID_PREFIX}%"))
).rowcount or 0
db.commit()
return n
def _build_record(spec: dict, owner_id: int | None, created_at: datetime) -> tuple[ComparisonRecord, float | None]:
"""按蓝本造一条记录,LLM 派生完全对齐 _backfill_llm_calls;返回 (记录, 冻结成本或 None)。"""
calls = spec["calls"]
src_name, src_cents = spec["source"]
best_name, best_cents = spec["best"]
# —— 与 _backfill_llm_calls 同款派生 ——
llm_call_count = len(calls)
retry_count = sum(1 for c in calls if c.get("error"))
input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls)
output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls)
if spec["freeze"]:
cost, snapshot = compute_llm_cost(calls, PRICE_CFG) # 复用生产纯函数
else:
cost, snapshot = None, None # 旧记录:回填这段代码上线前就有,只有 token 没成本
rec = ComparisonRecord(
user_id=owner_id,
device_id=f"{ID_PREFIX.lower()}dev",
business_type="food",
trace_id=f"{ID_PREFIX}{spec['label'][0]}", # ①..⑤ 各一,唯一
status="success",
source_platform_id=_PLATFORM_ID.get(src_name), source_platform_name=src_name,
source_price_cents=src_cents,
best_platform_id=_PLATFORM_ID.get(best_name), best_platform_name=best_name,
best_price_cents=best_cents,
saved_amount_cents=src_cents - best_cents,
is_source_best=False,
store_name=spec["store"],
product_names=spec["product"],
information=spec["info"],
items=[{"name": spec["product"], "qty": 1}],
comparison_results=[
{"platform_id": _PLATFORM_ID.get(src_name), "platform_name": src_name,
"price": src_cents / 100, "is_source": True, "rank": 2},
{"platform_id": _PLATFORM_ID.get(best_name), "platform_name": best_name,
"price": best_cents / 100, "is_source": False, "rank": 1},
],
total_ms=90_000 + llm_call_count * 1000,
step_count=llm_call_count * 3,
llm_call_count=llm_call_count,
retry_count=retry_count,
input_tokens=input_tokens,
output_tokens=output_tokens,
llm_calls=calls,
llm_cost_yuan=cost,
llm_price_snapshot=snapshot,
created_at=created_at,
)
return rec, cost
def seed(db) -> list[tuple[str, float | None]]:
app_config.set_value(db, "llm_token_price", PRICE_CFG, admin_id=None) # upsert 单价
owner_id = db.execute(select(User.id).order_by(User.id).limit(1)).scalar()
base = _naive_bj_now()
out: list[tuple[str, float | None]] = []
for i, spec in enumerate(RECORDS):
rec, cost = _build_record(spec, owner_id, base - timedelta(minutes=i * 3))
db.add(rec)
out.append((spec["label"], cost))
db.commit()
return out, owner_id
def main() -> None:
parser = argparse.ArgumentParser(description="造带 LLM 成本的比价记录 + app_config 模型单价(测管理后端)")
parser.add_argument("--clean-only", action="store_true", help="只清 MOCKLLM- 记录,不重建(保留单价)")
args = parser.parse_args()
db = SessionLocal()
try:
removed = clean(db)
if removed:
print(f"🧹 已清理旧 mock 记录 {removed}")
if args.clean_only:
print("✅ 仅清理,已完成(app_config 单价保留)。")
return
results, owner_id = seed(db)
print(f"\n✅ 已写入 app_config.llm_token_price(单价)+ {len(results)} 条比价记录"
f"(挂 user_id={owner_id or 'NULL(孤儿行)'})")
print("\n📋 每条冻结成本(admin 详情「LLM 成本」应显示):")
for label, cost in results:
shown = "NULL → 前端回退「估算」" if cost is None else f"¥{cost}"
print(f" {label:<20} {shown}")
print("\n👉 验收:admin「比价记录」→ trace 搜「MOCKLLM-」→ 点开详情核对 LLM 成本 + 价格快照。")
print(" 配置页「系统配置」→「福利页」Tab →「LLM 成本」卡片,单价应为「已改」态。")
finally:
db.close()
if __name__ == "__main__":
main()
-61
View File
@@ -107,67 +107,6 @@ def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None:
assert r.status_code == 200, r.text
def test_sms_send_cooldown_reject_not_counted(client, monkeypatch) -> None:
"""发码额度只算「成功发码」:被单号 60s 冷却挡下的重发(429)不占设备额度。
做法:同号狂发只成功 1 其余被冷却挡下;把小时额度设 2,证明换号后仍能再成功发 1
若冷却重发也计数,额度早被那几次耗尽"""
from app.api.v1 import auth
from app.core import ratelimit
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 2)
ratelimit._buckets.clear()
device = "dev-cooldown"
phone_a = "13710137000"
# 首发成功(小时闸计 1/2)
assert client.post(
"/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device}
).status_code == 200
# 同号连发 3 次:都被单号 60s 冷却挡下 → 429,且**不占**设备额度
for _ in range(3):
r = client.post(
"/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device}
)
assert r.status_code == 429, r.text
# 换号再发:设备额度只用了 1/2(冷却那几次没算)→ 仍放行(计到 2/2)
assert client.post(
"/api/v1/auth/sms/send", json={"phone": "13710137001", "device_id": device}
).status_code == 200
# 又换号:此时小时闸已 2/2 → 429(反证成功发码确实各计了 1)
r = client.post(
"/api/v1/auth/sms/send", json={"phone": "13710137002", "device_id": device}
)
assert r.status_code == 429, r.text
def test_sms_send_daily_cap(client, monkeypatch) -> None:
"""每天发码上限(设备 + IP):成功发码累计到日上限即 429(用不同手机号绕开单号冷却)。
抬高小时闸单独测日闸;超限文案含今日以便前端提示明天再来"""
from app.api.v1 import auth
from app.core import ratelimit
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 100) # 抬高小时闸,不干扰
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_DAY_PER_DEVICE", 3)
ratelimit._buckets.clear()
device = "dev-daily"
for i in range(3):
r = client.post(
"/api/v1/auth/sms/send",
json={"phone": f"13720137{i:03d}", "device_id": device},
)
assert r.status_code == 200, f"{i + 1} 次应放行: {r.text}"
# 第 4 次:同设备同 IP 当日超限 → 429
r = client.post(
"/api/v1/auth/sms/send",
json={"phone": "13720137999", "device_id": device},
)
assert r.status_code == 429, r.text
assert "今日" in r.json()["detail"]
def test_sms_login_device_ip_rate_limit(client, monkeypatch) -> None:
"""防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试,超出 429。
conftest 默认 RATE_LIMIT_ENABLED=false(内存计数跨用例累加),本用例临时打开并清空计数隔离"""
-184
View File
@@ -1,184 +0,0 @@
"""LLM 调用成本计算 compute_llm_cost:按模型分桶累加 token × 单价;error/无 usage 跳过。"""
from __future__ import annotations
from app.services.llm_cost import compute_llm_cost
_PRICE = {
"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
}
def test_sums_per_model_single_model():
# 真实样本:4 次 qwen3.5-flash;Σprompt=6888、Σcompletion=337
calls = [
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1512, "completion_tokens": 22}},
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 2111, "completion_tokens": 160}},
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1940, "completion_tokens": 142}},
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1325, "completion_tokens": 13}},
]
cost, snapshot = compute_llm_cost(calls, _PRICE)
# 6888/1e6*0.8 + 337/1e6*2.0 = 0.0055104 + 0.000674 = 0.0061844 → round(6)
assert cost == 0.006184
assert snapshot == {
"mode": "per_model",
"prices": {
"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0, "_source": "per_model"},
},
}
def test_multi_model_prices_each_bucket_separately():
calls = [
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}},
{"model": "gpt-x", "error": None, "usage": {"prompt_tokens": 0, "completion_tokens": 1_000_000}},
]
price = {
"per_model": {
"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0},
"gpt-x": {"input_per_1m": 10.0, "output_per_1m": 30.0},
},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
}
cost, snap = compute_llm_cost(calls, price)
assert cost == 30.8 # qwen 1M入×0.8=0.8 + gpt-x 1M出×30=30.0
assert set(snap["prices"]) == {"qwen3.5-flash", "gpt-x"}
def test_unknown_model_falls_back_to_default():
calls = [{"model": "mystery", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}}]
price = {"per_model": {}, "default": {"input_per_1m": 3.0, "output_per_1m": 15.0}}
cost, snap = compute_llm_cost(calls, price)
assert cost == 3.0
assert snap["prices"]["mystery"]["_source"] == "default"
def test_unpriced_model_marked_and_zero_cost():
calls = [{"model": "mystery", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 999}}]
cost, snap = compute_llm_cost(calls, {"per_model": {}}) # 无 default
assert cost == 0.0
assert snap["prices"]["mystery"]["unpriced"] is True
def test_error_and_missing_usage_calls_skipped():
calls = [
{"model": "qwen3.5-flash", "error": "boom", "usage": {"prompt_tokens": 9_999_999, "completion_tokens": 9_999_999}},
{"model": "qwen3.5-flash", "error": None, "usage": None}, # 无 usage
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}},
]
cost, _ = compute_llm_cost(calls, _PRICE)
assert cost == 0.8 # 只有第 3 条计入
def test_empty_or_all_error_returns_none():
assert compute_llm_cost([], _PRICE) == (None, None)
assert compute_llm_cost(None, _PRICE) == (None, None)
all_error = [{"model": "x", "error": "boom", "usage": {"prompt_tokens": 100, "completion_tokens": 100}}]
assert compute_llm_cost(all_error, _PRICE) == (None, None)
def test_malformed_price_entry_is_treated_as_unpriced_not_raised():
# 手改配置页可能存出残缺/非法单价(缺 output_per_1m、非 dict);不能抛异常连累 token 回填。
calls = [
{"model": "bad-a", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 5}},
{"model": "bad-b", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 5}},
{"model": "ok", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}},
]
price = {
"per_model": {
"bad-a": {"input_per_1m": 0.8}, # 缺 output_per_1m
"bad-b": 5, # 非 dict
"ok": {"input_per_1m": 3.0, "output_per_1m": 15.0},
},
}
cost, snap = compute_llm_cost(calls, price) # 不得抛异常
assert cost == 3.0 # 只有 ok(1M 入 × 3.0)计入;两个残缺项按 unpriced
assert snap["prices"]["bad-a"].get("unpriced") is True
assert snap["prices"]["bad-b"].get("unpriced") is True
def test_get_llm_prices_falls_back_to_default_then_uses_override():
from app.db.session import SessionLocal
from app.models.app_config import AppConfig
from app.repositories import app_config
from app.services.llm_cost import get_llm_prices
db = SessionLocal()
try:
# 无 override → CONFIG_DEFS 默认(含 per_model / default)
prices = get_llm_prices(db)
assert "per_model" in prices and "default" in prices
# 有 override → 用 DB 值
app_config.set_value(
db, "llm_token_price",
{"per_model": {"m": {"input_per_1m": 1.0, "output_per_1m": 2.0}},
"default": {"input_per_1m": 0.0, "output_per_1m": 0.0}},
admin_id=1,
)
assert get_llm_prices(db)["per_model"]["m"]["input_per_1m"] == 1.0
finally:
row = db.get(AppConfig, "llm_token_price")
if row is not None:
db.delete(row)
db.commit()
db.close()
def test_backfill_llm_calls_stores_cost_and_snapshot(monkeypatch):
from datetime import UTC, datetime
from app.api.v1 import compare_record
from app.db.session import SessionLocal
from app.models.app_config import AppConfig
from app.models.comparison import ComparisonRecord
from app.repositories import app_config
sample = [
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1512, "completion_tokens": 22}},
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 2111, "completion_tokens": 160}},
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1940, "completion_tokens": 142}},
{"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1325, "completion_tokens": 13}},
]
monkeypatch.setattr(compare_record, "fetch_llm_calls", lambda trace_id: sample)
db = SessionLocal()
try:
app_config.set_value(
db, "llm_token_price",
{"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0}},
admin_id=1,
)
rec = ComparisonRecord(
trace_id="llmcost-bf-1", status="success",
created_at=datetime.now(UTC).replace(tzinfo=None),
)
db.add(rec)
db.commit()
rid = rec.id
finally:
db.close()
compare_record._backfill_llm_calls(rid, "llmcost-bf-1") # 独立 session 内回填
db = SessionLocal()
try:
rec = db.get(ComparisonRecord, rid)
assert rec.llm_cost_yuan == 0.006184
assert rec.llm_price_snapshot["prices"]["qwen3.5-flash"]["input_per_1m"] == 0.8
assert rec.input_tokens == 6888 # 现有 token 派生仍在
finally:
db.delete(db.get(ComparisonRecord, rid))
row = db.get(AppConfig, "llm_token_price")
if row is not None:
db.delete(row)
db.commit()
db.close()
def test_admin_detail_schema_exposes_llm_cost_fields():
from app.admin.schemas.comparison import AdminComparisonDetail
fields = AdminComparisonDetail.model_fields
assert "llm_cost_yuan" in fields
assert "llm_price_snapshot" in fields
-57
View File
@@ -1,57 +0,0 @@
"""ratelimit 内存桶过期清理(GC)测试。
回归重点:_buckets **全局共享**混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸) key
GC 必须按每个 key 自己存的 window_sec判过期,而不是当前调用方的窗口 否则高频的 60s 端点
触发 GC 时会把本该存活更久的 3600s/86400s 计数(如短信日闸)一并删掉,使其被反复清零限流失效
monkeypatch _GC_THRESHOLD 0 强制每次都扫,免造上万条(仿 test_auth 里对 sms._GC_THRESHOLD 的做法)
"""
from __future__ import annotations
from app.core import ratelimit
def test_purge_expired_respects_each_key_own_window(monkeypatch) -> None:
"""短窗口(60s)触发的 GC 只删真正过期的 key,不得删掉仍在自身窗口内的长窗口 key。"""
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0) # 强制每次都扫
ratelimit._buckets.clear()
now = 1_000_000.0
# 日闸:100s 前开窗、window=86400 → 远未过期,必须保留
ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 100, 7, 86400.0)
# 登录:1800s、window=3600 → 未过期,保留
ratelimit._buckets["sms-login-device:D:IP"] = (now - 1800, 2, 3600.0)
# 广告:120s、window=60 → 已过期,应删
ratelimit._buckets["ad-watch-report:IP2"] = (now - 120, 3, 60.0)
ratelimit._purge_expired(now)
assert "sms-send-device-daily:D:IP" in ratelimit._buckets
assert "sms-login-device:D:IP" in ratelimit._buckets
assert "ad-watch-report:IP2" not in ratelimit._buckets
def test_purge_expired_keeps_long_window_key_older_than_short_window(monkeypatch) -> None:
"""反证旧 bug:日闸 key 已老于 3600s,旧代码在 60s/3600s 端点触发 GC 时会误删它;
现在按自身 86400s 窗口判 未过期 必须保留"""
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0)
ratelimit._buckets.clear()
now = 2_000_000.0
# 3700s 前开窗(> 1 小时),但 window=86400 → 未过期
ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 3700, 20, 86400.0)
ratelimit._purge_expired(now)
assert "sms-send-device-daily:D:IP" in ratelimit._buckets
def test_purge_expired_noop_below_threshold(monkeypatch) -> None:
"""未超阈值时不扫(即便有过期 key 也不动),避免每次请求都 O(n) 扫全表。"""
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 10)
ratelimit._buckets.clear()
now = 3_000_000.0
ratelimit._buckets["stale:IP"] = (now - 999, 1, 60.0) # 早过期,但没超阈值
ratelimit._purge_expired(now)
assert "stale:IP" in ratelimit._buckets # 桶数没超阈值 → 不清理
+198
View File
@@ -0,0 +1,198 @@
"""微信登录 M1 测试:bind_ticket 令牌、wechat-login(openid 命中/未命中)、
bind-phone(建号/占用/令牌过期)
沿用 tests/test_auth.py 风格:HTTP client fixture;微信 codeopenid monkeypatch
拦掉(conftest WECHAT_APP_ID/SECRET dummy,不真连微信);短信走 SMS_MOCK(任意 6 位通过)
"""
from __future__ import annotations
import pytest
from app.api.v1 import auth
from app.core import security
from app.integrations import wxpay
def _fake_userinfo(openid: str, nickname: str | None = "微信昵称", avatar: str | None = "http://x/a.png"):
"""返回一个可传给 monkeypatch 的假 code_to_userinfo(忽略 code,固定返回给定 openid)。"""
def _f(code: str) -> dict:
return {"openid": openid, "nickname": nickname, "avatar_url": avatar, "raw": {}}
return _f
# ===== Task 1: bind_ticket 令牌 =====
def test_bind_ticket_roundtrip() -> None:
token = security.create_bind_ticket(openid="oid1", wechat_nickname="", wechat_avatar_url="http://a")
claims = security.decode_bind_ticket(token)
assert claims["openid"] == "oid1"
assert claims["wnk"] == ""
assert claims["wav"] == "http://a"
def test_bind_ticket_wrong_type_rejected() -> None:
# 用 access token 冒充 bind_ticket → TokenError(typ 不匹配)
access, _ = security.create_token(user_id=1, token_type="access")
with pytest.raises(security.TokenError):
security.decode_bind_ticket(access)
def test_bind_ticket_expired_rejected(monkeypatch) -> None:
monkeypatch.setattr(security.settings, "WECHAT_BIND_TICKET_EXPIRE_MINUTES", -1)
token = security.create_bind_ticket(openid="oid", wechat_nickname=None, wechat_avatar_url=None)
with pytest.raises(security.TokenError):
security.decode_bind_ticket(token)
# ===== Task 2: wechat-login =====
def test_wechat_login_new_openid_returns_bind_ticket(client, monkeypatch) -> None:
monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_new_1", "小明", "http://x/m.png"))
r = client.post("/api/v1/auth/wechat-login", json={"code": "wxcode1", "device_id": "devA"})
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "need_bind_phone"
assert body["bind_ticket"]
assert body["wechat_nickname"] == "小明"
assert body["wechat_avatar_url"] == "http://x/m.png"
assert body["token"] is None
def test_wechat_login_invalid_code_returns_400(client, monkeypatch) -> None:
def _raise(code: str) -> dict:
raise ValueError("微信授权失败: invalid code")
monkeypatch.setattr(wxpay, "code_to_userinfo", _raise)
r = client.post("/api/v1/auth/wechat-login", json={"code": "bad", "device_id": "devA"})
assert r.status_code == 400, r.text
# ===== Task 3: bind-phone/sms =====
def test_wechat_bind_sms_creates_account_then_openid_logs_in(client, monkeypatch) -> None:
"""未占用 → 建微信账号(channel=wechat,昵称头像取微信);再次同 openid 登录 → 直接登入同一账号。"""
monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_flow_2", "阿花", "http://x/h.png"))
phone = "13900139002"
# 1) 微信登录 → 未命中 → 拿 ticket
r = client.post("/api/v1/auth/wechat-login", json={"code": "c1", "device_id": "devB"})
ticket = r.json()["bind_ticket"]
assert ticket
# 2) 短信绑号(SMS_MOCK:任意 6 位通过)→ 建号 + 登入
r = client.post(
"/api/v1/auth/wechat/bind-phone/sms",
json={"bind_ticket": ticket, "phone": phone, "code": "123456", "device_id": "devB"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "logged_in"
user = body["token"]["user"]
assert user["phone"] == phone
assert user["register_channel"] == "wechat"
assert user["nickname"] == "阿花"
assert user["avatar_url"] == "http://x/h.png"
uid = user["id"]
# 3) 再次微信登录(同 openid)→ 命中 → 直接登入同一账号
r = client.post("/api/v1/auth/wechat-login", json={"code": "c2", "device_id": "devB"})
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "logged_in"
assert body["token"]["user"]["id"] == uid
def test_wechat_bind_sms_phone_occupied(client, monkeypatch) -> None:
"""手机号已被其他账号占用 → 返回 phone_occupied + 原账号信息(不建号)。"""
phone = "13900139003"
# 先用普通短信登录占用该手机号(register_channel=sms)
assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 200
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
occupied_nickname = r.json()["user"]["nickname"]
# 微信登录(新 openid)→ 未命中 → ticket
monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_occ_3"))
ticket = client.post(
"/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devC"}
).json()["bind_ticket"]
# 绑同一手机号 → 占用
r = client.post(
"/api/v1/auth/wechat/bind-phone/sms",
json={"bind_ticket": ticket, "phone": phone, "code": "123456", "device_id": "devC"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "phone_occupied"
assert body["token"] is None
assert body["occupied_account"]["nickname"] == occupied_nickname
assert body["occupied_account"]["avatar_url"] is None # 短信注册账号无头像 → 序列化为 null
assert body["occupied_account"]["created_at"]
def test_wechat_bind_sms_expired_ticket_returns_401(client, monkeypatch) -> None:
"""过期 bind_ticket → 401。"""
monkeypatch.setattr(security.settings, "WECHAT_BIND_TICKET_EXPIRE_MINUTES", -1)
expired = security.create_bind_ticket(openid="openid_exp", wechat_nickname="x", wechat_avatar_url=None)
r = client.post(
"/api/v1/auth/wechat/bind-phone/sms",
json={"bind_ticket": expired, "phone": "13900139009", "code": "123456", "device_id": "devD"},
)
assert r.status_code == 401, r.text
# ===== Task 4: bind-phone/jverify =====
def test_wechat_bind_jverify_creates_account(client, monkeypatch) -> None:
"""本机号(极光)绑定路径:verify_and_get_phone 拦掉,未占用 → 建号登入。"""
monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_jv_5", "极光用户", None))
phone = "13900139005"
# 极光 loginToken→手机号 在 auth 模块命名空间打桩(auth.py 顶部 from ...jiguang import verify_and_get_phone)
monkeypatch.setattr(auth, "verify_and_get_phone", lambda token: phone)
ticket = client.post(
"/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devE"}
).json()["bind_ticket"]
r = client.post(
"/api/v1/auth/wechat/bind-phone/jverify",
json={"bind_ticket": ticket, "login_token": "jgtoken", "device_id": "devE"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "logged_in"
user = body["token"]["user"]
assert user["phone"] == phone
assert user["register_channel"] == "wechat"
assert user["nickname"] == "极光用户"
# 微信 userinfo 隐私脱敏 avatar=None → 头像为空(客户端兜底默认头像)
assert user["avatar_url"] is None
def test_wechat_bind_jverify_expired_ticket_returns_401(client, monkeypatch) -> None:
"""过期 bind_ticket → 401(极光绑号路径,decode 先于极光核验)。"""
monkeypatch.setattr(security.settings, "WECHAT_BIND_TICKET_EXPIRE_MINUTES", -1)
expired = security.create_bind_ticket(openid="openid_jv_exp", wechat_nickname="x", wechat_avatar_url=None)
r = client.post(
"/api/v1/auth/wechat/bind-phone/jverify",
json={"bind_ticket": expired, "login_token": "jgtoken", "device_id": "devE"},
)
assert r.status_code == 401, r.text
def test_wechat_bind_jverify_jiguang_error_returns_502(client, monkeypatch) -> None:
"""极光核验失败(JiguangError)→ 502。"""
monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_jv_err"))
def _raise(token: str) -> str:
raise auth.JiguangError("mock jg failure")
monkeypatch.setattr(auth, "verify_and_get_phone", _raise)
ticket = client.post(
"/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devE"}
).json()["bind_ticket"]
r = client.post(
"/api/v1/auth/wechat/bind-phone/jverify",
json={"bind_ticket": ticket, "login_token": "badtoken", "device_id": "devE"},
)
assert r.status_code == 502, r.text