feat: 接入数据大盘聚合与美团 CPS 对账

改了什么:新增新版大盘日期窗口聚合、广告收益拆分、比价耗时字段、美团 CPS 拉单入库与大盘展示,并同步反馈审核和邀请奖励下线口径。
验证:python -m pytest tests/test_admin_read.py tests/test_admin_write.py tests/test_compare_record.py tests/test_invite.py tests/test_feedback.py -q。
This commit is contained in:
lowmaster-chen
2026-06-28 09:56:13 +08:00
parent 900b64d4f9
commit e1bd0e3ef7
49 changed files with 2376 additions and 119 deletions
+4 -3
View File
@@ -241,11 +241,12 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
db, user.id,
ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
ad_session_id=payload.ad_session_id,
adn=payload.adn, slot_id=payload.slot_id,
adn=payload.adn, slot_id=payload.slot_id, feed_scene=payload.feed_scene,
)
logger.info(
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s",
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
"ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s adn=%s slot=%s",
user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id,
payload.ecpm, payload.adn, payload.slot_id,
)
return EcpmReportOut(ok=True)
+59 -6
View File
@@ -1,7 +1,8 @@
"""帮助与反馈 endpoint。
路由前缀 `/api/v1/feedback`,需 Bearer 鉴权(反馈绑到登录用户,便于回访)。
POST / 提交反馈(multipart:content / contact 必填,images 可选 ≤4 张)
POST / 提交反馈(multipart:content,images 可选 ≤4 张)
GET /records 我的反馈历史(pending/adopted/rejected)
截图复用 [app.core.media] 落盘到 /media/feedback/。
"""
@@ -9,12 +10,18 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import feedback as feedback_repo
from app.schemas.feedback import FeedbackOut
from app.schemas.feedback import (
FeedbackConfigOut,
FeedbackOut,
FeedbackRecordCountsOut,
FeedbackRecordOut,
FeedbackRecordsOut,
)
logger = logging.getLogger("shagua.feedback")
@@ -23,6 +30,52 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 4
_CONTENT_MAX = 2000
_CONTACT_MAX = 128
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
def _app_status(db_status: str) -> str:
return {
"new": "pending",
"handled": "adopted",
"approved": "adopted",
}.get(db_status, db_status)
def _record_out(fb) -> FeedbackRecordOut:
return FeedbackRecordOut(
id=fb.id,
content=fb.content,
images=fb.images or [],
status=_app_status(fb.status),
reject_reason=getattr(fb, "reject_reason", None),
reward_coins=getattr(fb, "reward_coins", None),
created_at=fb.created_at,
)
@router.get("/config", response_model=FeedbackConfigOut, summary="反馈页配置")
def feedback_config(_: CurrentUser) -> FeedbackConfigOut:
return FeedbackConfigOut()
@router.get("/records", response_model=FeedbackRecordsOut, summary="我的反馈历史")
def feedback_records(
user: CurrentUser,
db: DbSession,
status: str | None = Query(default=None),
) -> FeedbackRecordsOut:
if status is not None and status not in _VALID_RECORD_STATUS:
raise HTTPException(status_code=400, detail="invalid status")
all_records = [_record_out(fb) for fb in feedback_repo.list_feedback(db, user_id=user.id)]
counts = FeedbackRecordCountsOut(
all=len(all_records),
pending=sum(1 for r in all_records if r.status == "pending"),
adopted=sum(1 for r in all_records if r.status == "adopted"),
rejected=sum(1 for r in all_records if r.status == "rejected"),
)
records = [r for r in all_records if r.status == status] if status else all_records
return FeedbackRecordsOut(records=records, counts=counts)
@router.post("", response_model=FeedbackOut, summary="提交反馈")
@@ -30,7 +83,9 @@ async def submit_feedback(
user: CurrentUser,
db: DbSession,
content: str = Form(...),
contact: str = Form(...),
# 原型改版后 App 已去掉"联系方式"字段,故 contact 改为可选(默认空串);
# 仍兼容旧端/后台带 contact 的提交。DB 列为 NOT NULL,空串即可满足。
contact: str = Form(default=""),
images: list[UploadFile] = File(default=[]),
) -> FeedbackOut:
content = content.strip()
@@ -39,8 +94,6 @@ async def submit_feedback(
raise HTTPException(status_code=400, detail="反馈内容不能为空")
if len(content) > _CONTENT_MAX:
raise HTTPException(status_code=400, detail="反馈内容过长")
if not contact:
raise HTTPException(status_code=400, detail="联系方式不能为空")
if len(contact) > _CONTACT_MAX:
raise HTTPException(status_code=400, detail="联系方式过长")
+4 -4
View File
@@ -2,14 +2,14 @@
路由前缀 /api/v1/invite,需 Bearer 鉴权(用户级数据);唯一例外是 /landing-track
(落地页 dl.html 浏览器访问、无 token,详见任务 3 [[invite-three-tasks]]):
GET /me 我的邀请码 + 分享链接 + 已邀人数/已得金币(邀请页展示)
GET /me 我的邀请码 + 分享链接 + 已邀人数(邀请页展示)
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码;支持三种归因:
① clipboard:首启读剪贴板拿邀请码 → 上报码
② manual:用户在邀请页输入邀请码 → 上报码
③ fingerprint:剪贴板没拿到时上报指纹,后端反查
POST /landing-track 落地页 dl.html 访问时上报指纹(剪贴板兜底的服务端一端,无需鉴权)
绑定的真正逻辑(邀请码生成/反查、幂等、自邀屏蔽、发金币、指纹反查)在
绑定的真正逻辑(邀请码生成/反查、幂等、自邀屏蔽、指纹反查)在
repositories/invite.py。
"""
from __future__ import annotations
@@ -38,7 +38,7 @@ logger = logging.getLogger("shagua.invite")
router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
_BIND_MESSAGES = {
"success": "邀请绑定成功,金币已到账",
"success": "邀请绑定成功",
"already_bound": "你已绑定过邀请人",
"invalid_code": "邀请码无效",
"self_invite": "不能填写自己的邀请码",
@@ -154,7 +154,7 @@ def landing_track(
return LandingTrackOut(status="ok")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,不发邀请金币)")
def bind_invite(
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
) -> BindInviteOut: