diff --git a/alembic/versions/feedback_submit_env.py b/alembic/versions/feedback_submit_env.py new file mode 100644 index 0000000..c6d5516 --- /dev/null +++ b/alembic/versions/feedback_submit_env.py @@ -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") diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index 062fa75..ec6bd4a 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -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": diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 93a2d80..488bcf7 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -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: diff --git a/app/admin/routers/ad_revenue.py b/app/admin/routers/ad_revenue.py index e9d81a9..dc6129f 100644 --- a/app/admin/routers/ad_revenue.py +++ b/app/admin/routers/ad_revenue.py @@ -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( diff --git a/app/admin/routers/feedback.py b/app/admin/routers/feedback.py index 0af2fca..8f27bda 100644 --- a/app/admin/routers/feedback.py +++ b/app/admin/routers/feedback.py @@ -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, diff --git a/app/admin/routers/price_report.py b/app/admin/routers/price_report.py index 8e04b6d..daf3e81 100644 --- a/app/admin/routers/price_report.py +++ b/app/admin/routers/price_report.py @@ -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], diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index 4b98bea..7e4a81b 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -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": diff --git a/app/admin/schemas/feedback.py b/app/admin/schemas/feedback.py index bec9107..3988b87 100644 --- a/app/admin/schemas/feedback.py +++ b/app/admin/schemas/feedback.py @@ -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 diff --git a/app/admin/schemas/price_report.py b/app/admin/schemas/price_report.py index ddf3278..abb89d9 100644 --- a/app/admin/schemas/price_report.py +++ b/app/admin/schemas/price_report.py @@ -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): diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index fad0a62..8e7020e 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -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)" ) diff --git a/app/api/v1/feedback.py b/app/api/v1/feedback.py index 7f8b562..5455d21 100644 --- a/app/api/v1/feedback.py +++ b/app/api/v1/feedback.py @@ -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) diff --git a/app/models/feedback.py b/app/models/feedback.py index b8d7ad6..fb3d68a 100644 --- a/app/models/feedback.py +++ b/app/models/feedback.py @@ -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) diff --git a/app/repositories/feedback.py b/app/repositories/feedback.py index 6c5f8a5..aa74678 100644 --- a/app/repositories/feedback.py +++ b/app/repositories/feedback.py @@ -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), )