Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3146224944 | |||
| 3621d606ad | |||
| c1b00eca0c | |||
| a5fdcb53d8 | |||
| fe9b749154 | |||
| 88313cefdf | |||
| 2a82b20365 |
@@ -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")
|
||||
@@ -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
|
||||
|
||||
+165
-1
@@ -17,7 +17,14 @@ 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 enforce_rate_limit
|
||||
from app.core.security import TokenError, decode_token, issue_token_pair
|
||||
from app.core.security import (
|
||||
TokenError,
|
||||
create_bind_ticket,
|
||||
decode_bind_ticket,
|
||||
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
|
||||
@@ -25,6 +32,7 @@ from app.repositories import user as user_repo
|
||||
from app.schemas.auth import (
|
||||
JverifyLoginRequest,
|
||||
LogoutResponse,
|
||||
OccupiedAccountInfo,
|
||||
RefreshRequest,
|
||||
SmsLoginRequest,
|
||||
SmsSendRequest,
|
||||
@@ -32,6 +40,11 @@ from app.schemas.auth import (
|
||||
TokenPair,
|
||||
TokenWithUser,
|
||||
UserOut,
|
||||
WechatBindPhoneJverifyRequest,
|
||||
WechatBindPhoneSmsRequest,
|
||||
WechatBindResultResponse,
|
||||
WechatLoginRequest,
|
||||
WechatLoginResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.auth")
|
||||
@@ -166,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 对")
|
||||
|
||||
@@ -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
-8
@@ -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 无法越权访问后台。
|
||||
@@ -169,14 +172,6 @@ class Settings(BaseSettings):
|
||||
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
|
||||
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
|
||||
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
|
||||
# 连续 N 天无活跃(无比价 / 领券,登录不算)账户金币+现金清零:进程内 worker
|
||||
# app.core.inactive_clear_worker 跨北京 0 点跑一轮 inactive_clear.clear_inactive_accounts。
|
||||
# 邀请奖励金物理隔离,不在清空范围。⚠️ 不可逆批量资金操作,口径见 repositories/inactive_clear。
|
||||
# 运营要临时停在 .env 置 false(worker 不启动)。
|
||||
INACTIVE_CLEAR_ENABLED: bool = True
|
||||
# 连续无活跃**满这么多整天之后**才清:15 → 最后活跃在 15 天前当天仍保留,第 16 天起清。
|
||||
INACTIVE_CLEAR_DAYS: int = 15
|
||||
INACTIVE_CLEAR_CHECK_INTERVAL_SEC: int = 600
|
||||
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
|
||||
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
|
||||
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
|
||||
|
||||
@@ -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 兜底未登记的模型。改价只影响之后回填的新记录,历史记录用当时价格快照。"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
"""连续 N 天无活跃账户金币 / 现金清零的进程内定时任务。
|
||||
|
||||
结构同 app.core.daily_exchange_worker:每 `INACTIVE_CLEAR_CHECK_INTERVAL_SEC` 醒一次,
|
||||
跨进北京新的一天(0 点后)就跑一轮 inactive_clear.clear_inactive_accounts。
|
||||
|
||||
健壮性:
|
||||
- **天然幂等**:只清有余额的账户,清完余额=0,下一轮 / 重启 / 多次唤醒都不重复清。
|
||||
- **当天首跑即补**:进程起来时若当天还没跑过,立即跑一轮。
|
||||
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑。
|
||||
- **开关**:settings.INACTIVE_CLEAR_ENABLED=false 时不启动。
|
||||
|
||||
⚠️ 这是不可逆的批量资金操作(清空用户金币 + 金币现金,邀请金除外)。活跃口径与清零
|
||||
细节见 app.repositories.inactive_clear。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import inactive_clear as inactive_clear_repo
|
||||
|
||||
logger = logging.getLogger("shagua.inactive_clear")
|
||||
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "inactive_clear.lock"
|
||||
|
||||
|
||||
def _touch_lock() -> None:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.utime(_LOCK_PATH, None)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
|
||||
"""同机多进程保护:同一时间只允许一个清零 worker 运行。"""
|
||||
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd: int | None = None
|
||||
try:
|
||||
try:
|
||||
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
try:
|
||||
age = time.time() - _LOCK_PATH.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
age = stale_after_sec + 1
|
||||
if age > stale_after_sec:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
_LOCK_PATH.unlink()
|
||||
try:
|
||||
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
fd = None
|
||||
|
||||
if fd is None:
|
||||
yield False
|
||||
return
|
||||
|
||||
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
|
||||
yield True
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
_LOCK_PATH.unlink()
|
||||
|
||||
|
||||
def _clear_once() -> dict:
|
||||
with SessionLocal() as db:
|
||||
return inactive_clear_repo.clear_inactive_accounts(
|
||||
db, days=settings.INACTIVE_CLEAR_DAYS
|
||||
)
|
||||
|
||||
|
||||
async def _run_loop() -> None:
|
||||
interval = max(60, int(settings.INACTIVE_CLEAR_CHECK_INTERVAL_SEC))
|
||||
lock_stale_after = max(interval * 3, 1800)
|
||||
with _single_instance_lock(lock_stale_after) as lock_acquired:
|
||||
if not lock_acquired:
|
||||
logger.warning("inactive-clear skipped: another worker owns lock")
|
||||
return
|
||||
await _run_locked_loop(interval)
|
||||
|
||||
|
||||
async def _run_locked_loop(interval: int) -> None:
|
||||
logger.info(
|
||||
"inactive-clear worker started interval=%ss days=%s",
|
||||
interval,
|
||||
settings.INACTIVE_CLEAR_DAYS,
|
||||
)
|
||||
# 本进程上次跑过的北京日;None=尚未跑过本进程(启动即补当天)。
|
||||
last_run: date | None = None
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
_touch_lock()
|
||||
today = rewards.cn_today()
|
||||
if last_run != today:
|
||||
result = await asyncio.to_thread(_clear_once)
|
||||
last_run = today
|
||||
logger.info("inactive-clear done date=%s result=%s", today, result)
|
||||
except SQLAlchemyError:
|
||||
logger.exception("inactive-clear db error")
|
||||
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
|
||||
logger.exception("inactive-clear unexpected error")
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("inactive-clear worker stopped")
|
||||
raise
|
||||
|
||||
|
||||
def start_inactive_clear_worker() -> asyncio.Task | None:
|
||||
if not settings.INACTIVE_CLEAR_ENABLED:
|
||||
logger.info("inactive-clear disabled (INACTIVE_CLEAR_ENABLED=false)")
|
||||
return None
|
||||
return asyncio.create_task(_run_loop(), name="inactive-clear")
|
||||
|
||||
|
||||
async def stop_inactive_clear_worker(task: asyncio.Task | None) -> None:
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
@@ -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 登录。
|
||||
|
||||
|
||||
@@ -49,10 +49,6 @@ from app.core.heartbeat_monitor_worker import (
|
||||
start_heartbeat_monitor,
|
||||
stop_heartbeat_monitor,
|
||||
)
|
||||
from app.core.inactive_clear_worker import (
|
||||
start_inactive_clear_worker,
|
||||
stop_inactive_clear_worker,
|
||||
)
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
|
||||
from app.core.withdraw_reconcile_worker import (
|
||||
@@ -78,21 +74,18 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
try:
|
||||
# 预热离线地理库:首次加载 ~2.5M 行 CSV + 建 KDTree,摊到启动、不砸首个按城市过滤的请求
|
||||
from app.utils import geo
|
||||
|
||||
geo.ensure_loaded()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("reverse_geocoder 预热失败(城市反查将在首个请求时懒加载)")
|
||||
reconcile_task = start_withdraw_reconcile_worker()
|
||||
heartbeat_task = start_heartbeat_monitor()
|
||||
daily_exchange_task = start_daily_exchange_worker()
|
||||
inactive_clear_task = start_inactive_clear_worker()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await stop_heartbeat_monitor(heartbeat_task)
|
||||
await stop_withdraw_reconcile_worker(reconcile_task)
|
||||
await stop_daily_exchange_worker(daily_exchange_task)
|
||||
await stop_inactive_clear_worker(inactive_clear_task)
|
||||
await aclose_pricebot_client()
|
||||
logger.info("shutting down")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
"""连续 N 天无活跃账户的金币 / 现金清零。
|
||||
|
||||
规则(2026-07-15 产品定):某用户连续 `days` 天(默认 15)既没发起比价、也没发起领券
|
||||
→ 判定为「流失」,清空其金币余额(coin_balance)与金币兑换现金(cash_balance_cents)。
|
||||
**单纯登录(打开 App)不算活跃**——光登录、不用比价/领券的用户视为流失照清。
|
||||
**邀请奖励金(invite_cash_balance_cents)物理隔离,不在清空范围**(产品红线,见
|
||||
models/wallet.py CoinAccount 注释);total_coin_earned(历史累计赚取,只增不减)也不动。
|
||||
|
||||
活跃口径:发起比价 real_compare_start + 发起领券 real_coupon_start / claim_started
|
||||
两路并集(**不含登录 last_login_at**)。⚠️与 admin 大盘 DAU / 后台「最近活跃」列**不同**
|
||||
——那两处含登录,此处刻意去掉,只认真正用了核心功能;若两处口径要同步须留意此差异。
|
||||
|
||||
新用户保护:从没发起过比价/领券的用户永远不在活跃集合,故用**注册时间兜底**——注册未满
|
||||
days 天(created_at 在 today-days 之后)的用户豁免清零,新用户有 days 天宽限,不会刚注册
|
||||
(有礼金)就被清。(登录已不算活跃,故不能再靠 last_login 兜底新用户。)
|
||||
|
||||
清零走 wallet.grant_coins / grant_cash(负数出账入口):更新余额快照 + 写一笔
|
||||
biz_type=inactive_clear 的流水(带 balance_after),可逐笔回溯 / 人工恢复;grant_coins
|
||||
对负数不累加 total_coin_earned。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount
|
||||
from app.repositories.wallet import get_or_create_account, grant_cash, grant_coins
|
||||
|
||||
CLEAR_BIZ_TYPE = "inactive_clear"
|
||||
# 「活跃」计入的埋点事件:发起比价 + 发起领券(登录不算)
|
||||
_ACTIVE_EVENTS = ("real_compare_start", "real_coupon_start")
|
||||
|
||||
|
||||
def _cn_date_start_utc(d: date) -> datetime:
|
||||
"""北京自然日 d 的 00:00 → UTC aware 下界。
|
||||
|
||||
analytics_event.created_at / user.created_at 存 UTC(见各自模型),须用 UTC 边界比较;
|
||||
CN_TZ 是固定 +8 偏移(rewards.CN_TZ),无 DST 歧义。
|
||||
"""
|
||||
return datetime(d.year, d.month, d.day, tzinfo=rewards.CN_TZ).astimezone(UTC)
|
||||
|
||||
|
||||
def _active_user_ids_since(db: Session, since_cn: date) -> set[int]:
|
||||
"""北京自然日 since_cn(含)当天 0 点起 **发起过比价 / 领券** 的 user_id 集合。
|
||||
|
||||
**登录不算活跃**(产品定):只认核心功能行为。两路并集:
|
||||
- 发起比价 / 领券:analytics_event.event in _ACTIVE_EVENTS 且 created_at >= since(UTC 边界)
|
||||
- 发起领券:coupon_prompt_engagement.engage_type=claim_started 且 engage_date >= since(北京日列)
|
||||
"""
|
||||
since_utc = _cn_date_start_utc(since_cn)
|
||||
ids: set[int] = set()
|
||||
|
||||
ids.update(
|
||||
uid
|
||||
for uid in db.execute(
|
||||
select(AnalyticsEvent.user_id).where(
|
||||
AnalyticsEvent.user_id.is_not(None),
|
||||
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
|
||||
AnalyticsEvent.created_at >= since_utc,
|
||||
)
|
||||
).scalars()
|
||||
if uid is not None
|
||||
)
|
||||
ids.update(
|
||||
uid
|
||||
for uid in db.execute(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.user_id.is_not(None),
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
CouponPromptEngagement.engage_date >= since_cn,
|
||||
)
|
||||
).scalars()
|
||||
if uid is not None
|
||||
)
|
||||
return ids
|
||||
|
||||
|
||||
def _new_user_ids_since(db: Session, since_cn: date) -> set[int]:
|
||||
"""注册时间 >= since_cn 北京日 0 点的 user_id——注册未满 days 天的新用户,豁免清零。"""
|
||||
since_utc = _cn_date_start_utc(since_cn)
|
||||
return set(db.execute(select(User.id).where(User.created_at >= since_utc)).scalars())
|
||||
|
||||
|
||||
def clear_inactive_accounts(db: Session, *, days: int, dry_run: bool = False) -> dict:
|
||||
"""连续无活跃**满 `days` 整天之后**把用户金币 + 金币现金清零(邀请金不动),逐用户独立事务。
|
||||
|
||||
- 活跃 = 发起比价 / 发起领券(**登录不算**)。last_active >= today-days 视为活跃;连续无
|
||||
比价无领券满 days 天(且注册也满 days 天)才清。days=15 → 最后一次比价/领券在 15 天前
|
||||
当天仍留、第 16 天起清。today 一律北京时(rewards.cn_today())。
|
||||
- 新用户保护:注册未满 days 天(created_at 在 today-days 之后)豁免,不会刚注册就被清。
|
||||
- 只扫描有余额(coin_balance>0 或 cash_balance_cents>0)的账户:清零后余额=0,下一轮
|
||||
自然跳过 → 天然幂等,重启 / 多次唤醒 / 补跑都安全。
|
||||
- 逐用户独立事务:单用户异常 rollback 不影响其他人。
|
||||
- dry_run=True:只统计不写库(供运营上线前预演:看会清哪些人、清多少)。
|
||||
|
||||
返回统计 dict(scanned/cleared/skipped_active/skipped_new_user/coin_cleared/
|
||||
cents_cleared/failed)。
|
||||
"""
|
||||
# days<1 会把 active_since 推到未来 → 全员判流失清空(灾难);防御性钳到 >=1。
|
||||
days = max(1, days)
|
||||
today = rewards.cn_today()
|
||||
# 连续无活跃满 days 整天之后才清:active_since=today-days,活跃/新用户都以此为边界。
|
||||
active_since = today - timedelta(days=days)
|
||||
active_ids = _active_user_ids_since(db, active_since)
|
||||
new_user_ids = _new_user_ids_since(db, active_since)
|
||||
|
||||
# 只取候选 user_id;清零金额在循环内读**实时**余额(不用此处快照)——活跃集合算完到逐行
|
||||
# 清零之间余额可能变(如并发看广告发币),用快照 grant 会清不干净、balance_after≠0。
|
||||
candidate_ids = (
|
||||
db.execute(
|
||||
select(CoinAccount.user_id).where(
|
||||
or_(CoinAccount.coin_balance > 0, CoinAccount.cash_balance_cents > 0)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
stats = {
|
||||
"scanned": 0,
|
||||
"cleared": 0,
|
||||
"skipped_active": 0,
|
||||
"skipped_new_user": 0,
|
||||
"coin_cleared": 0,
|
||||
"cents_cleared": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
remark = f"连续{days}天无活跃清零"
|
||||
for user_id in candidate_ids:
|
||||
stats["scanned"] += 1
|
||||
if user_id in active_ids:
|
||||
stats["skipped_active"] += 1
|
||||
continue
|
||||
if user_id in new_user_ids: # 注册未满 days 天,新用户宽限
|
||||
stats["skipped_new_user"] += 1
|
||||
continue
|
||||
if dry_run:
|
||||
acc = db.get(CoinAccount, user_id)
|
||||
if acc is not None:
|
||||
stats["cleared"] += 1
|
||||
stats["coin_cleared"] += acc.coin_balance
|
||||
stats["cents_cleared"] += acc.cash_balance_cents
|
||||
continue
|
||||
try:
|
||||
# lock=True 对该账户行加 FOR UPDATE(PG 生效,SQLite no-op),读-清-写串行化防并发
|
||||
acc = get_or_create_account(db, user_id, commit=False, lock=True)
|
||||
coin, cents = acc.coin_balance, acc.cash_balance_cents
|
||||
if coin <= 0 and cents <= 0:
|
||||
continue # 快照后被并发清空 / 变动,无需再清
|
||||
if coin > 0:
|
||||
grant_coins(db, user_id, -coin, biz_type=CLEAR_BIZ_TYPE, remark=remark)
|
||||
if cents > 0:
|
||||
grant_cash(db, user_id, -cents, biz_type=CLEAR_BIZ_TYPE, remark=remark)
|
||||
db.commit()
|
||||
stats["cleared"] += 1
|
||||
stats["coin_cleared"] += coin
|
||||
stats["cents_cleared"] += cents
|
||||
except Exception: # noqa: BLE001 - 批处理不因单用户异常中断
|
||||
db.rollback()
|
||||
stats["failed"] += 1
|
||||
|
||||
return stats
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
@@ -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 集合` 现挂到实例上供出参用。
|
||||
|
||||
@@ -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「比价记录」→ 找 trace「MOCKLLM-」的 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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,198 @@
|
||||
"""微信登录 M1 测试:bind_ticket 令牌、wechat-login(openid 命中/未命中)、
|
||||
bind-phone(建号/占用/令牌过期)。
|
||||
|
||||
沿用 tests/test_auth.py 风格:HTTP 走 client fixture;微信 code→openid 用 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
|
||||
Reference in New Issue
Block a user