Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a98e5147c |
@@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -31,7 +32,6 @@ from app.api.deps import DbSession, OptionalUser
|
||||
from app.core.config import settings
|
||||
from app.core.logging import trace_id_ctx
|
||||
from app.core.pricebot_client import get_pricebot_client
|
||||
from app.core.trace_ids import new_trace_id
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import comparison as crud_compare
|
||||
@@ -142,7 +142,7 @@ async def _forward(
|
||||
trace_id = meta.get("trace_id")
|
||||
minted = False
|
||||
if not trace_id:
|
||||
trace_id = new_trace_id()
|
||||
trace_id = str(uuid.uuid4())
|
||||
meta["trace_id"] = trace_id
|
||||
raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes
|
||||
minted = True
|
||||
|
||||
@@ -17,7 +17,6 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import limit_policy
|
||||
from app.core.trace_ids import new_trace_id
|
||||
from app.repositories import comparison as crud_compare
|
||||
from app.repositories import risk as risk_repo
|
||||
from app.schemas.compare_record import (
|
||||
@@ -54,10 +53,6 @@ def reserve_compare_start(
|
||||
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用")
|
||||
# trace_id 统一由服务端签发(客户端不带时):预占额度本就是任务的第一个请求,
|
||||
# 签发与建 running 行合一,此后 Phase1/Phase2/记录/前端日志全链用同一个 id。
|
||||
# 客户端带了则沿用——老客户端兼容 + 同 trace 重试幂等(reserve_daily_start 按 trace_id 去重)。
|
||||
trace_id = payload.trace_id or new_trace_id()
|
||||
try:
|
||||
policy = limit_policy.resolve(
|
||||
db,
|
||||
@@ -68,7 +63,7 @@ def reserve_compare_start(
|
||||
rec, used = crud_compare.reserve_daily_start(
|
||||
db,
|
||||
user_id=user.id,
|
||||
trace_id=trace_id,
|
||||
trace_id=payload.trace_id,
|
||||
business_type=payload.business_type,
|
||||
device_id=payload.device_id,
|
||||
limit=policy.limit,
|
||||
@@ -99,7 +94,6 @@ def reserve_compare_start(
|
||||
limit=policy.limit,
|
||||
used=used,
|
||||
remaining=max(policy.limit - used, 0) if policy.limit is not None else None,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+5
-31
@@ -21,7 +21,6 @@ from fastapi.concurrency import run_in_threadpool
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core.config import settings
|
||||
from app.core.pricebot_client import get_pricebot_client
|
||||
from app.core.trace_ids import new_trace_id
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import coupon_state as coupon_repo
|
||||
@@ -31,7 +30,6 @@ from app.schemas.coupon_state import (
|
||||
CouponPromptShouldShowOut,
|
||||
CouponPromptShownIn,
|
||||
CouponSessionIn,
|
||||
CouponSessionOut,
|
||||
CouponStatsOut,
|
||||
)
|
||||
|
||||
@@ -177,12 +175,6 @@ async def coupon_step(
|
||||
)
|
||||
|
||||
resp_json = resp.json()
|
||||
# 每帧响应顶层回传本次任务 trace_id(对齐 compare _forward 的 setdefault):客户端任一帧
|
||||
# 都能从响应拿到全链 id。**只回显请求里带的、不 mint**——step 是循环接口,每帧签新 id
|
||||
# 会把一次任务打散;领券 trace_id 的唯一签发点在 /coupon/session (status=started)。
|
||||
# pricebot 响应顶层本无 trace_id(只有 trace_url),setdefault 不会覆盖任何上游值。
|
||||
if isinstance(resp_json, dict) and trace_id:
|
||||
resp_json.setdefault("trace_id", trace_id)
|
||||
|
||||
# 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。
|
||||
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
|
||||
@@ -212,33 +204,15 @@ async def coupon_step(
|
||||
return resp_json
|
||||
|
||||
|
||||
@router.post(
|
||||
"/session",
|
||||
response_model=CouponSessionOut,
|
||||
summary="领券任务流水上报(admin 领券数据看板数据源;started 兼签发本轮 trace_id)",
|
||||
)
|
||||
def coupon_session(payload: CouponSessionIn, db: DbSession) -> CouponSessionOut:
|
||||
@router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)")
|
||||
def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
|
||||
"""客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert
|
||||
到 coupon_session。不鉴权(同领券循环 MVP,按 device_id/trace_id);供 admin「领券数据」看板算
|
||||
发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。
|
||||
|
||||
trace_id 统一由后端签发:started 不带 trace_id → 签发 uuid 并随响应返回,客户端全程用它
|
||||
(领券 step 循环 / 收尾上报 / 前端运行日志)。签发不依赖写库成功——写库失败照样返回 trace_id,
|
||||
后续收尾上报 upsert 会补建行。非 started 缺 trace_id 不签发(收尾没有 id 只能是异常调用,
|
||||
签发新 id 只会造出一行查不到发起信息的孤儿),不写库、trace_id=null 返回。
|
||||
"""
|
||||
trace_id = payload.trace_id or (
|
||||
new_trace_id() if payload.status == "started" else None
|
||||
)
|
||||
if trace_id is None:
|
||||
logger.warning(
|
||||
"coupon session missing trace_id for status=%s (skip write)", payload.status
|
||||
)
|
||||
return CouponSessionOut(ok=True, trace_id=None)
|
||||
发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。"""
|
||||
try:
|
||||
coupon_repo.upsert_coupon_session(
|
||||
db,
|
||||
trace_id=trace_id,
|
||||
trace_id=payload.trace_id,
|
||||
device_id=payload.device_id,
|
||||
status=payload.status,
|
||||
started_at_ms=payload.started_at_ms,
|
||||
@@ -255,7 +229,7 @@ def coupon_session(payload: CouponSessionIn, db: DbSession) -> CouponSessionOut:
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("coupon session write failed: %s", e)
|
||||
return CouponSessionOut(ok=True, trace_id=trace_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
|
||||
|
||||
@@ -54,11 +54,13 @@ def _app_status(db_status: str) -> str:
|
||||
|
||||
|
||||
def _record_out(fb) -> FeedbackRecordOut:
|
||||
images = fb.images or []
|
||||
return FeedbackRecordOut(
|
||||
id=fb.id,
|
||||
content=fb.content,
|
||||
scene=getattr(fb, "scene", None),
|
||||
images=fb.images or [],
|
||||
images=images,
|
||||
image_thumbnails=[media.feedback_thumbnail_url(url) for url in images],
|
||||
status=_app_status(fb.status),
|
||||
reject_reason=getattr(fb, "reject_reason", None),
|
||||
reward_coins=getattr(fb, "reward_coins", None),
|
||||
@@ -84,7 +86,7 @@ async def submit_feedback(
|
||||
device_model: str = Form(default=""),
|
||||
rom_name: str = Form(default=""),
|
||||
android_version: str = Form(default=""),
|
||||
images: list[UploadFile] = File(default=[]),
|
||||
images: list[UploadFile] = File(default=[]), # noqa: B008 - FastAPI dependency declaration
|
||||
) -> FeedbackOut:
|
||||
content = content.strip()
|
||||
contact = contact.strip()
|
||||
|
||||
+90
-2
@@ -11,6 +11,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
@@ -18,8 +19,17 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("shagua.media")
|
||||
|
||||
_FEEDBACK_DIR = "feedback"
|
||||
_FEEDBACK_THUMB_DIR = "feedback_thumbs"
|
||||
_FEEDBACK_THUMB_MAX_PX = 256
|
||||
_FEEDBACK_THUMB_QUALITY = 78
|
||||
|
||||
|
||||
class MediaError(Exception):
|
||||
"""上传文件不合法(类型/大小)。调用方转 400。"""
|
||||
@@ -68,8 +78,86 @@ def save_avatar(user_id: int, data: bytes) -> str:
|
||||
|
||||
|
||||
def save_feedback_image(user_id: int, data: bytes) -> str:
|
||||
"""保存反馈截图,返回相对 URL(`/media/feedback/<file>`)。"""
|
||||
return _save_image("feedback", user_id, data)
|
||||
"""保存反馈截图并预生成历史页缩略图,返回原图相对 URL。"""
|
||||
url = _save_image(_FEEDBACK_DIR, user_id, data)
|
||||
# 缩略图失败不影响反馈受理;读取缩略图 URL 时会按需重试并回退原图。
|
||||
ensure_feedback_thumbnail(url)
|
||||
return url
|
||||
|
||||
|
||||
def feedback_thumbnail_url(image_url: str) -> str:
|
||||
"""把反馈原图 URL 映射成确定的缩略图 URL,不在 records 接口内做图片解码。
|
||||
|
||||
上传文件名由服务端生成且不会覆盖;旧数据在客户端真正请求可见图片时按需补图。
|
||||
"""
|
||||
paths = _feedback_thumbnail_paths(image_url)
|
||||
return paths[2] if paths is not None else image_url
|
||||
|
||||
|
||||
def _feedback_thumbnail_paths(image_url: str) -> tuple[Path, Path, str] | None:
|
||||
prefix = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_DIR}/"
|
||||
if not image_url.startswith(prefix):
|
||||
return None
|
||||
|
||||
filename = image_url.removeprefix(prefix)
|
||||
# 只接受当前目录下的单个文件名,避免数据库脏数据造成路径穿越。
|
||||
if not filename or Path(filename).name != filename:
|
||||
return None
|
||||
|
||||
source = _media_dir(_FEEDBACK_DIR) / filename
|
||||
thumb_name = f"{Path(filename).stem}.jpg"
|
||||
thumb = _media_dir(_FEEDBACK_THUMB_DIR) / thumb_name
|
||||
thumb_url = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_THUMB_DIR}/{thumb_name}"
|
||||
return source, thumb, thumb_url
|
||||
|
||||
|
||||
def ensure_feedback_thumbnail(image_url: str) -> Path | None:
|
||||
"""确保缩略图存在并返回文件;生成失败时回退原图,供动态缩略图路由使用。"""
|
||||
paths = _feedback_thumbnail_paths(image_url)
|
||||
if paths is None:
|
||||
return None
|
||||
source, thumb, _ = paths
|
||||
if thumb.is_file():
|
||||
return thumb
|
||||
if not source.is_file():
|
||||
return None
|
||||
|
||||
temp = thumb.with_name(f".{thumb.name}.{secrets.token_hex(4)}.tmp")
|
||||
try:
|
||||
with Image.open(source) as opened:
|
||||
image = ImageOps.exif_transpose(opened)
|
||||
image.thumbnail(
|
||||
(_FEEDBACK_THUMB_MAX_PX, _FEEDBACK_THUMB_MAX_PX),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
if image.mode != "RGB":
|
||||
image = image.convert("RGB")
|
||||
image.save(
|
||||
temp,
|
||||
format="JPEG",
|
||||
quality=_FEEDBACK_THUMB_QUALITY,
|
||||
optimize=True,
|
||||
)
|
||||
os.replace(temp, thumb)
|
||||
return thumb
|
||||
except (Image.DecompressionBombError, OSError, ValueError):
|
||||
logger.warning("生成反馈缩略图失败: %s", source, exc_info=True)
|
||||
return source
|
||||
finally:
|
||||
temp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def feedback_thumbnail_file(filename: str) -> Path | None:
|
||||
"""由缩略图文件名找到原反馈图并按需生成,非法/不存在返回 None。"""
|
||||
if not filename or Path(filename).name != filename or Path(filename).suffix.lower() != ".jpg":
|
||||
return None
|
||||
stem = Path(filename).stem
|
||||
for ext in (".jpg", ".png", ".webp"):
|
||||
original = _media_dir(_FEEDBACK_DIR) / f"{stem}{ext}"
|
||||
if original.is_file():
|
||||
original_url = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_DIR}/{original.name}"
|
||||
return ensure_feedback_thumbnail(original_url)
|
||||
return None
|
||||
|
||||
|
||||
def save_report_image(user_id: int, data: bytes) -> str:
|
||||
|
||||
+4
-8
@@ -234,17 +234,13 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i
|
||||
会铸天量金币;钳在这唯一入口,feed 与 reward_video 回退客户端 eCPM 的路径都护住,且阈值高于
|
||||
所有真实值,不影响正规发奖。
|
||||
|
||||
下限(2026-08,产品口径「看了就保底 1」):**任何输入**都至少 1 金币,与前端展示公式
|
||||
FeedRewardFormula.singleUnitCoin 完全对齐(那边注释:"无论 eCPM 是否为空、非法或非正数,
|
||||
单条广告最低都发 1 金币,不能出现 +0")。此前 eCPM 缺失/为 0 返 0,造成两端不一致:
|
||||
小球显示 +1、后端信息流记 too_short 零发;激励视频侧 "0" 字符串还是 truthy、绕过
|
||||
ecpm_missing 的 `if not ecpm_raw` 判定,落成 granted 0 币且白占当日额度/LT 计数。
|
||||
防刷影响:伪造 eCPM≤0 每天至多多骗 每日上限×1 金币(500 金币=0.05 元),量级可控;
|
||||
天价伪造仍由 AD_ECPM_MAX_FEN 钳顶把守。
|
||||
下限(2026-07):有真实正 eCPM 时单份至少 1 金币——低 eCPM 单份收益四舍五入成 0 时兜底为 1,
|
||||
避免用户看了广告却因数值太小被记 too_short 零发。eCPM 缺失/为 0/非法(没有真实广告价值)仍返 0,
|
||||
不凭空铸币、不破坏 ecpm_missing 语义;防刷上限仍由 AD_ECPM_MAX_FEN 钳顶把守。
|
||||
"""
|
||||
ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0)
|
||||
if ecpm_yuan <= 0:
|
||||
return 1 # 保底:缺失/为 0/非法也发 1(镜像前端 validEcpmFen 判非法 → 直接返 1)
|
||||
return 0
|
||||
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this)
|
||||
return max(1, round(yuan * COIN_PER_YUAN))
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"""trace_id 签发(全后端唯一签发口径, 2026-07 起替代裸 uuid4)。
|
||||
|
||||
格式: "YYYYMMDD_HHMMSS_" + 12 位小写 hex 随机, 共 28 字符, 如
|
||||
20260731_162254_a1b2c3d4e5f6
|
||||
|
||||
Why 带时间前缀: pricebot 落盘目录名/trace_url 尾段**直接用 trace_id 本身**
|
||||
(见 pricebot app/utils/trace_ids.py), id/目录/URL 三者合一——此前 uuid trace_id
|
||||
与 {首帧时刻}_{uuid[:16]} 目录名是两套标识, URL 只有 pricebot 能拼、按前缀反查
|
||||
还有同秒歧义。时间用北京时间(CN_TZ)——不依赖各机器 TZ 配置, 与业务时区一致。
|
||||
|
||||
唯一性: 秒级前缀 + 48bit 随机(hex12), 同一秒内碰撞概率可忽略(比价/领券发起 QPS
|
||||
远低于产生生日碰撞的量级); pricebot 侧同秒多 trace 靠随机段区分(目录精确匹配、
|
||||
llm jsonl 按尾 12 分文件, 不做前缀模糊匹配)。
|
||||
|
||||
兼容: 三个签发点(compare/start、coupon/session started、compare.py _forward mint)
|
||||
统一走这里; 客户端自带 trace_id(老客户端/重试幂等)仍原样沿用——pricebot 对老
|
||||
uuid 格式保持既有目录/短标识行为, 两代 id 并行不冲突。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
|
||||
|
||||
def new_trace_id() -> str:
|
||||
"""签发一个自描述 trace_id: 北京时间前缀 + 12 位 hex 随机。"""
|
||||
return f"{datetime.now(CN_TZ):%Y%m%d_%H%M%S}_{uuid.uuid4().hex[:12]}"
|
||||
+32
-2
@@ -10,7 +10,7 @@ from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -44,6 +44,7 @@ from app.api.v1.tasks import router as tasks_router
|
||||
from app.api.v1.user import router as user_router
|
||||
from app.api.v1.wallet import router as wallet_router
|
||||
from app.api.v1.wxpay import router as wxpay_router
|
||||
from app.core import media
|
||||
from app.core.config import settings
|
||||
from app.core.cps_reconcile_worker import (
|
||||
start_cps_reconcile_worker,
|
||||
@@ -82,6 +83,19 @@ setup_logging(debug=settings.APP_DEBUG)
|
||||
logger = logging.getLogger("shagua.main")
|
||||
|
||||
|
||||
class FeedbackMediaStaticFiles(StaticFiles):
|
||||
"""反馈原图/缩略图文件名不可变,可长期缓存,避免列表反复回源。"""
|
||||
|
||||
async def get_response(self, path: str, scope):
|
||||
response = await super().get_response(path, scope)
|
||||
media_path = path.replace("\\", "/").lstrip("/")
|
||||
if response.status_code == 200 and media_path.startswith(
|
||||
("feedback/", "feedback_thumbs/")
|
||||
):
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
# 提示而非强制建表:生产用 alembic upgrade head,本地 dev 也建议先跑一次 migration。
|
||||
@@ -212,8 +226,24 @@ def download_apk() -> FileResponse:
|
||||
)
|
||||
|
||||
|
||||
@app.get(
|
||||
f"{settings.MEDIA_URL_PREFIX}/feedback_thumbs/{{filename}}",
|
||||
tags=["feedback"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
def feedback_thumbnail(filename: str) -> FileResponse:
|
||||
"""旧反馈图按首次可见请求补缩略图;新图上传时已预生成。"""
|
||||
path = media.feedback_thumbnail_file(filename)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="图片不存在")
|
||||
return FileResponse(
|
||||
path,
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||
)
|
||||
|
||||
|
||||
app.mount(
|
||||
settings.MEDIA_URL_PREFIX,
|
||||
StaticFiles(directory=str(_media_root)),
|
||||
FeedbackMediaStaticFiles(directory=str(_media_root)),
|
||||
name="media",
|
||||
)
|
||||
|
||||
@@ -214,13 +214,9 @@ class ComparisonRecordCreatedOut(BaseModel):
|
||||
|
||||
|
||||
class CompareStartReserveIn(BaseModel):
|
||||
"""Reserve one authenticated comparison start before the agent begins.
|
||||
"""Reserve one authenticated comparison start before the agent begins."""
|
||||
|
||||
trace_id 可选:不带 = 请服务端签发(统一 trace_id 由后端下发,前端/SLS 日志/
|
||||
pricebot 全链用同一个 id);带 = 沿用客户端值(老客户端兼容 + 网络重试幂等)。
|
||||
"""
|
||||
|
||||
trace_id: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
trace_id: str = Field(..., min_length=1, max_length=64)
|
||||
business_type: str = Field(default="food", min_length=1, max_length=16)
|
||||
device_id: str | None = Field(default=None, max_length=64)
|
||||
|
||||
@@ -229,9 +225,6 @@ class CompareStartReserveOut(BaseModel):
|
||||
limit: int | None
|
||||
used: int
|
||||
remaining: int | None
|
||||
# 本次比价全链 trace_id(服务端签发的,或回显客户端带来的)。客户端必须以它为准,
|
||||
# 贯穿 Phase1/Phase2 step、比价记录、trace 收尾与前端运行日志上报。
|
||||
trace_id: str
|
||||
|
||||
|
||||
class CompareStatsOut(BaseModel):
|
||||
|
||||
@@ -58,13 +58,9 @@ class CouponSessionIn(BaseModel):
|
||||
- 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)。
|
||||
- 收尾(completed/failed/abandoned):带 elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count。
|
||||
不鉴权(同领券循环 MVP,按 device_id/trace_id),user_id 登录态带上做留痕(可空)。
|
||||
|
||||
trace_id 可选:started 不带 = 请服务端签发本轮领券 trace_id(统一 trace_id 由后端下发,
|
||||
响应 CouponSessionOut.trace_id 返回,客户端全程用它);带 = 沿用客户端值(老客户端兼容)。
|
||||
非 started 缺 trace_id 不签发(防孤儿行),返回 trace_id=null 且不写库。
|
||||
"""
|
||||
|
||||
trace_id: str | None = None
|
||||
trace_id: str
|
||||
device_id: str
|
||||
status: str # started / completed / failed / abandoned
|
||||
started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis)
|
||||
@@ -78,16 +74,3 @@ class CouponSessionIn(BaseModel):
|
||||
platform_elapsed: dict[str, int] | None = None
|
||||
claimed_count: int | None = None
|
||||
trace_url: str | None = None
|
||||
|
||||
|
||||
class CouponSessionOut(BaseModel):
|
||||
"""POST /api/v1/coupon/session 响应。
|
||||
|
||||
trace_id = 本轮领券全链 id(服务端签发的,或回显客户端带来的);客户端以它为准贯穿
|
||||
/coupon/step 循环、收尾上报与前端运行日志。⚠️ 不能沿用旧的 dict[str, bool] 返回注解——
|
||||
FastAPI 会按注解校验响应,字符串 trace_id 过 bool 校验必炸,故显式建模。
|
||||
非 started 且缺 trace_id 时为 null(不签发防孤儿行)。
|
||||
"""
|
||||
|
||||
ok: bool = True
|
||||
trace_id: str | None = None
|
||||
|
||||
@@ -33,6 +33,8 @@ class FeedbackRecordOut(BaseModel):
|
||||
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
|
||||
scene: str | None = None
|
||||
images: list[str] = Field(default_factory=list)
|
||||
# 与 images 下标一一对应;生成失败时该项回退原图 URL,兼容历史数据。
|
||||
image_thumbnails: list[str] = Field(default_factory=list)
|
||||
status: str
|
||||
reject_reason: str | None = None
|
||||
reward_coins: int | None = None
|
||||
|
||||
@@ -35,6 +35,9 @@ dependencies = [
|
||||
# multipart form (FastAPI 表单上传依赖)
|
||||
"python-multipart>=0.0.9",
|
||||
|
||||
# 用户反馈截图缩略图,避免 App 历史页为 48dp 小图下载数 MB 原图
|
||||
"pillow>=11.0.0",
|
||||
|
||||
# admin 后台账号密码 hash(用户侧是手机号+验证码登录,不需要密码;admin 才用)
|
||||
"bcrypt>=4.0.0",
|
||||
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
"""发奖公式下限:看了就保底 1 金币,与前端展示公式完全对齐。
|
||||
"""发奖公式下限:有真实正 eCPM 时最低发 1 金币,eCPM 缺失/为 0 仍发 0。
|
||||
|
||||
产品口径(2026-08「看了就保底 1」):`calculate_ad_reward_coin` 对**任何输入**都至少返回 1,
|
||||
镜像客户端 FeedRewardFormula.singleUnitCoin(那边:eCPM 空/非法/非正数都返 1)。此前 eCPM
|
||||
缺失/为 0 返 0,与前端小球显示的 +1 不一致,且信息流侧把这类看满一份的广告记成 too_short 零发。
|
||||
公式是发奖与后台审计对账的唯一口径,改这一处两边同源。
|
||||
|
||||
注:激励视频(S2S 回调)路径在公式之前还有一道 `if not ecpm_raw` 早退——**完全没上报 eCPM**
|
||||
的回调仍记 ecpm_missing 零发(见 test_ad_reward.test_callback_without_ecpm_records_exception),
|
||||
那是"回调缺字段"的数据完整性闸,区别于"广告如实上报 eCPM=0"(=看了真广告 → 保底 1)。
|
||||
针对「eCPM 过低时公式四舍五入成 0 金币 → 被记 too_short 不发」的问题:只要广告有真实
|
||||
正 eCPM,单份金币至少 1(不再 0);但 eCPM 缺失/为 0/非法(没有真实广告价值)仍发 0,
|
||||
不凭空铸币、不破坏 ecpm_missing 语义。公式是发奖与后台审计对账的唯一口径,改这一处两边同源。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,23 +10,19 @@ from app.core.rewards import calculate_ad_reward_coin
|
||||
|
||||
|
||||
def test_low_positive_ecpm_floors_to_one_coin() -> None:
|
||||
"""真实但极低的 eCPM 原本四舍五入成 0 金币,兜底为 1(线上 record 4667 的 43 分场景)。"""
|
||||
"""真实但极低的 eCPM 原本四舍五入成 0 金币,现在兜底为 1(线上 record 4667 的 43 分场景)。"""
|
||||
# 43 分 = ¥0.43 CPM,因子1=0.1,重度用户 LT 第 69 条=1.0 → 0.43/1000×0.1×1.0×10000=0.43 → 旧口径 round=0
|
||||
assert calculate_ad_reward_coin("43", 69) == 1
|
||||
# 更低的 5 分同理:算出来 <0.5,旧口径也是 0
|
||||
assert calculate_ad_reward_coin("5", 11) == 1
|
||||
|
||||
|
||||
def test_zero_or_missing_ecpm_also_floors_to_one() -> None:
|
||||
"""eCPM 为 0 / 缺失 / 非法都保底 1(2026-08「看了就保底 1」,与前端 FeedRewardFormula 对齐)。
|
||||
|
||||
尤其 "0"(广告如实上报零价值)此前返 0,导致小球显示 +1、后端信息流记 too_short 零发的
|
||||
前后端不一致 —— 现在两端都 1。
|
||||
"""
|
||||
assert calculate_ad_reward_coin("0", 1) == 1
|
||||
assert calculate_ad_reward_coin(None, 1) == 1
|
||||
assert calculate_ad_reward_coin("", 1) == 1
|
||||
assert calculate_ad_reward_coin("abc", 1) == 1
|
||||
def test_zero_or_missing_ecpm_stays_zero() -> None:
|
||||
"""eCPM 缺失 / 为 0 / 非法(没有真实广告价值)不兜底,仍发 0。"""
|
||||
assert calculate_ad_reward_coin("0", 1) == 0
|
||||
assert calculate_ad_reward_coin(None, 1) == 0
|
||||
assert calculate_ad_reward_coin("", 1) == 0
|
||||
assert calculate_ad_reward_coin("abc", 1) == 0
|
||||
|
||||
|
||||
def test_normal_ecpm_value_unchanged() -> None:
|
||||
@@ -66,31 +57,3 @@ def test_feed_reward_low_ecpm_grants_one_coin_instead_of_too_short() -> None:
|
||||
assert rec.coin == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_feed_reward_zero_ecpm_grants_one_coin() -> None:
|
||||
"""端到端:eCPM 如实上报 0(用户反馈的真实广告返回 eCPM=0 场景),看满一份也保底 1、
|
||||
状态 granted —— 与前端小球显示的 +1 一致,不再前显示后零发。"""
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
from app.repositories.ad_feed_reward import grant_feed_reward
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = User(phone="19900000044", username="feedzero44", register_channel="sms")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
rec = grant_feed_reward(
|
||||
db, user.id,
|
||||
client_event_id="feed-zero-ecpm-0001",
|
||||
ecpm="0", # 广告如实上报 eCPM=0
|
||||
duration_seconds=15, # 看满一份
|
||||
ad_type="draw",
|
||||
feed_scene="comparison",
|
||||
)
|
||||
assert rec.status == "granted"
|
||||
assert rec.coin == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -48,10 +48,7 @@ def test_compare_start_is_idempotent_by_trace_id(client) -> None:
|
||||
retry = client.post("/api/v1/compare/start", json=payload, headers=_headers(token))
|
||||
|
||||
assert first.status_code == 200, first.text
|
||||
# trace_id 回显客户端带来的值(老协议幂等路径)
|
||||
assert first.json() == {
|
||||
"limit": 100, "used": 1, "remaining": 99, "trace_id": payload["trace_id"],
|
||||
}
|
||||
assert first.json() == {"limit": 100, "used": 1, "remaining": 99}
|
||||
assert retry.status_code == 200, retry.text
|
||||
assert retry.json() == first.json()
|
||||
with SessionLocal() as db:
|
||||
@@ -72,28 +69,6 @@ def test_compare_start_is_idempotent_by_trace_id(client) -> None:
|
||||
assert record.device_id == "quota-device"
|
||||
|
||||
|
||||
def test_compare_start_issues_trace_id_when_absent(client) -> None:
|
||||
"""新客户端不带 trace_id → 服务端签发并随响应返回,running 行以签发 id 建。"""
|
||||
token, user_id = _login(client)
|
||||
response = client.post(
|
||||
"/api/v1/compare/start",
|
||||
json={"business_type": "food", "device_id": "quota-device-issue"},
|
||||
headers=_headers(token),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
issued = body["trace_id"]
|
||||
assert issued # 非空签发
|
||||
assert body["used"] == 1
|
||||
with SessionLocal() as db:
|
||||
record = db.execute(
|
||||
select(ComparisonRecord).where(ComparisonRecord.trace_id == issued)
|
||||
).scalar_one()
|
||||
assert record.user_id == user_id
|
||||
assert record.status == "running"
|
||||
assert record.device_id == "quota-device-issue"
|
||||
|
||||
|
||||
def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
|
||||
token, user_id = _login(client)
|
||||
now = datetime.now(CN_TZ).replace(tzinfo=None)
|
||||
@@ -126,9 +101,7 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
|
||||
headers=_headers(token),
|
||||
)
|
||||
assert allowed.status_code == 200, allowed.text
|
||||
assert allowed.json() == {
|
||||
"limit": 100, "used": 100, "remaining": 0, "trace_id": final_allowed_trace,
|
||||
}
|
||||
assert allowed.json() == {"limit": 100, "used": 100, "remaining": 0}
|
||||
|
||||
rejected_trace = f"quota-rejected-{user_id}"
|
||||
response = client.post(
|
||||
|
||||
@@ -8,7 +8,6 @@ mock 掉对 pricebot 的 httpx 调用,验证:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -63,8 +62,7 @@ def test_coupon_step_no_auth_required(client) -> None:
|
||||
|
||||
|
||||
def test_coupon_step_passes_body_through(client, access_token) -> None:
|
||||
"""带 token + pricebot 200 → 请求 body 原样转发到 /api/coupon/step;
|
||||
响应在透传基础上顶层回显本次任务 trace_id(setdefault 注入,其余字段原样)。"""
|
||||
"""带 token + pricebot 200 → 响应原样透传,请求 body 原样转发到 /api/coupon/step。"""
|
||||
fake_pricebot_resp = {
|
||||
"success": True,
|
||||
"action": {
|
||||
@@ -85,12 +83,9 @@ def test_coupon_step_passes_body_through(client, access_token) -> None:
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
# ⚠️ coupon_step 转发用 content=raw(原始字节透传,不重新 dumps),不是 json= ——
|
||||
# fake 必须捕 content。旧 fake 只捕 json= 导致 captured["json"] 恒 None,本测试
|
||||
# 自 content=raw 优化后一直红着(pre-existing),本次顺手修正。
|
||||
async def fake_post(self, url, content=None, **kw):
|
||||
async def fake_post(self, url, json=None, **kw):
|
||||
captured["url"] = url
|
||||
captured["content"] = content
|
||||
captured["json"] = json
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json = lambda: fake_pricebot_resp
|
||||
@@ -104,10 +99,9 @@ def test_coupon_step_passes_body_through(client, access_token) -> None:
|
||||
)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
# 顶层多出 trace_id 回显(值=请求带的;不 mint,签发点唯一在 /coupon/session started)
|
||||
assert r.json() == {**fake_pricebot_resp, "trace_id": "test-trace-1"}
|
||||
# 验证请求被原样转发(body 字节不动 + URL 指向 pricebot)
|
||||
assert json.loads(captured["content"]) == _stub_request_body()
|
||||
assert r.json() == fake_pricebot_resp
|
||||
# 验证请求被原样转发(body 不动 + URL 指向 pricebot)
|
||||
assert captured["json"] == _stub_request_body()
|
||||
assert captured["url"].endswith("/api/coupon/step")
|
||||
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""POST /api/v1/coupon/session 的 trace_id 签发行为(统一 trace_id 由后端下发)。
|
||||
|
||||
- started 不带 trace_id → 服务端签发并返回,行以签发 id 建;
|
||||
- started 带 trace_id → 回显沿用(老客户端兼容);
|
||||
- 非 started 缺 trace_id → 不签发不写库,trace_id=null(防孤儿行)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.coupon_state import CouponSession
|
||||
|
||||
|
||||
def _base_payload(**overrides) -> dict:
|
||||
payload = {
|
||||
"device_id": "cs-issue-device",
|
||||
"status": "started",
|
||||
"started_at_ms": 1_722_000_000_000,
|
||||
"platforms": ["meituan-waimai"],
|
||||
"app_env": "dev",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_session_started_issues_trace_id_when_absent(client) -> None:
|
||||
response = client.post("/api/v1/coupon/session", json=_base_payload())
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["ok"] is True
|
||||
issued = body["trace_id"]
|
||||
assert issued
|
||||
with SessionLocal() as db:
|
||||
row = db.execute(
|
||||
select(CouponSession).where(CouponSession.trace_id == issued)
|
||||
).scalar_one()
|
||||
assert row.device_id == "cs-issue-device"
|
||||
assert row.status == "started"
|
||||
|
||||
|
||||
def test_session_started_echoes_client_trace_id(client) -> None:
|
||||
response = client.post(
|
||||
"/api/v1/coupon/session", json=_base_payload(trace_id="cs-legacy-1")
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["trace_id"] == "cs-legacy-1"
|
||||
with SessionLocal() as db:
|
||||
count = db.scalar(
|
||||
select(func.count(CouponSession.id)).where(
|
||||
CouponSession.trace_id == "cs-legacy-1"
|
||||
)
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_session_terminal_without_trace_id_skips_write(client) -> None:
|
||||
response = client.post(
|
||||
"/api/v1/coupon/session",
|
||||
json=_base_payload(status="completed", elapsed_ms=1234, claimed_count=2),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["ok"] is True
|
||||
assert body["trace_id"] is None
|
||||
Reference in New Issue
Block a user