This commit is contained in:
陈世睿
2026-06-30 15:07:46 +08:00
13 changed files with 215 additions and 21 deletions
+34
View File
@@ -0,0 +1,34 @@
"""feedback 加提交端环境快照(app_version / device_model / rom_name / android_version)
Revision ID: feedback_submit_env
Revises: ad_pangle_daily_revenue
Create Date: 2026-06-29 00:00:00.000000
admin 用户反馈页要展示「提交版本号」「机型OS版本」,需在提交时落库端环境。仅新增可空列,
SQLite 原生支持 add_column、不用 batch;downgrade 的 drop_column 在 SQLite 走 batch 兜底。
历史反馈无此快照 → 留 NULL(无法回填);客户端改版带上后的新反馈才有值。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "feedback_submit_env"
down_revision: Union[str, Sequence[str], None] = "ad_pangle_daily_revenue"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("feedback", sa.Column("app_version", sa.String(length=32), nullable=True))
op.add_column("feedback", sa.Column("device_model", sa.String(length=64), nullable=True))
op.add_column("feedback", sa.Column("rom_name", sa.String(length=32), nullable=True))
op.add_column("feedback", sa.Column("android_version", sa.String(length=16), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("feedback") as batch_op:
batch_op.drop_column("android_version")
batch_op.drop_column("rom_name")
batch_op.drop_column("device_model")
batch_op.drop_column("app_version")
+15 -2
View File
@@ -87,6 +87,7 @@ def ad_revenue_report(
user_id: int | None = None,
ad_type: str | None = None,
feed_scene: str | None = None,
app_env: str | None = None,
granularity: str = "day",
limit: int = 500,
offset: int = 0,
@@ -156,8 +157,12 @@ def ad_revenue_report(
"has_impression": True,
"impressions": 1,
"ecpm": rec.ecpm_raw,
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次);与发奖同源解析,口径一致。
"revenue_yuan": round(rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0, 6),
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次)。eCPM 先钳到 AD_ECPM_MAX_FEN(¥500 CPM)
# 再折收益,与发奖口径 [rewards.calculate_ad_reward_coin] 一致(2026-06-29 修:原裸 parse_ecpm_yuan
# 不钳,伪造/异常天价 eCPM 会把报表预估收益冲到任意大;金币侧已钳、收益侧漏钳)。
"revenue_yuan": round(
min(rewards.parse_ecpm_yuan(rec.ecpm_raw), rewards.AD_ECPM_MAX_FEN / 100.0) / 1000.0, 6,
),
"adn": rec.adn,
"slot_id": rec.slot_id,
}
@@ -213,6 +218,14 @@ def ad_revenue_report(
if feed_scene is not None:
events = [e for e in events if e.get("feed_scene") == feed_scene]
# app_env 过滤(2026-06-29 新增能力,修隐患:测试应用上报的假 eCPM 如 ¥678 CPM 会污染正式收益合计/平均):
# 显式传 "prod"/"test" 只看该环境;不传=全部(维持现状)。**不擅自把默认改成排除 test**——本地 dev 库多为
# test 数据、默认排除会使本地报表空,且「正式报表是否含 test」属产品口径。建议前端报表页加 app_env 筛选器
# (默认选 prod),或产品确认后再把默认改成排除 test。注:穿山甲后台收益列(total_pangle_*)暂未联动此过滤
# (它是独立对照列,且 pangle 的 test 是真实小额、非客户端那种假值)。
if app_env is not None:
events = [e for e in events if e.get("app_env") == app_env]
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
if sort == "ecpm":
+70 -5
View File
@@ -125,8 +125,9 @@ def list_users(
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None:
"""给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。"""
def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None:
"""给每条记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。
按 user_id 鸭子类型,比价记录/反馈/上报通用。"""
uids = {r.user_id for r in records}
if not uids:
return
@@ -562,7 +563,25 @@ def list_feedbacks(
sort_col = sort_cols.get(sort_by, Feedback.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
items, next_cursor, total = offset_paginate(
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
)
_attach_user_info(db, items) # 列表展示完整手机号(点手机号查该用户全部反馈)
return items, next_cursor, total
def feedback_summary(db: Session) -> dict:
"""反馈审核台各状态计数(待审核/已采纳/未采纳/合计)。待审核含历史 new 态(与前端 isPending 一致)。"""
rows = db.execute(
select(Feedback.status, func.count(Feedback.id)).group_by(Feedback.status)
).all()
by_status = {status: int(count) for status, count in rows}
return {
"pending": by_status.get("pending", 0) + by_status.get("new", 0),
"adopted": by_status.get("adopted", 0),
"rejected": by_status.get("rejected", 0),
"total": sum(by_status.values()),
}
def list_analytics_events(
@@ -769,6 +788,7 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
"user": user,
"coin_balance": acc.coin_balance if acc else 0,
"cash_balance_cents": acc.cash_balance_cents if acc else 0,
"invite_cash_balance_cents": acc.invite_cash_balance_cents if acc else 0,
"total_coin_earned": acc.total_coin_earned if acc else 0,
"comparison_total": _count(ComparisonRecord, ComparisonRecord.user_id == user_id),
"comparison_success": _count(
@@ -978,21 +998,66 @@ def user_coin_records(
return rows[offset:offset + limit], (offset + limit if has_more else None), total
def _attach_price_report_comparison(db: Session, records: list[PriceReport]) -> None:
"""给每条上报瞬态挂关联比价记录的 trace + 设备/版本快照:
trace_id/trace_url(点 Trace 看完整比价过程)、device_model/rom_name/android_version(机型OS版本列)、
app_version(提交版本号列,= 提交时我们 app 的 versionName)。
comparison_record_id 为空 / 关联记录查不到 → 全 None。"""
cids = {r.comparison_record_id for r in records if r.comparison_record_id is not None}
rows = (
db.execute(
select(
ComparisonRecord.id,
ComparisonRecord.trace_id,
ComparisonRecord.trace_url,
ComparisonRecord.device_model,
ComparisonRecord.rom_name,
ComparisonRecord.android_version,
ComparisonRecord.app_version,
).where(ComparisonRecord.id.in_(cids))
).all()
if cids
else []
)
cmap = {row.id: row for row in rows}
for r in records:
row = cmap.get(r.comparison_record_id)
r.trace_id = row.trace_id if row else None
r.trace_url = row.trace_url if row else None
r.device_model = row.device_model if row else None
r.rom_name = row.rom_name if row else None
r.android_version = row.android_version if row else None
r.app_version = row.app_version if row else None
def list_price_reports(
db: Session,
*,
status: str | None = None,
user_id: int | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[PriceReport], int | None, int]:
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,id 倒序。"""
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,按 id·提交时间排序。
join User 取 phone 瞬态挂(列表展示完整手机号);按 comparison_record_id 挂 trace_id/trace_url。"""
stmt = select(PriceReport)
if status:
stmt = stmt.where(PriceReport.status == status)
if user_id is not None:
stmt = stmt.where(PriceReport.user_id == user_id)
return offset_paginate(db, stmt, (PriceReport.id.desc(),), limit=limit, cursor=cursor)
sort_cols = {"id": PriceReport.id, "created_at": PriceReport.created_at}
sort_col = sort_cols.get(sort_by, PriceReport.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(PriceReport.id) if sort_order == "asc" else desc(PriceReport.id)
items, next_cursor, total = offset_paginate(
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
)
_attach_user_info(db, items)
_attach_price_report_comparison(db, items)
return items, next_cursor, total
def price_report_summary(db: Session) -> dict:
+8 -1
View File
@@ -56,6 +56,13 @@ def get_ad_revenue_report(
"全局筛选,同时影响明细 / 合计 / 趋势"
),
] = None,
app_env: Annotated[
str | None,
Query(
description="prod(正式应用) / test(测试应用);不传=全部。"
"建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均"
),
] = None,
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
] = "day",
@@ -75,7 +82,7 @@ def get_ad_revenue_report(
result = ad_revenue.ad_revenue_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
user_id=user_id, ad_type=ad_type, feed_scene=feed_scene,
user_id=user_id, ad_type=ad_type, feed_scene=feed_scene, app_env=app_env,
granularity=granularity, limit=limit, offset=offset, sort=sort,
)
return AdRevenueReportOut(
+11 -1
View File
@@ -10,7 +10,12 @@ from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.repositories import mutations, queries
from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.feedback import FeedbackApproveRequest, FeedbackOut, FeedbackRejectRequest
from app.admin.schemas.feedback import (
FeedbackApproveRequest,
FeedbackOut,
FeedbackRejectRequest,
FeedbackSummary,
)
from app.models.admin import AdminUser
from app.models.feedback import Feedback
from app.repositories import wallet as wallet_repo
@@ -59,6 +64,11 @@ def list_feedbacks(
)
@router.get("/summary", response_model=FeedbackSummary, summary="反馈审核统计(各状态计数)")
def feedback_summary(db: AdminDb) -> FeedbackSummary:
return FeedbackSummary.model_validate(queries.feedback_summary(db))
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
def handle_feedback(
feedback_id: int,
+4 -1
View File
@@ -37,11 +37,14 @@ def list_price_reports(
db: AdminDb,
status: Annotated[str | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[PriceReportOut]:
items, next_cursor, total = queries.list_price_reports(
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
db, status=status, user_id=user_id,
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
)
return CursorPage(
items=[PriceReportOut.model_validate(r) for r in items],
+19 -11
View File
@@ -210,22 +210,28 @@ def grant_user_cash(
db: AdminDb,
) -> OkResponse:
"""给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。
account=coin_cash(金币兑现金)/ invite_cash(邀请奖励金):两本账物理隔离各调各的
主要用于让无现金用户直接测试提现"""
user = user_repo.get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
# set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护)
# 按目标账户选「余额字段 + 变动入口」(grant_invite_cash 与 grant_cash 同构)
is_invite = body.account == "invite_cash"
balance_attr = "invite_cash_balance_cents" if is_invite else "cash_balance_cents"
grant_fn = wallet_repo.grant_invite_cash if is_invite else wallet_repo.grant_cash
acct_label = "邀请奖励金" if is_invite else "现金"
before: int | None = None
# set=设为目标值:读当前余额算差值,仍复用 grant 写一笔流水(沿用原子/审计/扣负保护)
if body.mode == "set":
if body.amount_cents < 0:
raise HTTPException(status_code=400, detail="目标现金值不能为负")
raise HTTPException(status_code=400, detail=f"目标{acct_label}值不能为负")
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
before = wallet_repo.get_or_create_account(
db, user_id, commit=False, lock=True
).cash_balance_cents
acc_locked = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
before = getattr(acc_locked, balance_attr)
delta = body.amount_cents - before
if delta == 0:
raise HTTPException(
status_code=400, detail=f"当前现金已为 {body.amount_cents} 分,无需调整"
status_code=400, detail=f"当前{acct_label}已为 {body.amount_cents} 分,无需调整"
)
else:
if body.amount_cents == 0:
@@ -234,18 +240,20 @@ def grant_user_cash(
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
if delta < 0:
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
if acc_now.cash_balance_cents + delta < 0:
if getattr(acc_now, balance_attr) + delta < 0:
raise HTTPException(
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)"
status_code=400,
detail=f"扣减后{acct_label}为负(当前余额 {getattr(acc_now, balance_attr)} 分)",
)
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
# grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = wallet_repo.grant_cash(
# grant 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = grant_fn(
db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
)
detail = {
"account": body.account,
"amount_cents": delta,
"balance_after_cents": acc.cash_balance_cents,
"balance_after_cents": getattr(acc, balance_attr),
"reason": body.reason,
}
if body.mode == "set":
+17
View File
@@ -23,6 +23,14 @@ class FeedbackOut(BaseModel):
reviewed_by_admin_id: int | None = None
reviewed_at: datetime | None = None
created_at: datetime
# 提交端环境快照(feedback 表列):提交版本号 / 机型OS版本;改版前的历史反馈为 None
app_version: str | None = None
device_model: str | None = None
rom_name: str | None = None
android_version: str | None = None
# 联表瞬态字段(queries._attach_user_info 挂):列表展示完整手机号,点手机号查该用户全部反馈
phone: str | None = None
nickname: str | None = None
class FeedbackApproveRequest(BaseModel):
@@ -37,3 +45,12 @@ class FeedbackApproveRequest(BaseModel):
class FeedbackRejectRequest(BaseModel):
reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见")
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
class FeedbackSummary(BaseModel):
"""审核台顶部各状态计数(pending 含历史 new 态)。"""
pending: int
adopted: int
rejected: int
total: int
+11
View File
@@ -31,6 +31,17 @@ class PriceReportOut(BaseModel):
reward_coins: int | None = None
reviewed_at: datetime | None = None
created_at: datetime
# 联表瞬态字段:phone/nickname 由 _attach_user_info 挂(展示完整手机号、点击查该用户全部上报);
# 其余由 _attach_price_report_comparison 从关联比价记录挂:trace_*(Trace 列点跳调试链接)、
# device_model/rom_name/android_version(机型OS版本列)、app_version(提交版本号列)
phone: str | None = None
nickname: str | None = None
trace_id: str | None = None
trace_url: str | None = None
device_model: str | None = None
rom_name: str | None = None
android_version: str | None = None
app_version: str | None = None
class PriceReportRejectRequest(BaseModel):
+6
View File
@@ -29,6 +29,7 @@ class AdminUserOverview(BaseModel):
user: AdminUserListItem
coin_balance: int
cash_balance_cents: int
invite_cash_balance_cents: int # 邀请奖励金余额(与 cash_balance_cents 物理隔离)
total_coin_earned: int
comparison_total: int
comparison_success: int
@@ -88,6 +89,11 @@ class GrantCoinsRequest(BaseModel):
class GrantCashRequest(BaseModel):
# 目标账户:金币兑换的现金(cash_balance_cents)与邀请奖励金(invite_cash_balance_cents)物理隔离,
# 各调各的、不可串。默认 coin_cash 兼容旧调用。
account: Literal["coin_cash", "invite_cash"] = Field(
"coin_cash", description="目标账户:coin_cash=金币兑现金账户 / invite_cash=邀请奖励金账户"
)
mode: Literal["delta", "set"] = Field(
"delta", description="delta=增减(amount_cents 为变动量) / set=设为(amount_cents 为目标值,须≥0)"
)
+7
View File
@@ -61,6 +61,11 @@ async def submit_feedback(
content: str = Form(...),
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
contact: str = Form(default=""),
# 提交端环境快照(admin 反馈页展示「提交版本号」「机型OS版本」);旧端不带 → 空串 → 存 NULL。
app_version: str = Form(default=""),
device_model: str = Form(default=""),
rom_name: str = Form(default=""),
android_version: str = Form(default=""),
images: list[UploadFile] = File(default=[]),
) -> FeedbackOut:
content = content.strip()
@@ -86,6 +91,8 @@ async def submit_feedback(
fb = feedback_repo.create_feedback(
db, user_id=user.id, content=content, contact=contact, images=urls,
app_version=app_version.strip(), device_model=device_model.strip(),
rom_name=rom_name.strip(), android_version=android_version.strip(),
)
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
return FeedbackOut.model_validate(fb)
+5
View File
@@ -25,6 +25,11 @@ class Feedback(Base):
contact: Mapped[str] = mapped_column(String(128), nullable=False)
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
# 提交时的端环境快照(admin 排查用;客户端改版带上后的新反馈才有,历史数据为 NULL)
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # 我们 app versionName
device_model: Mapped[str | None] = mapped_column(String(64), nullable=True) # Build.MODEL
rom_name: Mapped[str | None] = mapped_column(String(32), nullable=True) # OemDetector os:ColorOS/MIUI/...
android_version: Mapped[str | None] = mapped_column(String(16), nullable=True) # Build.VERSION.RELEASE
# pending(审核中) / adopted(已采纳) / rejected(未采纳)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
reject_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
+8
View File
@@ -17,12 +17,20 @@ def create_feedback(
content: str,
contact: str,
images: list[str] | None,
app_version: str | None = None,
device_model: str | None = None,
rom_name: str | None = None,
android_version: str | None = None,
) -> Feedback:
fb = Feedback(
user_id=user_id,
content=content,
contact=contact,
images=images or None,
app_version=app_version or None,
device_model=device_model or None,
rom_name=rom_name or None,
android_version=android_version or None,
status="pending",
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
)